diff --git a/.eslintignore b/.eslintignore index f839c94645..856bb61f3f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,9 +2,8 @@ dist/ loader/ hydrate/ src/components.d.ts -storybook-static/ www/ -convenience/generate-component/boilerplate/ +tools/generate-component/boilerplate/ # not ignored folders/files !.github/ diff --git a/.eslintrc.json b/.eslintrc.json index 5947a02c46..406aa7858f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,7 +13,6 @@ "jsx": true } }, - "ignorePatterns": ["**/react-library/**/*"], "overrides": [ { "files": ["*.js", "*.jsx"], @@ -30,7 +29,14 @@ }, { "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + "plugin:lit/recommended", + "plugin:lyne/all", + "prettier" + ], "rules": { "@typescript-eslint/array-type": "error", "@typescript-eslint/explicit-function-return-type": ["warn", { "allowExpressions": true }], @@ -82,7 +88,33 @@ // TODO: Remove this after fixing issues "@typescript-eslint/no-var-requires": "off", // TODO: Evaluate this rule - "@typescript-eslint/semi": ["error"], + "@typescript-eslint/semi": "error", + "import/first": "error", + "import/no-absolute-path": "error", + "import/no-cycle": "error", + "import/no-self-import": "error", + "import/no-unresolved": [ + "error", + { + "ignore": [ + "\\.md\\?raw$", + "\\.svg\\?raw$", + "\\.scss\\?lit\\&inline", + // Broken. Maybe due to commonjs? + "@storybook/addon-actions/decorator" + ] + } + ], + "import/no-useless-path-segments": "error", + "import/order": [ + "error", + { + "alphabetize": { "order": "asc", "caseInsensitive": true }, + "newlines-between": "always" + } + ], + // TODO Discuss this with the team + "lit/no-invalid-html": "off", "camelcase": "off" } }, @@ -90,14 +122,6 @@ "files": ["*.yaml", "*.yml"], "plugins": ["yaml"] }, - { - "files": ["*e2e.ts", "*spec.ts"], - "env": { - "jest/globals": true - }, - "extends": ["plugin:jest/recommended"], - "plugins": ["jest"] - }, { "files": ["*.tsx", "*.jsx"], "extends": ["plugin:jsx-a11y/recommended"], diff --git a/.github/workflows/continuous-integration-secure.yml b/.github/workflows/continuous-integration-secure.yml index 8042fb534e..21d954c9da 100644 --- a/.github/workflows/continuous-integration-secure.yml +++ b/.github/workflows/continuous-integration-secure.yml @@ -32,10 +32,10 @@ jobs: uses: ./.github/actions/download-artifacts-from-workflow with: artifacts: 'storybook' - - run: mkdir storybook-static - - run: unzip storybook.zip -d storybook-static + - run: mkdir -p dist/storybook + - run: unzip storybook.zip -d dist/storybook - name: Remove files with forbidden extensions - run: node ./ci/clean-storybook-files.js + run: node ./ci/clean-storybook-files.cjs - name: Create GitHub Deployment id: tag-name @@ -100,6 +100,6 @@ jobs: directory: coverage override_branch: ${{ github.event.workflow_run.head_branch }} override_commit: ${{ github.event.workflow_run.head_commit.id }} - override_pr: ${{ steps.yarn-cache-dir-path.outputs.pr }} + override_pr: ${{ steps.pr-number.outputs.pr }} fail_ci_if_error: true verbose: true diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 6e2f60f466..9ac41487bb 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -39,11 +39,6 @@ jobs: test: runs-on: ubuntu-latest steps: - # Attempt to prevent "The operation was canceled" error - - name: Set Swap Space - uses: pierotofy/set-swap-space@master - with: - swap-size-gb: 5 - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: @@ -51,11 +46,12 @@ jobs: cache: 'yarn' - run: yarn install --frozen-lockfile --non-interactive + - name: Install browser dependencies + run: yarn playwright install-deps - name: Run tests - # Try yarn test:prod up to three times, if it fails and exit with the exit code from the last execution - run: for i in $(seq 1 3); do [ $i -gt 1 ] && sleep 5; yarn test:prod && s=0 && break || s=$?; done; (exit $s) + run: yarn test env: - NODE_OPTIONS: '--max-old-space-size=6144' + NODE_ENV: production - name: Store coverage if: github.event_name == 'pull_request' uses: actions/upload-artifact@v3 @@ -85,21 +81,14 @@ jobs: - run: yarn install --frozen-lockfile --non-interactive - name: Run build - run: STORYBOOK_COMPONENTS_VERSION=$GITHUB_SHA yarn build - - name: Store stencil artifacts - uses: actions/upload-artifact@v3 - with: - name: stencil - path: | - dist/ - hydrate/ - loader/ - react-library/dist/ + run: yarn build + env: + STORYBOOK_COMPONENTS_VERSION: ${{ github.event.pull_request.head.sha || github.sha }} - name: Store storybook artifacts uses: actions/upload-artifact@v3 with: name: storybook - path: storybook-static/ + path: dist/storybook/ chromatic: runs-on: ubuntu-latest @@ -114,18 +103,16 @@ jobs: node-version-file: '.nvmrc' cache: 'yarn' - run: yarn install --frozen-lockfile --non-interactive - - name: Restore stencil artifacts - uses: actions/download-artifact@v3 - with: - name: stencil - path: . - name: Run build - run: yarn build:chromatic-stories && yarn build:storybook + run: yarn generate:chromatic-stories && yarn build:storybook + env: + CHROMATIC: true - name: Publish to Chromatic id: chromatic-publish uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_TOKEN }} - storybookBuildDir: storybook-static + storybookBuildDir: dist/storybook exitOnceUploaded: true exitZeroOnChanges: true + zip: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f79cf22cb..38b8a7c779 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,11 +48,6 @@ jobs: registry-url: 'https://registry.npmjs.org' scope: sbb-esta - run: yarn install --frozen-lockfile --non-interactive - - name: Run build - run: yarn build:chromatic-stories && yarn build - - - name: Bundle stories - run: node ./ci/bundleStories.js - name: 'Release: Set git user' run: | @@ -65,45 +60,22 @@ jobs: run: echo "value=$(jq --raw-output .version ./package.json)" >> $GITHUB_OUTPUT - name: 'Release: Push release to repository' run: git push --follow-tags origin master + - name: Run build + run: STORYBOOK_COMPONENTS_VERSION=${{ steps.version.outputs.value }} yarn build - name: 'Release: Determine npm tag' id: npm_tag run: echo "npm_tag=$([[ "${{ steps.version.outputs.value }}" == *"-"* ]] && echo "next" || echo "latest")" >> $GITHUB_OUTPUT - name: 'Release: Publish @sbb-esta/lyne-components' - run: yarn publish --tag ${{ steps.npm_tag.outputs.npm_tag }} + run: yarn publish dist/components --tag ${{ steps.npm_tag.outputs.npm_tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: 'Release: Assign current dependency version' - uses: actions/github-script@v6 - with: - script: | - const fs = require('fs'); - const path = `${process.env.GITHUB_WORKSPACE}/react-library/package.json`; - const pkgJson = fs.readFileSync(path, 'utf8'); - fs.writeFileSync(path, pkgJson.replace(/0.0.0-PLACEHOLDER/g, '${{ steps.version.outputs.value }}'), 'utf8'); - name: 'Release: Publish @sbb-esta/lyne-components-react' - run: yarn publish react-library --tag ${{ steps.npm_tag.outputs.npm_tag }} + run: yarn publish dist/react --tag ${{ steps.npm_tag.outputs.npm_tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Create versioned storybook for chromatic - run: STORYBOOK_COMPONENTS_VERSION=${{ steps.version.outputs.value }} yarn build:storybook - # Send storybook to chromatic. These snapshots should be accepted as new - # baseline in storybook. - - name: Publish to Chromatic - uses: chromaui/action@v1 - with: - projectToken: ${{ secrets.CHROMATIC_TOKEN }} - storybookBuildDir: storybook-static - autoAcceptChanges: true - exitZeroOnChanges: true - - - name: Remove chromatic stories - run: cd src && git clean -f -X - - name: Create versioned storybook for image - run: STORYBOOK_COMPONENTS_VERSION=${{ steps.version.outputs.value }} yarn build:storybook - name: Remove files with forbidden extensions - run: node ./ci/clean-storybook-files.js - + run: node ./ci/clean-storybook-files.cjs - name: 'Container: Login to GitHub Container Repository' run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io --username ${{ github.actor }} --password-stdin - name: 'Container: Build image' @@ -114,3 +86,18 @@ jobs: run: docker push $IMAGE_REPO:${{ steps.version.outputs.value }} - name: 'Container: Publish image as latest' run: docker push $IMAGE_REPO:latest + + - name: Generate chromatic stories + run: yarn generate:chromatic-stories + - name: Create versioned storybook for chromatic + run: STORYBOOK_COMPONENTS_VERSION=${{ steps.version.outputs.value }} yarn build:storybook + # Send storybook to chromatic. These snapshots should be accepted as new + # baseline in storybook. + - name: Publish to Chromatic + uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_TOKEN }} + storybookBuildDir: dist/storybook + autoAcceptChanges: true + exitZeroOnChanges: true + zip: true diff --git a/.gitignore b/.gitignore index 03fa1b25e9..9bbefc3ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ /dist -/www -/loader -/hydrate -/storybook-static /coverage -/react-library/dist *~ *.sw[mnpcod] @@ -18,7 +13,6 @@ log.txt **/*.chromatic.stories.tsx package-lock.json -.stencil/ .idea/ .vscode/ .sass-cache/ diff --git a/.husky/commit-msg b/.husky/commit-msg index ee8c7d54da..db9f148c19 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -3,4 +3,4 @@ . "$(dirname "$0")/_/husky.sh" -npx --no-install commitlint --edit $1 +yarn commitlint --edit $1 diff --git a/.nvmrc b/.nvmrc index 3876fd4986..f3f52b42d3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.16.1 +20.9.0 diff --git a/.prettierignore b/.prettierignore index c519c7a155..03f6318114 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,10 +1,5 @@ coverage dist -hydrate -loader -storybook-static -src/components.d.ts -src/components/*/readme.md -src/global/core/components/*/readme.md -convenience/generate-component/boilerplate/readme.md -react-library + +# needed for apexes in `HTMLElementTagNameMap`, which otherwise would be stripped +tools/generate-component/boilerplate/component.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 56aae74bda..6335d0c2c5 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,25 +1,35 @@ -import type { StorybookConfig } from '@storybook/html-webpack5'; +import type { StorybookConfig } from '@storybook/web-components-vite'; +import { BuildOptions, UserConfig, mergeConfig } from 'vite'; const config: StorybookConfig = { - stories: ['../src/**/*.stories.tsx', '../src/**/*.stories.mdx'], - addons: [ - '@storybook/addon-essentials', - '@storybook/addon-a11y', - '@storybook/addon-interactions', - '@storybook/preset-scss', - '@storybook/addon-mdx-gfm', - ], - features: {}, - typescript: { - check: false, - }, + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-a11y', '@storybook/addon-interactions'], framework: { - name: '@storybook/html-webpack5', + name: '@storybook/web-components-vite', options: {}, }, docs: { autodocs: true, }, -}; + async viteFinal(config) { + let build: BuildOptions = {}; + if (process.env.CHROMATIC) { + build = { + sourcemap: false, + rollupOptions: { + output: { + manualChunks(id) { + return 'main'; + }, + }, + }, + }; + } + return mergeConfig(config, { + assetsInclude: ['src/**/*.md'], + build, + }); + }, +}; export default config; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index cf1d1e879a..8b11de558a 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -3,6 +3,28 @@ --> + + + + + +``` + +## Style + +The component has a negative variant which can be set using the `negative` property. + +```html + +``` + +The aspect ratio of the logo can be changed using the `protectiveRoom` property. +Possible values are `ideal` (default), `minimal` and `none`. + +```html + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | --------------------- | ------- | -------------------------------- | --------- | ------------------------------------------------------------ | +| `negative` | `negative` | public | `boolean` | `false` | Variants of the logo. | +| `protectiveRoom` | `protective-room` | public | `SbbProtectiveRoom \| undefined` | `'ideal'` | Visual protective room around logo. | +| `accessibilityLabel` | `accessibility-label` | public | `string` | `'Logo'` | Accessibility label which will be forwarded to the SVG logo. | diff --git a/src/components/map-container/index.ts b/src/components/map-container/index.ts new file mode 100644 index 0000000000..761112cb4f --- /dev/null +++ b/src/components/map-container/index.ts @@ -0,0 +1 @@ +export * from './map-container'; diff --git a/src/components/map-container/map-container.e2e.ts b/src/components/map-container/map-container.e2e.ts new file mode 100644 index 0000000000..bab4f0602a --- /dev/null +++ b/src/components/map-container/map-container.e2e.ts @@ -0,0 +1,49 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { setViewport } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { waitForCondition } from '../core/testing'; + +import { SbbMapContainer } from './map-container'; +import '.'; + +describe('sbb-map-container', () => { + let element: SbbMapContainer; + + it('should react to scrolling', async () => { + await setViewport({ width: 320, height: 600 }); + + await fixture( + html` +
+ Operations & Disruptions + ${[...Array(10).keys()].map( + (value) => + html`
+

Situation ${value}

+
`, + )} +
+
+
map
+
+
`, + ); + element = document.querySelector('sbb-map-container'); + assert.instanceOf(element, SbbMapContainer); + + function getInert(): boolean { + return element.shadowRoot.querySelector('sbb-button').hasAttribute('inert'); + } + + expect(element).not.to.have.attribute('data-scroll-up-button-visible'); + expect(getInert()).to.be.equal(true); + + // Scroll down + window.scrollTo(0, 400); + await waitForCondition(async () => !getInert()); + + expect(element).to.have.attribute('data-scroll-up-button-visible'); + expect(getInert()).to.be.equal(false); + }); +}); diff --git a/src/components/map-container/map-container.scss b/src/components/map-container/map-container.scss new file mode 100644 index 0000000000..eac3b0b048 --- /dev/null +++ b/src/components/map-container/map-container.scss @@ -0,0 +1,108 @@ +@use '../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-map-container-map-height-zero: #{sbb.px-to-rem-build(295)}; + --sbb-map-container-map-height-small: #{sbb.px-to-rem-build(320)}; + --sbb-map-container-sidebar-width: #{sbb.px-to-rem-build(400)}; + --sbb-map-container-sidebar-background-color: var(--sbb-color-white-default); + --sbb-map-container-map-height: calc( + var(--sbb-map-container-map-height-zero) + var(--sbb-border-radius-4x) + ); + --sbb-map-container-border-radius: var(--sbb-border-radius-4x); + --sbb-map-container-animation-duration: var(--sbb-animation-duration-4x); + + @include sbb.mq($from: small) { + --sbb-map-container-map-height: calc( + var(--sbb-map-container-map-height-small) + var(--sbb-map-container-border-radius) + ); + } +} + +.sbb-map-container { + display: grid; + grid-template-rows: auto auto; + height: 100%; + + @include sbb.mq($from: medium) { + grid-template-columns: max(var(--sbb-map-container-sidebar-width)) calc( + 100% - var(--sbb-map-container-sidebar-width) + ); + height: calc(100vh - var(--sbb-map-container-margin-start, var(--sbb-header-height))); + overflow: hidden; + } +} + +.sbb-map-container__sidebar-button { + position: fixed; + left: 50%; + inset-block-end: var(--sbb-spacing-fixed-5x); + visibility: hidden; + opacity: 0; + transform: translateX(-50%) translateY(calc(100% + var(--sbb-spacing-fixed-5x))); + + // Hide transition, visibility should be delayed + transition: + opacity var(--sbb-map-container-animation-duration) var(--sbb-animation-easing), + visibility var(--sbb-map-container-animation-duration) var(--sbb-animation-easing) + var(--sbb-map-container-animation-duration), + transform var(--sbb-map-container-animation-duration) var(--sbb-animation-easing); + + :host([data-scroll-up-button-visible]) & { + // Show transition + transition: + visibility var(--sbb-map-container-animation-duration) var(--sbb-animation-easing), + opacity var(--sbb-map-container-animation-duration) var(--sbb-animation-easing), + transform var(--sbb-map-container-animation-duration) ease-out; + visibility: visible; + opacity: 1; + transform: translateX(-50%) translateY(0); + } + + @include sbb.mq($from: medium) { + display: none; + } +} + +.sbb-map-container__sidebar { + @include sbb.scrollbar; + + grid-column: 1 / 3; + grid-row: 2 / 3; + width: 100%; + height: calc(100% + var(--sbb-map-container-border-radius)); + overflow: hidden auto; + position: relative; + z-index: 2; + border-start-end-radius: var(--sbb-map-container-border-radius); + border-start-start-radius: var(--sbb-map-container-border-radius); + margin-block-start: calc(var(--sbb-map-container-border-radius) * -1); + background-color: var(--sbb-map-container-sidebar-background-color); + + @include sbb.mq($from: medium) { + grid-column: 1 / 2; + grid-row: 1 / 3; + border-radius: 0; + margin-block-start: 0; + } +} + +.sbb-map-container__map { + position: sticky; + inset-block-start: 0; + inset-inline: 0; + z-index: 1; + grid-row: 1 / 2; + grid-column: 1 / 3; + height: var(--sbb-map-container-map-height); + + @include sbb.mq($from: medium) { + grid-row: 1 / 3; + grid-column: 2 / 3; + height: 100%; + position: relative; + } +} diff --git a/src/components/map-container/map-container.spec.ts b/src/components/map-container/map-container.spec.ts new file mode 100644 index 0000000000..a6cf44f664 --- /dev/null +++ b/src/components/map-container/map-container.spec.ts @@ -0,0 +1,66 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbMapContainer } from './map-container'; +import '.'; + +describe('sbb-map-container', () => { + let element: SbbMapContainer; + + it('renders the container with button', async () => { + element = await fixture(html``); + + expect(element).dom.to.be.equal( + ` + + + `, + ); + expect(element).shadowDom.to.be.equal( + ` +
+
+ + + Show map +
+
+ +
+
+ `, + ); + }); + it('renders the container without button', async () => { + element = await fixture(html``); + + expect(element).dom.to.be.equal( + ` + + + `, + ); + expect(element).shadowDom.to.be.equal( + ` +
+
+ +
+
+ +
+
+ `, + ); + }); +}); diff --git a/src/components/map-container/map-container.stories.tsx b/src/components/map-container/map-container.stories.tsx new file mode 100644 index 0000000000..419d40d1eb --- /dev/null +++ b/src/components/map-container/map-container.stories.tsx @@ -0,0 +1,103 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './map-container'; +import '../form-field'; +import '../icon'; +import '../title'; +import '../header'; + +const hideScrollUpButton: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + 'hide-scroll-up-button': hideScrollUpButton, +}; + +const defaultArgs: Args = { + 'hide-scroll-up-button': false, +}; + +const Template = (args): JSX.Element => ( + +
+ + + + + + Operations & Disruptions + {[...Array(10).keys()].map((value) => ( +
+

Situation {value}

+
+ ))} +
+ +
+
+ map +
+
+
+); + +export const MapContainer: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
+ + + Menu + + + +
+ ), + ], + parameters: { + chromatic: { disableSnapshot: false }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + layout: 'fullscreen', + }, + title: 'components/sbb-map-container', +}; + +export default meta; diff --git a/src/components/map-container/map-container.ts b/src/components/map-container/map-container.ts new file mode 100644 index 0000000000..5210a75401 --- /dev/null +++ b/src/components/map-container/map-container.ts @@ -0,0 +1,130 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { toggleDatasetEntry } from '../core/dom'; +import { documentLanguage, HandlerRepository, languageChangeHandlerAspect } from '../core/eventing'; +import { i18nMapContainerButtonLabel } from '../core/i18n'; +import { AgnosticIntersectionObserver } from '../core/observers'; + +import style from './map-container.scss?lit&inline'; +import '../button'; + +/** + * It can be used as a container for maps. + * + * @slot - Use the unnamed slot to add content to the sidebar. + * @slot map - Used for slotting the map. + */ +@customElement('sbb-map-container') +export class SbbMapContainer extends LitElement { + public static override styles: CSSResult = style; + + /** Flag to show/hide the scroll up button inside the sidebar on mobile. */ + @property({ attribute: 'hide-scroll-up-button', reflect: true, type: Boolean }) + public hideScrollUpButton = false; + + @state() private _scrollUpButtonVisible = false; + + /** Current document language used for translation of the button label. */ + @state() private _currentLanguage = documentLanguage(); + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + ); + + private _intersector: HTMLSpanElement; + private _observer = new AgnosticIntersectionObserver((entries) => + this._toggleButtonVisibilityOnIntersect(entries), + ); + + /** + * Button click callback to trigger the scroll to container top + * @private + */ + private _onScrollButtonClick(): void { + this.scrollIntoView({ behavior: 'smooth' }); + } + /** + * Intersection callback. Toggles the visibility. + * @param entries + * @private + */ + private _toggleButtonVisibilityOnIntersect(entries: IntersectionObserverEntry[]): void { + entries.forEach((entry) => { + const mapIsHidden = !entry.isIntersecting; + toggleDatasetEntry(this, 'scrollUpButtonVisible', mapIsHidden); + this._scrollUpButtonVisible = mapIsHidden; + }); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + this._updateIntersectionObserver(); + } + + private _updateIntersectionObserver(): void { + this._observer.disconnect(); + if (!this.hideScrollUpButton && this._intersector) { + this._observer.observe(this._intersector); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.connect(); + this._observer.disconnect(); + } + + protected override render(): TemplateResult { + return html` +
+
+ ${!this.hideScrollUpButton + ? html` { + if (this._intersector === el) { + return; + } + this._intersector = el; + this._updateIntersectionObserver(); + })} + >` + : nothing} + + + + ${!this.hideScrollUpButton + ? html` { + if (ref) { + ref.inert = !this._scrollUpButtonVisible; + } + })} + variant="tertiary" + size="l" + icon-name="location-pin-map-small" + type="button" + @click=${() => this._onScrollButtonClick()} + > + ${i18nMapContainerButtonLabel[this._currentLanguage]} + ` + : nothing} +
+
+ +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-map-container': SbbMapContainer; + } +} diff --git a/src/components/map-container/readme.md b/src/components/map-container/readme.md new file mode 100644 index 0000000000..6f48e9d46c --- /dev/null +++ b/src/components/map-container/readme.md @@ -0,0 +1,35 @@ +This component is the layout container for the disruption map, the level 3 navigation and the future ATLAS. + +## Slots + +It provides two slots: one unnamed slot for the sidebar content, and one named `map` for the map. + +```html + +
Content
+
Here comes the map.
+
+``` + +On mobile, the map is sticky above the sidebar, and the sidebar content is scrolling over the map. +On desktop, the sidebar and the map are shown in a two column layout side by side. + +## Style + +The component comes along with a height calculation that subtracts the height of the header. +The header height can be overridden setting the variable `--sbb-map-container-margin-start`, if needed. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | ----------------------- | ------- | --------- | ------- | -------------------------------------------------------------------- | +| `hideScrollUpButton` | `hide-scroll-up-button` | public | `boolean` | `false` | Flag to show/hide the scroll up button inside the sidebar on mobile. | + +## Slots + +| Name | Description | +| ----- | --------------------------------------------------- | +| | Use the unnamed slot to add content to the sidebar. | +| `map` | Used for slotting the map. | diff --git a/src/components/menu/index.ts b/src/components/menu/index.ts new file mode 100644 index 0000000000..b025d13966 --- /dev/null +++ b/src/components/menu/index.ts @@ -0,0 +1,2 @@ +export * from './menu'; +export * from './menu-action'; diff --git a/src/components/menu/menu-action/index.ts b/src/components/menu/menu-action/index.ts new file mode 100644 index 0000000000..5779d4f7cd --- /dev/null +++ b/src/components/menu/menu-action/index.ts @@ -0,0 +1 @@ +export * from './menu-action'; diff --git a/src/components/menu/menu-action/menu-action.e2e.ts b/src/components/menu/menu-action/menu-action.e2e.ts new file mode 100644 index 0000000000..76d87ed35f --- /dev/null +++ b/src/components/menu/menu-action/menu-action.e2e.ts @@ -0,0 +1,90 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing'; + +import { SbbMenuAction } from './menu-action'; + +describe('sbb-menu-action', () => { + let element: SbbMenuAction; + + beforeEach(async () => { + element = await fixture(html`Menu Action`); + }); + + describe('events', () => { + it('dispatches event on click', async () => { + const changeSpy = new EventSpy('click'); + + element.click(); + await waitForCondition(() => changeSpy.events.length === 1); + expect(changeSpy.count).to.be.equal(1); + }); + + it('should not dispatch event on click if disabled', async () => { + element.setAttribute('disabled', 'true'); + + await waitForLitRender(element); + + const clickSpy = new EventSpy('click'); + + element.dispatchEvent( + new CustomEvent('click', { bubbles: true, cancelable: true, composed: true }), + ); + expect(clickSpy.count).not.to.be.greaterThan(0); + }); + + it('should dispatch click event on pressing Enter', async () => { + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should dispatch click event on pressing Space', async () => { + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should dispatch click event on pressing Enter with href', async () => { + element.setAttribute('href', '#'); + await waitForLitRender(element); + + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should not dispatch click event on pressing Space with href', async () => { + element.setAttribute('href', '#'); + await waitForLitRender(element); + + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(changeSpy.count).not.to.be.greaterThan(0); + }); + + it('should receive focus', async () => { + element.focus(); + await waitForLitRender(element); + + expect(document.activeElement.id).to.be.equal('focus-id'); + }); + }); + + it('renders as a button and triggers click event', async () => { + element = await fixture(html``); + + assert.instanceOf(element, SbbMenuAction); + + const clickedSpy = new EventSpy('click'); + element.click(); + await waitForCondition(() => clickedSpy.events.length === 1); + expect(clickedSpy.count).to.be.equal(1); + }); +}); diff --git a/src/components/menu/menu-action/menu-action.scss b/src/components/menu/menu-action/menu-action.scss new file mode 100644 index 0000000000..731a15a8ee --- /dev/null +++ b/src/components/menu/menu-action/menu-action.scss @@ -0,0 +1,107 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; + + --sbb-menu-action-border-radius: var(--sbb-border-radius-infinity); + --sbb-menu-action-outer-horizontal-padding: var(--sbb-spacing-fixed-3x); + --sbb-menu-action-gap: var(--sbb-spacing-fixed-2x); + --sbb-menu-action-cursor: pointer; + --sbb-menu-action-color: var(--sbb-color-white-default); + --sbb-menu-action-forced-color-border-color: CanvasText; +} + +:host(:hover:not([disabled])) { + @include sbb.hover-mq($hover: true) { + --sbb-menu-background-color: var(--sbb-color-iron-default); + --sbb-menu-action-forced-color-border-color: Highlight; + } +} + +:host([disabled]) { + --sbb-menu-action-cursor: default; + --sbb-menu-action-color: var(--sbb-color-graphite-default); + --sbb-menu-action-forced-color-border-color: GrayText; + + pointer-events: none; + cursor: default; + + @include sbb.if-forced-colors { + --sbb-menu-action-color: GrayText; + } +} + +:host([role='button']) { + @include sbb.if-forced-colors { + --sbb-menu-action-color: ButtonText; + } +} + +.sbb-menu-action { + text-decoration: none; + display: block; + width: 100%; + color: var(--sbb-menu-action-color); + padding: var(--sbb-spacing-fixed-1x) var(--sbb-menu-action-outer-horizontal-padding); + cursor: var(--sbb-menu-action-cursor); +} + +.sbb-menu-action__content { + @include sbb.text-xs--regular; + + display: flex; + align-items: center; + gap: var(--sbb-menu-action-gap); + padding: var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x); + border-radius: var(--sbb-menu-action-border-radius); + user-select: none; + -webkit-tap-highlight-color: transparent; + background-color: var(--sbb-menu-background-color); + + // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. + :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); + + @include sbb.focus-outline; + } + + @include sbb.if-forced-colors { + border: var(--sbb-border-width-2x) solid var(--sbb-menu-action-forced-color-border-color); + } +} + +.sbb-menu-action__icon { + display: flex; + min-width: var(--sbb-size-icon-ui-small); + min-height: var(--sbb-size-icon-ui-small); +} + +.sbb-menu-action__label { + @include sbb.ellipsis; + + :host([disabled]) & { + text-decoration: line-through; + } +} + +.sbb-menu-action__amount { + @include sbb.badge; + + margin-inline-start: auto; + width: var(--sbb-spacing-fixed-4x); + max-height: var(--sbb-spacing-fixed-4x); + + @include sbb.if-forced-colors { + color: var(--sbb-menu-action-color); + } +} + +.sbb-menu-action__opens-in-new-window { + @include sbb.screen-reader-only; +} diff --git a/src/components/menu/menu-action/menu-action.spec.ts b/src/components/menu/menu-action/menu-action.spec.ts new file mode 100644 index 0000000000..1c41cb958f --- /dev/null +++ b/src/components/menu/menu-action/menu-action.spec.ts @@ -0,0 +1,84 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './menu-action'; + +describe('sbb-menu-action', () => { + it('renders component as button', async () => { + const root = await fixture(html` + + Action + + `); + + expect(root).dom.to.be.equal( + ` + + Action + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + + + + + + + + `, + ); + }); + + it('renders component as link with icon and amount', async () => { + const root = await fixture(html` + + Action + + `); + + expect(root).dom.to.be.equal( + ` + + Action + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + + + + + + + + + 123456 + + + + . Link target opens in new window. + + + `, + ); + }); +}); diff --git a/src/components/menu/menu-action/menu-action.stories.tsx b/src/components/menu/menu-action/menu-action.stories.tsx new file mode 100644 index 0000000000..990a2dea42 --- /dev/null +++ b/src/components/menu/menu-action/menu-action.stories.tsx @@ -0,0 +1,257 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './menu-action'; +import '../../icon'; + +const getBasicTemplate = ({ text, ...args }, id, iconSlot = false): JSX.Element => ( + + {text} {id} + {iconSlot && } + +); + +const TemplateMenuAction = (args): JSX.Element => ( +
+ {getBasicTemplate(args, 1)} + {getBasicTemplate(args, 2)} + {getBasicTemplate(args, 3)} +
+); + +const TemplateMenuActionCustomIcon = (args): JSX.Element => ( +
+ {getBasicTemplate(args, 1, true)} + {getBasicTemplate(args, 2, false)} + {getBasicTemplate(args, 3, true)} +
+); + +const text: InputType = { + control: { + type: 'text', + }, +}; + +const amount: InputType = { + control: { + type: 'text', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Icon', + }, +}; + +const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; +const href: InputType = { + options: Object.keys(hrefs), + mapping: hrefs, + control: { + type: 'select', + labels: { + 0: 'sbb.ch', + 1: 'GitHub Lyne Components', + }, + }, + table: { + category: 'Link', + }, +}; + +const target: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Link', + }, +}; + +const rel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Link', + }, +}; + +const download: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Link', + }, +}; + +const type: InputType = { + control: { + type: 'select', + }, + options: ['button', 'reset', 'submit'], + table: { + category: 'Button', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Button', + }, +}; + +const name: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const form: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + text, + amount, + 'icon-name': iconName, + href, + target, + rel, + download, + type, + disabled, + name, + value, + form, + 'aria-label': ariaLabel, +}; + +const defaultArgs: Args = { + text: 'Details', + amount: '99', + 'icon-name': 'tick-small', + href: href.options[0], + target: '_blank', + rel: undefined, + download: false, + type: undefined, + disabled: false, + name: undefined, + value: undefined, + form: undefined, + 'aria-label': ariaLabel, +}; + +const buttonArgs: Args = { + ...defaultArgs, + href: undefined, + type: type.options[0], + name: 'detail', + value: 'Value', + form: 'form-name', +}; + +export const menuActionLink: StoryObj = { + render: TemplateMenuAction, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +export const menuActionButton: StoryObj = { + render: TemplateMenuAction, + argTypes: defaultArgTypes, + args: buttonArgs, +}; + +export const menuActionLinkCustomIconNoAmount: StoryObj = { + render: TemplateMenuActionCustomIcon, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + amount: undefined, + 'icon-name': undefined, + }, +}; + +export const menuActionLinkNoIconNoAmount: StoryObj = { + render: TemplateMenuAction, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'icon-name': undefined, amount: undefined }, +}; + +export const menuActionButtonDisabled: StoryObj = { + render: TemplateMenuAction, + argTypes: defaultArgTypes, + args: { ...buttonArgs, disabled: true }, +}; + +export const menuActionButtonEllipsis: StoryObj = { + render: TemplateMenuAction, + argTypes: defaultArgTypes, + args: { + ...buttonArgs, + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
+ +
+ ), + withActions as Decorator, + ], + parameters: { + actions: { + handles: ['click'], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-menu/sbb-menu-action', +}; + +export default meta; diff --git a/src/components/menu/menu-action/menu-action.ts b/src/components/menu/menu-action/menu-action.ts new file mode 100644 index 0000000000..23cd47df23 --- /dev/null +++ b/src/components/menu/menu-action/menu-action.ts @@ -0,0 +1,136 @@ +import { spread } from '@open-wc/lit-helpers'; +import { CSSResult, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import { setAttributes } from '../../core/dom'; +import { + documentLanguage, + HandlerRepository, + actionElementHandlerAspect, + languageChangeHandlerAspect, +} from '../../core/eventing'; +import { i18nTargetOpensInNewWindow } from '../../core/i18n'; +import { + ButtonType, + LinkButtonProperties, + LinkButtonRenderVariables, + LinkTargetType, + resolveRenderVariables, + targetsNewWindow, +} from '../../core/interfaces'; + +import style from './menu-action.scss?lit&inline'; +import '../../icon'; + +/** + * It displays an action element that can be used in the `sbb-menu` component. + * + * @slot - Use the unnamed slot to add content to the `sbb-menu-action`. + * @slot icon - Use this slot to provide an icon. If `icon-name` is set, a `sbb-icon` will be used. + */ +@customElement('sbb-menu-action') +export class SbbMenuAction extends LitElement implements LinkButtonProperties { + public static override styles: CSSResult = style; + + /** + * The name of the icon, choose from the small icon variants + * from the ui-icons category from here + * https://icons.app.sbb.ch. + */ + @property({ attribute: 'icon-name' }) public iconName?: string | undefined; + + /** Value shown as badge at component end. */ + @property() public amount?: string | undefined; + + /** The href value you want to link to (if it is not present menu action becomes a button). */ + @property() public href: string | undefined; + + /** Where to display the linked URL. */ + @property() public target?: LinkTargetType | string | undefined; + + /** The relationship of the linked URL as space-separated link types. */ + @property() public rel?: string | undefined; + + /** Whether the browser will show the download dialog on click. */ + @property({ type: Boolean }) public download?: boolean; + + /** The type attribute to use for the button. */ + @property() public type: ButtonType | undefined; + + /** Whether the button is disabled. */ + @property({ reflect: true, type: Boolean }) public disabled = false; + + /** The name attribute to use for the button. */ + @property({ reflect: true }) public name: string | undefined; + + /** The value attribute to use for the button. */ + @property() public value?: string; + + /** The
element to associate the button with. */ + @property() public form?: string; + + @state() private _currentLanguage = documentLanguage(); + + private _handlerRepository = new HandlerRepository( + this, + actionElementHandlerAspect, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + ); + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + protected override render(): TemplateResult { + const { + tagName: TAG_NAME, + hostAttributes, + attributes, + }: LinkButtonRenderVariables = resolveRenderVariables(this); + + setAttributes(this, hostAttributes); + + /* eslint-disable lit/binding-positions */ + return html` + <${unsafeStatic(TAG_NAME)} class="sbb-menu-action" ${spread(attributes)}> + + + ${this.iconName ? html`` : nothing} + + + + + ${ + this.amount && !this.disabled + ? html`${this.amount}` + : nothing + } + + ${ + targetsNewWindow(this) + ? html` + . ${i18nTargetOpensInNewWindow[this._currentLanguage]} + ` + : nothing + } + + `; + /* eslint-disable lit/binding-positions */ + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-menu-action': SbbMenuAction; + } +} diff --git a/src/components/menu/menu-action/readme.md b/src/components/menu/menu-action/readme.md new file mode 100644 index 0000000000..f0ad4f863d --- /dev/null +++ b/src/components/menu/menu-action/readme.md @@ -0,0 +1,61 @@ +The component represents an action element contained by the [sbb-menu](/docs/components-sbb-menu-sbb-menu--docs) component. + +## Slots + +It is possible to provide a label via an unnamed slot; the component can optionally display a `sbb-icon` +at the component start using the `iconName` property or via custom content using the `icon` slot. + +```html +Text + +Another text +``` + +An amount can be rendered at the end of the action element as white text in a red circle via the `amount` property. + +```html +Amount text +``` + +## Link / button properties + +As the [sbb-link](/docs/components-sbb-link--docs) and the [sbb-button](/docs/components-sbb-button--docs), +the component can be internally rendered as a button or as a link, +depending on the value of the `href` property, so the associated properties are available +(`href`, `target`, `rel` and `download` for link; `type`, `name`, `value` and `form` for button). + +```html +Link + +Button +``` + +## Style + +For cases where smaller outer paddings are needed, +you can set the css variable `--sbb-menu-action-outer-horizontal-padding` to your desired outer padding. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | +| `iconName` | `icon-name` | public | `string \| undefined \| undefined` | | The name of the icon, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | +| `amount` | `amount` | public | `string \| undefined \| undefined` | | Value shown as badge at component end. | +| `href` | `href` | public | `string \| undefined` | | The href value you want to link to (if it is not present menu action becomes a button). | +| `target` | `target` | public | `LinkTargetType \| string \| undefined \| undefined` | | Where to display the linked URL. | +| `rel` | `rel` | public | `string \| undefined \| undefined` | | The relationship of the linked URL as space-separated link types. | +| `download` | `download` | public | `boolean \| undefined` | | Whether the browser will show the download dialog on click. | +| `type` | `type` | public | `ButtonType \| undefined` | | The type attribute to use for the button. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the button is disabled. | +| `name` | `name` | public | `string \| undefined` | | The name attribute to use for the button. | +| `value` | `value` | public | `string \| undefined` | | The value attribute to use for the button. | +| `form` | `form` | public | `string \| undefined` | | The element to associate the button with. | + +## Slots + +| Name | Description | +| ------ | ----------------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the `sbb-menu-action`. | +| `icon` | Use this slot to provide an icon. If `icon-name` is set, a `sbb-icon` will be used. | diff --git a/src/components/menu/menu/index.ts b/src/components/menu/menu/index.ts new file mode 100644 index 0000000000..8267df70b3 --- /dev/null +++ b/src/components/menu/menu/index.ts @@ -0,0 +1 @@ +export * from './menu'; diff --git a/src/components/menu/menu/menu.e2e.ts b/src/components/menu/menu/menu.e2e.ts new file mode 100644 index 0000000000..8d3ea9b820 --- /dev/null +++ b/src/components/menu/menu/menu.e2e.ts @@ -0,0 +1,246 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys, setViewport } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing'; + +import { SbbMenu } from './menu'; +import '../../button'; +import '../menu-action'; +import '../../link'; +import '../../divider'; + +describe('sbb-menu', () => { + let element: SbbMenu, trigger: HTMLElement; + + beforeEach(async () => { + await fixture(html` + Menu trigger + + Profile + View + Edit + Details + + Cancel + + `); + trigger = document.querySelector('sbb-button'); + element = document.querySelector('sbb-menu'); + }); + + it('renders', () => { + assert.instanceOf(element, SbbMenu); + }); + + it('opens on trigger click', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + + trigger.click(); + await waitForLitRender(element); + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + + await waitForLitRender(element); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + await waitForLitRender(element); + expect(element).to.have.attribute('data-state', 'opened'); + }); + + it('closes on Esc keypress', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbMenu.events.willClose); + const didCloseEventSpy = new EventSpy(SbbMenu.events.didClose); + + trigger.click(); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); + + await sendKeys({ down: 'Escape' }); + await waitForLitRender(element); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('closes on menu action click', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbMenu.events.willClose); + const didCloseEventSpy = new EventSpy(SbbMenu.events.didClose); + const menuAction = document.querySelector('sbb-menu > sbb-menu-action') as HTMLElement; + + trigger.click(); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(menuAction).not.to.be.null; + + menuAction.click(); + await waitForLitRender(element); + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + + await waitForLitRender(element); + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + + await waitForLitRender(element); + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('closes on interactive element click', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbMenu.events.willClose); + const didCloseEventSpy = new EventSpy(SbbMenu.events.didClose); + const menuLink = document.querySelector('sbb-menu > sbb-link') as HTMLElement; + + trigger.click(); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(menuLink).not.to.be.null; + + menuLink.click(); + await waitForLitRender(element); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('is correctly positioned on desktop', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + await setViewport({ width: 1200, height: 800 }); + const menu: HTMLElement = element.shadowRoot.querySelector('.sbb-menu'); + + trigger.click(); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + const buttonHeight = getComputedStyle(document.documentElement).getPropertyValue( + `--sbb-size-button-l-min-height-large`, + ); + expect(buttonHeight.trim()).to.be.equal('3.5rem'); + + const buttonHeightPx = parseFloat(buttonHeight) * 16; + expect(trigger.offsetHeight).to.be.equal(buttonHeightPx); + expect(trigger.offsetTop).to.be.equal(0); + expect(trigger.offsetLeft).to.be.equal(0); + + // Expect menu offsetTop to be equal to the trigger height + the menu offset (8px) + expect(menu.offsetTop).to.be.equal(buttonHeightPx + 8); + expect(menu.offsetLeft).to.be.equal(0); + }); + + it('is correctly positioned on mobile', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + + await setViewport({ width: 800, height: 600 }); + const menu: HTMLElement = element.shadowRoot.querySelector('.sbb-menu'); + + trigger.click(); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + const menuOffsetTop = menu.offsetTop; + const menuHeight = menu.offsetHeight; + const pageHeight = window.innerHeight; + + expect(menuOffsetTop).to.be.equal(pageHeight - menuHeight); + }); + + it('sets the focus to the first focusable element when the menu is opened by keyboard', async () => { + const willOpenEventSpy = new EventSpy(SbbMenu.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbMenu.events.didOpen); + + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); + + await sendKeys({ down: 'Enter' }); + await waitForLitRender(element); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + await waitForLitRender(element); + expect(document.activeElement.id).to.be.equal('menu-link'); + }); +}); diff --git a/src/components/menu/menu/menu.scss b/src/components/menu/menu/menu.scss new file mode 100644 index 0000000000..d95140effb --- /dev/null +++ b/src/components/menu/menu/menu.scss @@ -0,0 +1,206 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-menu-position-x: 0; + --sbb-menu-position-y: 0; + --sbb-menu-animation-duration: var(--sbb-animation-duration-6x); + --sbb-menu-animation-easing: ease; + --sbb-menu-transform: translateY(100%); + --sbb-menu-max-width: 100%; + --sbb-menu-min-width: 100%; + --sbb-menu-inset: 0 auto auto 0; + --sbb-menu-container-height: 100vh; + + // Needed for backwards compatibility on Chromatic + // TODO: Remove once not needed + @supports (height: 100dvh) { + --sbb-menu-container-height: 100dvh; + } + + // 85vh is not an exact value but looks optimized for mobile view. + --sbb-menu-max-height: calc(85vh - var(--sbb-spacing-fixed-8x)); + --sbb-menu-min-height: #{sbb.px-to-rem-build(48.5)}; + --sbb-menu-border-radius: var(--sbb-border-radius-4x); + --sbb-menu-visibility: hidden; + --sbb-menu-backdrop-color: transparent; + + // We use this rule to make the inner container element to appear as if it were a + // direct child of the host's parent element. This is useful because the host + // should be ignored when using CSS grid or similar layout techniques. + display: contents; + + @include sbb.mq($from: medium) { + --sbb-menu-transform: translateY(var(--sbb-spacing-fixed-2x)); + --sbb-menu-max-width: #{sbb.px-to-rem-build(320)}; + --sbb-menu-min-width: #{sbb.px-to-rem-build(180)}; + } +} + +:host(:is([data-state='opened'], [data-state='opening'])) { + --sbb-menu-visibility: visible; + --sbb-menu-backdrop-color: var(--sbb-color-black-alpha-20); + + @include sbb.mq($from: medium) { + --sbb-menu-backdrop-color: transparent; + } +} + +:host(:not([data-state='closed'])) { + --sbb-menu-inset: 0; +} + +:host([disable-animation]) { + --sbb-menu-animation-duration: 0.1ms; +} + +::slotted(:not(sbb-menu-action, sbb-divider)) { + display: block; + padding-inline: var(--sbb-spacing-fixed-5x); +} + +::slotted(sbb-divider) { + --sbb-divider-color: var(--sbb-color-iron-default); + + margin-block: var(--sbb-spacing-fixed-2x); +} + +.sbb-menu__container { + position: fixed; + pointer-events: none; + inset: var(--sbb-menu-inset); + height: var(--sbb-menu-container-height); + z-index: var(--sbb-menu-z-index, var(--sbb-overlay-z-index)); + + // Menu backdrop (only visible on mobile) + &::before { + content: ''; + visibility: var(--sbb-menu-visibility); + pointer-events: all; + position: fixed; + inset: var(--sbb-menu-inset); + height: var(--sbb-menu-container-height); + background-color: var(--sbb-menu-backdrop-color); + transition: { + duration: var(--sbb-menu-animation-duration); + timing-function: var(--sbb-menu-animation-easing); + property: background-color, visibility; + } + } +} + +.sbb-menu { + display: none; + opacity: 0; + pointer-events: none; + max-width: var(--sbb-menu-max-width); + min-width: var(--sbb-menu-min-width); + text-align: start; + position: absolute; + inset-inline-start: 0; + inset-block-start: unset; + inset-block-end: 0; + inset-inline-end: unset; + color: var(--sbb-color-white-default); + border: none; + border-radius: var(--sbb-menu-border-radius) var(--sbb-menu-border-radius) 0 0; + background-color: var(--sbb-color-black-default); + padding: 0; + overflow: hidden; + + :host(:not([data-state='closed'])) & { + display: block; + opacity: 1; + pointer-events: all; + animation: { + name: open; + duration: var(--sbb-menu-animation-duration); + timing-function: var(--sbb-menu-animation-easing); + } + } + + :host([data-state='closing']) & { + pointer-events: none; + animation-name: close; + } + + @include sbb.if-forced-colors { + outline: var(--sbb-border-width-1x) solid CanvasText; + } + + @include sbb.mq($from: medium) { + top: 0; + bottom: unset; + left: 0; + right: unset; + max-height: fit-content; + border-radius: var(--sbb-menu-border-radius); + + :host(:not([data-state='closed'])) & { + top: var(--sbb-menu-position-y); + left: var(--sbb-menu-position-x); + max-height: var(--sbb-menu-max-height); + min-height: var(--sbb-menu-min-height); + } + } +} + +.sbb-menu__content { + @include sbb.scrollbar($negative: true); + + max-height: var(--sbb-menu-max-height); + padding-block: var(--sbb-spacing-fixed-1x); + overflow: auto; + outline: none; + + // Margin bottom in mobile variant + &::after { + content: ''; + display: block; + height: var(--sbb-spacing-fixed-8x); + } + + @include sbb.mq($from: medium) { + max-height: fit-content; + + :host(:not([data-state='closed'])) & { + max-height: var(--sbb-menu-max-height); + min-height: var(--sbb-menu-min-height); + } + + &::after { + display: none; + } + } +} + +.sbb-menu-list { + @include sbb.list-reset; +} + +@keyframes open { + from { + opacity: 0; + transform: var(--sbb-menu-transform); + } + + to { + opacity: 1; + transform: translateY(0%); + } +} + +@keyframes close { + from { + opacity: 1; + transform: translateY(0%); + } + + to { + opacity: 0; + transform: var(--sbb-menu-transform); + } +} diff --git a/src/components/menu/menu/menu.spec.ts b/src/components/menu/menu/menu.spec.ts new file mode 100644 index 0000000000..ce10f8cbda --- /dev/null +++ b/src/components/menu/menu/menu.spec.ts @@ -0,0 +1,114 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './menu'; + +describe('sbb-menu', () => { + it('renders', async () => { + await fixture(html` + Menu trigger + + Profile + View + Edit + Details + + Cancel + + `); + const menu = document.querySelector('sbb-menu'); + + expect(menu).dom.to.be.equal( + ` + + + Profile + + + View + + + Edit + + + Details + + + + Cancel + + + `, + ); + expect(menu).shadowDom.to.be.equal( + ` +
+
+
+ +
+
+
+ `, + ); + }); + + it('renders with list', async () => { + await fixture( + html` Menu trigger + + View + Edit + Details + Cancel + `, + ); + const menu = document.querySelector('sbb-menu'); + + expect(menu).dom.to.be.equal( + ` + + + + View + + + Edit + + + Details + + + Cancel + + + `, + ); + expect(menu).shadowDom.to.be.equal( + ` +
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+
+
+ `, + ); + }); +}); diff --git a/src/components/menu/menu/menu.stories.tsx b/src/components/menu/menu/menu.stories.tsx new file mode 100644 index 0000000000..aec883b238 --- /dev/null +++ b/src/components/menu/menu/menu.stories.tsx @@ -0,0 +1,306 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import { userEvent, within } from '@storybook/testing-library'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import { waitForComponentsReady } from '../../../storybook/testing/wait-for-components-ready'; +import { waitForStablePosition } from '../../../storybook/testing/wait-for-stable-position'; + +import { SbbMenu } from './menu'; +import readme from './readme.md?raw'; + +import '../../button'; +import '../../divider'; +import '../../link'; +import '../menu-action'; + +// Story interaction executed after the story renders +const playStory = async ({ canvasElement }): Promise => { + const canvas = within(canvasElement); + + await waitForComponentsReady(() => + canvas.getByTestId('menu').shadowRoot.querySelector('.sbb-menu'), + ); + + await waitForStablePosition(() => canvas.getByTestId('menu-trigger')); + + const button = canvas.getByTestId('menu-trigger'); + await userEvent.click(button); +}; + +const iconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Menu action', + }, +}; + +const amount: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Menu action', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Menu action', + }, +}; + +const disableAnimation: InputType = { + control: { type: 'boolean' }, +}; + +const defaultArgTypes: ArgTypes = { + 'icon-name': iconName, + amount, + disabled, + 'disable-animation': disableAnimation, +}; + +const defaultArgs: Args = { + 'icon-name': 'link-small', + amount: '123', + disabled: false, + 'disable-animation': isChromatic(), +}; + +const userNameStyle: Args = { + fontFamily: 'var(--sbb-typo-type-face-sbb-bold)', + fontSize: 'var(--sbb-font-size-text-xs)', + marginTop: 'var(--sbb-spacing-fixed-1x)', +}; + +const userInfoStyle: Args = { + color: 'var(--sbb-color-graphite-default)', + fontFamily: 'var(--sbb-typo-type-face-sbb-regular)', + fontSize: 'var(--sbb-font-size-text-xxs)', +}; + +const triggerButton = (id): JSX.Element => ( + + Menu trigger + +); + +const DefaultTemplate = (args): JSX.Element => ( + + {triggerButton('menu-trigger-1')} + + + View + + + Edit + + + Details + + + Cancel + + +); + +const ListTemplate = (args): JSX.Element => ( + + {triggerButton('menu-trigger-1')} + + + View + + + Edit + + + Details + + Cancel + + +); + +const CustomContentTemplate = (args): JSX.Element => ( + + {triggerButton('menu-trigger-2')} + +
Christina Müller
+ UIS9057 + + Profile + + + + View + + + Tickets + + + Cart + + + Log Out +
+
+); + +const LongContentTemplate = (args): JSX.Element => ( + + {triggerButton('menu-trigger-3')} + + + English + + Deutsch + Français + Italiano + Rumantsch + Español + Português + 日本語 + 한국어 + 广州话 + Afrikaans + Svenska + Dansk + Nederlands + Suomi + українська мова + አማርኛ + ქართული ენა + Afrikaans + Svenska + Dansk + Nederlands + Suomi + + Cancel + + +); + +const EllipsisTemplate = (args): JSX.Element => ( + + {triggerButton('menu-trigger-4')} + +
Christina Müller
+ UIS9057 + + Profile + + + + View + + + Edit + + + Very long label that exceeds the maximum width of the menu, very long label that exceeds the + maximum width of the menu, very long label that exceeds the maximum width of the menu + + + Cancel +
+
+); + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, + play: isChromatic() && playStory, +}; + +export const List: StoryObj = { + render: ListTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, + play: isChromatic() && playStory, +}; + +export const CustomContent: StoryObj = { + render: CustomContentTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, amount: '2' }, + play: isChromatic() && playStory, +}; + +export const LongContent: StoryObj = { + render: LongContentTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'icon-name': 'tick-small', amount: undefined }, + play: isChromatic() && playStory, +}; + +export const Ellipsis: StoryObj = { + render: EllipsisTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + play: isChromatic() && playStory, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
+ +
+ ), + withActions as Decorator, + ], + parameters: { + chromatic: { disableSnapshot: false }, + actions: { + handles: [ + SbbMenu.events.willOpen, + SbbMenu.events.didOpen, + SbbMenu.events.didClose, + SbbMenu.events.willClose, + ], + }, + backgrounds: { + disable: true, + }, + docs: { + story: { inline: false, iframeHeight: '400px' }, + + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-menu/sbb-menu', +}; + +export default meta; diff --git a/src/components/menu/menu/menu.ts b/src/components/menu/menu/menu.ts new file mode 100644 index 0000000000..51a50b00e7 --- /dev/null +++ b/src/components/menu/menu/menu.ts @@ -0,0 +1,431 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { + assignId, + FocusTrap, + getNextElementIndex, + interactivityChecker, + IS_FOCUSABLE_QUERY, + isArrowKeyPressed, + setModalityOnNextFocus, +} from '../../core/a11y'; +import { + findReferencedElement, + isBreakpoint, + isValidAttribute, + ScrollHandler, + setAttribute, +} from '../../core/dom'; +import { EventEmitter, ConnectedAbortController } from '../../core/eventing'; +import { + applyInertMechanism, + getElementPosition, + isEventOnElement, + removeAriaOverlayTriggerAttributes, + removeInertMechanism, + SbbOverlayState, + setAriaOverlayTriggerAttributes, +} from '../../core/overlay'; +import type { SbbMenuAction } from '../menu-action'; + +import style from './menu.scss?lit&inline'; + +const MENU_OFFSET = 8; +const INTERACTIVE_ELEMENTS = ['A', 'BUTTON', 'SBB-BUTTON', 'SBB-LINK']; + +let nextId = 0; + +/** + * It displays a contextual menu with one or more action element. + * + * @slot - Use the unnamed slot to add `sbb-menu-action` or other elements to the menu. + * @event {CustomEvent} will-open - Emits whenever the `sbb-menu` starts the opening transition. + * @event {CustomEvent} did-open - Emits whenever the `sbb-menu` is opened. + * @event {CustomEvent} will-close - Emits whenever the `sbb-menu` begins the closing transition. + * @event {CustomEvent} did-close - Emits whenever the `sbb-menu` is closed. + */ +@customElement('sbb-menu') +export class SbbMenu extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', + } as const; + + /** + * The element that will trigger the menu overlay. + * Accepts both a string (id of an element) or an HTML element. + */ + @property() + public set trigger(value: string | HTMLElement) { + const oldValue = this._trigger; + this._trigger = value; + this._removeTriggerClickListener(this._trigger, oldValue); + } + public get trigger(): string | HTMLElement { + return this._trigger; + } + private _trigger: string | HTMLElement = null; + + /** + * Whether the animation is enabled. + */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** + * This will be forwarded as aria-label to the inner list. + * Used only if the menu automatically renders the actions inside as a list. + */ + @property({ attribute: 'list-accessibility-label' }) public listAccessibilityLabel?: string; + + /** + * The state of the menu. + */ + @state() private _state: SbbOverlayState = 'closed'; + + /** Sbb-Link elements */ + @state() private _actions: SbbMenuAction[]; + + /** Emits whenever the `sbb-menu` starts the opening transition. */ + private _willOpen: EventEmitter = new EventEmitter(this, SbbMenu.events.willOpen, { + bubbles: true, + composed: true, + }); + + /** Emits whenever the `sbb-menu` is opened. */ + private _didOpen: EventEmitter = new EventEmitter(this, SbbMenu.events.didOpen, { + bubbles: true, + composed: true, + }); + + /** Emits whenever the `sbb-menu` begins the closing transition. */ + private _willClose: EventEmitter = new EventEmitter(this, SbbMenu.events.willClose, { + bubbles: true, + composed: true, + }); + + /** Emits whenever the `sbb-menu` is closed. */ + private _didClose: EventEmitter = new EventEmitter(this, SbbMenu.events.didClose, { + bubbles: true, + composed: true, + }); + + private _menu: HTMLDivElement; + private _triggerElement: HTMLElement; + private _menuContentElement: HTMLElement; + private _isPointerDownEventOnMenu: boolean; + private _menuController: AbortController; + private _windowEventsController: AbortController; + private _abort = new ConnectedAbortController(this); + private _focusTrap = new FocusTrap(); + private _scrollHandler = new ScrollHandler(); + private _menuId = `sbb-menu-${++nextId}`; + + /** + * Opens the menu on trigger click. + */ + public open(): void { + if (this._state === 'closing' || !this._menu) { + return; + } + + this._willOpen.emit(); + this._state = 'opening'; + this._setMenuPosition(); + this._triggerElement?.setAttribute('aria-expanded', 'true'); + + // Starting from breakpoint medium, disable scroll + if (!isBreakpoint('medium')) { + this._scrollHandler.disableScroll(); + } + } + + /** + * Closes the menu. + */ + public close(): void { + if (this._state === 'opening') { + return; + } + + this._willClose.emit(); + this._state = 'closing'; + this._triggerElement?.setAttribute('aria-expanded', 'false'); + } + + /** + * Handles click and checks if its target is a sbb-menu-action. + */ + private _onClick(event: Event): void { + const target = event.target as HTMLElement | undefined; + if (target?.tagName === 'SBB-MENU-ACTION') { + this.close(); + } + } + + private _handleKeyDown(evt: KeyboardEvent): void { + if (!isArrowKeyPressed(evt)) { + return; + } + evt.preventDefault(); + + const enabledActions: Element[] = Array.from(this.querySelectorAll('sbb-menu-action')).filter( + (el: HTMLElement) => el.tabIndex === 0 && interactivityChecker.isVisible(el), + ); + + const current = enabledActions.findIndex((e: Element) => e === evt.target); + const nextIndex = getNextElementIndex(evt, current, enabledActions.length); + + (enabledActions[nextIndex] as HTMLElement).focus(); + } + + // Closes the menu on "Esc" key pressed and traps focus within the menu. + private async _onKeydownEvent(event: KeyboardEvent): Promise { + if (this._state !== 'opened') { + return; + } + + if (event.key === 'Escape') { + this.close(); + return; + } + } + + // Removes trigger click listener on trigger change. + private _removeTriggerClickListener( + newValue: string | HTMLElement, + oldValue: string | HTMLElement, + ): void { + if (newValue !== oldValue) { + this._menuController?.abort(); + this._windowEventsController?.abort(); + this._configure(this.trigger); + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('click', (e) => this._onClick(e), { signal }); + this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); + // Validate trigger element and attach event listeners + this._configure(this.trigger); + this._readActions(); + + if (this._state === 'opened') { + applyInertMechanism(this); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._menuController?.abort(); + this._windowEventsController?.abort(); + this._focusTrap.disconnect(); + removeInertMechanism(); + } + + // Check if the trigger is valid and attach click event listeners. + private _configure(trigger: string | HTMLElement): void { + removeAriaOverlayTriggerAttributes(this._triggerElement); + + if (!trigger) { + return; + } + + this._triggerElement = findReferencedElement(trigger); + + if (!this._triggerElement) { + return; + } + + setAriaOverlayTriggerAttributes( + this._triggerElement, + 'menu', + this.id || this._menuId, + this._state, + ); + this._menuController = new AbortController(); + this._triggerElement.addEventListener('click', () => this.open(), { + signal: this._menuController.signal, + }); + } + + private _attachWindowEvents(): void { + this._windowEventsController = new AbortController(); + document.addEventListener('scroll', () => this._setMenuPosition(), { + passive: true, + signal: this._windowEventsController.signal, + }); + window.addEventListener('resize', () => this._setMenuPosition(), { + passive: true, + signal: this._windowEventsController.signal, + }); + window.addEventListener('keydown', (event: KeyboardEvent) => this._onKeydownEvent(event), { + signal: this._windowEventsController.signal, + }); + + // Close menu on backdrop click + window.addEventListener('pointerdown', this._pointerDownListener, { + signal: this._windowEventsController.signal, + }); + window.addEventListener('pointerup', this._closeOnBackdropClick, { + signal: this._windowEventsController.signal, + }); + } + + // Close menu at any click on an interactive element inside the that bubbles to the container. + private _closeOnInteractiveElementClick(event: Event): void { + const target = event.target as HTMLElement; + if (INTERACTIVE_ELEMENTS.includes(target.nodeName) && !isValidAttribute(target, 'disabled')) { + this.close(); + } + } + + // Check if the pointerdown event target is triggered on the menu. + private _pointerDownListener = (event: PointerEvent): void => { + this._isPointerDownEventOnMenu = isEventOnElement(this._menu, event); + }; + + // Close menu on backdrop click. + private _closeOnBackdropClick = (event: PointerEvent): void => { + if (!this._isPointerDownEventOnMenu && !isEventOnElement(this._menu, event)) { + this.close(); + } + }; + + // Set menu position (x, y) to '0' once the menu is closed and the transition ended to prevent the + // viewport from overflowing. And set the focus to the first focusable element once the menu is open. + // In rare cases it can be that the animationEnd event is triggered twice. + // To avoid entering a corrupt state, exit when state is not expected. + private _onMenuAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this._state === 'opening') { + this._state = 'opened'; + this._didOpen.emit(); + applyInertMechanism(this); + this._setMenuFocus(); + this._focusTrap.trap(this); + this._attachWindowEvents(); + } else if (event.animationName === 'close' && this._state === 'closing') { + this._state = 'closed'; + this._menu.firstElementChild.scrollTo(0, 0); + removeInertMechanism(); + setModalityOnNextFocus(this._triggerElement); + // Manually focus last focused element + this._triggerElement?.focus({ + // When inside the sbb-header, we prevent the scroll to avoid the snapping to the top of the page + preventScroll: this._triggerElement.tagName === 'SBB-HEADER-ACTION', + }); + this._didClose.emit(); + this._windowEventsController?.abort(); + this._focusTrap.disconnect(); + + // Starting from breakpoint medium, enable scroll + this._scrollHandler.enableScroll(); + } + } + + // Set focus on the first focusable element. + private _setMenuFocus(): void { + const firstFocusable = this.querySelector(IS_FOCUSABLE_QUERY) as HTMLElement; + setModalityOnNextFocus(firstFocusable); + firstFocusable.focus(); + } + + // Set menu position and max height if the breakpoint is medium-ultra. + private _setMenuPosition(): void { + // Starting from breakpoint medium + if ( + !isBreakpoint('medium') || + !this._menu || + !this._triggerElement || + this._state === 'closing' + ) { + return; + } + + const menuPosition = getElementPosition(this._menuContentElement, this._triggerElement, { + verticalOffset: MENU_OFFSET, + }); + + this.style.setProperty('--sbb-menu-position-x', `${menuPosition.left}px`); + this.style.setProperty('--sbb-menu-position-y', `${menuPosition.top}px`); + this.style.setProperty('--sbb-menu-max-height', menuPosition.maxHeight); + } + + /** + * Create an array with only the sbb-menu-action children + */ + private _readActions(): void { + const actions = Array.from(this.children); + // If the slotted actions have not changed, we can skip syncing and updating the actions. + if ( + this._actions && + actions.length === this._actions.length && + this._actions.every((e, i) => actions[i] === e) + ) { + return; + } + + if (actions.every((e) => e.tagName === 'SBB-MENU-ACTION')) { + this._actions = actions as SbbMenuAction[]; + } else { + this._actions?.forEach((a) => a.removeAttribute('slot')); + this._actions = undefined; + } + } + + protected override render(): TemplateResult { + if (this._actions) { + this._actions.forEach((action, index) => action.setAttribute('slot', `action-${index}`)); + } + + setAttribute(this, 'data-state', this._state); + assignId(() => this._menuId)(this); + + return html` +
+
this._onMenuAnimationEnd(event)} + ${ref((el) => (this._menu = el as HTMLDivElement))} + class="sbb-menu" + > +
this._closeOnInteractiveElementClick(event)} + ${ref((menuContentRef) => (this._menuContentElement = menuContentRef as HTMLElement))} + class="sbb-menu__content" + > + ${this._actions + ? html`
    + ${this._actions.map( + (_, index) => + html`
  • + this._readActions()} + > +
  • `, + )} +
+ ` + : html` this._readActions()}>`} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-menu': SbbMenu; + } +} diff --git a/src/components/menu/menu/readme.md b/src/components/menu/menu/readme.md new file mode 100644 index 0000000000..c2c3babf75 --- /dev/null +++ b/src/components/menu/menu/readme.md @@ -0,0 +1,93 @@ +The `sbb-menu` is a component that can be attached to any element to open and display a custom context menu, +which allows to perform actions relevant to the current task or to navigate within or outside the application +by using the [sbb-menu-action](/docs/components-sbb-menu-sbb-menu-action--docs) component along with it. + +## Interactions + +The element that will trigger the menu dialog must be set using the `trigger` property. + +The `sbb-menu` appears on trigger left click, and it is displayed as a sheet with a backdrop on mobile, +while on desktop it will be shown as a floating menu, and it will calculate the optimal position relative to the trigger element +by evaluating the available space with the following priority: start/below, start/above, end/below, end/above. + +Clicking in the backdrop or pressing the `ESC` key closes the menu. + +```html + +Menu trigger + + + + View + Edit + Details + + Cancel + +``` + +You can also provide custom content inside the `sbb-menu`: + +```html + +Menu trigger + + + +
Christina Müller
+ UIS9057 + Profile + + View + Edit + Details + + Cancel +
+``` + +## Style + +If only `sbb-menu-action` components are provided, the items are automatically grouped within a list +using `
    ` and `
  • ` items, for more complex scenarios the grouping must be done manually. + +The default `z-index` of the component is set to `1000`; +to specify a custom stack order, the `z-index` can be changed by defining the CSS variable `--sbb-menu-z-index`. + +## Accessibility + +As the menu opens, the focus will automatically be set to the first focusable item within the component. +When using the `sbb-menu` as a select (e.g. language selection) it's recommended to use the `aria-pressed` attribute +to identify which actions are active and which are not. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------------ | -------------------------- | ------- | ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `trigger` | `trigger` | public | `string \| HTMLElement` | | The element that will trigger the menu overlay. Accepts both a string (id of an element) or an HTML element. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | +| `listAccessibilityLabel` | `list-accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the inner list. Used only if the menu automatically renders the actions inside as a list. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | -------------------------------- | ---------- | ------ | -------------- | +| `open` | public | Opens the menu on trigger click. | | `void` | | +| `close` | public | Closes the menu. | | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ------------ | ------------------- | ------------------------------------------------------------ | -------------- | +| `will-open` | `CustomEvent` | Emits whenever the `sbb-menu` starts the opening transition. | | +| `did-open` | `CustomEvent` | Emits whenever the `sbb-menu` is opened. | | +| `will-close` | `CustomEvent` | Emits whenever the `sbb-menu` begins the closing transition. | | +| `did-close` | `CustomEvent` | Emits whenever the `sbb-menu` is closed. | | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-menu-action` or other elements to the menu. | diff --git a/src/components/message/index.ts b/src/components/message/index.ts new file mode 100644 index 0000000000..f54558745b --- /dev/null +++ b/src/components/message/index.ts @@ -0,0 +1 @@ +export * from './message'; diff --git a/src/components/message/message.e2e.ts b/src/components/message/message.e2e.ts new file mode 100644 index 0000000000..cfce5c7957 --- /dev/null +++ b/src/components/message/message.e2e.ts @@ -0,0 +1,13 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbMessage } from './message'; + +describe('sbb-message', () => { + let element: SbbMessage; + + it('renders', async () => { + element = await fixture(html``); + assert.instanceOf(element, SbbMessage); + }); +}); diff --git a/src/components/message/message.scss b/src/components/message/message.scss new file mode 100644 index 0000000000..c34e452753 --- /dev/null +++ b/src/components/message/message.scss @@ -0,0 +1,47 @@ +@use '../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-message-subtitle-color: var(--sbb-color-granite-default); + --sbb-message-image-margin-block: 0 var(--sbb-spacing-responsive-s); + --sbb-message-legend-margin-block: var(--sbb-spacing-responsive-xxxs) 0; + --sbb-message-action-margin-block: var(--sbb-spacing-responsive-xxxs) 0; +} + +.sbb-message__container { + text-align: center; +} + +.sbb-message__title { + // Overwrite sbb-title default margin + margin: 0; +} + +::slotted([slot='title']) { + margin: 0; +} + +::slotted([slot='image']) { + margin-block: var(--sbb-message-image-margin-block); + width: 100%; +} + +::slotted([slot='subtitle']) { + @include sbb.text-s--regular; + + color: var(--sbb-message-subtitle-color); + margin: 0; +} + +::slotted([slot='legend']) { + @include sbb.legend; + + margin-block: var(--sbb-message-legend-margin-block); +} + +::slotted([slot='action']) { + margin-block: var(--sbb-message-action-margin-block); +} diff --git a/src/components/message/message.spec.ts b/src/components/message/message.spec.ts new file mode 100644 index 0000000000..6d31c867c9 --- /dev/null +++ b/src/components/message/message.spec.ts @@ -0,0 +1,81 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '.'; + +describe('sbb-message', () => { + it('renders', async () => { + const root = await fixture( + html` + +

    Subtitle.

    +

    Error code: 0001

    + +
    `, + ); + + expect(root).dom.to.be.equal( + ` + + + +

    + Subtitle. +

    +

    + Error code: 0001 +

    + +
    + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
    + + + + Title. + + + + + +
    + `, + ); + }); + + it('renders without optional slots', async () => { + const root = await fixture( + html` +

    Subtitle.

    +
    `, + ); + + expect(root).dom.to.be.equal( + ` + + +

    + Subtitle. +

    +
    + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
    + + + + Title. + + + + + +
    + `, + ); + }); +}); diff --git a/src/components/message/message.stories.tsx b/src/components/message/message.stories.tsx new file mode 100644 index 0000000000..ab636bfc5e --- /dev/null +++ b/src/components/message/message.stories.tsx @@ -0,0 +1,149 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import images from '../core/images'; + +import readme from './readme.md?raw'; +import '../image'; +import '../title'; +import '../button'; +import './message'; + +const DefaultTemplate = (args): JSX.Element => ( + + +

    Please reload the page or try your search again later.

    +

    Error code: 0001

    + +
    +); + +const NoImageTemplate = (args): JSX.Element => ( + +

    Please reload the page or try your search again later.

    +

    Error code: 0001

    + +
    +); + +const NoErrorCodeTemplate = (args): JSX.Element => ( + + +

    Please reload the page or try your search again later.

    + +
    +); + +const NoActionTemplate = (args): JSX.Element => ( + + +

    Please reload the page or try your search again later.

    +

    Error code: 0001

    +
    +); + +const SlottedTitleTemplate = (): JSX.Element => ( + + +

    Unfortunately, an error has occurred.

    +

    Please reload the page or try your search again later.

    +

    Error code: 0001

    + +
    +); + +const titleContent: InputType = { + control: { + type: 'text', + }, +}; + +const titleLevel: InputType = { + control: { + type: 'inline-radio', + }, + options: [1, 2, 3, 4, 5, 6], +}; + +const defaultArgTypes: ArgTypes = { + 'title-content': titleContent, + 'title-level': titleLevel, +}; + +const defaultArgs: Args = { + 'title-content': 'Unfortunately, an error has occurred.', + 'title-level': 3, +}; + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +export const NoImage: StoryObj = { + render: NoImageTemplate, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +export const NoErrorCode: StoryObj = { + render: NoErrorCodeTemplate, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +export const NoAction: StoryObj = { + render: NoActionTemplate, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +export const SlottedTitle: StoryObj = { + render: SlottedTitleTemplate, + argTypes: defaultArgTypes, + args: { 'title-content': undefined, 'title-level': undefined }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
    + +
    + ), + withActions as Decorator, + ], + parameters: { + actions: { + handles: [], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-message', +}; + +export default meta; diff --git a/src/components/message/message.ts b/src/components/message/message.ts new file mode 100644 index 0000000000..bc6df332aa --- /dev/null +++ b/src/components/message/message.ts @@ -0,0 +1,48 @@ +import { CSSResult, html, LitElement, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { TitleLevel } from '../title'; +import '../title'; + +import style from './message.scss?lit&inline'; + +/** + * It displays a complex message combining a title, an image, an action and some content. + * + * @slot image - Use this slot to provide a sbb-image component. + * @slot title - Use this slot to provide title text for the component. + * @slot subtitle - Use this slot to provide a subtitle, must be a paragraph. + * @slot legend - Use this slot to provide a legend, must be a paragraph. + * @slot action - Use this slot to provide a sbb-button. + */ +@customElement('sbb-message') +export class SbbMessage extends LitElement { + public static override styles: CSSResult = style; + + /** Content of title. */ + @property({ attribute: 'title-content' }) public titleContent?: string; + + /** Level of title, it will be rendered as heading tag (e.g., h3). Defaults to level 3. */ + @property({ attribute: 'title-level' }) public titleLevel: TitleLevel = '3'; + + protected override render(): TemplateResult { + return html` +
    + + + ${this.titleContent} + + + + +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-message': SbbMessage; + } +} diff --git a/src/components/message/readme.md b/src/components/message/readme.md new file mode 100644 index 0000000000..7044608aa7 --- /dev/null +++ b/src/components/message/readme.md @@ -0,0 +1,45 @@ +The `sbb-message` component can be used to display a complex message. + +## Slots + +It renders by default a [sbb-title](/docs/components-sbb-title--docs), +which can be provided via `titleContent` property or `title` slot. +Optionally, the user can provide other elements such as a subtitle paragraph via the `subtitle` slot, +a [sbb-image](/docs/components-sbb-image--docs) to provide an image via the `image` slot, +a paragraph to provide an error code via the `legend` slot, +and a [sbb-button](/docs/components-sbb-button--docs) to provide a custom action via the `action` slot. + +```html + + +

    Subtitle

    +

    Error code: 0001

    + Action +
    +``` + +## Accessibility + +By default, the `sbb-title` has a visual level of 5 and an actual level of 3. +This can be changed by the user via the `title-level` property. +As all other elements are regularly slotted, their accessibility relies on the standard techniques provided +by the used components (e.g. `alt-text` and `aria-label`). + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------- | --------------- | ------- | --------------------- | ------- | ----------------------------------------------------------------------------------- | +| `titleContent` | `title-content` | public | `string \| undefined` | | Content of title. | +| `titleLevel` | `title-level` | public | `TitleLevel` | `'3'` | Level of title, it will be rendered as heading tag (e.g., h3). Defaults to level 3. | + +## Slots + +| Name | Description | +| ---------- | --------------------------------------------------------- | +| `image` | Use this slot to provide a sbb-image component. | +| `title` | Use this slot to provide title text for the component. | +| `subtitle` | Use this slot to provide a subtitle, must be a paragraph. | +| `legend` | Use this slot to provide a legend, must be a paragraph. | +| `action` | Use this slot to provide a sbb-button. | diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts new file mode 100644 index 0000000000..ba994440fb --- /dev/null +++ b/src/components/navigation/index.ts @@ -0,0 +1,5 @@ +export * from './navigation'; +export * from './navigation-action'; +export * from './navigation-list'; +export * from './navigation-marker'; +export * from './navigation-section'; diff --git a/src/components/navigation/navigation-action/index.ts b/src/components/navigation/navigation-action/index.ts new file mode 100644 index 0000000000..2b5cf873e0 --- /dev/null +++ b/src/components/navigation/navigation-action/index.ts @@ -0,0 +1 @@ +export * from './navigation-action'; diff --git a/src/components/navigation/navigation-action/navigation-action.e2e.ts b/src/components/navigation/navigation-action/navigation-action.e2e.ts new file mode 100644 index 0000000000..a2df855e65 --- /dev/null +++ b/src/components/navigation/navigation-action/navigation-action.e2e.ts @@ -0,0 +1,77 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing'; + +import { SbbNavigationAction } from './navigation-action'; +import '.'; + +describe('sbb-navigation-action', () => { + let element: SbbNavigationAction; + + beforeEach(async () => { + element = await fixture( + html`Navigation Action`, + ); + }); + + describe('events', () => { + it('dispatches event on click', async () => { + const navigationAction = document.querySelector('sbb-navigation-action'); + const changeSpy = new EventSpy('click'); + navigationAction.click(); + await waitForCondition(() => changeSpy.events.length === 1); + expect(changeSpy.count).to.be.equal(1); + }); + + it('should dispatch click event on pressing Enter', async () => { + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should dispatch click event on pressing Space', async () => { + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should dispatch click event on pressing Enter with href', async () => { + element.setAttribute('href', '#'); + await waitForLitRender(element); + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(changeSpy.count).to.be.greaterThan(0); + }); + + it('should not dispatch click event on pressing Space with href', async () => { + element.setAttribute('href', '#'); + await waitForLitRender(element); + + const changeSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(changeSpy.count).not.to.be.greaterThan(0); + }); + + it('should receive focus', async () => { + element.focus(); + await waitForLitRender(element); + expect(document.activeElement.id).to.be.equal('focus-id'); + }); + }); + + it('renders as a button and triggers click event', async () => { + element = await fixture(html`Label`); + assert.instanceOf(element, SbbNavigationAction); + + const clickedSpy = new EventSpy('click'); + element.click(); + await waitForCondition(() => clickedSpy.events.length === 1); + expect(clickedSpy.count).to.be.equal(1); + }); +}); diff --git a/src/components/navigation/navigation-action/navigation-action.scss b/src/components/navigation/navigation-action/navigation-action.scss new file mode 100644 index 0000000000..7601440f27 --- /dev/null +++ b/src/components/navigation/navigation-action/navigation-action.scss @@ -0,0 +1,70 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; + + --sbb-navigation-action-color: var(--sbb-color-cloud-default); +} + +:host([active]) { + --sbb-navigation-action-color: var(--sbb-color-storm-default); + + @include sbb.if-forced-colors { + --sbb-navigation-action-color: Highlight; + } +} + +@include sbb.hover-mq($hover: true) { + :host(:hover) { + --sbb-navigation-action-color: var(--sbb-color-storm-default); + } +} + +:host([role='button']) { + @include sbb.if-forced-colors { + --sbb-navigation-action-color: ButtonText; + } +} + +.sbb-navigation-action { + @include sbb.title-4($exclude-spacing: true); + + cursor: pointer; + text-decoration: none; + display: flex; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: color var(--sbb-animation-duration-3x) ease; + hyphens: auto; + text-align: left; + color: var(--sbb-navigation-action-color); + + @include sbb.if-forced-colors { + transition: none; + } + + :host([size='m']) & { + @include sbb.text-s--bold; + } + + :host([size='s']) & { + @include sbb.text-xxs--bold; + } + + // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. + :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { + @include sbb.focus-outline; + + border-radius: calc(var(--sbb-border-radius-4x) - var(--sbb-focus-outline-offset)); + } +} + +.sbb-navigation-action__opens-in-new-window { + @include sbb.screen-reader-only; +} diff --git a/src/components/navigation/navigation-action/navigation-action.spec.ts b/src/components/navigation/navigation-action/navigation-action.spec.ts new file mode 100644 index 0000000000..46ad68fc7c --- /dev/null +++ b/src/components/navigation/navigation-action/navigation-action.spec.ts @@ -0,0 +1,23 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '.'; + +describe('sbb-navigation-action', () => { + it('renders', async () => { + const root = await fixture(html``); + + expect(root).dom.to.be.equal( + ` + + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + `, + ); + }); +}); diff --git a/src/components/navigation/navigation-action/navigation-action.stories.tsx b/src/components/navigation/navigation-action/navigation-action.stories.tsx new file mode 100644 index 0000000000..d2f13d37a6 --- /dev/null +++ b/src/components/navigation/navigation-action/navigation-action.stories.tsx @@ -0,0 +1,103 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './navigation-action'; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['l', 'm', 's'], +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; +const href: InputType = { + options: Object.keys(hrefs), + mapping: hrefs, + control: { + type: 'select', + labels: { + 0: 'sbb.ch', + 1: 'GitHub Lyne Components', + }, + }, + table: { + category: 'Link', + }, +}; + +const defaultArgTypes: ArgTypes = { + size, + href, + 'aria-label': ariaLabel, +}; + +const defaultArgs: Args = { + size: size.options[0], + href: undefined, + 'aria-label': undefined, +}; + +const Template = (args): JSX.Element => ( + Label +); + +const style = { + 'background-color': 'var(--sbb-color-midnight-default)', + width: 'max-content', + padding: '1rem 2rem', +}; + +export const SizeL: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const SizeM: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[1] }, +}; + +export const SizeS: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[2] }, +}; + +export const Link: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, href: href.options[1] }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
    + +
    + ), + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-navigation/sbb-navigation-action', +}; + +export default meta; diff --git a/src/components/navigation/navigation-action/navigation-action.ts b/src/components/navigation/navigation-action/navigation-action.ts new file mode 100644 index 0000000000..7913a7bbf4 --- /dev/null +++ b/src/components/navigation/navigation-action/navigation-action.ts @@ -0,0 +1,166 @@ +import { spread } from '@open-wc/lit-helpers'; +import { CSSResult, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import { hostContext, setAttributes } from '../../core/dom'; +import { + documentLanguage, + HandlerRepository, + actionElementHandlerAspect, + languageChangeHandlerAspect, + ConnectedAbortController, +} from '../../core/eventing'; +import { i18nTargetOpensInNewWindow } from '../../core/i18n'; +import { + ButtonType, + LinkButtonRenderVariables, + LinkTargetType, + resolveRenderVariables, + targetsNewWindow, +} from '../../core/interfaces'; +import type { SbbNavigationMarker } from '../navigation-marker'; + +import style from './navigation-action.scss?lit&inline'; + +/** + * It displays an action element that can be used in the `sbb-navigation` component. + * + * @slot - Use the unnamed slot to add content to the `sbb-navigation-action`. + */ +@customElement('sbb-navigation-action') +export class SbbNavigationAction extends LitElement { + public static override styles: CSSResult = style; + + /** + * Action size variant. + */ + @property({ reflect: true }) public size?: 'l' | 'm' | 's' = 'l'; + + /** + * The href value you want to link to (if it is not present, navigation action becomes a button). + */ + @property() public href: string | undefined; + + /** + * Where to display the linked URL. + */ + @property() public target?: LinkTargetType | string | undefined; + + /** + * The relationship of the linked URL as space-separated link types. + */ + @property() public rel?: string | undefined; + + /** + * Whether the browser will show the download dialog on click. + */ + @property({ type: Boolean }) public download?: boolean; + + /** + * The type attribute to use for the button. + */ + @property() public type: ButtonType | undefined; + + /** + * Whether the action is active. + */ + @property({ reflect: true, type: Boolean }) + public set active(value: boolean) { + const oldValue = this.active; + if (value !== oldValue) { + this._active = value; + this._handleActiveChange(this.active, oldValue); + } + } + public get active(): boolean { + return this._active; + } + private _active = false; + + /** + * The name attribute to use for the button. + */ + @property({ reflect: true }) public name: string | undefined; + + /** + * The value attribute to use for the button. + */ + @property() public value?: string; + + @state() private _currentLanguage = documentLanguage(); + + private _navigationMarker: SbbNavigationMarker; + private _abort = new ConnectedAbortController(this); + + private _handlerRepository = new HandlerRepository( + this, + actionElementHandlerAspect, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + ); + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener( + 'click', + () => { + if (!this.active && this._navigationMarker) { + this.active = true; + } + }, + { signal }, + ); + this._handlerRepository.connect(); + + // Check if the current element is nested inside a navigation marker. + this._navigationMarker = hostContext('sbb-navigation-marker', this) as SbbNavigationMarker; + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + // Check whether the `active` attribute has been added or removed from the DOM + // and call the `select()` or `reset()` method accordingly. + private _handleActiveChange(newValue: boolean, oldValue: boolean): void { + if (newValue && !oldValue) { + this._navigationMarker?.select(this); + } else if (!newValue && oldValue) { + this._navigationMarker?.reset(); + } + } + + protected override render(): TemplateResult { + const { + tagName: TAG_NAME, + attributes, + hostAttributes, + }: LinkButtonRenderVariables = resolveRenderVariables(this); + + setAttributes(this, hostAttributes); + + /* eslint-disable lit/binding-positions */ + return html` + <${unsafeStatic(TAG_NAME)} class="sbb-navigation-action" ${spread(attributes)}> + + ${ + targetsNewWindow(this) + ? html` + . ${i18nTargetOpensInNewWindow[this._currentLanguage]} + ` + : nothing + } + + `; + /* eslint-disable lit/binding-positions */ + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-navigation-action': SbbNavigationAction; + } +} diff --git a/src/components/navigation/navigation-action/readme.md b/src/components/navigation/navigation-action/readme.md new file mode 100644 index 0000000000..b2aab13175 --- /dev/null +++ b/src/components/navigation/navigation-action/readme.md @@ -0,0 +1,48 @@ +The `sbb-navigation-action` component is an action element contained by +a [sbb-navigation-list](/docs/components-sbb-navigation-sbb-navigation-list--docs) component +or a [sbb-navigation-marker](/docs/components-sbb-navigation-sbb-navigation-marker--docs) component. + +## Link / button properties + +As the [sbb-link](/docs/components-sbb-link--docs) and the [sbb-button](/docs/components-sbb-button--docs), +the component can be internally rendered as a button or as a link, +depending on the value of the `href` property, so the associated properties are available +(`href`, `target`, `rel` and `download` for link; `type`, `name`, `value` and `form` for button). + +```html +Link + +Button +``` + +## Style + +The component has three different sizes, which can be changed using the `size` property (`l`, which is the default, `m` and `s`). + +```html +Link + +Button +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | ---------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------- | +| `size` | `size` | public | `'l' \| 'm' \| 's' \| undefined` | `'l'` | Action size variant. | +| `href` | `href` | public | `string \| undefined` | | The href value you want to link to (if it is not present, navigation action becomes a button). | +| `target` | `target` | public | `LinkTargetType \| string \| undefined \| undefined` | | Where to display the linked URL. | +| `rel` | `rel` | public | `string \| undefined \| undefined` | | The relationship of the linked URL as space-separated link types. | +| `download` | `download` | public | `boolean \| undefined` | | Whether the browser will show the download dialog on click. | +| `type` | `type` | public | `ButtonType \| undefined` | | The type attribute to use for the button. | +| `active` | `active` | public | `boolean` | | Whether the action is active. | +| `name` | `name` | public | `string \| undefined` | | The name attribute to use for the button. | +| `value` | `value` | public | `string \| undefined` | | The value attribute to use for the button. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the `sbb-navigation-action`. | diff --git a/src/components/navigation/navigation-list/index.ts b/src/components/navigation/navigation-list/index.ts new file mode 100644 index 0000000000..d2cd952eac --- /dev/null +++ b/src/components/navigation/navigation-list/index.ts @@ -0,0 +1 @@ +export * from './navigation-list'; diff --git a/src/components/navigation/navigation-list/navigation-list.e2e.ts b/src/components/navigation/navigation-list/navigation-list.e2e.ts new file mode 100644 index 0000000000..258464a80b --- /dev/null +++ b/src/components/navigation/navigation-list/navigation-list.e2e.ts @@ -0,0 +1,34 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbNavigationList } from './navigation-list'; +import '../navigation-action'; + +describe('sbb-navigation-list', () => { + let element: SbbNavigationList; + + beforeEach(async () => { + element = await fixture(html` + + Label + + `); + }); + + it('renders', () => { + assert.instanceOf(element, SbbNavigationList); + }); + + it('automatic list generation', () => { + const list = element.shadowRoot.querySelector('ul'); + expect(list.className).to.be.equal('sbb-navigation-list__content'); + + const listItem = list.querySelector('li'); + expect(listItem).to.have.class('sbb-navigation-list__action'); + }); + + it('force size on children elements', () => { + const action = element.querySelector('sbb-navigation-action'); + expect(action).to.have.attribute('size', 'm'); + }); +}); diff --git a/src/components/navigation/navigation-list/navigation-list.scss b/src/components/navigation/navigation-list/navigation-list.scss new file mode 100644 index 0000000000..e15b90b330 --- /dev/null +++ b/src/components/navigation/navigation-list/navigation-list.scss @@ -0,0 +1,23 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +.sbb-navigation-list__content { + @include sbb.list-reset; + + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--sbb-spacing-fixed-2x); + margin-block: var(--sbb-spacing-fixed-1x); +} + +.sbb-navigation-list__label { + @include sbb.text-xxs--bold; + + display: block; + color: var(--sbb-color-storm-default); + padding-block-end: var(--sbb-spacing-fixed-2x); +} diff --git a/src/components/navigation/navigation-list/navigation-list.spec.ts b/src/components/navigation/navigation-list/navigation-list.spec.ts new file mode 100644 index 0000000000..42d18fd62c --- /dev/null +++ b/src/components/navigation/navigation-list/navigation-list.spec.ts @@ -0,0 +1,56 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './navigation-list'; + +describe('sbb-navigation-list', () => { + it('renders', async () => { + const root = await fixture( + html` + Tickets & Offers + Vacations & Recreation + Travel information + Help & Contact + `, + ); + + expect(root).dom.to.be.equal( + ` + + + Tickets & Offers + + + Vacations & Recreation + + + Travel information + + + Help & Contact + + + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    + + `, + ); + }); +}); diff --git a/src/components/navigation/navigation-list/navigation-list.stories.tsx b/src/components/navigation/navigation-list/navigation-list.stories.tsx new file mode 100644 index 0000000000..f4d36591fc --- /dev/null +++ b/src/components/navigation/navigation-list/navigation-list.stories.tsx @@ -0,0 +1,79 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './navigation-list'; +import '../navigation-action'; + +const label: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + label, +}; + +const defaultArgs: Args = { + label: 'Label', +}; + +const navigationActions = (): JSX.Element[] => [ + Tickets & Offers, + Vacations & Recreation, + Travel information, + Help & Contact, +]; + +const style = { + 'background-color': 'var(--sbb-color-midnight-default)', + width: 'max-content', + padding: '2rem', +}; + +const DefaultTemplate = (args): JSX.Element => ( + {navigationActions()} +); + +const SlottedLabelTemplate = (args): JSX.Element => ( + + Slotted label + {navigationActions()} + +); + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const SlottedLabel: StoryObj = { + render: SlottedLabelTemplate, + argTypes: defaultArgTypes, + args: {}, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
    + +
    + ), + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-navigation/sbb-navigation-list', +}; + +export default meta; diff --git a/src/components/navigation/navigation-list/navigation-list.ts b/src/components/navigation/navigation-list/navigation-list.ts new file mode 100644 index 0000000000..57671b14ea --- /dev/null +++ b/src/components/navigation/navigation-list/navigation-list.ts @@ -0,0 +1,104 @@ +import { spread } from '@open-wc/lit-helpers'; +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { setAttribute } from '../../core/dom'; +import { + createNamedSlotState, + HandlerRepository, + namedSlotChangeHandlerAspect, +} from '../../core/eventing'; +import type { SbbNavigationAction } from '../navigation-action'; + +import style from './navigation-list.scss?lit&inline'; + +/** + * It can be used as a container for one or more `sbb-navigation-action` within a `sbb-navigation-section`. + * + * @slot - Use the unnamed slot to add content to the `sbb-navigation-list`. + * @slot label - Use this to provide a label element. + */ +@customElement('sbb-navigation-list') +export class SbbNavigationList extends LitElement { + public static override styles: CSSResult = style; + + /* + * The label to be shown before the action list. + */ + @property() public label?: string; + + /* + * Navigation action elements. + */ + @state() private _actions: SbbNavigationAction[]; + + /** + * State of listed named slots, by indicating whether any element for a named slot is defined. + */ + @state() private _namedSlots = createNamedSlotState('label'); + + private _handlerRepository = new HandlerRepository( + this, + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + ); + + /** + * Create an array with only the sbb-navigation-action children. + */ + private _readActions(): void { + this._actions = Array.from(this.children).filter( + (e): e is SbbNavigationAction => e.tagName === 'SBB-NAVIGATION-ACTION', + ); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + this._readActions(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + protected override render(): TemplateResult { + const hasLabel = !!this.label || this._namedSlots['label']; + this._actions.forEach((action, index) => { + action.setAttribute('slot', `action-${index}`); + action.size = 'm'; + }); + const ariaLabelledByAttribute = hasLabel + ? { 'aria-labelledby': 'sbb-navigation-link-label-id' } + : {}; + + setAttribute(this, 'class', 'sbb-navigation-list'); + + return html` + ${hasLabel + ? html` + ${this.label} + ` + : nothing} +
      + ${this._actions.map( + (_, index) => html` +
    • + this._readActions()}> +
    • + `, + )} +
    + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-navigation-list': SbbNavigationList; + } +} diff --git a/src/components/navigation/navigation-list/readme.md b/src/components/navigation/navigation-list/readme.md new file mode 100644 index 0000000000..03ac1218c2 --- /dev/null +++ b/src/components/navigation/navigation-list/readme.md @@ -0,0 +1,26 @@ +The `sbb-navigation-list` component is a collection of [sbb-navigation-action](/docs/components-sbb-navigation-sbb-navigation-action--docs). +Its intended use is inside a [sbb-navigation-section](/docs/components-sbb-navigation-sbb-navigation-section--docs) component. +Optionally, a label can be provided via slot via the self-named property or the self-named slot. + +```html + + Label 1.1.1 + Label 1.1.2 + Label 1.1.3 + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------- | --------- | ------- | --------------------- | ------- | ----------- | +| `label` | `label` | public | `string \| undefined` | | | + +## Slots + +| Name | Description | +| ------- | ----------------------------------------------------------------- | +| | Use the unnamed slot to add content to the `sbb-navigation-list`. | +| `label` | Use this to provide a label element. | diff --git a/src/components/navigation/navigation-marker/index.ts b/src/components/navigation/navigation-marker/index.ts new file mode 100644 index 0000000000..4cfd2b5d13 --- /dev/null +++ b/src/components/navigation/navigation-marker/index.ts @@ -0,0 +1 @@ +export * from './navigation-marker'; diff --git a/src/components/navigation/navigation-marker/navigation-marker.e2e.ts b/src/components/navigation/navigation-marker/navigation-marker.e2e.ts new file mode 100644 index 0000000000..2d8ae0d1fe --- /dev/null +++ b/src/components/navigation/navigation-marker/navigation-marker.e2e.ts @@ -0,0 +1,57 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import '../navigation-action'; +import { waitForLitRender } from '../../core/testing'; + +import { SbbNavigationMarker } from './navigation-marker'; +import '.'; + +describe('sbb-navigation-marker', () => { + let element: SbbNavigationMarker; + + beforeEach(async () => { + element = await fixture( + html` + Tickets & Offers + Vacations & Recreation + Travel information + Help & Contact + `, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbNavigationMarker); + }); + + it('selects action on click', async () => { + const firstAction = element.querySelector('sbb-navigation-action#nav-1') as HTMLElement; + const secondAction = element.querySelector('sbb-navigation-action#nav-2') as HTMLElement; + + secondAction.click(); + await waitForLitRender(element); + + expect(secondAction).to.have.attribute('active'); + expect(firstAction).not.to.have.attribute('active'); + + firstAction.click(); + await waitForLitRender(element); + + expect(firstAction).to.have.attribute('active'); + expect(secondAction).not.to.have.attribute('active'); + }); + + it('automatic list generation', () => { + const list = element.shadowRoot.querySelector('ul'); + expect(list.className).to.be.equal('sbb-navigation-marker'); + + const listItem = list.querySelector('li'); + expect(listItem).to.have.class('sbb-navigation-marker__action'); + }); + + it('force size on children elements', () => { + const firstAction = element.querySelector('sbb-navigation-action#nav-1'); + expect(firstAction).to.have.attribute('size', 'l'); + }); +}); diff --git a/src/components/navigation/navigation-marker/navigation-marker.scss b/src/components/navigation/navigation-marker/navigation-marker.scss new file mode 100644 index 0000000000..aaf22260c0 --- /dev/null +++ b/src/components/navigation/navigation-marker/navigation-marker.scss @@ -0,0 +1,77 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-navigation-action-gap: var(--sbb-spacing-responsive-xs); + --sbb-navigation-marker-position-x: var(--sbb-spacing-fixed-1x); + --sbb-navigation-marker-position-y: unset; + --sbb-navigation-marker-width: #{sbb.px-to-rem-build(17)}; + --sbb-navigation-marker-border: var(--sbb-border-width-1x); + --sbb-navigation-marker-padding-inline-start: var(--sbb-spacing-fixed-6x); + --sbb-navigation-marker-typo-line-height: var(--sbb-typo-line-height-titles); + --sbb-navigation-margin-inline-start: var(--sbb-spacing-fixed-3x); + --sbb-navigation-marker-margin-block: calc( + 1em * var(--sbb-navigation-marker-typo-line-height) / 2 - var(--sbb-navigation-marker-border) / + 2 + ); +} + +:host([size='s']) { + --sbb-navigation-action-gap: var(--sbb-spacing-fixed-2x); + --sbb-navigation-marker-width: #{sbb.px-to-rem-build(8)}; + --sbb-navigation-marker-typo-line-height: var(--sbb-typo-line-height-body-text); + --sbb-navigation-margin-inline-start: var(--sbb-spacing-fixed-2x); + --sbb-navigation-marker-position-x: calc( + var(--sbb-navigation-marker-padding-inline-start) - var(--sbb-spacing-fixed-2x) + ); + --sbb-navigation-marker-padding-inline-start: calc( + var(--sbb-spacing-fixed-6x) + var(--sbb-spacing-fixed-1x) + ); +} + +.sbb-navigation-marker { + @include sbb.list-reset; + @include sbb.title-4($exclude-spacing: true); + + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--sbb-navigation-action-gap); + padding-inline-start: var(--sbb-navigation-marker-padding-inline-start); + + &::before { + content: ''; + position: absolute; + opacity: 0; + inset-inline-start: var(--sbb-navigation-marker-position-x); + inset-block-start: var(--sbb-navigation-marker-position-y); + width: var(--sbb-navigation-marker-width); + border-block-start: var(--sbb-navigation-marker-border) solid var(--sbb-color-storm-default); + margin-block: var(--sbb-navigation-marker-margin-block); + transition: { + duration: var(--sbb-animation-duration-6x); + timing-function: ease; + property: opacity, inset-block-start; + } + + :host([data-has-active-action]) & { + opacity: 1; + } + + @include sbb.if-forced-colors { + border-color: CanvasText; + } + } + + :host([size='s']) & { + @include sbb.text-xxs--bold; + } +} + +::slotted(sbb-navigation-action) { + margin-inline-start: var(--sbb-navigation-margin-inline-start); +} diff --git a/src/components/navigation/navigation-marker/navigation-marker.spec.ts b/src/components/navigation/navigation-marker/navigation-marker.spec.ts new file mode 100644 index 0000000000..13d31a9a8f --- /dev/null +++ b/src/components/navigation/navigation-marker/navigation-marker.spec.ts @@ -0,0 +1,19 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '.'; + +describe('sbb-navigation-marker', () => { + it('renders', async () => { + const root = await fixture(html``); + + expect(root).dom.to.be.equal(``); + expect(root).shadowDom.to.be.equal( + ` +
      + + `, + ); + }); +}); diff --git a/src/components/navigation/navigation-marker/navigation-marker.stories.tsx b/src/components/navigation/navigation-marker/navigation-marker.stories.tsx new file mode 100644 index 0000000000..7cb7f9e418 --- /dev/null +++ b/src/components/navigation/navigation-marker/navigation-marker.stories.tsx @@ -0,0 +1,108 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './navigation-marker'; +import '../navigation-action'; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['l', 's'], +}; + +const defaultArgTypes: ArgTypes = { + size, +}; + +const defaultArgs: Args = { + size: size.options[0], +}; + +const style = { + 'background-color': 'var(--sbb-color-midnight-default)', + width: 'max-content', + padding: '2rem', +}; + +const navigationActionsL = (active): JSX.Element[] => [ + Tickets & Offers, + + Vacations & Recreation + , + Travel information, + Help & Contact, +]; + +const navigationActionsS = (active): JSX.Element[] => [ + Deutsch, + Français, + + Italiano + , + English, +]; + +const SizeLTemplate = (args): JSX.Element => ( + {navigationActionsL(false)} +); + +const SizeSTemplate = (args): JSX.Element => ( + {navigationActionsS(false)} +); + +const SizeLActiveTemplate = (args): JSX.Element => ( + {navigationActionsL(true)} +); + +const SizeSActiveTemplate = (args): JSX.Element => ( + {navigationActionsS(true)} +); + +export const SizeL: StoryObj = { + render: SizeLTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const SizeS: StoryObj = { + render: SizeSTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[1] }, +}; + +export const SizeLActive: StoryObj = { + render: SizeLActiveTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const SizeSActive: StoryObj = { + render: SizeSActiveTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[1] }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-navigation/sbb-navigation-marker', +}; + +export default meta; diff --git a/src/components/navigation/navigation-marker/navigation-marker.ts b/src/components/navigation/navigation-marker/navigation-marker.ts new file mode 100644 index 0000000000..5a621c37b2 --- /dev/null +++ b/src/components/navigation/navigation-marker/navigation-marker.ts @@ -0,0 +1,132 @@ +import { CSSResult, html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { setAttribute } from '../../core/dom'; +import { AgnosticResizeObserver } from '../../core/observers'; +import type { SbbNavigationAction } from '../navigation-action'; + +import style from './navigation-marker.scss?lit&inline'; + +/** + * It can be used as a container for one or more `sbb-navigation-action` within a `sbb-navigation`. + * + * @slot - Use the unnamed slot to add `sbb-navigation-action` elements into the `sbb-navigation-marker`. + */ +@customElement('sbb-navigation-marker') +export class SbbNavigationMarker extends LitElement { + public static override styles: CSSResult = style; + + /** + * Marker size variant. + */ + @property({ reflect: true }) public size?: 'l' | 's' = 'l'; + + /** + * Whether the list has an active action. + */ + @state() private _hasActiveAction = false; + + /** + * Navigation action elements. + */ + @state() private _actions: SbbNavigationAction[]; + + private _currentActiveAction: SbbNavigationAction; + private _navigationMarkerResizeObserver = new AgnosticResizeObserver(() => + this._setMarkerPosition(), + ); + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('size')) { + this._updateMarkerActions(); + } + } + + private _updateMarkerActions(): void { + for (const action of this._navigationActions) { + action.size = this.size; + } + + this._hasActiveAction = !!this._activeNavigationAction; + this._currentActiveAction = this._activeNavigationAction; + this._setMarkerPosition(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._navigationMarkerResizeObserver.observe(this); + this._readActions(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._navigationMarkerResizeObserver.disconnect(); + } + + public select(action: SbbNavigationAction): void { + this.reset(); + action.active = true; + this._currentActiveAction = action; + this._hasActiveAction = true; + setTimeout(() => this._setMarkerPosition()); + } + + public reset(): void { + if (!this._hasActiveAction) { + return; + } + this._currentActiveAction.active = false; + this._hasActiveAction = false; + } + + private get _navigationActions(): SbbNavigationAction[] { + return Array.from(this.querySelectorAll('sbb-navigation-action')); + } + + private get _activeNavigationAction(): SbbNavigationAction { + return this._navigationActions.find((action) => action.active); + } + + // Create an array with only the sbb-navigation-action children. + private _readActions(): void { + this._actions = Array.from(this.children).filter( + (e): e is SbbNavigationAction => e.tagName === 'SBB-NAVIGATION-ACTION', + ); + } + + private _setMarkerPosition(): void { + if (this._hasActiveAction) { + this?.style.setProperty( + '--sbb-navigation-marker-position-y', + `${(this.shadowRoot.querySelector('[data-active]') as HTMLElement)?.offsetTop}px`, + ); + } + } + + protected override render(): TemplateResult { + this._actions.forEach((action, index) => action.setAttribute('slot', `action-${index}`)); + setAttribute(this, 'data-has-active-action', this._hasActiveAction); + + return html` +
        + ${this._actions.map( + (action, index) => html` +
      • + this._readActions()}> +
      • + `, + )} +
      + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-navigation-marker': SbbNavigationMarker; + } +} diff --git a/src/components/navigation/navigation-marker/readme.md b/src/components/navigation/navigation-marker/readme.md new file mode 100644 index 0000000000..e0f55c1595 --- /dev/null +++ b/src/components/navigation/navigation-marker/readme.md @@ -0,0 +1,44 @@ +The `sbb-navigation-marker` component is a collection of [sbb-navigation-action](/docs/components-sbb-navigation-sbb-navigation-action--docs). +Its intended use is inside a [sbb-navigation](/docs/components-sbb-navigation-sbb-navigation--docs) component. + +```html + + Label 1 + Label 2 + Label 3 + +``` + +## Style + +The component has a property named `size` which is proxied to all the `sbb-navigation-action` within it. +Possible values are `l` (default) and `s`. + +```html + + ... + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------ | --------- | ------- | ------------------------- | ------- | -------------------- | +| `size` | `size` | public | `'l' \| 's' \| undefined` | `'l'` | Marker size variant. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------- | ------- | ----------- | ----------------------------- | ------ | -------------- | +| `select` | public | | `action: SbbNavigationAction` | `void` | | +| `reset` | public | | | `void` | | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-navigation-action` elements into the `sbb-navigation-marker`. | diff --git a/src/components/navigation/navigation-section/index.ts b/src/components/navigation/navigation-section/index.ts new file mode 100644 index 0000000000..3c507ddeab --- /dev/null +++ b/src/components/navigation/navigation-section/index.ts @@ -0,0 +1 @@ +export * from './navigation-section'; diff --git a/src/components/navigation/navigation-section/navigation-section.e2e.ts b/src/components/navigation/navigation-section/navigation-section.e2e.ts new file mode 100644 index 0000000000..d98263d4aa --- /dev/null +++ b/src/components/navigation/navigation-section/navigation-section.e2e.ts @@ -0,0 +1,55 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForCondition, waitForLitRender } from '../../core/testing'; + +import { SbbNavigationSection } from './navigation-section'; +import '../navigation'; +import '../navigation-list'; +import '../navigation-action'; + +describe('sbb-navigation-section', () => { + let element: SbbNavigationSection; + + beforeEach(async () => { + await fixture(html` + + + + Tickets & Offers + Vacations & Recreation + Travel information + Help & Contact + + + + `); + element = document.querySelector('sbb-navigation-section'); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbNavigationSection); + }); + + it('opens the section', async () => { + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => element.getAttribute('data-state') === 'opened'); + expect(element).to.have.attribute('data-state', 'opened'); + }); + + it('closes the section', async () => { + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => element.getAttribute('data-state') === 'opened'); + expect(element).to.have.attribute('data-state', 'opened'); + + element.close(); + await waitForLitRender(element); + + await waitForCondition(() => element.getAttribute('data-state') === 'closed'); + expect(element).to.have.attribute('data-state', 'closed'); + }); +}); diff --git a/src/components/navigation/navigation-section/navigation-section.scss b/src/components/navigation/navigation-section/navigation-section.scss new file mode 100644 index 0000000000..04efadccbe --- /dev/null +++ b/src/components/navigation/navigation-section/navigation-section.scss @@ -0,0 +1,241 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-navigation-section-display: none; + --sbb-navigation-section-column: 1 / 5; + --sbb-navigation-section-position: fixed; + --sbb-navigation-section-pointer-events: none; + --sbb-navigation-section-animation-duration: var(--sbb-animation-duration-3x); + --sbb-navigation-section-animation-easing: ease-out; + --sbb-navigation-section-padding-inline: var(--sbb-layout-base-offset-responsive); + --sbb-navigation-section-padding-block: var(--sbb-spacing-responsive-l); + --sbb-navigation-section-transform: translateX(100%); + --sbb-navigation-section-content-padding-inline-start: var(--sbb-spacing-fixed-12x); + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); + --sbb-navigation-section-width: 100vw; + --sbb-navigation-section-height: 100vh; + + // We have to place the styles on the host as it has to be aligned on the grid of the navigation + display: var(--sbb-navigation-section-display); + position: var(--sbb-navigation-section-position); + grid-column: var(--sbb-navigation-section-column); + inset-inline-start: 0; + inset-block-start: 0; + width: var(--sbb-navigation-section-width); + height: var(--sbb-navigation-section-height); + z-index: var(--sbb-navigation-z-index, var(--sbb-overlay-z-index)); + + // Needed for backwards compatibility on Chromatic + // TODO: Remove once not needed + @supports (width: 100dvw) { + --sbb-navigation-section-width: 100dvw; + } + + // Needed for backwards compatibility on Chromatic + // TODO: Remove once not needed + @supports (height: 100dvh) { + --sbb-navigation-section-height: 100dvh; + } + + @include sbb.mq($from: large) { + --sbb-navigation-section-column: 5 / 9; + --sbb-navigation-section-animation-duration: var(--sbb-animation-duration-4x); + --sbb-navigation-section-padding-block: var(--sbb-spacing-responsive-xl); + --sbb-navigation-section-padding-inline: var(--sbb-spacing-fixed-8x) + var(--sbb-layout-base-offset-responsive); + --sbb-navigation-section-position: relative; + --sbb-navigation-section-transform: translateX(0%); + --sbb-navigation-section-content-padding-inline-start: 0; + --sbb-navigation-section-width: calc( + 100% + var(--sbb-layout-base-offset-responsive) + var(--sbb-grid-base-gutter-responsive) + ); + + transform: translateX(calc(var(--sbb-grid-base-gutter-responsive) * -1)); + } + + @include sbb.mq($from: wide) { + --sbb-navigation-section-column: 5 / 13; + } + + @include sbb.mq($from: ultra) { + --sbb-navigation-section-column: 6 / 17; + --sbb-navigation-section-padding-block: var(--sbb-spacing-responsive-xxl) + var(--sbb-spacing-responsive-l); + } +} + +:host([data-state='opened']) { + --sbb-navigation-section-pointer-events: all; +} + +:host([data-state='opening']) { + --sbb-navigation-section-position: absolute; +} + +:host(:is([data-state='opening'], [data-state='closing'])) { + --sbb-navigation-section-pointer-events: none; +} + +:host(:not([data-state='closed'])) { + --sbb-navigation-section-display: block; +} + +:host([disable-animation]) { + --sbb-navigation-section-animation-duration: 0.1ms; +} + +::slotted(*) { + padding-inline-start: var(--sbb-navigation-section-content-padding-inline-start); +} + +// Always place the sbb-button on a new row +::slotted(sbb-button) { + grid-column-start: 1; +} + +.sbb-navigation-section__container { + pointer-events: var(--sbb-navigation-section-pointer-events); + height: var(--sbb-navigation-section-height); +} + +.sbb-navigation-section { + display: none; + border: none; + margin: 0; + width: 100%; + height: 100%; + color: var(--sbb-color-white-default); + background-color: transparent; + padding: 0; + overflow: hidden; + + :host(:not([data-state='closed'])) & { + display: block; + + animation: { + name: open; + duration: var(--sbb-navigation-section-animation-duration); + timing-function: var(--sbb-navigation-section-animation-easing); + } + } + + :host([data-state='closing']) & { + animation-name: close; + } + + @include sbb.if-forced-colors { + outline: var(--sbb-border-width-1x) solid CanvasText; + } +} + +.sbb-navigation-section__wrapper { + @include sbb.scrollbar($negative: true); + + height: 100%; + padding-block: var(--sbb-navigation-section-padding-block); + outline: none; + overflow-y: auto; + + :host(:is([data-state='opening'], [data-state='closing'])) & { + --sbb-scrollbar-color: transparent; + + scrollbar-color: transparent transparent; + } +} + +.sbb-navigation-section__header { + position: relative; + display: flex; + align-items: center; + gap: var(--sbb-spacing-fixed-1x); + margin-block-start: var(--sbb-spacing-responsive-xxl); + padding-inline-start: var(--sbb-navigation-section-content-padding-inline-start); + + @include sbb.mq($from: large) { + margin-block-start: 0; + padding-inline-start: 0; + } + + @include sbb.mq($from: wide) { + grid-column: 1 / 4; + } +} + +.sbb-navigation-section__back { + position: absolute; + transform: translateX(calc((100% + var(--sbb-spacing-fixed-1x)) * -1)); +} + +.sbb-navigation-section__title { + @include sbb.title-4($exclude-spacing: true); + + @include sbb.mq($from: large) { + @include sbb.title-2($exclude-spacing: true); + } +} + +.sbb-navigation-section__divider { + display: none; + + @include sbb.mq($from: wide) { + display: block; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + } +} + +.sbb-navigation-section__content { + display: grid; + grid-template-columns: 1fr; + grid-gap: var(--sbb-spacing-responsive-l) var(--sbb-grid-base-gutter-responsive); + padding-inline: var(--sbb-navigation-section-padding-inline); + + @include sbb.mq($from: large) { + opacity: 0; + transform: translateY(var(--sbb-spacing-fixed-3x)); + transition: { + duration: var(--sbb-navigation-section-animation-duration); + delay: var(--sbb-navigation-section-animation-duration); + timing-function: var(--sbb-navigation-section-animation-easing); + property: opacity, transform; + } + + :host([data-state='opened']) & { + opacity: 1; + transform: translateY(0); + } + } + + @include sbb.mq($from: wide) { + grid-template-columns: repeat(3, 1fr); + } + + :host([data-state='closing']) & { + transition-delay: 0s; + } +} + +@keyframes open { + from { + transform: var(--sbb-navigation-section-transform); + } + + to { + transform: translate(0%, 0%); + } +} + +@keyframes close { + from { + transform: translate(0%, 0%); + } + + to { + transform: var(--sbb-navigation-section-transform); + } +} diff --git a/src/components/navigation/navigation-section/navigation-section.spec.ts b/src/components/navigation/navigation-section/navigation-section.spec.ts new file mode 100644 index 0000000000..e610393a27 --- /dev/null +++ b/src/components/navigation/navigation-section/navigation-section.spec.ts @@ -0,0 +1,30 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './navigation-section'; + +describe('sbb-navigation-section', () => { + it('renders', async () => { + const root = await fixture(html``); + + expect(root).dom.to.be.equal( + ` + + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      + +
      + `, + ); + }); +}); diff --git a/src/components/navigation/navigation-section/navigation-section.stories.tsx b/src/components/navigation/navigation-section/navigation-section.stories.tsx new file mode 100644 index 0000000000..31fe205613 --- /dev/null +++ b/src/components/navigation/navigation-section/navigation-section.stories.tsx @@ -0,0 +1,200 @@ +/** @jsx h */ +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import { waitForComponentsReady } from '../../../storybook/testing/wait-for-components-ready'; +import type { SbbNavigationMarker } from '../navigation-marker'; +import '../navigation-list'; +import '../navigation-action'; +import '../navigation-marker'; +import '../navigation'; +import '../../button'; +import '.'; + +import readme from './readme.md?raw'; + +// Story interaction executed after the story renders +const playStory = async (trigger, canvasElement): Promise => { + const canvas = within(canvasElement); + + await waitForComponentsReady(() => + canvas.getByTestId('navigation').shadowRoot.querySelector('.sbb-navigation'), + ); + + const button = canvas.getByTestId('navigation-trigger'); + await userEvent.click(button); + await waitFor(() => + expect(canvas.getByTestId('navigation').getAttribute('data-state') === 'opened').toBeTruthy(), + ); + await waitFor(() => + expect( + canvas.getByTestId('navigation-section').shadowRoot.querySelector('.sbb-navigation-section'), + ).toBeTruthy(), + ); + const action = canvas.getByTestId(trigger); + await userEvent.click(action); +}; + +const accessibilityLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const basicArgTypes: ArgTypes = { + 'accessibility-label': accessibilityLabel, + 'disable-animation': disableAnimation, +}; + +const basicArgs: Args = { + 'accessibility-label': undefined, + 'disable-animation': isChromatic(), +}; + +const triggerButton = (id): JSX.Element => ( + +); + +const navigationActionsL = (): JSX.Element[] => [ + + Label + , + + Label + , + Label, +]; + +const navigationList = (label): JSX.Element[] => [ + + Label + Label + + Label + + , +]; + +const onNavigationClose = (dialog): void => { + dialog.addEventListener('didClose', () => { + (document.getElementById('nav-marker') as SbbNavigationMarker).reset(); + }); +}; + +const DefaultTemplate = (args): JSX.Element => ( + + {triggerButton('navigation-trigger-1')} + onNavigationClose(dialog)} + > + {navigationActionsL()} + + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + + Button + + + + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + {navigationList('Label')} + {navigationList('Label')} + + + Close navigation + + + + +); + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: ({ canvasElement }) => + isChromatic() && playStory('navigation-section-trigger-1', canvasElement), +}; + +export const LongContent: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: ({ canvasElement }) => + isChromatic() && playStory('navigation-section-trigger-2', canvasElement), +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + ], + parameters: { + chromatic: { disableSnapshot: false }, + backgrounds: { + disable: true, + }, + docs: { + story: { inline: false, iframeHeight: '600px' }, + + extractComponentDescription: () => readme, + }, + layout: 'fullscreen', + }, + title: 'components/sbb-navigation/sbb-navigation-section', +}; + +export default meta; diff --git a/src/components/navigation/navigation-section/navigation-section.ts b/src/components/navigation/navigation-section/navigation-section.ts new file mode 100644 index 0000000000..131be316aa --- /dev/null +++ b/src/components/navigation/navigation-section/navigation-section.ts @@ -0,0 +1,415 @@ +import { spread } from '@open-wc/lit-helpers'; +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { + assignId, + getFirstFocusableElement, + getFocusableElements, + setModalityOnNextFocus, +} from '../../core/a11y'; +import { + findReferencedElement, + isBreakpoint, + isValidAttribute, + setAttribute, +} from '../../core/dom'; +import { + createNamedSlotState, + documentLanguage, + HandlerRepository, + languageChangeHandlerAspect, + namedSlotChangeHandlerAspect, +} from '../../core/eventing'; +import { i18nGoBack } from '../../core/i18n'; +import { + removeAriaOverlayTriggerAttributes, + SbbOverlayState, + setAriaOverlayTriggerAttributes, +} from '../../core/overlay'; +import type { SbbNavigation } from '../navigation'; +import type { SbbNavigationMarker } from '../navigation-marker'; + +import style from './navigation-section.scss?lit&inline'; +import '../../divider'; +import '../../button'; + +let nextId = 0; + +/** + * It can be used as a container for `sbb-navigation-list` within a `sbb-navigation`. + * + * @slot - Use the unnamed slot to add content into the `sbb-navigation-section`. + */ +@customElement('sbb-navigation-section') +export class SbbNavigationSection extends LitElement { + public static override styles: CSSResult = style; + + /* + * The label to be shown before the action list. + */ + @property({ attribute: 'title-content' }) public titleContent?: string; + + /** + * The element that will trigger the navigation section. + * Accepts both a string (id of an element) or an HTML element. + */ + @property() + public set trigger(value: string | HTMLElement) { + const oldValue = this._trigger; + this._trigger = value; + this._removeTriggerClickListener(this._trigger, oldValue); + } + public get trigger(): string | HTMLElement { + return this._trigger; + } + private _trigger: string | HTMLElement = null; + + /** + * This will be forwarded as aria-label to the nav element and is read as a title of the navigation-section. + */ + @property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined; + + /** + * This will be forwarded as aria-label to the back button element. + */ + @property({ attribute: 'accessibility-back-label' }) public accessibilityBackLabel: + | string + | undefined; + + /** + * Whether the animation is enabled. + */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** + * The state of the navigation section. + */ + @state() private _state: SbbOverlayState = 'closed'; + + /** + * State of listed named slots, by indicating whether any element for a named slot is defined. + */ + @state() private _namedSlots = createNamedSlotState('title'); + + @state() private _currentLanguage = documentLanguage(); + + @state() private _renderBackButton = this._isZeroToLargeBreakpoint(); + + private _firstLevelNavigation: SbbNavigation; + private _navigationSection: HTMLElement; + private _navigationSectionContainerElement: HTMLElement; + private _triggerElement: HTMLElement; + private _navigationSectionController: AbortController; + private _windowEventsController: AbortController; + private _timeoutController: ReturnType; + private _navigationSectionId = `sbb-navigation-section-${++nextId}`; + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + ); + + /** + * Opens the navigation section on trigger click. + */ + public open(): void { + if (this._state !== 'closed' || !this._navigationSection) { + return; + } + + this._state = 'opening'; + this.inert = true; + this._renderBackButton = this._isZeroToLargeBreakpoint(); + this._triggerElement?.setAttribute('aria-expanded', 'true'); + } + + /** + * Closes the navigation section. + */ + public close(): void { + if (this._state !== 'opened') { + return; + } + + this._resetMarker(); + this._state = 'closing'; + this.inert = true; + this._triggerElement?.setAttribute('aria-expanded', 'false'); + } + + // Removes trigger click listener on trigger change. + private _removeTriggerClickListener( + newValue: string | HTMLElement, + oldValue: string | HTMLElement, + ): void { + if (newValue !== oldValue) { + this._navigationSectionController?.abort(); + this._windowEventsController?.abort(); + this._configure(this.trigger); + } + } + + // Check if the trigger is valid and attach click event listeners. + private _configure(trigger: string | HTMLElement): void { + removeAriaOverlayTriggerAttributes(this._triggerElement); + + if (!trigger) { + return; + } + + this._triggerElement = findReferencedElement(trigger); + + if (!this._triggerElement) { + return; + } + + setAriaOverlayTriggerAttributes( + this._triggerElement, + 'menu', + this.id || this._navigationSectionId, + this._state, + ); + this._navigationSectionController = new AbortController(); + this._triggerElement.addEventListener('click', () => this.open(), { + signal: this._navigationSectionController.signal, + }); + this.addEventListener('keydown', (event) => this._handleNavigationSectionFocus(event), { + signal: this._navigationSectionController.signal, + }); + } + + private _setNavigationInert(): void { + if (!this._firstLevelNavigation) { + return; + } + ( + this._firstLevelNavigation.shadowRoot.querySelector('.sbb-navigation__content') as HTMLElement + ).inert = this._isZeroToLargeBreakpoint() && this._state !== 'closed'; + } + + // In rare cases it can be that the animationEnd event is triggered twice. + // To avoid entering a corrupt state, exit when state is not expected. + private _onAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this._state === 'opening') { + this._state = 'opened'; + this.inert = false; + this._attachWindowEvents(); + this._setNavigationInert(); + this._setNavigationSectionFocus(); + } else if (event.animationName === 'close' && this._state === 'closing') { + this._state = 'closed'; + this._navigationSectionContainerElement.scrollTo(0, 0); + this._windowEventsController?.abort(); + this._setNavigationInert(); + if (this._isZeroToLargeBreakpoint() && this._triggerElement) { + setModalityOnNextFocus(this._triggerElement); + this._triggerElement.focus(); + } + } + } + + private _attachWindowEvents(): void { + this._windowEventsController = new AbortController(); + window.addEventListener('keydown', (event: KeyboardEvent) => this._onKeydownEvent(event), { + signal: this._windowEventsController.signal, + }); + + // Close navigation section on action click or sbb-navigation-section-close click + window.addEventListener('click', this._handleNavigationSectionClose, { + signal: this._windowEventsController.signal, + }); + + window.addEventListener( + 'resize', + () => { + this._renderBackButton = this._isZeroToLargeBreakpoint(); + }, + { signal: this._windowEventsController.signal }, + ); + } + + // Check if the click was triggered on an element that should close the section. + private _handleNavigationSectionClose = (event: Event): void => { + const composedPathElements = event + .composedPath() + .filter((el) => el instanceof window.HTMLElement); + if (composedPathElements.some((el) => this._isCloseElement(el as HTMLElement))) { + this.close(); + } + }; + + private _isCloseElement(element: HTMLElement): boolean { + // Check if the element is a navigation action belonging to the same group as the trigger. + const isActionElement = + element !== this._triggerElement && + element.nodeName === 'SBB-NAVIGATION-ACTION' && + element.parentElement === this._triggerElement.parentElement; + + return ( + isActionElement || + element.nodeName === 'A' || + (!isValidAttribute(element, 'disabled') && + (element.hasAttribute('sbb-navigation-close') || + element.hasAttribute('sbb-navigation-section-close'))) + ); + } + + private _isZeroToLargeBreakpoint(): boolean { + return isBreakpoint('zero', 'large'); + } + + private _resetMarker(): void { + if (this._isZeroToLargeBreakpoint()) { + (this._triggerElement?.parentElement as SbbNavigationMarker)?.reset(); + } + } + + // Closes the navigation on "Esc" key pressed. + private _onKeydownEvent(event: KeyboardEvent): void { + if (this._state === 'opened' && event.key === 'Escape') { + this.close(); + } + } + + // Set focus on the first focusable element. + private _setNavigationSectionFocus(): void { + const firstFocusableElement = getFirstFocusableElement( + [this.shadowRoot.querySelector('#sbb-navigation-section-back-button')] + .concat(Array.from(this.children)) + .filter((e): e is HTMLElement => e instanceof window.HTMLElement), + ); + if (firstFocusableElement) { + setModalityOnNextFocus(firstFocusableElement); + firstFocusableElement.focus(); + } + } + + private _handleNavigationSectionFocus(event: KeyboardEvent): void { + if (event.key !== 'Tab' || this._isZeroToLargeBreakpoint()) { + return; + } + + // Dynamically get first and last focusable element, as this might have changed since opening overlay + const navigationChildren: HTMLElement[] = Array.from( + this.closest('sbb-navigation').shadowRoot.children, + ) as HTMLElement[]; + const navigationFocusableElements = getFocusableElements( + navigationChildren, + (el) => el.nodeName === 'SBB-NAVIGATION-SECTION', + ); + + const sectionChildren: HTMLElement[] = Array.from(this.shadowRoot.children) as HTMLElement[]; + const sectionFocusableElements = getFocusableElements(sectionChildren); + + const firstFocusable = sectionFocusableElements[0] as HTMLElement; + const lastFocusable = sectionFocusableElements[ + sectionFocusableElements.length - 1 + ] as HTMLElement; + + const elementToFocus = event.shiftKey + ? this._triggerElement + : navigationFocusableElements[navigationFocusableElements.indexOf(this._triggerElement) + 1]; + const pivot = event.shiftKey ? firstFocusable : lastFocusable; + + if ( + !!elementToFocus && + ((firstFocusable.getRootNode() as Document | ShadowRoot).activeElement === pivot || + (lastFocusable.getRootNode() as Document | ShadowRoot).activeElement === pivot) + ) { + elementToFocus.focus(); + event.preventDefault(); + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + // Validate trigger element and attach event listeners + this._configure(this.trigger); + this._firstLevelNavigation = this._triggerElement?.closest('sbb-navigation'); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + this._navigationSectionController?.abort(); + this._windowEventsController?.abort(); + clearTimeout(this._timeoutController); + } + + protected override render(): TemplateResult { + const backButton = html` + + `; + + const labelElement = html` +
      + ${this._renderBackButton ? backButton : nothing} + + ${this.titleContent} + +
      + `; + + // Accessibility label should win over aria-labelledby + let accessibilityAttributes: Record = { 'aria-labelledby': 'title' }; + if (this.accessibilityLabel) { + accessibilityAttributes = { 'aria-label': this.accessibilityLabel }; + } + + setAttribute(this, 'slot', 'navigation-section'); + setAttribute(this, 'data-state', this._state); + setAttribute(this, 'aria-hidden', this._state !== 'opened' ? 'true' : null); + assignId(() => this._navigationSectionId)(this); + + return html` +
      (this._navigationSectionContainerElement = el as HTMLElement))} + > + +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-navigation-section': SbbNavigationSection; + } +} diff --git a/src/components/navigation/navigation-section/readme.md b/src/components/navigation/navigation-section/readme.md new file mode 100644 index 0000000000..0db5c1645b --- /dev/null +++ b/src/components/navigation/navigation-section/readme.md @@ -0,0 +1,48 @@ +The `sbb-navigation-section` is a container for both [sbb-navigation-list](/docs/components-sbb-navigation-sbb-navigation-list--docs) and [sbb-button](/docs/components-sbb-button--docs). +Its intended use is inside a [sbb-navigation](/docs/components-sbb-navigation-sbb-navigation--docs) component, in which it can be seen as a 'second-level' panel. + +## Trigger + +To display the `sbb-navigation-section` component you must provide a trigger element using the `trigger` property, +Optionally a label can be provided via slot or via the `titleContent` property. + +```html + + + Label 1.1.1 + Label 1.1.2 + ... + + Something + +``` + +## Accessibility + +When a navigation action is marked to indicate the user is currently on that page, `aria-current="page"` should be set on that action. +Similarly, if a navigation action is marked to indicate a selected option (e.g., the selected language) `aria-pressed` should be set on that action. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------------ | -------------------------- | ------- | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------ | +| `titleContent` | `title-content` | public | `string \| undefined` | | | +| `trigger` | `trigger` | public | `string \| HTMLElement` | | The element that will trigger the navigation section. Accepts both a string (id of an element) or an HTML element. | +| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the nav element and is read as a title of the navigation-section. | +| `accessibilityBackLabel` | `accessibility-back-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the back button element. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ---------------------------------------------- | ---------- | ------ | -------------- | +| `open` | public | Opens the navigation section on trigger click. | | `void` | | +| `close` | public | Closes the navigation section. | | `void` | | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------------------------------------- | +| | Use the unnamed slot to add content into the `sbb-navigation-section`. | diff --git a/src/components/navigation/navigation/index.ts b/src/components/navigation/navigation/index.ts new file mode 100644 index 0000000000..701e50504d --- /dev/null +++ b/src/components/navigation/navigation/index.ts @@ -0,0 +1 @@ +export * from './navigation'; diff --git a/src/components/navigation/navigation/navigation.e2e.ts b/src/components/navigation/navigation/navigation.e2e.ts new file mode 100644 index 0000000000..23bcffdae6 --- /dev/null +++ b/src/components/navigation/navigation/navigation.e2e.ts @@ -0,0 +1,326 @@ +import { assert, expect, fixture, nextFrame } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import '../navigation-marker'; +import { SbbButton } from '../../button'; +import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing'; +import type { SbbNavigationAction } from '../navigation-action'; +import '../navigation-action'; +import type { SbbNavigationSection } from '../navigation-section'; +import '../navigation-section'; + +import { SbbNavigation } from './navigation'; + +describe('sbb-navigation', () => { + let element: SbbNavigation; + + beforeEach(async () => { + element = await fixture(html` + + + Tickets & Offers + Vacations & Recreation + Travel information + Help & Contact + + + + Label + Label + + + Label + Label + + + `); + }); + + it('renders', () => { + assert.instanceOf(element, SbbNavigation); + }); + + it('opens the navigation', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + }); + + it('closes the navigation', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + element.close(); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('closes the navigation on close button click', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + const closeButton: SbbButton = element.shadowRoot.querySelector('.sbb-navigation__close'); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + closeButton.click(); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('closes the navigation on Esc key press', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); + + await sendKeys({ down: 'Escape' }); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('closes navigation with sbb-navigation-close', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + const section: SbbNavigationSection = element.querySelector('#first-section'); + const action: SbbNavigationAction = element.querySelector( + 'sbb-navigation-marker > sbb-navigation-action#action-1', + ); + const closeEl: SbbNavigationAction = element.querySelector( + 'sbb-navigation-marker > sbb-navigation-action[sbb-navigation-close]', + ); + + element.open(); + await waitForLitRender(element); + + action.click(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'opened'); + + closeEl.click(); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + expect(section).to.have.attribute('data-state', 'closed'); + }); + + it('opens navigation and opens section', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const section: SbbNavigationSection = element.querySelector('#first-section'); + const action: SbbNavigationAction = document.querySelector( + 'sbb-navigation > sbb-navigation-marker > sbb-navigation-action#action-1', + ); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'closed'); + + action.click(); + await waitForLitRender(element); + + await waitForCondition(() => section.getAttribute('data-state') === 'opened'); + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'opened'); + }); + + it('opens navigation and toggles sections', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const firstSection: SbbNavigationSection = document.querySelector('#first-section'); + const secondSection: SbbNavigationSection = document.querySelector('#second-section'); + const firstAction: SbbNavigationAction = document.querySelector( + 'sbb-navigation-marker > sbb-navigation-action#action-1', + ); + const secondAction: SbbNavigationAction = document.querySelector( + 'sbb-navigation-marker > sbb-navigation-action#action-2', + ); + + element.open(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(firstSection).to.have.attribute('data-state', 'closed'); + expect(secondSection).to.have.attribute('data-state', 'closed'); + + firstAction.click(); + + await waitForCondition(() => firstSection.getAttribute('data-state') === 'opened'); + expect(firstSection).to.have.attribute('data-state', 'opened'); + expect(secondSection).to.have.attribute('data-state', 'closed'); + + secondAction.click(); + + await waitForCondition(() => secondSection.getAttribute('data-state') === 'opened'); + expect(firstSection).to.have.attribute('data-state', 'closed'); + expect(secondSection).to.have.attribute('data-state', 'opened'); + }); + + it('closes the navigation and the section on close button click', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + const section: SbbNavigationSection = element.querySelector('#first-section'); + const action: SbbNavigationAction = document.querySelector( + 'sbb-navigation > sbb-navigation-marker > sbb-navigation-action#action-1', + ); + const closeButton: SbbButton = element.shadowRoot.querySelector('.sbb-navigation__close'); + + element.open(); + await waitForLitRender(element); + await nextFrame(); + + action.click(); + await waitForLitRender(element); + await nextFrame(); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + await waitForCondition(() => section.getAttribute('data-state') === 'opened'); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'opened'); + + closeButton.click(); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + await waitForCondition(() => section.getAttribute('data-state') === 'closed'); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + expect(section).to.have.attribute('data-state', 'closed'); + }); + + it('closes the navigation and the section on Esc key press', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbNavigation.events.didClose); + const section: SbbNavigationSection = element.querySelector('#first-section'); + const action: SbbNavigationAction = document.querySelector( + 'sbb-navigation > sbb-navigation-marker > sbb-navigation-action#action-1', + ); + + element.open(); + await waitForLitRender(element); + + action.click(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'opened'); + + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); + + await sendKeys({ down: 'Escape' }); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + expect(section).to.have.attribute('data-state', 'closed'); + }); + + it('closes section with sbb-navigation-section-close', async () => { + const didOpenEventSpy = new EventSpy(SbbNavigation.events.didOpen); + const section: SbbNavigationSection = document.querySelector('#first-section'); + const action: SbbNavigationAction = document.querySelector( + 'sbb-navigation > sbb-navigation-marker > sbb-navigation-action#action-1', + ); + const closeEl: SbbNavigationAction = document.querySelector( + 'sbb-navigation > sbb-navigation-section > sbb-navigation-action[sbb-navigation-section-close]', + ); + + element.open(); + await waitForLitRender(element); + + action.click(); + await waitForLitRender(element); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'opened'); + + closeEl.click(); + await waitForLitRender(element); + await waitForCondition(() => section.getAttribute('data-state') === 'closed'); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(section).to.have.attribute('data-state', 'closed'); + }); +}); diff --git a/src/components/navigation/navigation/navigation.scss b/src/components/navigation/navigation/navigation.scss new file mode 100644 index 0000000000..80a8df69e2 --- /dev/null +++ b/src/components/navigation/navigation/navigation.scss @@ -0,0 +1,230 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +@mixin transition($properties...) { + transition: { + duration: var(--sbb-navigation-animation-duration); + timing-function: var(--sbb-navigation-animation-easing); + property: $properties; + } +} + +:host { + --sbb-navigation-grid-column: 1 / 5; + --sbb-navigation-animation-duration: var(--sbb-animation-duration-3x); + --sbb-navigation-animation-easing: ease-in; + --sbb-navigation-padding-inline: var(--sbb-layout-base-offset-responsive); + --sbb-navigation-padding-block-start: var(--sbb-spacing-responsive-l); + --sbb-navigation-padding-block-end: var(--sbb-spacing-responsive-xl); + --sbb-navigation-backdrop-visibility: hidden; + --sbb-navigation-backdrop-pointer-events: none; + --sbb-navigation-backdrop-color: transparent; + --sbb-navigation-list-margin-block-start: var(--sbb-spacing-responsive-xxl); + --sbb-navigation-inline-start: 0; + --sbb-navigation-expanded-width: 100%; + --sbb-navigation-inset: 0 auto auto 0; + --sbb-navigation-transform: translateX(-100%); + --sbb-navigation-content-transform: translateX(0); + --sbb-navigation-width: 100%; + --sbb-navigation-height: 100vh; + + position: fixed; + inset: var(--sbb-navigation-inset); + z-index: var(--sbb-navigation-z-index, var(--sbb-overlay-z-index)); + overflow: hidden; + + // Needed for backwards compatibility on Chromatic + // TODO: Remove once not needed + @supports (height: 100dvh) { + --sbb-navigation-height: 100dvh; + } + + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); + + @include sbb.mq($from: medium) { + --sbb-navigation-grid-column: 1 / 9; + } + + @include sbb.mq($from: large) { + --sbb-navigation-grid-column: 1 / 5; + --sbb-navigation-animation-duration: var(--sbb-animation-duration-6x); + --sbb-navigation-padding-block-start: var(--sbb-spacing-responsive-xl); + --sbb-navigation-padding-inline: var(--sbb-layout-base-offset-responsive) 0; + --sbb-navigation-list-margin-block-start: var(--sbb-spacing-fixed-1x); + --sbb-navigation-inline-start: calc(var(--sbb-layout-base-offset-responsive) * -1); + --sbb-navigation-width: calc(100% + var(--sbb-layout-base-offset-responsive)); + } + + @include sbb.mq($from: ultra) { + --sbb-navigation-grid-column: 1 / 6; + --sbb-navigation-padding-block-start: var(--sbb-spacing-responsive-xxl); + } +} + +:host([data-state='opened']) { + --sbb-navigation-animation-easing: ease-out; +} + +:host(:is([data-state='opened'], [data-state='opening'])) { + --sbb-navigation-backdrop-visibility: visible; + --sbb-navigation-backdrop-pointer-events: all; + --sbb-navigation-backdrop-color: var(--sbb-color-black-alpha-70); +} + +:host(:not([data-state='closed'])) { + --sbb-navigation-inset: 0; + --sbb-navigation-transform: translateX(0); +} + +:host([data-has-navigation-section]) { + --sbb-navigation-content-transform: translateX(-100%); + + @include sbb.mq($from: 'large') { + --sbb-navigation-expanded-width: 100vw; + --sbb-navigation-content-transform: translateX(0%); + + // Needed for backwards compatibility on Chromatic + // TODO: Remove once not needed + @supports (height: 100dvw) { + --sbb-navigation-expanded-width: 100dvw; + } + } +} + +:host([disable-animation]) { + --sbb-navigation-animation-duration: 0.1ms; +} + +.sbb-navigation__container { + @include sbb.grid-base; + + padding-inline: 0; + pointer-events: none; + transform: var(--sbb-navigation-transform); + + @include sbb.mq($from: large) { + padding-inline: var(--sbb-layout-base-offset-responsive); + + // Navigation backdrop (not visible on mobile) + &::before { + @include transition(background-color, visibility); + + content: ''; + visibility: var(--sbb-navigation-backdrop-visibility); + pointer-events: var(--sbb-navigation-backdrop-pointer-events); + position: fixed; + inset: var(--sbb-navigation-inset); + background-color: var(--sbb-navigation-backdrop-color); + } + } +} + +.sbb-navigation { + @include transition(width); + + display: none; + width: var(--sbb-navigation-width); + grid-column: var(--sbb-navigation-grid-column); + padding: 0; + margin: 0; + position: relative; + inset-inline-start: var(--sbb-navigation-inline-start); + inset-block-start: 0; + border: none; + pointer-events: none; + height: var(--sbb-navigation-height); + color: var(--sbb-color-white-default); + background-color: var(--sbb-color-midnight-default); + + &::before { + @include transition(width); + + content: ''; + position: absolute; + width: var(--sbb-navigation-expanded-width); + height: var(--sbb-navigation-height); + background: var(--sbb-color-midnight-default); + } + + :host(:not([data-state='closed'])) & { + display: block; + pointer-events: all; + + animation: { + name: open; + duration: var(--sbb-navigation-animation-duration); + timing-function: var(--sbb-navigation-animation-easing); + } + } + + :host([data-state='closing']) & { + pointer-events: none; + animation-name: close; + } + + @include sbb.if-forced-colors { + outline: var(--sbb-border-width-1x) solid CanvasText; + } +} + +.sbb-navigation__wrapper { + outline: none; +} + +.sbb-navigation__header { + @include transition(width); + + display: flex; + justify-content: flex-end; + position: absolute; + width: var(--sbb-navigation-expanded-width); + pointer-events: none; + padding: var(--sbb-spacing-responsive-xs); + z-index: calc(var(--sbb-navigation-z-index, var(--sbb-overlay-z-index)) + 1); +} + +.sbb-navigation__close { + pointer-events: all; +} + +.sbb-navigation__content { + @include transition(transform); + @include sbb.scrollbar($negative: true); + + display: flex; + flex-direction: column; + gap: var(--sbb-spacing-responsive-xxl); + position: relative; + height: var(--sbb-navigation-height); + padding-inline: var(--sbb-navigation-padding-inline); + padding-block: var(--sbb-navigation-padding-block-start) var(--sbb-navigation-padding-block-end); + overflow-y: auto; + transform: var(--sbb-navigation-content-transform); +} + +::slotted(:first-child) { + margin-block-start: var(--sbb-navigation-list-margin-block-start); +} + +@keyframes open { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0%); + } +} + +@keyframes close { + from { + transform: translateX(0%); + } + + to { + transform: translateX(-100%); + } +} diff --git a/src/components/navigation/navigation/navigation.spec.ts b/src/components/navigation/navigation/navigation.spec.ts new file mode 100644 index 0000000000..acf3c226cb --- /dev/null +++ b/src/components/navigation/navigation/navigation.spec.ts @@ -0,0 +1,66 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './navigation'; +import '../../button'; + +describe('sbb-navigation', () => { + it('renders', async () => { + await fixture(html` + Navigation trigger + + + Tickets & Offers + Vacations & Recreation + + + `); + const nav = document.querySelector('sbb-navigation'); + + expect(nav).dom.to.be.equal( + ` + + + + Tickets & Offers + + + Vacations & Recreation + + + + `, + ); + expect(nav).shadowDom.to.be.equal( + ` +
      +
      +
      + + +
      +
      +
      + +
      +
      +
      + +
      + `, + ); + }); +}); diff --git a/src/components/navigation/navigation/navigation.stories.tsx b/src/components/navigation/navigation/navigation.stories.tsx new file mode 100644 index 0000000000..9e877e2ca6 --- /dev/null +++ b/src/components/navigation/navigation/navigation.stories.tsx @@ -0,0 +1,297 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import { waitForComponentsReady } from '../../../storybook/testing/wait-for-components-ready'; +import type { SbbNavigationMarker } from '../navigation-marker'; + +import { SbbNavigation } from './navigation'; +import readme from './readme.md?raw'; + +import '../navigation-section'; +import '../navigation-marker'; +import '../navigation-list'; +import '../navigation-action'; +import '../../button'; + +// Story interaction executed after the story renders +const playStory = async ({ canvasElement }): Promise => { + const canvas = within(canvasElement); + + await waitForComponentsReady(() => + canvas.getByTestId('navigation').shadowRoot.querySelector('.sbb-navigation'), + ); + + const button = canvas.getByTestId('navigation-trigger'); + await userEvent.click(button); + + await waitFor(() => + expect(canvas.getByTestId('navigation').getAttribute('data-state') === 'opened').toBeTruthy(), + ); +}; + +const playStoryWithSection = async ({ canvasElement }): Promise => { + await playStory({ canvasElement }); + const canvas = within(canvasElement); + + await waitFor(() => + expect( + canvas.getByTestId('navigation-section').shadowRoot.querySelector('.sbb-navigation-section'), + ).toBeTruthy(), + ); + const actionL = canvas.getByTestId('navigation-section-trigger-1'); + await userEvent.click(actionL); + + await waitFor(() => + expect( + canvas.getByTestId('navigation-section').getAttribute('data-state') === 'opened', + ).toBeTruthy(), + ); +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const accessibilityCloseLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const basicArgTypes: ArgTypes = { + 'aria-label': ariaLabel, + 'accessibility-close-label': accessibilityCloseLabel, + 'disable-animation': disableAnimation, +}; + +const basicArgs: Args = { + 'aria-label': undefined, + 'accessibility-close-label': undefined, + 'disable-animation': isChromatic(), +}; + +const triggerButton = (id): JSX.Element => ( + +); + +const navigationActionsL = (): JSX.Element[] => [ + + Tickets & Offers + , + Vacations & Recreation, + Travel information, + + Help & Contact + , +]; + +const navigationActionsS = (): JSX.Element[] => [ + Deutsch, + Français, + + Italiano + , + English, +]; + +const navigationList = (label): JSX.Element[] => [ + + Label + Label + + Label + + , +]; + +const actionLabels = (num): JSX.Element[] => { + const labels = [Label]; + for (let i = 1; i <= num; i++) { + labels.push(Label); + } + return labels; +}; + +const onNavigationClose = (dialog): void => { + dialog.addEventListener('didClose', () => { + (document.getElementById('nav-marker') as SbbNavigationMarker).reset(); + }); +}; + +const DefaultTemplate = (args): JSX.Element => ( + + {triggerButton('navigation-trigger-1')} + onNavigationClose(dialog)} + {...args} + > + {navigationActionsL()} + {navigationActionsS()} + + +); + +const LongContentTemplate = (args): JSX.Element => ( + + {triggerButton('navigation-trigger-1')} + + {navigationActionsL()} + {actionLabels(20)} + + +); + +const WithNavigationSectionTemplate = (args): JSX.Element => ( + + {triggerButton('navigation-trigger-1')} + onNavigationClose(dialog)} + {...args} + > + {navigationActionsL()} + {navigationActionsS()} + + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + All Tickets & Offers + + + + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + + + {navigationList('Label')} + {navigationList('Label')} + {navigationList('Label')} + + Travel Information + + + + +); + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() && playStory, +}; + +export const LongContent: StoryObj = { + render: LongContentTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() && playStory, +}; + +export const WithNavigationSection: StoryObj = { + render: WithNavigationSectionTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() && playStoryWithSection, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + withActions as Decorator, + ], + parameters: { + chromatic: { disableSnapshot: false }, + actions: { + handles: [ + SbbNavigation.events.willOpen, + SbbNavigation.events.didOpen, + SbbNavigation.events.didClose, + SbbNavigation.events.willClose, + ], + }, + backgrounds: { + disable: true, + }, + docs: { + story: { inline: false, iframeHeight: '600px' }, + + extractComponentDescription: () => readme, + }, + layout: 'fullscreen', + }, + title: 'components/sbb-navigation/sbb-navigation', +}; + +export default meta; diff --git a/src/components/navigation/navigation/navigation.ts b/src/components/navigation/navigation/navigation.ts new file mode 100644 index 0000000000..cfb1f99aaf --- /dev/null +++ b/src/components/navigation/navigation/navigation.ts @@ -0,0 +1,373 @@ +import { LitElement, CSSResult, TemplateResult, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { FocusTrap, assignId, setModalityOnNextFocus } from '../../core/a11y'; +import { + ScrollHandler, + isValidAttribute, + findReferencedElement, + setAttribute, +} from '../../core/dom'; +import { + documentLanguage, + HandlerRepository, + languageChangeHandlerAspect, + EventEmitter, + ConnectedAbortController, +} from '../../core/eventing'; +import { i18nCloseNavigation } from '../../core/i18n'; +import { AgnosticMutationObserver } from '../../core/observers'; +import { + removeAriaOverlayTriggerAttributes, + setAriaOverlayTriggerAttributes, + isEventOnElement, + SbbOverlayState, + applyInertMechanism, + removeInertMechanism, +} from '../../core/overlay'; +import '../../button'; + +import style from './navigation.scss?lit&inline'; + +/** Configuration for the attribute to look at if a navigation section is displayed */ +const navigationObserverConfig: MutationObserverInit = { + subtree: true, + attributeFilter: ['data-state'], +}; + +let nextId = 0; + +/** + * It displays a navigation menu, wrapping one or more `sbb-navigation-*` components. + * + * @slot - Use the unnamed slot to add `sbb-navigation-action` elements into the sbb-navigation menu. + * @event {CustomEvent} will-open - Emits whenever the `sbb-navigation` begins the opening transition. + * @event {CustomEvent} did-open - Emits whenever the `sbb-navigation` is opened. + * @event {CustomEvent} will-close - Emits whenever the `sbb-navigation` begins the closing transition. + * @event {CustomEvent} did-close - Emits whenever the `sbb-navigation` is closed. + */ +@customElement('sbb-navigation') +export class SbbNavigation extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', + } as const; + + /** + * The element that will trigger the navigation. + * Accepts both a string (id of an element) or an HTML element. + */ + @property() + public set trigger(value: string | HTMLElement) { + const oldValue = this._trigger; + this._trigger = value; + this._removeTriggerClickListener(this._trigger, oldValue); + } + public get trigger(): string | HTMLElement { + return this._trigger; + } + private _trigger: string | HTMLElement = null; + + /** + * This will be forwarded as aria-label to the close button element. + */ + @property({ attribute: 'accessibility-close-label' }) public accessibilityCloseLabel: + | string + | undefined; + + /** + * Whether the animation is enabled. + */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** + * The state of the navigation. + */ + @state() private _state: SbbOverlayState = 'closed'; + + /** + * Whether a navigation section is displayed. + */ + @state() private _activeNavigationSection: HTMLElement; + + @state() private _currentLanguage = documentLanguage(); + + /** Emits whenever the `sbb-navigation` begins the opening transition. */ + private _willOpen: EventEmitter = new EventEmitter(this, SbbNavigation.events.willOpen); + + /** Emits whenever the `sbb-navigation` is opened. */ + private _didOpen: EventEmitter = new EventEmitter(this, SbbNavigation.events.didOpen); + + /** Emits whenever the `sbb-navigation` begins the closing transition. */ + private _willClose: EventEmitter = new EventEmitter(this, SbbNavigation.events.willClose); + + /** Emits whenever the `sbb-navigation` is closed. */ + private _didClose: EventEmitter = new EventEmitter(this, SbbNavigation.events.didClose); + + private _navigation: HTMLDivElement; + private _navigationContentElement: HTMLElement; + private _triggerElement: HTMLElement; + private _navigationController: AbortController; + private _windowEventsController: AbortController; + private _abort = new ConnectedAbortController(this); + private _focusTrap = new FocusTrap(); + private _scrollHandler = new ScrollHandler(); + private _isPointerDownEventOnNavigation: boolean; + private _navigationObserver = new AgnosticMutationObserver((mutationsList: MutationRecord[]) => + this._onNavigationSectionChange(mutationsList), + ); + private _navigationId = `sbb-navigation-${++nextId}`; + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + ); + + /** + * Opens the navigation. + */ + public open(): void { + if (this._state !== 'closed' || !this._navigation) { + return; + } + + this._willOpen.emit(); + this._state = 'opening'; + + // Disable scrolling for content below the navigation + this._scrollHandler.disableScroll(); + this._triggerElement?.setAttribute('aria-expanded', 'true'); + } + + /** + * Closes the navigation. + */ + public close(): void { + if (this._state !== 'opened') { + return; + } + + this._willClose.emit(); + this._state = 'closing'; + this._triggerElement?.setAttribute('aria-expanded', 'false'); + } + + // Removes trigger click listener on trigger change. + private _removeTriggerClickListener( + newValue: string | HTMLElement, + oldValue: string | HTMLElement, + ): void { + if (newValue !== oldValue) { + this._navigationController?.abort(); + this._windowEventsController?.abort(); + this._configure(this.trigger); + } + } + + // Check if the trigger is valid and attach click event listeners. + private _configure(trigger: string | HTMLElement): void { + removeAriaOverlayTriggerAttributes(this._triggerElement); + + if (!trigger) { + return; + } + + this._triggerElement = findReferencedElement(trigger); + + if (!this._triggerElement) { + return; + } + + setAriaOverlayTriggerAttributes( + this._triggerElement, + 'menu', + this.id || this._navigationId, + this._state, + ); + this._navigationController = new AbortController(); + this._triggerElement.addEventListener('click', () => this.open(), { + signal: this._navigationController.signal, + }); + } + + private _trapFocusFilter = (el: HTMLElement): boolean => { + return el.nodeName === 'SBB-NAVIGATION-SECTION' && el.getAttribute('data-state') !== 'opened'; + }; + + // In rare cases it can be that the animationEnd event is triggered twice. + // To avoid entering a corrupt state, exit when state is not expected. + private _onAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this._state === 'opening') { + this._state = 'opened'; + this._didOpen.emit(); + applyInertMechanism(this); + this._focusTrap.trap(this, this._trapFocusFilter); + this._attachWindowEvents(); + this._setNavigationFocus(); + } else if (event.animationName === 'close' && this._state === 'closing') { + this._state = 'closed'; + this._navigationContentElement.scrollTo(0, 0); + setModalityOnNextFocus(this._triggerElement); + removeInertMechanism(); + // To enable focusing other element than the trigger, we need to call focus() a second time. + this._triggerElement?.focus(); + this._didClose.emit(); + this._windowEventsController?.abort(); + this._focusTrap.disconnect(); + + // Enable scrolling for content below the navigation + this._scrollHandler.enableScroll(); + } + } + + private _attachWindowEvents(): void { + this._windowEventsController = new AbortController(); + window.addEventListener('keydown', (event: KeyboardEvent) => this._onKeydownEvent(event), { + signal: this._windowEventsController.signal, + }); + } + + private _handleNavigationClose(event: Event): void { + const composedPathElements = event + .composedPath() + .filter((el) => el instanceof window.HTMLElement); + if (composedPathElements.some((el) => this._isCloseElement(el as HTMLElement))) { + this.close(); + } + } + + private _isCloseElement(element: HTMLElement): boolean { + return ( + element.nodeName === 'A' || + (element.hasAttribute('sbb-navigation-close') && !isValidAttribute(element, 'disabled')) + ); + } + + // Closes the navigation on "Esc" key pressed. + private _onKeydownEvent(event: KeyboardEvent): void { + if (this._state === 'opened' && event.key === 'Escape') { + this.close(); + } + } + + // Set focus on the first focusable element. + private _setNavigationFocus(): void { + const closeButton = this.shadowRoot.querySelector( + '#sbb-navigation-close-button', + ) as HTMLElement; + setModalityOnNextFocus(closeButton); + closeButton.focus(); + } + + // Check if the pointerdown event target is triggered on the navigation. + private _pointerDownListener = (event: PointerEvent): void => { + this._isPointerDownEventOnNavigation = + isEventOnElement(this._navigation, event) || + isEventOnElement( + this.querySelector('sbb-navigation-section[data-state="opened"]')?.shadowRoot.querySelector( + 'nav.sbb-navigation-section', + ) as HTMLElement, + event, + ); + }; + + // Close navigation on backdrop click. + private _closeOnBackdropClick = (event: PointerEvent): void => { + if (!this._isPointerDownEventOnNavigation && !isEventOnElement(this._navigation, event)) { + this.close(); + } + }; + + // Observe changes on navigation section data-state. + private _onNavigationSectionChange(mutationsList: MutationRecord[]): void { + for (const mutation of mutationsList) { + if ((mutation.target as HTMLElement).nodeName === 'SBB-NAVIGATION-SECTION') { + this._activeNavigationSection = this.querySelector( + 'sbb-navigation-section[data-state="opening"], sbb-navigation-section[data-state="opened"]', + ); + } + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('click', (e) => this._handleNavigationClose(e), { signal }); + this._handlerRepository.connect(); + // Validate trigger element and attach event listeners + this._configure(this.trigger); + this._navigationObserver.observe(this, navigationObserverConfig); + this.addEventListener('pointerup', (event) => this._closeOnBackdropClick(event), { signal }); + this.addEventListener('pointerdown', (event) => this._pointerDownListener(event), { signal }); + + if (this._state === 'opened') { + applyInertMechanism(this); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + this._navigationController?.abort(); + this._windowEventsController?.abort(); + this._focusTrap.disconnect(); + this._navigationObserver.disconnect(); + removeInertMechanism(); + } + + protected override render(): TemplateResult { + const closeButton = html` + + `; + + setAttribute(this, 'role', 'navigation'); + setAttribute(this, 'data-has-navigation-section', !!this._activeNavigationSection); + setAttribute(this, 'data-state', this._state); + assignId(() => this._navigationId)(this); + + return html` +
      +
      (this._navigation = navigationRef as HTMLDivElement))} + id="sbb-navigation-overlay" + @animationend=${(event: AnimationEvent) => this._onAnimationEnd(event)} + class="sbb-navigation" + > +
      ${closeButton}
      +
      +
      (this._navigationContentElement = el as HTMLElement))} + > + +
      +
      +
      + +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-navigation': SbbNavigation; + } +} diff --git a/src/components/navigation/navigation/readme.md b/src/components/navigation/navigation/readme.md new file mode 100644 index 0000000000..1d6d02c60b --- /dev/null +++ b/src/components/navigation/navigation/readme.md @@ -0,0 +1,91 @@ +The `sbb-navigation` component provides a way to present a navigation menu. + +Some of its features are: + +- uses a native dialog element; +- creates a backdrop for disabling interaction below the navigation; +- disables scrolling of the page content while open; +- manages focus properly by setting it on the first focusable element; +- can act as a host for components as [sbb-navigation-list](/docs/components-sbb-navigation-sbb-navigation-list--docs), + [sbb-navigation-marker](/docs/components-sbb-navigation-sbb-navigation-marker--docs) + and [sbb-navigation-section](/docs/components-sbb-navigation-sbb-navigation-section--docs); + +## Interactions + +To display the `sbb-navigation` component you can either provide a trigger element using the `trigger` property, +or call the `open()` method on the `sbb-navigation` component. + +```html + +Navigation trigger + + + + + Label 1 + Label 2 + Label 3 + + + + Language 1 + Language 2 + Language 3 + + + + Title 1 + + Label 1.1 + Label 1.1.1 + Label 1.1.2 + Label 1.1.3 + + ... + Something + + ... + +``` + +## Style + +The default `z-index` of the component is set to `1000`; +to specify a custom stack order, the `z-index` can be changed by defining the CSS variable `--sbb-navigation-z-index`. + +## Accessibility + +When a navigation action is marked to indicate the user is currently on that page, `aria-current="page"` should be set on that action. +Similarly, if a navigation action is marked to indicate a selected option (e.g., the selected language) `aria-pressed` should be set on that action. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------------- | --------------------------- | ------- | ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | +| `trigger` | `trigger` | public | `string \| HTMLElement` | | The element that will trigger the navigation. Accepts both a string (id of an element) or an HTML element. | +| `accessibilityCloseLabel` | `accessibility-close-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the close button element. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ---------------------- | ---------- | ------ | -------------- | +| `open` | public | Opens the navigation. | | `void` | | +| `close` | public | Closes the navigation. | | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ------------ | ------------------- | ------------------------------------------------------------------ | -------------- | +| `will-open` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the opening transition. | | +| `did-open` | `CustomEvent` | Emits whenever the `sbb-navigation` is opened. | | +| `will-close` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the closing transition. | | +| `did-close` | `CustomEvent` | Emits whenever the `sbb-navigation` is closed. | | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------------------------ | +| | Use the unnamed slot to add `sbb-navigation-action` elements into the sbb-navigation menu. | diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts new file mode 100644 index 0000000000..d9b217ce3b --- /dev/null +++ b/src/components/notification/index.ts @@ -0,0 +1 @@ +export * from './notification'; diff --git a/src/components/notification/notification.e2e.ts b/src/components/notification/notification.e2e.ts new file mode 100644 index 0000000000..ca2de53ed0 --- /dev/null +++ b/src/components/notification/notification.e2e.ts @@ -0,0 +1,75 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbButton } from '../button'; +import { waitForCondition, EventSpy, waitForLitRender } from '../core/testing'; + +import { SbbNotification } from './notification'; + +import '../link'; + +describe('sbb-notification', () => { + let element: SbbNotification; + + beforeEach(async () => { + element = await fixture(html` + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + Link one + + `); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbNotification); + }); + + it('closes the notification and removes it from the DOM', async () => { + const willCloseEventSpy = new EventSpy(SbbNotification.events.willClose); + const didCloseEventSpy = new EventSpy(SbbNotification.events.didClose); + + expect(element).not.to.be.null; + expect(element).to.have.attribute('data-state', 'opened'); + + element.close(); + await waitForLitRender(element); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + + element = document.querySelector('sbb-notification'); + expect(element).to.be.null; + }); + + it('closes the notification and removes it from the DOM on close button click', async () => { + const willCloseEventSpy = new EventSpy(SbbNotification.events.willClose); + const didCloseEventSpy = new EventSpy(SbbNotification.events.didClose); + const closeButton = element.shadowRoot.querySelector('.sbb-notification__close') as SbbButton; + + expect(element).not.to.be.null; + expect(element).to.have.attribute('data-state', 'opened'); + + closeButton.click(); + await waitForLitRender(element); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + + element = document.querySelector('sbb-notification'); + expect(element).to.be.null; + }); +}); diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss new file mode 100644 index 0000000000..000f0dec87 --- /dev/null +++ b/src/components/notification/notification.scss @@ -0,0 +1,218 @@ +@use '../core/styles' as sbb; +@use 'sass:color'; +@use 'node_modules/@sbb-esta/lyne-design-tokens/dist/scss/sbb-variables.scss' as sbb-tokens; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-notification-visibility: hidden; + --sbb-notification-opacity: 0; + --sbb-notification-max-height: 0; + --sbb-notification-margin: 0; + --sbb-notification-padding-block: var(--sbb-spacing-responsive-xxs); + --sbb-notification-padding-inline: var(--sbb-spacing-responsive-xs); + --sbb-notification-color: var(--sbb-color-charcoal-default); + --sbb-notification-icon-color: var(--sbb-notification-type-color); + --sbb-notification-border-width: var(--sbb-border-width-1x); + --sbb-notification-border: var(--sbb-notification-border-width) solid + var(--sbb-notification-type-color); + --sbb-notification-base-radius: var(--sbb-border-radius-4x); + --sbb-notification-border-radius: calc(var(--sbb-notification-base-radius) / 2) + var(--sbb-notification-base-radius) var(--sbb-notification-base-radius) + calc(var(--sbb-notification-base-radius) / 2); + --sbb-notification-animation-duration: var(--sbb-animation-duration-4x); + --sbb-notification-timing-function: ease-in; + --sbb-notification-transition: max-height var(--sbb-notification-animation-duration) + var(--sbb-animation-duration-2x) var(--sbb-notification-timing-function), + padding var(--sbb-notification-animation-duration) var(--sbb-animation-duration-2x) + var(--sbb-notification-timing-function), + border var(--sbb-notification-animation-duration) var(--sbb-animation-duration-2x) + var(--sbb-notification-timing-function), + visibility var(--sbb-notification-animation-duration) var(--sbb-notification-timing-function), + opacity var(--sbb-notification-animation-duration) var(--sbb-notification-timing-function); + + // As the notification has always a light background, we have to fix the focus outline color + // to default color for cases where the notification is used in a negative context. + --sbb-focus-outline-color: var(--sbb-focus-outline-color-default); + + margin: 0; + transition: margin var(--sbb-notification-animation-duration) + var(--sbb-notification-timing-function); +} + +:host(:is([data-state='opened'], [data-state='opening'])) { + --sbb-notification-visibility: visible; + --sbb-notification-opacity: 1; + --sbb-notification-max-height: calc( + var(--sbb-notification-height) + (var(--sbb-notification-border-width) * 2) + ); + --sbb-notification-transition: max-height var(--sbb-notification-animation-duration) + var(--sbb-notification-timing-function), + padding var(--sbb-notification-animation-duration) var(--sbb-notification-timing-function), + border var(--sbb-notification-animation-duration) var(--sbb-notification-timing-function), + opacity var(--sbb-notification-animation-duration) var(--sbb-notification-animation-duration) + var(--sbb-notification-timing-function); + + margin: var(--sbb-notification-margin); +} + +:host(:is([data-resize-disable-animation], [disable-animation])) { + --sbb-notification-animation-duration: 0.1ms; +} + +/* Types */ + +:host([type='info']) { + --sbb-notification-type-color: var(--sbb-color-smoke-default); + --sbb-notification-type-color-sass: #{color.mix(sbb-tokens.$sbb-color-smoke-default, white, 5%)}; + --sbb-notification-icon-color: var(--sbb-notification-color); +} + +:host([type='success']) { + --sbb-notification-type-color: var(--sbb-color-green-default); + --sbb-notification-type-color-sass: #{color.mix(sbb-tokens.$sbb-color-green-default, white, 5%)}; +} + +:host([type='warn']) { + --sbb-notification-type-color: var(--sbb-color-peach-default); + --sbb-notification-type-color-sass: #{color.mix(sbb-tokens.$sbb-color-peach-default, white, 5%)}; + --sbb-notification-icon-color: var(--sbb-notification-color); +} + +:host([type='error']) { + --sbb-notification-type-color: var(--sbb-color-red-default); + --sbb-notification-type-color-sass: #{color.mix(sbb-tokens.$sbb-color-red-default, white, 5%)}; +} + +.sbb-notification__wrapper { + position: relative; + inset-inline-start: calc( + var(--sbb-notification-base-radius) - var(--sbb-notification-border-width) + ); + width: calc( + 100% - calc(var(--sbb-notification-base-radius) - var(--sbb-notification-border-width)) + ); + visibility: var(--sbb-notification-visibility); + opacity: var(--sbb-notification-opacity); + max-height: var(--sbb-notification-max-height); + transition: var(--sbb-notification-transition); + border: var(--sbb-notification-border); + border-radius: var(--sbb-notification-border-radius); + animation: { + name: open; + duration: var(--sbb-notification-animation-duration); + timing-function: var(--sbb-notification-timing-function); + } + + &::before { + content: ''; + position: absolute; + inset: calc(var(--sbb-notification-border-width) * -1) var(--sbb-notification-base-radius) + calc(var(--sbb-notification-border-width) * -1) calc(var(--sbb-notification-base-radius) * -1); + background-color: var(--sbb-notification-type-color); + border: var(--sbb-notification-border); + border-radius: var(--sbb-notification-base-radius); + z-index: -1; + } +} + +.sbb-notification { + @include sbb.text-s--regular; + + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding-block: var(--sbb-notification-padding-block); + padding-inline: var(--sbb-notification-padding-inline); + color: var(--sbb-notification-color); + border-radius: var(--sbb-notification-border-radius); + overflow: hidden; + + // We apply SASS calculated background color as default/fallback for older browsers. + background-color: var(--sbb-notification-type-color-sass); + + // If supported, try to mix color with CSS + @supports (background-color: color-mix(in srgb, transparent 5%, white)) { + background-color: color-mix(in srgb, var(--sbb-notification-type-color) 5%, white); + } + + @include sbb.mq($from: small) { + grid-template-columns: auto 1fr auto; + align-items: flex-start; + } +} + +.sbb-notification__icon { + color: var(--sbb-notification-icon-color); + + @include sbb.mq($from: small) { + margin-block-start: calc( + ((1em * var(--sbb-typo-line-height-body-text)) - var(--sbb-size-icon-ui-small)) / 2 + ); + + :host([data-has-title]) & { + margin-block-start: calc( + ( + (var(--sbb-font-size-title-5) * var(--sbb-typo-line-height-body-text)) - var( + --sbb-size-icon-ui-small + ) + ) / 2 + ); + } + } +} + +.sbb-notification__title { + // Overwrite sbb-title default margin + margin: 0; +} + +.sbb-notification__content { + order: 3; + grid-area: 2 / 1 / 3 / 3; + margin-block-start: var(--sbb-spacing-fixed-2x); + + @include sbb.mq($from: small) { + order: initial; + grid-area: initial; + margin-block-start: 0; + padding-inline: var(--sbb-spacing-responsive-xxxs) var(--sbb-spacing-responsive-xs); + } +} + +.sbb-notification__close-wrapper { + display: flex; + align-items: center; + gap: var(--sbb-spacing-responsive-xxs); + height: 100%; +} + +.sbb-notification__divider { + --sbb-divider-color: var(--sbb-notification-type-color); + + display: none; + position: relative; + inset-inline-start: var(--sbb-border-width-1x); + opacity: 0.2; + + @include sbb.mq($from: small) { + display: block; + height: calc(100% - (var(--sbb-spacing-fixed-1x) * 2)); + } +} + +@keyframes open { + from { + visibility: hidden; + opacity: 0; + max-height: 0; + } + + to { + visibility: visible; + opacity: 1; + max-height: var(--sbb-notification-max-height); + } +} diff --git a/src/components/notification/notification.spec.ts b/src/components/notification/notification.spec.ts new file mode 100644 index 0000000000..6ecc30a906 --- /dev/null +++ b/src/components/notification/notification.spec.ts @@ -0,0 +1,146 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './notification'; +import '../link'; +import '../button'; +import '../icon'; +import '../divider'; + +describe('sbb-notification', () => { + it('renders', async () => { + const root = await fixture( + html`The quick brown fox jumps over the lazy dog.`, + ); + + expect(root).dom.to.be.equal( + ` + + The quick brown fox jumps over the lazy dog. + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      +
      + + + + + + + + +
      +
      + `, + ); + }); + + it('renders with a title', async () => { + const root = await fixture( + html`The quick brown fox jumps over the lazy dog.`, + ); + + expect(root).dom.to.be.equal( + ` + + The quick brown fox jumps over the lazy dog. + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      +
      + + + + + Title + + + + + + + + +
      +
      + `, + ); + }); + + it('renders with a slotted title', async () => { + const root = await fixture( + html`Slotted title + The quick brown fox jumps over the lazy dog. + `, + ); + + expect(root).dom.to.be.equal( + ` + + + Slotted title + + The quick brown fox jumps over the lazy dog. + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      +
      + + + + + + + + + + + +
      +
      + `, + ); + }); + + it('renders without the close button', async () => { + const root = await fixture( + html`The quick brown fox jumps over the lazy dog.`, + ); + + expect(root).dom.to.be.equal( + ` + + The quick brown fox jumps over the lazy dog. + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      +
      + + + + + Title + + + + +
      +
      + `, + ); + }); +}); diff --git a/src/components/notification/notification.stories.tsx b/src/components/notification/notification.stories.tsx new file mode 100644 index 0000000000..5399c84654 --- /dev/null +++ b/src/components/notification/notification.stories.tsx @@ -0,0 +1,245 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import isChromatic from 'chromatic/isChromatic'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import { SbbNotification } from './notification'; +import readme from './readme.md?raw'; + +import '../button'; +import '../link'; + +const titleContent: InputType = { + control: { + type: 'text', + }, +}; + +const type: InputType = { + control: { + type: 'select', + }, + options: ['info', 'success', 'warn', 'error'], +}; + +const readonly: InputType = { + control: { + type: 'boolean', + }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const basicArgTypes: ArgTypes = { + 'title-content': titleContent, + type: type, + readonly: readonly, + 'disable-animation': disableAnimation, +}; + +const basicArgs: Args = { + 'title-content': 'Title', + type: type.options[0], + readonly: false, + 'disable-animation': isChromatic(), +}; + +const appendNotification = (args): void => { + const newNotification = document.createElement('sbb-notification'); + newNotification.style.setProperty( + '--sbb-notification-margin', + '0 0 var(--sbb-spacing-fixed-4x) 0', + ); + newNotification.titleContent = args['title-content']; + newNotification.type = args['type']; + newNotification.readonly = args['readonly']; + newNotification.disableAnimation = args['disable-animation']; + newNotification.innerHTML = + 'Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet.'; + document.querySelector('.notification-container').append(newNotification); +}; + +const trigger = (args): JSX.Element => ( + appendNotification(args)} + icon-name="circle-plus-small" + > + Add notification + +); + +const notification = (args): JSX.Element => ( + + notification.addEventListener( + 'did-open', + () => (notification.disableAnimation = args['disable-animation']), + { + once: true, + }, + ) + } + > + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.  + + Link one + +   + + Link two + +   + + Link three + + +); + +const pageContent = (): JSX.Element => ( +

      + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis{' '} + + link + {' '} + nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +

      +); + +const DefaultTemplate = (args): JSX.Element => ( + + {trigger(args)} +
      + {notification(args)} +
      + {pageContent()} +
      +); + +const SlottedTitleTemplate = (args): JSX.Element => ( + + {trigger(args)} +
      + + notification.addEventListener( + 'did-open', + () => (notification.disableAnimation = args['disable-animation']), + { + once: true, + }, + ) + } + > + Slotted title + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy + dog.  + + Link one + +   + + Link two + +   + + Link three + + +
      + {pageContent()} +
      +); + +export const Info: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, +}; + +export const Success: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, type: type.options[1] }, +}; + +export const Warn: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, type: type.options[2] }, +}; + +export const Error: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, type: type.options[3] }, +}; + +export const Readonly: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, readonly: true }, +}; + +export const NoTitle: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, 'title-content': undefined }, +}; + +export const ReadonlyNoTitle: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, 'title-content': undefined, readonly: true }, +}; + +export const SlottedTitle: StoryObj = { + render: SlottedTitleTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, 'title-content': undefined }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + withActions as Decorator, + ], + parameters: { + actions: { + handles: [ + SbbNotification.events.didOpen, + SbbNotification.events.didClose, + SbbNotification.events.willOpen, + SbbNotification.events.willClose, + ], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-notification', +}; + +export default meta; diff --git a/src/components/notification/notification.ts b/src/components/notification/notification.ts new file mode 100644 index 0000000000..ecfa133970 --- /dev/null +++ b/src/components/notification/notification.ts @@ -0,0 +1,250 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { setAttribute, toggleDatasetEntry } from '../core/dom'; +import { + createNamedSlotState, + documentLanguage, + HandlerRepository, + languageChangeHandlerAspect, + namedSlotChangeHandlerAspect, + EventEmitter, +} from '../core/eventing'; +import { i18nCloseNotification } from '../core/i18n'; +import { AgnosticResizeObserver } from '../core/observers'; +import type { TitleLevel } from '../title'; + +import style from './notification.scss?lit&inline'; + +import '../button'; +import '../divider'; +import '../icon'; +import '../title'; + +const notificationTypes = new Map([ + ['info', 'circle-information-small'], + ['success', 'circle-tick-small'], + ['warn', 'circle-exclamation-point-small'], + ['error', 'circle-cross-small'], +]); + +/** + * It displays messages which require a user's attention without interrupting its tasks. + * + * @slot - Use the unnamed slot to add content to the notification message. + * @slot title - Use this to provide a notification title (optional). + * @event {CustomEvent} will-open - Emits whenever the `sbb-notification` starts the opening transition. + * @event {CustomEvent} did-open - Emits whenever the `sbb-notification` is opened. + * @event {CustomEvent} will-close - Emits whenever the `sbb-notification` begins the closing transition. + * @event {CustomEvent} did-close - Emits whenever the `sbb-notification` is closed. + */ +@customElement('sbb-notification') +export class SbbNotification extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', + } as const; + + /** + * The type of the notification. + */ + @property({ reflect: true }) public type?: 'info' | 'success' | 'warn' | 'error' = 'info'; + + /** + * Content of title. + */ + @property({ attribute: 'title-content' }) public titleContent?: string; + + /** + * Level of title, it will be rendered as heading tag (e.g. h3). Defaults to level 3. + */ + @property({ attribute: 'title-level' }) public titleLevel: TitleLevel = '3'; + + /** + * Whether the notification is readonly. + * In readonly mode, there is no dismiss button offered to the user. + */ + @property({ reflect: true, type: Boolean }) public readonly = false; + + /** + * Whether the animation is enabled. + */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** + * State of listed named slots, by indicating whether any element for a named slot is defined. + */ + @state() private _namedSlots = createNamedSlotState('title'); + + /** + * The state of the notification. + */ + @state() private _state: 'closed' | 'opening' | 'opened' | 'closing' = 'opened'; + + @state() private _currentLanguage = documentLanguage(); + + private _notificationElement: HTMLElement; + private _resizeObserverTimeout: ReturnType | null = null; + private _notificationResizeObserver = new AgnosticResizeObserver(() => + this._onNotificationResize(), + ); + + /** Emits whenever the `sbb-notification` starts the opening transition. */ + private _willOpen: EventEmitter = new EventEmitter(this, SbbNotification.events.willOpen); + + /** Emits whenever the `sbb-notification` is opened. */ + private _didOpen: EventEmitter = new EventEmitter(this, SbbNotification.events.didOpen); + + /** Emits whenever the `sbb-notification` begins the closing transition. */ + private _willClose: EventEmitter = new EventEmitter(this, SbbNotification.events.willClose); + + /** Emits whenever the `sbb-notification` is closed. */ + private _didClose: EventEmitter = new EventEmitter(this, SbbNotification.events.didClose); + + public close(): void { + if (this._state === 'opened') { + this._state = 'closing'; + this._willClose.emit(); + this.disableAnimation && this._handleClosing(); + } + } + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + ); + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + this._setInlineLinks(); + } + + protected override firstUpdated(): void { + this._willOpen.emit(); + this._setNotificationHeight(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + this._notificationResizeObserver.disconnect(); + } + + private _setInlineLinks(): void { + this.querySelectorAll('sbb-link')?.forEach((link) => (link.variant = 'inline')); + } + + private _setNotificationHeight(): void { + const notificationHeight = + this._notificationElement.scrollHeight && !this.disableAnimation + ? `${this._notificationElement.scrollHeight}px` + : 'auto'; + this.style.setProperty('--sbb-notification-height', notificationHeight); + } + + private _onNotificationResize(): void { + if (this._state !== 'opened') { + return; + } + + clearTimeout(this._resizeObserverTimeout); + + toggleDatasetEntry(this, 'resizeDisableAnimation', true); + this._setNotificationHeight(); + + // Disable the animation when resizing the notification to avoid strange height transition effects. + this._resizeObserverTimeout = setTimeout( + () => toggleDatasetEntry(this, 'resizeDisableAnimation', false), + 150, + ); + } + + private _onNotificationTransitionEnd(event: TransitionEvent): void { + if (this._state === 'closing' && event.propertyName === 'max-height') { + this._handleClosing(); + } + } + + private _onNotificationAnimationEnd(event: AnimationEvent): void { + if (this._state === 'opened' && event.animationName === 'open') { + this._handleOpening(); + } + } + + private _handleOpening(): void { + this._state = 'opened'; + this._didOpen.emit(); + this._notificationResizeObserver.observe(this._notificationElement); + } + + private _handleClosing(): void { + this._state = 'closed'; + this._didClose.emit(); + this._notificationResizeObserver.unobserve(this._notificationElement); + this.remove(); + } + + protected override render(): TemplateResult { + const hasTitle = !!this.titleContent || this._namedSlots['title']; + + setAttribute(this, 'data-state', this._state); + setAttribute(this, 'data-has-title', hasTitle); + + return html` +
      (this._notificationElement = el as HTMLElement))} + @transitionend=${(event: TransitionEvent) => this._onNotificationTransitionEnd(event)} + @animationend=${(event: AnimationEvent) => this._onNotificationAnimationEnd(event)} + > +
      + + + + ${hasTitle + ? html` + ${this.titleContent} + ` + : nothing} + this._setInlineLinks()}> + + + ${!this.readonly + ? html` + + this.close()} + aria-label=${i18nCloseNotification[this._currentLanguage]} + class="sbb-notification__close" + > + ` + : nothing} +
      +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-notification': SbbNotification; + } +} diff --git a/src/components/notification/readme.md b/src/components/notification/readme.md new file mode 100644 index 0000000000..3764719321 --- /dev/null +++ b/src/components/notification/readme.md @@ -0,0 +1,93 @@ +The `sbb-notification` is a component which purpose is to inform users of updates. +A notification is an element that displays a brief, important message +in a way that attracts the user's attention without interrupting the user's task. + +Inline notifications show up in task flows, to notify users of an action status or other information. +They usually appear at the top of the primary content area or close to the item needing attention. + +The `sbb-notification` is structured in the following way: + +- Icon: informs users of the notification type at a glance. +- Title (optional): gives users a quick overview of the notification. +- Close button (optional): closes the notification. +- Message: provides additional detail and/or actionable steps for the user to take. + +```html + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + Link one + Link two + Link three + +``` + +Note that the notification only supports inline links, therefore any slotted link will be forced to be a `variant="inline"` link. + +## Variants + +The `sbb-notification` supports four types: `info` (default), `success`, `warn` and `error`, based on the type of the information displayed. + +```html +... + +... + +... +``` + +## States + +It is possible to display the component in `readonly` state by using the self-named property. +In this case, the close button will not be shown. + +```html + ... +``` + +## Interactions + +Inline notifications do not dismiss automatically. +They persist on the page until the user dismisses them or takes action that resolves the notification. + +By default, a close button is displayed to dismiss inline notifications. Including the close button is optional +and should not be included if it is critical for a user to read or interact with the notification by setting the `readonly` property to `true`. + +## Style + +If the `sbb-notification` host needs a margin, in order to properly animate it on open/close, +we suggest using the `--sbb-notification-margin` variable to set it. +For example, use `--sbb-notification-margin: 0 0 var(--sbb-spacing-fixed-4x) 0` to apply a bottom margin. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------ | ------------------- | ------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `type` | `type` | public | `'info' \| 'success' \| 'warn' \| 'error' \| undefined` | `'info'` | The type of the notification. | +| `titleContent` | `title-content` | public | `string \| undefined` | | Content of title. | +| `titleLevel` | `title-level` | public | `TitleLevel` | `'3'` | Level of title, it will be rendered as heading tag (e.g. h3). Defaults to level 3. | +| `readonly` | `readonly` | public | `boolean` | `false` | Whether the notification is readonly. In readonly mode, there is no dismiss button offered to the user. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ----------- | ---------- | ------ | -------------- | +| `close` | public | | | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ------------ | ------------------- | -------------------------------------------------------------------- | -------------- | +| `will-open` | `CustomEvent` | Emits whenever the `sbb-notification` starts the opening transition. | | +| `did-open` | `CustomEvent` | Emits whenever the `sbb-notification` is opened. | | +| `will-close` | `CustomEvent` | Emits whenever the `sbb-notification` begins the closing transition. | | +| `did-close` | `CustomEvent` | Emits whenever the `sbb-notification` is closed. | | + +## Slots + +| Name | Description | +| ------- | ---------------------------------------------------------------- | +| | Use the unnamed slot to add content to the notification message. | +| `title` | Use this to provide a notification title (optional). | diff --git a/src/components/option/index.ts b/src/components/option/index.ts new file mode 100644 index 0000000000..aa9755a578 --- /dev/null +++ b/src/components/option/index.ts @@ -0,0 +1,2 @@ +export * from './optgroup'; +export * from './option'; diff --git a/src/components/option/optgroup/index.ts b/src/components/option/optgroup/index.ts new file mode 100644 index 0000000000..592fec910f --- /dev/null +++ b/src/components/option/optgroup/index.ts @@ -0,0 +1 @@ +export * from './optgroup'; diff --git a/src/components/option/optgroup/optgroup.e2e.ts b/src/components/option/optgroup/optgroup.e2e.ts new file mode 100644 index 0000000000..7ebebf2bdc --- /dev/null +++ b/src/components/option/optgroup/optgroup.e2e.ts @@ -0,0 +1,76 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../../core/testing'; +import type { SbbOption } from '../option'; +import '../option'; + +import { SbbOptGroup } from './optgroup'; + +describe('sbb-optgroup', () => { + let element: SbbOptGroup; + + beforeEach(async () => { + element = await fixture(html` + + Label 1 + Label 2 + Label 3 + + `); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbOptGroup); + }); + + it('disabled status is inherited', async () => { + const optionOne = document.querySelector('sbb-optgroup > sbb-option#option-1'); + const optionTwo = document.querySelector('sbb-optgroup > sbb-option#option-2'); + const optionThree = document.querySelector('sbb-optgroup > sbb-option#option-3'); + element.setAttribute('disabled', ''); + await waitForLitRender(element); + + expect(element).to.have.attribute('disabled'); + expect(optionOne).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); + expect(optionThree).to.have.attribute('data-group-disabled'); + + element.removeAttribute('disabled'); + await waitForLitRender(element); + expect(optionTwo).not.to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); + }); + + it('disabled status prevents changes', async () => { + const optionOne: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-1'); + const optionTwo: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-2'); + const optionThree: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-3'); + const options = [optionOne, optionTwo, optionThree]; + + options.forEach((opt) => expect(opt).not.to.have.attribute('selected')); + + element.setAttribute('disabled', ''); + await waitForLitRender(element); + expect(element).to.have.attribute('disabled'); + + // clicks should have no effect since the group is disabled + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); + expect(opt).not.to.have.attribute('selected'); + } + + element.removeAttribute('disabled'); + await waitForLitRender(element); + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); + } + + expect(optionOne).to.have.attribute('selected'); + expect(optionTwo).not.to.have.attribute('selected'); + expect(optionThree).to.have.attribute('selected'); + }); +}); diff --git a/src/components/option/optgroup/optgroup.scss b/src/components/option/optgroup/optgroup.scss new file mode 100644 index 0000000000..b747083a87 --- /dev/null +++ b/src/components/option/optgroup/optgroup.scss @@ -0,0 +1,68 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-optgroup-divider-display: block; + --sbb-optgroup-divider-spacing: 0; + --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-4x); + --sbb-optgroup-label-padding-inline: var(--sbb-spacing-fixed-4x); + --sbb-optgroup-label-font-size: var(--sbb-typo-scale-0-75x); + --sbb-optgroup-label-color: var(--sbb-color-metal-default); +} + +:host(:first-child) { + --sbb-optgroup-divider-display: none; + --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-2x); +} + +:host([data-variant='select']) { + --sbb-optgroup-divider-spacing: var(--sbb-spacing-fixed-4x); + --sbb-optgroup-label-padding-inline: var(--sbb-spacing-responsive-xxxs); + --sbb-optgroup-label-padding-start: 0; + --sbb-optgroup-label-font-size: inherit; +} + +:host([data-variant='select']:first-child) { + --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-4x); +} + +:host([data-negative]) { + --sbb-optgroup-label-color: var(--sbb-color-smoke-default); +} + +.sbb-optgroup { + margin-block: var(--sbb-spacing-fixed-4x); + margin-inline: var(--sbb-spacing-fixed-4x); +} + +.sbb-optgroup__label { + @include sbb.text-xxs--regular; + + display: flex; + column-gap: var(--sbb-spacing-responsive-xxxs); + color: var(--sbb-optgroup-label-color); + -webkit-text-fill-color: var(--sbb-optgroup-label-color); + padding-inline: var(--sbb-optgroup-label-padding-inline); + padding-block: var(--sbb-optgroup-label-padding-start) var(--sbb-spacing-fixed-2x); + + :host([data-variant='select'][data-multiple]) & { + @include sbb.text-xs--regular; + + padding-inline-start: calc(var(--sbb-spacing-responsive-xxxs) + var(--sbb-spacing-fixed-8x)); + } +} + +.sbb-optgroup__divider { + display: var(--sbb-optgroup-divider-display); + padding-block: var(--sbb-optgroup-divider-spacing); +} + +// Align the group label to the option label +.sbb-optgroup__icon-space { + // Can be overridden by the 'preserve-icon-space' on the autocomplete + display: var(--sbb-option-icon-container-display, none); + min-width: var(--sbb-size-icon-ui-small); +} diff --git a/src/components/option/optgroup/optgroup.spec.ts b/src/components/option/optgroup/optgroup.spec.ts new file mode 100644 index 0000000000..d067263e5c --- /dev/null +++ b/src/components/option/optgroup/optgroup.spec.ts @@ -0,0 +1,78 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import '../../autocomplete'; +import '../option'; +import './optgroup'; +import { isSafari } from '../../core/dom'; + +describe('sbb-optgroup', () => { + describe('autocomplete', function () { + it('renders', async () => { + const root = ( + await fixture(html` + + + 1 + 2 + + +
      + `) + ).querySelector('sbb-optgroup'); + const groupRoleAttr = 'aria-disabled="false" aria-label="Label" role="group"'; + + expect(root).dom.to.be.equal(` + + 1 + 2 + + `); + expect(root).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders disabled', async () => { + const root = ( + await fixture(html` + + + 1 + 2 + + +
      + `) + ).querySelector('sbb-optgroup'); + const groupRoleAttr = 'aria-disabled="true" aria-label="Label" role="group"'; + + expect(root).dom.to.be.equal(` + + 1 + 2 + + `); + + expect(root).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + }); +}); diff --git a/src/components/option/optgroup/optgroup.stories.tsx b/src/components/option/optgroup/optgroup.stories.tsx new file mode 100644 index 0000000000..71884b6ed5 --- /dev/null +++ b/src/components/option/optgroup/optgroup.stories.tsx @@ -0,0 +1,203 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import { StoryContext } from '@storybook/web-components'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import '../../form-field'; +import '../../autocomplete'; +import '../../select'; +import '../option'; +import './optgroup'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative + ? 'var(--sbb-color-black-default)' + : 'var(--sbb-color-white-default)', +}); + +const label: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option group', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option group', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option', + }, +}; + +const disabledSingle: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option', + }, +}; + +const multiple: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Select', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete/Select', + }, +}; + +const numberOfOptions: InputType = { + control: { + type: 'number', + }, +}; + +const defaultArgTypes: ArgTypes = { + label, + 'icon-name': iconName, + value, + disabled, + disabledSingle, + numberOfOptions, +}; + +const defaultArgs: Args = { + label: 'Option group', + 'icon-name': undefined, + value: 'Option', + disabled: false, + disabledSingle: false, + numberOfOptions: 3, +}; + +const borderDecorator: Decorator = (Story) => ( +
      + +
      +); + +const createOptions = (args): JSX.Element[] => + new Array(args.numberOfOptions).fill(null).map((_, i) => { + return ( + + {`${args.value} ${i + 1}`} + + ); + }); + +const Template = ({ label, disabled, ...args }): JSX.Element => ( + + + {createOptions(args)} + + + {createOptions(args)} + + +); + +const TemplateAutocomplete = (args): JSX.Element => { + return ( + + + {Template(args)} + + ); +}; + +const TemplateSelect = (args): JSX.Element => { + return ( + + + {Template(args)} + + + ); +}; + +export const Standalone: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + decorators: [borderDecorator], +}; + +export const Autocomplete: StoryObj = { + render: TemplateAutocomplete, + argTypes: { ...defaultArgTypes, negative }, + args: { ...defaultArgs, negative: false }, +}; + +export const Select: StoryObj = { + render: TemplateSelect, + argTypes: { ...defaultArgTypes, negative, multiple }, + args: { ...defaultArgs, multiple: false, negative: false }, +}; + +export const MultipleSelect: StoryObj = { + render: TemplateSelect, + argTypes: { ...defaultArgTypes, negative, multiple }, + args: { ...defaultArgs, multiple: true, negative: false }, +}; + +const meta: Meta = { + decorators: [ + (Story, context) => ( +
      + +
      + ), + ], + parameters: { + actions: { + handles: ['click'], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-option/sbb-optgroup', +}; + +export default meta; diff --git a/src/components/option/optgroup/optgroup.ts b/src/components/option/optgroup/optgroup.ts new file mode 100644 index 0000000000..98d7fb3ac8 --- /dev/null +++ b/src/components/option/optgroup/optgroup.ts @@ -0,0 +1,127 @@ +import { CSSResult, html, LitElement, TemplateResult, PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { isSafari, isValidAttribute, toggleDatasetEntry, setAttribute } from '../../core/dom'; +import { AgnosticMutationObserver } from '../../core/observers'; +import type { SbbOption, SbbOptionVariant } from '../option'; + +import style from './optgroup.scss?lit&inline'; +import '../../divider'; + +/** + * It can be used as a container for one or more `sbb-option`. + * + * @slot - Use the unnamed slot to add `sbb-option` elements to the `sbb-optgroup`. + */ +@customElement('sbb-optgroup') +export class SbbOptGroup extends LitElement { + public static override styles: CSSResult = style; + + /** Option group label. */ + @property() public label: string; + + /** Whether the group is disabled. */ + @property({ type: Boolean }) public disabled = false; + + @state() private _negative = false; + + private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); + + private _variant: SbbOptionVariant; + + /** + * On Safari, the groups labels are not read by VoiceOver. + * To solve the problem, we remove the role="group" and add a hidden span containing the group name + * TODO: We should periodically check if it has been solved and, if so, remove the property. + */ + private _inertAriaGroups = isSafari(); + + private get _isMultiple(): boolean { + return this._variant === 'select' && this.closest('sbb-select')?.hasAttribute('multiple'); + } + + private get _options(): SbbOption[] { + return Array.from(this.querySelectorAll('sbb-option')) as SbbOption[]; + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._negativeObserver?.disconnect(); + this._negative = !!this.closest(`:is(sbb-autocomplete, sbb-select, sbb-form-field)[negative]`); + toggleDatasetEntry(this, 'negative', this._negative); + + this._negativeObserver.observe(this, { + attributes: true, + attributeFilter: ['data-negative'], + }); + + this._setVariantByContext(); + this._proxyGroupLabelToOptions(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('disabled')) { + this._proxyDisabledToOptions(); + } + if (changedProperties.has('label')) { + this._proxyGroupLabelToOptions(); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._negativeObserver?.disconnect(); + } + + private _setVariantByContext(): void { + if (this.closest('sbb-autocomplete')) { + this._variant = 'autocomplete'; + } else if (this.closest('sbb-select')) { + this._variant = 'select'; + } + } + + private _proxyGroupLabelToOptions(): void { + if (!this._inertAriaGroups) { + return; + } + + this._options.forEach((opt) => opt.setGroupLabel(this.label)); + } + + private _proxyDisabledToOptions(): void { + for (const option of this._options) { + toggleDatasetEntry(option, 'groupDisabled', this.disabled); + } + } + + private _onNegativeChange(): void { + this._negative = isValidAttribute(this, 'data-negative'); + } + + protected override render(): TemplateResult { + setAttribute(this, 'role', !this._inertAriaGroups ? 'group' : null); + setAttribute(this, 'data-variant', this._variant); + setAttribute(this, 'data-multiple', this._isMultiple); + setAttribute(this, 'aria-label', !this._inertAriaGroups && this.label); + setAttribute(this, 'aria-disabled', !this._inertAriaGroups && this.disabled.toString()); + + return html` +
      + +
      + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-optgroup': SbbOptGroup; + } +} diff --git a/src/components/option/optgroup/readme.md b/src/components/option/optgroup/readme.md new file mode 100644 index 0000000000..98096a88cf --- /dev/null +++ b/src/components/option/optgroup/readme.md @@ -0,0 +1,45 @@ +The `sbb-optgroup` is a component used to group more [sbb-option](/docs/components-sbb-option-sbb-option--docs) +within a [sbb-autocomplete](/docs/components-sbb-autocomplete--docs) +or a [sbb-select](/docs/components-sbb-select--docs) component. + +A [sbb-divider](/docs/components-sbb-divider--docs) is displayed at the bottom of the component. + +## Slots + +It is possible to provide a set of `sbb-option` via an unnamed slot; +the component has also a `label` property as name of the group. + +```html + + 1 + 2 + 3 + +``` + +## States + +The component has a `disabled` property which sets all the `sbb-option` in the group as disabled. + +```html + + A + B + C + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | --------- | ------- | ------------------------------ | +| `label` | `label` | public | `string` | | Option group label. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the group is disabled. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------ | +| | Use the unnamed slot to add `sbb-option` elements to the `sbb-optgroup`. | diff --git a/src/components/option/option/index.ts b/src/components/option/option/index.ts new file mode 100644 index 0000000000..4216a00732 --- /dev/null +++ b/src/components/option/option/index.ts @@ -0,0 +1 @@ +export * from './option'; diff --git a/src/components/option/option/option.e2e.ts b/src/components/option/option/option.e2e.ts new file mode 100644 index 0000000000..5844bb67cd --- /dev/null +++ b/src/components/option/option/option.e2e.ts @@ -0,0 +1,79 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import '../../autocomplete'; +import { waitForLitRender, EventSpy } from '../../core/testing'; +import type { SbbFormField } from '../../form-field'; +import '../../form-field'; + +import { SbbOption } from './option'; + +describe('sbb-option', () => { + describe('autocomplete', () => { + let element: SbbFormField; + + beforeEach(async () => { + element = await fixture(html` + + + + Option 1 + Option 2 + Option 3 + + + `); + }); + + it('renders', async () => { + const option = element.querySelector('sbb-option'); + assert.instanceOf(option, SbbOption); + }); + + it('set selected and emits on click', async () => { + const selectionChangeSpy = new EventSpy(SbbOption.events.selectionChange); + const optionOne = element.querySelector('sbb-option'); + + optionOne.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + + expect(optionOne.selected).to.be.equal(true); + expect(selectionChangeSpy.count).to.be.equal(1); + }); + + it('highlight on input', async () => { + const input = element.querySelector('input'); + const autocomplete = element.querySelector('sbb-autocomplete'); + const options = element.querySelectorAll('sbb-option'); + const optionOneLabel = options[0].shadowRoot.querySelector('.sbb-option__label'); + const optionTwoLabel = options[1].shadowRoot.querySelector('.sbb-option__label'); + const optionThreeLabel = options[2].shadowRoot.querySelector('.sbb-option__label'); + + input.focus(); + await sendKeys({ press: '1' }); + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + Option + 1 + + + `); + expect(optionTwoLabel).dom.to.be.equal(` + + + Option 2 + + `); + expect(optionThreeLabel).dom.to.be.equal(` + + + Option 3 + + `); + }); + }); +}); diff --git a/src/components/option/option/option.scss b/src/components/option/option/option.scss new file mode 100644 index 0000000000..e662a33235 --- /dev/null +++ b/src/components/option/option/option.scss @@ -0,0 +1,146 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-option-color: var(--sbb-color-charcoal-default); + --sbb-option-background-color: inherit; + --sbb-option-background-color-hover: var(--sbb-color-milk-default); + --sbb-option-background-color-active: var(--sbb-color-cloud-default); + --sbb-option-disabled-border-color: var(--sbb-color-graphite-default); + --sbb-option-disabled-background-color: var(--sbb-color-milk-default); + --sbb-option-padding-inline: var(--sbb-spacing-responsive-xxxs); + --sbb-option-padding-block: calc(var(--sbb-spacing-fixed-2x) + var(--sbb-border-width-2x)); + --sbb-option-column-gap: var(--sbb-spacing-responsive-xxxs); + --sbb-option-justify-content: start; + --sbb-option-min-height: var(--sbb-size-button-m-min-height); + --sbb-option-cursor: pointer; + --sbb-option-border-radius: var(--sbb-border-radius-4x); + --sbb-option-icon-color: var(--sbb-color-metal-default); +} + +:host([data-negative]) { + --sbb-option-color: var(--sbb-color-milk-default); + --sbb-option-icon-color: var(--sbb-color-smoke-default); + --sbb-option-background-color-hover: var(--sbb-color-charcoal-default); + --sbb-option-background-color-active: var(--sbb-color-iron-default); + --sbb-option-disabled-border-color: var(--sbb-color-smoke-default); + --sbb-option-disabled-background-color: var(--sbb-color-charcoal-default); + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); +} + +:host([active]) { + --sbb-focus-outline-offset: calc(-1 * var(--sbb-spacing-fixed-1x)); +} + +:host(:hover:not([disabled], [data-group-disabled])) { + @include sbb.hover-mq($hover: true) { + --sbb-option-background-color: var(--sbb-option-background-color-hover); + } +} + +:host(:active:not([disabled], [data-group-disabled])) { + --sbb-option-background-color: var(--sbb-option-background-color-active); +} + +// if the highlight is enabled, hide the slot content +:host(:not([data-disable-highlight])) { + .sbb-option__label slot { + display: none; + } +} + +:host(:is([data-group-disabled], [disabled])) { + --sbb-option-cursor: default; + + @include sbb.if-forced-colors { + --sbb-option-color: GrayText; + } +} + +:host([data-variant='select']) { + --sbb-option-column-gap: var(--sbb-spacing-fixed-2x); + --sbb-option-justify-content: space-between; +} + +:host([data-variant='select'][data-multiple]) { + --sbb-option-justify-content: start; +} + +.sbb-option__label--highlight { + :host(:not(:is([disabled], [data-group-disabled]))) & { + @include sbb.text--bold; + @include sbb.if-forced-colors { + color: Highlight; + } + } +} + +.sbb-option__container { + background-color: var(--sbb-option-background-color); +} + +.sbb-option { + @include sbb.text-s--regular; + + display: flex; + align-items: center; + column-gap: var(--sbb-option-column-gap); + padding-inline: var(--sbb-option-padding-inline); + padding-block: var(--sbb-option-padding-block); + justify-content: var(--sbb-option-justify-content); + color: var(--sbb-option-color); + background-color: var(--sbb-option-background-color); + cursor: var(--sbb-option-cursor); + -webkit-tap-highlight-color: transparent; + -webkit-text-fill-color: var(--sbb-option-color); + + :host([active]) & { + @include sbb.focus-outline; + + border-radius: var(--sbb-option-border-radius); + } + + // Add inner border and background for disabled option when it's not multiple + :host(:is([data-group-disabled], [disabled]):not([data-multiple])) & { + position: relative; + z-index: 0; + + &::before { + content: ''; + display: block; + position: absolute; + inset: #{sbb.px-to-rem-build(6)}; + border: var(--sbb-border-width-1x) dashed var(--sbb-option-disabled-border-color); + border-radius: var(--sbb-border-radius-2x); + background-color: var(--sbb-option-disabled-background-color); + z-index: -1; + + @include sbb.if-forced-colors { + border-color: GrayText; + } + } + } +} + +.sbb-option__icon { + display: flex; + min-width: var(--sbb-size-icon-ui-small); + min-height: var(--sbb-size-icon-ui-small); + color: var(--sbb-option-icon-color); +} + +.sbb-option__icon--empty { + // Can be overridden by the 'preserve-icon-space' on the autocomplete + display: var(--sbb-option-icon-container-display, none); +} + +.sbb-option__label { + white-space: initial; +} + +.sbb-option__group-label--visually-hidden { + @include sbb.screen-reader-only; +} diff --git a/src/components/option/option/option.spec.ts b/src/components/option/option/option.spec.ts new file mode 100644 index 0000000000..dcee5aec32 --- /dev/null +++ b/src/components/option/option/option.spec.ts @@ -0,0 +1,68 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '../../autocomplete'; +import './option'; + +describe('sbb-option', () => { + describe('autocomplete', () => { + it('renders selected and active', async () => { + const root = ( + await fixture(html` + + Option 1 + +
      + `) + ).querySelector('sbb-option'); + + expect(root).dom.to.be.equal(` + + Option 1 + + `); + expect(root).shadowDom.to.be.equal(` +
      +
      + + + + + + Option 1 + +
      +
      + `); + }); + + it('renders disabled', async () => { + const root = ( + await fixture(html` + + Option 1 + +
      + `) + ).querySelector('sbb-option'); + + expect(root).dom.to.be.equal(` + + Option 1 + + `); + expect(root).shadowDom.to.be.equal(` +
      +
      + + + + + + Option 1 + +
      +
      + `); + }); + }); +}); diff --git a/src/components/option/option/option.stories.tsx b/src/components/option/option/option.stories.tsx new file mode 100644 index 0000000000..cd274c29b6 --- /dev/null +++ b/src/components/option/option/option.stories.tsx @@ -0,0 +1,201 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import { StoryContext } from '@storybook/web-components'; +import { Fragment, h, type JSX } from 'jsx-dom'; + +import { SbbOption } from './option'; +import readme from './readme.md?raw'; +import '../../form-field'; +import '../../select'; +import '../../autocomplete'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative + ? 'var(--sbb-color-black-default)' + : 'var(--sbb-color-white-default)', +}); + +const preserveIconSpace: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Wrapper property', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const active: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const numberOfOptions: InputType = { + control: { + type: 'number', + }, +}; + +const defaultArgTypes: ArgTypes = { + value, + 'icon-name': iconName, + active, + disabled, + numberOfOptions, + preserveIconSpace, +}; + +const defaultArgs: Args = { + value: 'Value', + 'icon-name': undefined, + active: false, + disabled: false, + numberOfOptions: 5, + preserveIconSpace: false, +}; + +const createOptions = ({ + value, + active, + disabled, + numberOfOptions, + preserveIconSpace, + ...args +}): JSX.Element[] => { + const style = preserveIconSpace ? { '--sbb-option-icon-container-display': 'block' } : {}; + return [ + ...new Array(numberOfOptions).fill(null).map((_, i) => { + return ( + + {`${value} ${i + 1}`} + + ); + }), + + Option Lorem ipsum dolor sit amet. + , + ]; +}; + +const StandaloneTemplate = (args): JSX.Element => {createOptions(args)}; + +const AutocompleteTemplate = (args): JSX.Element => ( + + + {createOptions(args)} + +); + +const SelectTemplate = (args): JSX.Element => ( + + {createOptions(args)} + +); + +const borderDecorator: Decorator = (Story) => ( +
      + +
      +); + +export const Standalone: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + decorators: [borderDecorator], +}; + +export const WithIcon: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'icon-name': 'clock-small' }, + decorators: [borderDecorator], +}; + +export const WithDisabledState: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, + decorators: [borderDecorator], +}; + +export const WithActiveState: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, active: true }, + decorators: [borderDecorator], +}; + +export const WithIconSpace: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, preserveIconSpace: true }, + decorators: [borderDecorator], +}; + +export const Autocomplete: StoryObj = { + render: AutocompleteTemplate, + argTypes: { ...defaultArgTypes, negative }, + args: { ...defaultArgs, negative: false }, +}; + +export const Select: StoryObj = { + render: SelectTemplate, + argTypes: { ...defaultArgTypes, negative }, + args: { ...defaultArgs, negative: false }, +}; + +const meta: Meta = { + decorators: [ + (Story, context) => ( +
      + +
      + ), + ], + parameters: { + actions: { + handles: [SbbOption.events.selectionChange, SbbOption.events.optionSelected], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-option/sbb-option', +}; + +export default meta; diff --git a/src/components/option/option/option.ts b/src/components/option/option/option.ts new file mode 100644 index 0000000000..7fd35602ef --- /dev/null +++ b/src/components/option/option/option.ts @@ -0,0 +1,325 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { assignId } from '../../core/a11y'; +import { + isSafari, + isValidAttribute, + isAndroid, + toggleDatasetEntry, + setAttribute, +} from '../../core/dom'; +import { + createNamedSlotState, + HandlerRepository, + namedSlotChangeHandlerAspect, + EventEmitter, + ConnectedAbortController, +} from '../../core/eventing'; +import { AgnosticMutationObserver } from '../../core/observers'; + +import style from './option.scss?lit&inline'; +import '../../visual-checkbox'; +import '../../icon'; + +let nextId = 0; + +/** Configuration for the attribute to look at if component is nested in a sbb-checkbox-group */ +const optionObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-disabled', 'data-negative'], +}; + +export type SbbOptionVariant = 'autocomplete' | 'select'; + +/** + * It displays on option item which can be used in `sbb-select` or `sbb-autocomplete`. + * + * @slot - Use the unnamed slot to add content to the option label. + * @slot icon - Use this slot to provide an icon. If `icon-name` is set, a sbb-icon will be used. + * @event {CustomEvent} option-selection-change - Emits when the option selection status changes. + * @event {CustomEvent} option-selected - Emits when an option was selected by user. + */ +@customElement('sbb-option') +export class SbbOption extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + selectionChange: 'option-selection-change', + optionSelected: 'option-selected', + } as const; + + /** Value of the option. */ + @property() public value?: string; + + /** + * The icon name we want to use, choose from the small icon variants + * from the ui-icons category from here + * https://icons.app.sbb.ch. + */ + @property({ attribute: 'icon-name' }) public iconName?: string; + + /** Whether the option is currently active. */ + @property({ reflect: true, type: Boolean }) public active?: boolean; + + /** Whether the option is selected. */ + @property({ reflect: true, type: Boolean }) public selected = false; + + /** Whether the option is disabled. */ + @property({ type: Boolean }) public disabled?: boolean; + + /** Emits when the option selection status changes. */ + private _selectionChange: EventEmitter = new EventEmitter(this, SbbOption.events.selectionChange); + + /** Emits when an option was selected by user. */ + private _optionSelected: EventEmitter = new EventEmitter(this, SbbOption.events.optionSelected); + + /** Wheter to apply the negative styling */ + @state() private _negative = false; + + /** Whether the component must be set disabled due disabled attribute on sbb-checkbox-group. */ + @state() private _disabledFromGroup = false; + + /** State of listed named slots, by indicating whether any element for a named slot is defined. */ + @state() private _namedSlots = createNamedSlotState('icon'); + + @state() private _label: string; + + /** The portion of the highlighted label. */ + @state() private _highlightString: string; + + /** Disable the highlight of the label. */ + @state() private _disableLabelHighlight: boolean; + + @state() private _groupLabel: string; + + private _optionId = `sbb-option-${++nextId}`; + private _variant: SbbOptionVariant; + private _abort = new ConnectedAbortController(this); + + /** + * On Safari, the groups labels are not read by VoiceOver. + * To solve the problem, we remove the role="group" and add an hidden span containing the group name + * TODO: We should periodically check if it has been solved and, if so, remove the property. + */ + private _inertAriaGroups = isSafari(); + + private _handlerRepository = new HandlerRepository( + this, + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + ); + + /** MutationObserver on data attributes. */ + private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => + this._onOptionAttributesChange(mutationsList), + ); + + private get _isAutocomplete(): boolean { + return this._variant === 'autocomplete'; + } + private get _isSelect(): boolean { + return this._variant === 'select'; + } + private get _isMultiple(): boolean { + return this.closest('sbb-select')?.hasAttribute('multiple'); + } + + /** + * Highlight the label of the option + * @param value the highlighted portion of the label + * @internal + */ + public highlight(value: string): void { + this._highlightString = value; + } + + /** + * Set the option group label (used for a11y) + * @param value the label of the option group + */ + public setGroupLabel(value: string): void { + this._groupLabel = value; + } + + /** + * @internal + */ + public setSelectedViaUserInteraction(selected: boolean): void { + this.selected = selected; + this._selectionChange.emit(); + if (this.selected) { + this._optionSelected.emit(); + } + } + + private _selectByClick(event): void { + if (this.disabled || this._disabledFromGroup) { + event.stopPropagation(); + return; + } + + if (this._isMultiple) { + event.stopPropagation(); + this.setSelectedViaUserInteraction(!this.selected); + } else { + this.setSelectedViaUserInteraction(true); + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this._handlerRepository.connect(); + const parentGroup = this.closest('sbb-optgroup'); + if (parentGroup) { + this._disabledFromGroup = isValidAttribute(parentGroup, 'disabled'); + } + this._optionAttributeObserver.observe(this, optionObserverConfig); + + this._negative = !!this.closest( + // :is() selector not possible due to test environment + `sbb-autocomplete[negative],sbb-select[negative],sbb-form-field[negative]`, + ); + toggleDatasetEntry(this, 'negative', this._negative); + + this._setVariantByContext(); + + this.addEventListener('click', (e) => this._selectByClick(e), { signal, passive: true }); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + this._optionAttributeObserver.disconnect(); + } + + private _setVariantByContext(): void { + if (this.closest('sbb-autocomplete')) { + this._variant = 'autocomplete'; + return; + } + + if (this.closest('sbb-select')) { + this._variant = 'select'; + } + } + + /** Observe changes on data attributes and set the appropriate values. */ + private _onOptionAttributesChange(mutationsList: MutationRecord[]): void { + for (const mutation of mutationsList) { + if (mutation.attributeName === 'data-group-disabled') { + this._disabledFromGroup = isValidAttribute(this, 'data-group-disabled'); + } else if (mutation.attributeName === 'data-negative') { + this._negative = isValidAttribute(this, 'data-negative'); + } + } + } + + private _setupHighlightHandler(event): void { + if (!this._isAutocomplete) { + this._disableLabelHighlight = true; + return; + } + + const slotNodes = (event.target as HTMLSlotElement).assignedNodes(); + const labelNode = slotNodes.filter((el) => el.nodeType === Node.TEXT_NODE)[0] as Text; + + // Disable the highlight if the slot does not contain just a text node + if (!labelNode || slotNodes.length !== 1) { + this._disableLabelHighlight = true; + return; + } + this._label = labelNode.wholeText; + } + + private _getHighlightedLabel(): TemplateResult { + if (!this._highlightString || !this._highlightString.trim()) { + return html`${this._label}`; + } + + const matchIndex = this._label.toLowerCase().indexOf(this._highlightString.toLowerCase()); + + if (matchIndex === -1) { + return html`${this._label}`; + } + + const prefix = this._label.substring(0, matchIndex); + const highlighted = this._label.substring( + matchIndex, + matchIndex + this._highlightString.length, + ); + const postfix = this._label.substring(matchIndex + this._highlightString.length); + + return html` + ${prefix}${highlighted}${postfix} + `; + } + + protected override render(): TemplateResult { + const isMultiple = this._isMultiple; + setAttribute(this, 'role', 'option'); + setAttribute(this, 'tabindex', isAndroid() && !this.disabled && 0); + setAttribute(this, 'data-variant', this._variant); + setAttribute(this, 'data-multiple', isMultiple); + setAttribute(this, 'data-disable-highlight', this._disableLabelHighlight); + setAttribute(this, 'aria-selected', `${this.selected}`); + setAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + assignId(() => this._optionId)(this); + + return html` +
      +
      + + ${!isMultiple + ? html` + + ${this.iconName ? html`` : nothing} + + ` + : nothing} + + + ${isMultiple + ? html` ` + : nothing} + + + + + + + ${this._isAutocomplete && this._label && !this._disableLabelHighlight + ? this._getHighlightedLabel() + : nothing} + ${this._inertAriaGroups && this._groupLabel + ? html` + (${this._groupLabel})` + : nothing} + + + + ${this._isSelect && !isMultiple && this.selected + ? html`` + : nothing} +
      +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-option': SbbOption; + } +} diff --git a/src/components/option/option/readme.md b/src/components/option/option/readme.md new file mode 100644 index 0000000000..367875193b --- /dev/null +++ b/src/components/option/option/readme.md @@ -0,0 +1,88 @@ +The `sbb-option` is a component which can be used to display items in components like +[sbb-autocomplete](/docs/components-sbb-autocomplete--docs) or a [sbb-select](/docs/components-sbb-select--docs). + +## Slots + +It is possible to provide a label via an unnamed slot; the component can optionally display a `sbb-icon` +at the component start using the `iconName` property or via custom content using the `icon` slot. +Icon space can be reserved even if the `iconName` property is not set by overriding the `--sbb-option-icon-container-display` variable. + +```html +Option label + +Option label +``` + +## States + +Like the native `option`, the component has a `value` property. + +The `selected`, `disabled` and `active` properties are connected to the self-named states. +When disabled, the selection via click is prevented. +If the `sbb-option` is nested in a `sbb-optgroup` component, it inherits from the parent the `disabled` state. + +```html +Option label + +Option label + +Option label +``` + +## Events + +Consumers can listen to the `optionSelected` event on the `sbb-option` component to intercept the selected value; +the event is triggered if the element has been selected by some user interaction. Alternatively, +the `selectionChange` event can be listened to, which is triggered if the element has been both selected or deselected. + +## Style + +If the label slot contains only a **text node**, it is possible to search for text in the `sbb-option` using the +`highlight` method, passing the desired text; if the text is present it will be highlighted in bold. + +```html + + Highlightable caption + + + + Not highlightable caption + + + + + Highlightable caption + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `value` | public | `string \| undefined` | | Value of the option. | +| `iconName` | `icon-name` | public | `string \| undefined` | | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | +| `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | +| `selected` | `selected` | public | `boolean` | `false` | Whether the option is selected. | +| `disabled` | `disabled` | public | `boolean \| undefined` | | Whether the option is disabled. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| --------------- | ------- | ------------------------------------------ | --------------- | ------ | -------------- | +| `setGroupLabel` | public | Set the option group label (used for a11y) | `value: string` | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ------------------------- | ------------------- | ----------------------------------------------- | -------------- | +| `option-selection-change` | `CustomEvent` | Emits when the option selection status changes. | | +| `option-selected` | `CustomEvent` | Emits when an option was selected by user. | | + +## Slots + +| Name | Description | +| ------ | --------------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the option label. | +| `icon` | Use this slot to provide an icon. If `icon-name` is set, a sbb-icon will be used. | diff --git a/src/components/package.json b/src/components/package.json new file mode 100644 index 0000000000..b341b6159a --- /dev/null +++ b/src/components/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sbb-esta/lyne-components", + "version": "0.0.0-PLACEHOLDER", + "description": "Lyne Design System", + "keywords": [ + "design system", + "web components", + "lit", + "storybook" + ], + "type": "module", + "customElements": "custom-elements.json", + "dependencies": { + "lit": "0.0.0-LIT" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/components/pearl-chain-time/index.ts b/src/components/pearl-chain-time/index.ts new file mode 100644 index 0000000000..fbeecd6cbf --- /dev/null +++ b/src/components/pearl-chain-time/index.ts @@ -0,0 +1 @@ +export * from './pearl-chain-time'; diff --git a/src/components/pearl-chain-time/pearl-chain-time.scss b/src/components/pearl-chain-time/pearl-chain-time.scss new file mode 100644 index 0000000000..5ca1b60ee6 --- /dev/null +++ b/src/components/pearl-chain-time/pearl-chain-time.scss @@ -0,0 +1,74 @@ +@use '../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-pearl-chain-time-height: auto; + + @include sbb.mq($from: small) { + --sbb-pearl-chain-time-height: #{sbb.px-to-rem-build(25)}; + } + + @include sbb.mq($from: medium) { + --sbb-pearl-chain-time-height: #{sbb.px-to-rem-build(28)}; + } +} + +.sbb-pearl-chain__time { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + height: var(--sbb-pearl-chain-time-height); +} + +.sbb-pearl-chain__time-walktime, +.sbb-pearl-chain__time-transfer { + @include sbb.text-xxs--regular; + + display: inline-flex; + align-items: center; +} + +.sbb-pearl-chain__time-walktime--left { + transform: translateX(sbb.px-to-rem-build(-7)); + margin-inline-end: calc(var(--sbb-spacing-fixed-2x) - sbb.px-to-rem-build(7)); +} + +.sbb-pearl-chain__time-walktime--right { + margin-inline-start: calc(var(--sbb-spacing-fixed-2x) - sbb.px-to-rem-build(4)); +} + +.sbb-pearl-chain__time-walktime-prime-symbol { + float: right; +} + +.sbb-pearl-chain__time-transfer { + gap: var(--sbb-spacing-fixed-1x); +} + +.sbb-pearl-chain__time-transfer--departure { + margin-inline-end: var(--sbb-spacing-fixed-2x); +} + +.sbb-pearl-chain__time-transfer--arrival { + margin-inline-start: calc(var(--sbb-spacing-fixed-2x) - sbb.px-to-rem-build(4)); +} + +.sbb-pearl-chain__time-chain { + flex: 1 1 auto; + align-self: center; + margin-inline: var(--sbb-spacing-fixed-3x); +} + +.sbb-pearl-chain__time-time { + @include sbb.text-s--bold; + + color: var(--sbb-color-charcoal-default); +} + +.sbb-screenreaderonly { + @include sbb.screen-reader-only; +} diff --git a/src/components/pearl-chain-time/pearl-chain-time.spec.ts b/src/components/pearl-chain-time/pearl-chain-time.spec.ts new file mode 100644 index 0000000000..5af7892103 --- /dev/null +++ b/src/components/pearl-chain-time/pearl-chain-time.spec.ts @@ -0,0 +1,237 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../core/testing'; +import { PtRideLeg } from '../core/timetable'; + +import type { SbbPearlChainTime } from './pearl-chain-time'; + +import './pearl-chain-time'; + +const now = new Date('2022-08-16T15:00:00Z').valueOf(); + +describe('sbb-pearl-chain-time', () => { + it('should render component with time', async () => { + const element = await fixture(html` + + + `); + element.legs = [ + { + __typename: 'PTRideLeg', + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + + + +
      + `); + }); + + it('should render component with departure walk', async () => { + const element = await fixture(html` + + + `); + element.legs = [ + { + __typename: 'PTRideLeg', + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + + + + + + + +
      + `); + }); + + it('should render component with arrival walk', async () => { + const element = await fixture(html` + + + `); + element.legs = [ + { + __typename: 'PTRideLeg', + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + + + + + + + +
      + `); + }); + + it('should render component with departure and arrival walk', async () => { + const element = await fixture(html` + + + `); + element.legs = [ + { + __typename: 'PTRideLeg', + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + + + + + + + + + + + +
      + `); + }); +}); diff --git a/src/components/pearl-chain-time/pearl-chain-time.stories.tsx b/src/components/pearl-chain-time/pearl-chain-time.stories.tsx new file mode 100644 index 0000000000..eaf9178ac5 --- /dev/null +++ b/src/components/pearl-chain-time/pearl-chain-time.stories.tsx @@ -0,0 +1,136 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { h, type JSX } from 'jsx-dom'; + +import { extendedLeg, progressLeg } from '../pearl-chain/pearl-chain.sample-data'; + +import readme from './readme.md?raw'; + +import './pearl-chain-time'; + +const departureWalk: InputType = { + control: { + type: 'number', + }, +}; + +const arrivalWalk: InputType = { + control: { + type: 'number', + }, +}; + +const departureTime: InputType = { + control: { + type: 'text', + }, +}; + +const arrivalTime: InputType = { + control: { + type: 'text', + }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const now: InputType = { + control: { + type: 'date', + }, +}; + +const defaultArgTypes: ArgTypes = { + 'departure-walk': departureWalk, + 'arrival-walk': arrivalWalk, + 'arrival-time': arrivalTime, + 'departure-time': departureTime, + 'disable-animation': disableAnimation, + 'data-now': now, +}; + +const defaultArgs: Args = { + legs: [progressLeg], + 'arrival-time': '2022-12-11T14:11:00', + 'departure-time': '2022-12-11T12:11:00', + 'disable-animation': isChromatic(), + 'data-now': new Date('2022-12-01T12:11:00').valueOf(), +}; + +const Template = (args): JSX.Element => { + return ; +}; + +export const minimal: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; + +export const withDepartureWalk: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + 'departure-walk': '10', + }, +}; + +export const withArrivalWalk: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + 'arrival-walk': '5', + }, +}; + +export const mixed: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + 'departure-walk': '0', + 'arrival-walk': '5', + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + legs: [progressLeg], + }, +}; + +export const extendedEnter: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + 'departure-walk': '10', + 'arrival-walk': '5', + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + legs: [extendedLeg], + }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + ], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'timetable/sbb-pearl-chain-time', +}; + +export default meta; diff --git a/src/components/pearl-chain-time/pearl-chain-time.ts b/src/components/pearl-chain-time/pearl-chain-time.ts new file mode 100644 index 0000000000..c2ae8af656 --- /dev/null +++ b/src/components/pearl-chain-time/pearl-chain-time.ts @@ -0,0 +1,127 @@ +import { format } from 'date-fns'; +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { removeTimezoneFromISOTimeString } from '../core/datetime'; +import { documentLanguage, HandlerRepository, languageChangeHandlerAspect } from '../core/eventing'; +import { i18nDeparture, i18nArrival, i18nTransferProcedures } from '../core/i18n'; +import { getDepartureArrivalTimeAttribute, isRideLeg, Leg, PtRideLeg } from '../core/timetable'; + +import style from './pearl-chain-time.scss?lit&inline'; + +import '../pearl-chain'; + +/** + * Combined with `sbb-pearl-chain`, it displays walk time information. + */ +@customElement('sbb-pearl-chain-time') +export class SbbPearlChainTime extends LitElement { + public static override styles: CSSResult = style; + + /** + * define the legs of the pearl-chain. + * Format: + * `{"legs": [{"duration": 25}, ...]}` + * `duration` in minutes. Duration of the leg is relative + * to the total travel time. Example: departure 16:30, change at 16:40, + * arrival at 17:00. So the change should have a duration of 33.33%. + */ + @property({ type: Array }) public legs!: (Leg | PtRideLeg)[]; + + /** Prop to render the departure time - will be formatted as "H:mm" */ + @property({ attribute: 'departure-time' }) public departureTime?: string; + + /** Prop to render the arrival time - will be formatted as "H:mm" */ + @property({ attribute: 'arrival-time' }) public arrivalTime?: string; + + /** Optional prop to render the walk time (in minutes) before departure */ + @property({ attribute: 'departure-walk', type: Number }) public departureWalk?: number; + + /** Optional prop to render the walk time (in minutes) after arrival */ + @property({ attribute: 'arrival-walk', type: Number }) public arrivalWalk?: number; + + /** + * Per default, the current location has a pulsating animation. You can + * disable the animation with this property. + */ + @property({ attribute: 'disable-animation', type: Boolean }) public disableAnimation?: boolean; + + @state() private _currentLanguage = documentLanguage(); + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + ); + + public override connectedCallback(): void { + super.connectedCallback(); + this._handlerRepository.connect(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + private _now(): number { + const dataNow = +this.dataset?.now; + return isNaN(dataNow) ? Date.now() : dataNow; + } + + protected override render(): TemplateResult { + const departure: Date | undefined = this.departureTime + ? removeTimezoneFromISOTimeString(this.departureTime) + : undefined; + const arrival: Date | undefined = this.arrivalTime + ? removeTimezoneFromISOTimeString(this.arrivalTime) + : undefined; + + const { renderDepartureTimeAttribute, renderArrivalTimeAttribute } = + getDepartureArrivalTimeAttribute( + this.legs, + this.departureWalk || 0, + this.arrivalWalk || 0, + this._currentLanguage, + ); + + const rideLegs = this.legs?.filter((leg) => isRideLeg(leg)); + return html` +
      + ${renderDepartureTimeAttribute()} + ${departure + ? html`` + : nothing} + ${rideLegs?.length > 1 + ? html` + ${rideLegs?.length - 1} ${i18nTransferProcedures[this._currentLanguage]} + ` + : nothing} + + ${arrival + ? html`` + : nothing} + ${renderArrivalTimeAttribute()} +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-pearl-chain-time': SbbPearlChainTime; + } +} diff --git a/src/components/pearl-chain-time/readme.md b/src/components/pearl-chain-time/readme.md new file mode 100644 index 0000000000..ba81f9cb4e --- /dev/null +++ b/src/components/pearl-chain-time/readme.md @@ -0,0 +1,64 @@ +The `sbb-pearl-chain-time` component adds an optional walk icon and a duration in minutes +before and/or after the [sbb-pearl-chain](/docs/timetable-sbb-pearl-chain--docs). + +The walk time indicates that the user has to walk to get to the destination, or to the station to begin the journey. + +The `legs` property is mandatory. + +```json +[ + { + "__typename": "PTRideLeg", + "arrival": { + "time": "2022-12-11T12:13:00+01:00" + }, + "departure": { + "time": "2022-12-07T12:11:00+01:00" + }, + "serviceJourney": { + "serviceAlteration": { + "cancelled": false, + "delayText": "string", + "reachable": true, + "unplannedStopPointsText": "" + }, + "notices": [ + { + "name": "CI", + "text": { + "template": "Extended boarding time (45')" + } + } + ] + } + } +] +``` + +```html + +``` + +## Testing + +To specify a specific date for the current datetime, you can use the `data-now` attribute (timestamp in milliseconds). +This is helpful if you need a specific state of the component. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------ | ------------------- | ------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `legs` | `legs` | public | `(Leg \| PtRideLeg)[]` | | define the legs of the pearl-chain. Format: `{"legs": \[{"duration": 25}, ...]}` `duration` in minutes. Duration of the leg is relative to the total travel time. Example: departure 16:30, change at 16:40, arrival at 17:00. So the change should have a duration of 33.33%. | +| `departureTime` | `departure-time` | public | `string \| undefined` | | Prop to render the departure time - will be formatted as "H:mm" | +| `arrivalTime` | `arrival-time` | public | `string \| undefined` | | Prop to render the arrival time - will be formatted as "H:mm" | +| `departureWalk` | `departure-walk` | public | `number \| undefined` | | Optional prop to render the walk time (in minutes) before departure | +| `arrivalWalk` | `arrival-walk` | public | `number \| undefined` | | Optional prop to render the walk time (in minutes) after arrival | +| `disableAnimation` | `disable-animation` | public | `boolean \| undefined` | | Per default, the current location has a pulsating animation. You can disable the animation with this property. | diff --git a/src/components/pearl-chain-vertical-item/index.ts b/src/components/pearl-chain-vertical-item/index.ts new file mode 100644 index 0000000000..f5231c3ff5 --- /dev/null +++ b/src/components/pearl-chain-vertical-item/index.ts @@ -0,0 +1 @@ +export * from './pearl-chain-vertical-item'; diff --git a/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.scss b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.scss new file mode 100644 index 0000000000..4e7663b254 --- /dev/null +++ b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.scss @@ -0,0 +1,188 @@ +@use '../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + @include sbb.pearl-chain-bullet-variables; + + display: table-row; + position: relative; + + --sbb-pearl-chain-vertical-right-item-width: 100%; + --sbb-pearl-chain-vertical-middle-item-min-width: var(--sbb-pearl-chain-bullet-size-start-end); + --sbb-pearl-chain-vertical-item-border-width: var(--sbb-border-width-2x); + --sbb-pearl-chain-vertical-item-border-width-thin: var(--sbb-border-width-1x); + --sbb-pearl-chain-vertical-item-spacing-small: var(--sbb-spacing-fixed-1x); + --sbb-pearl-chain-vertical-item-spacing-medium: var(--sbb-spacing-fixed-4x); + --sbb-pearl-chain-vertical-item-color-default: var(--sbb-pearl-chain-bullet-color); + --sbb-pearl-chain-vertical-item-color-disruption: var(--sbb-pearl-chain-bullet-color-disruption); + --sbb-pearl-chain-vertical-item-color-past: var(--sbb-pearl-chain-bullet-color-past); + --sbb-pearl-chain-vertical-item-color-walk: var(--sbb-color-sky-default); + --sbb-pearl-chain-vertical-item-inline-start: 50%; + --sbb-pearl-chain-vertical-item-transform: translateX(-50%); +} + +// layout styles +slot[name='right'], +.sbb-pearl-chain-vertical-item__column { + display: table-cell; + position: relative; + vertical-align: top; +} + +slot[name='right'] { + width: var(--sbb-pearl-chain-vertical-right-item-width); +} + +.sbb-pearl-chain-vertical-item__column--middle { + min-width: var( + --sbb-pearl-chain-vertical-middle-item-min-width, + --sbb-pearl-chain-vertical-middle-min-width + ); +} + +slot[name='left']::slotted(*) { + margin-block-start: var(--sbb-pearl-chain-vertical-left-item-block-start); + padding-inline-end: var(--sbb-pearl-chain-vertical-left-item-inline-end); +} + +slot[name='right']::slotted(*) { + margin-block-start: var(--sbb-pearl-chain-vertical-right-item-block-start); + padding-inline-start: var(--sbb-pearl-chain-vertical-right-item-inline-start); +} + +.sbb-pearl-chain-vertical-item__middle { + vertical-align: top; + border-spacing: 0; +} + +// line styles + +.sbb-pearl-chain-vertical-item__line { + position: absolute; + bottom: 0; + inset-inline-start: var(--sbb-pearl-chain-vertical-item-inline-start); + transform: var(--sbb-pearl-chain-vertical-item-transform); + width: var(--sbb-border-width-2x); + inset-block-start: calc(var(--sbb-pearl-chain-bullet-size-start-end) / 2); + + &::after { + content: ''; + position: absolute; + inset-inline-start: 0; + inset-block-start: 0; + height: var(--sbb-pearl-chain-vertical-item-leg-status); + width: var(--sbb-pearl-chain-vertical-item-border-width); + background-color: var(--sbb-pearl-chain-vertical-item-color-past); + + @include sbb.if-forced-colors { + --sbb-pearl-chain-vertical-item-color-past: GrayText; + } + } +} + +.sbb-pearl-chain-vertical-item__line--dotted { + background-color: unset; + border-color: unset; + background-image: linear-gradient(to bottom, currentcolor 0%, currentcolor 50%, Canvas 50%); + background-repeat: repeat-y; + background-size: calc(2 * var(--sbb-pearl-chain-vertical-item-spacing-small)) + var(--sbb-pearl-chain-vertical-item-spacing-small); + + @include sbb.if-forced-colors { + background-color: unset !important; + border-inline-start: #{sbb.px-to-rem-build(1)} dashed Highlight; + transform: translateY(#{sbb.px-to-rem-build(1)}); + } +} + +.sbb-pearl-chain-vertical-item__line--thin { + width: var(--sbb-pearl-chain-vertical-item-border-width-thin); +} + +.sbb-pearl-chain-vertical-item__line--default { + @include sbb.if-forced-colors { + --sbb-pearl-chain-vertical-item-color-default: CanvasText; + } + + background-color: var(--sbb-pearl-chain-vertical-item-color-default); + border-color: var(--sbb-pearl-chain-vertical-item-color-default); + color: var(--sbb-pearl-chain-vertical-item-color-default); +} + +.sbb-pearl-chain-vertical-item__line--disruption { + border-color: var(--sbb-pearl-chain-vertical-item-color-disruption); + background-color: var(--sbb-pearl-chain-vertical-item-color-disruption); + color: var(--sbb-pearl-chain-vertical-item-color-disruption); + @include sbb.if-forced-colors { + --sbb-pearl-chain-vertical-item-color-disruption: Highlight; + } +} + +.sbb-pearl-chain-vertical-item__line--past { + @include sbb.if-forced-colors { + --sbb-pearl-chain-vertical-item-color-past: GrayText; + } + + border-color: var(--sbb-pearl-chain-vertical-item-color-past); + background-color: var(--sbb-pearl-chain-vertical-item-color-past); + color: var(--sbb-pearl-chain-vertical-item-color-past); +} + +.sbb-pearl-chain-vertical-item__line--walk { + border-color: var(--sbb-pearl-chain-vertical-item-color-walk); + background-color: var(--sbb-pearl-chain-vertical-item-color-walk); + color: var(--sbb-pearl-chain-vertical-item-color-walk); +} + +// Bullet styles + +.sbb-pearl-chain-vertical-item__bullet { + @include sbb.pearl-chain-bullet; + + position: relative; + inset-inline-start: var(--sbb-pearl-chain-vertical-item-inline-start); + transform: var(--sbb-pearl-chain-vertical-item-transform); +} + +.sbb-pearl-chain-vertical-item__bullet--start-end { + @include sbb.pearl-chain-bullet-start-end; +} + +.sbb-pearl-chain-vertical-item__bullet--stop { + @include sbb.pearl-chain-bullet-stop; +} + +.sbb-pearl-chain-vertical-item__bullet--disruption { + @include sbb.pearl-chain-bullet-disruption; +} + +.sbb-pearl-chain-vertical-item__bullet--irrelevant, +.sbb-pearl-chain-vertical-item__bullet--past { + @include sbb.pearl-chain-bullet-past; +} + +.sbb-pearl-chain-vertical-item__bullet--stop.sbb-pearl-chain-vertical-item__bullet--irrelevant { + @include sbb.pearl-chain-bullet-irrelevant-stop; +} + +.sbb-pearl-chain-vertical-item__bullet--start-end.sbb-pearl-chain-vertical-item__bullet--skipped { + @include sbb.pearl-chain-bullet-skipped; +} + +.sbb-pearl-chain-vertical-item__bullet--stop.sbb-pearl-chain-vertical-item__bullet--skipped { + @include sbb.pearl-chain-bullet-skipped-stop; +} + +.sbb-pearl-chain-vertical-item__bullet--position { + @include sbb.pearl-chain-bullet-position; + @include sbb.absolute-center-x; + + inset-block-start: var(--sbb-pearl-chain-vertical-item-position); + + :host([disable-animation]) & { + animation: unset !important; + } +} diff --git a/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.spec.ts b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.spec.ts new file mode 100644 index 0000000000..cd21e4f2ba --- /dev/null +++ b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.spec.ts @@ -0,0 +1,243 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../core/testing'; + +import type { LineColor, SbbPearlChainVerticalItem } from './pearl-chain-vertical-item'; + +import './pearl-chain-vertical-item'; + +describe('sbb-pearl-chain-vertical-item', () => { + it('renders component with charcoal standard line and bullet', async () => { + const element = await fixture(html` + + + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'default', + bulletType: 'default', + minHeight: 100, + hideLine: false, + bulletSize: 'start-end', + position: 0, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders component with red line and bullet', async () => { + const element = await fixture(html` + + + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'disruption', + bulletType: 'default', + minHeight: 100, + hideLine: false, + bulletSize: 'start-end', + position: 0, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + + + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders component with left slot', async () => { + const element = await fixture(html` + +
      content
      +
      + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'dotted', + lineColor: 'charcoal' as LineColor, + bulletType: 'default', + minHeight: 100, + bulletSize: 'start-end', + hideLine: true, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + +
      content
      +
      + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders component with right slot', async () => { + const element = await fixture(html` + +
      right content
      +
      + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'disruption', + bulletType: 'past', + minHeight: 100, + bulletSize: 'start-end', + hideLine: true, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + +
      right content
      +
      + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders component with both slots', async () => { + const element = await fixture(html` + +
      right content
      +
      left content
      +
      + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'disruption', + bulletType: 'past', + minHeight: 100, + bulletSize: 'start-end', + hideLine: true, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + +
      right content
      +
      left content
      +
      + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders a position', async () => { + const element = await fixture(html` + +
      right content
      +
      left content
      +
      + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'disruption', + bulletType: 'default', + minHeight: 100, + hideLine: true, + bulletSize: 'start-end', + position: 50, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + +
      right content
      +
      left content
      +
      + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); + + it('renders a crossed-bullet', async () => { + const element = await fixture(html` + +
      right content
      +
      left content
      +
      + `); + + element.pearlChainVerticalItemAttributes = { + lineType: 'standard', + lineColor: 'disruption', + bulletType: 'skipped', + minHeight: 100, + hideLine: true, + bulletSize: 'start-end', + position: 0, + }; + await waitForLitRender(element); + expect(element).dom.to.be.equal(` + +
      right content
      +
      left content
      +
      + `); + expect(element).shadowDom.to.be.equal(` +
      + +
      + + + `); + }); +}); diff --git a/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.stories.tsx b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.stories.tsx new file mode 100644 index 0000000000..aaa52ab909 --- /dev/null +++ b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.stories.tsx @@ -0,0 +1,79 @@ +/** @jsx h */ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import '../pearl-chain-vertical'; +import './pearl-chain-vertical-item'; + +// We need to lie to the compiler in order for the CSS variables to work. +// Remove once https://github.com/alex-kinokon/jsx-dom/pull/90 is merged and released. +const styleHack = (args: Record): Record => + Object.entries(args) + .map(([k, v]) => `${k}: ${v}`) + .join(';') as unknown as Record; + +const Template = (args): JSX.Element => { + return ( + + +
      + slot for content +
      more
      +
      more
      +
      more
      +
      more
      +
      more
      +
      +
      +
      + ); +}; + +export const pearlChainItem: StoryObj = { + render: Template, + args: { + lineType: 'standard', + lineColor: 'default', + bulletType: 'default', + minHeight: '100', + hideLine: false, + bulletSize: 'start-end', + }, +}; + +const meta: Meta = { + decorators: [(Story) => ], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + argTypes: { + lineType: { + options: ['dotted', 'standard', 'thin'], + control: { type: 'select' }, + }, + lineColor: { + options: ['default', 'disruption', 'past', 'sky'], + control: { type: 'select' }, + }, + bulletType: { + options: ['default', 'disruption', 'past', 'irrelevant', 'skipped'], + control: { type: 'select' }, + }, + bulletSize: { + options: ['start-end', 'stop'], + control: { type: 'select' }, + }, + }, + title: 'timetable/sbb-pearl-chain-vertical-item', +}; + +export default meta; diff --git a/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.ts b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.ts new file mode 100644 index 0000000000..05d9f2c325 --- /dev/null +++ b/src/components/pearl-chain-vertical-item/pearl-chain-vertical-item.ts @@ -0,0 +1,87 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import style from './pearl-chain-vertical-item.scss?lit&inline'; + +export type LineType = 'dotted' | 'standard' | 'thin'; + +export type BulletType = 'default' | 'past' | 'irrelevant' | 'skipped' | 'disruption'; + +export type LineColor = 'default' | 'past' | 'disruption' | 'walk'; + +export type BulletSize = 'start-end' | 'stop'; + +export interface PearlChainVerticalItemAttributes { + lineType: LineType; + lineColor: LineColor; + bulletType?: BulletType; + minHeight: number; + hideLine: boolean; + bulletSize: BulletSize; + position?: number; +} + +/** + * It displays details about connection between stations. + * + * @slot left - Content of the left side of the item + * @slot right - Content of the right side of the item + */ +@customElement('sbb-pearl-chain-vertical-item') +export class SbbPearlChainVerticalItem extends LitElement { + public static override styles: CSSResult = style; + + /** The pearlChainVerticalItemAttributes Prop for styling the bullets and line.*/ + @property({ attribute: 'pearl-chain-vertical-item-attributes', type: Object }) + public pearlChainVerticalItemAttributes!: PearlChainVerticalItemAttributes; + + /** If true, the position won't be animated. */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation?: boolean; + + protected override render(): TemplateResult { + const { bulletType, lineType, lineColor, hideLine, minHeight, bulletSize, position } = + this.pearlChainVerticalItemAttributes || {}; + + const bulletTypeClass = + position > 0 && position <= 100 + ? 'sbb-pearl-chain-vertical-item__bullet--past' + : `sbb-pearl-chain-vertical-item__bullet--${bulletType}`; + + return html` +
      + +
      + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-pearl-chain-vertical-item': SbbPearlChainVerticalItem; + } +} diff --git a/src/components/pearl-chain-vertical-item/readme.md b/src/components/pearl-chain-vertical-item/readme.md new file mode 100644 index 0000000000..bb5f9fae49 --- /dev/null +++ b/src/components/pearl-chain-vertical-item/readme.md @@ -0,0 +1,49 @@ +The `sbb-pearl-chain-vertical-item` is intended to be used +with the [sbb-pearl-chain-vertical](/docs/timetable-sbb-pearl-chain-vertical--docs)` component. + +It renders a table-row with three table-cells, and it is used to display the dots and line of the pearl-chain. +There are two slots named `left` and `right` which make it possible to display content on the component sides. + +The `pearlChainVerticalItemAttributes` property is mandatory. + +```json +{ + "lineType": "standard", + "lineColor": "charcoal", + "minHeight": "89", + "hideLine": false, + "bulletType": "thick-bullet", + "bulletSize": "small" +} +``` + +```html + +
      content
      +
      content
      + >
      +``` + +## Style + +The component has many styling options, which can be configured through the 'pearlChainVerticalItemAttributes' property. +The slots themselves are unstyled, so that they can be used in various ways. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------------------------------- | -------------------------------------- | ------- | ---------------------------------- | ------- | --------------------------------------------------------------------------- | +| `pearlChainVerticalItemAttributes` | `pearl-chain-vertical-item-attributes` | public | `PearlChainVerticalItemAttributes` | | The pearlChainVerticalItemAttributes Prop for styling the bullets and line. | +| `disableAnimation` | `disable-animation` | public | `boolean \| undefined` | | If true, the position won't be animated. | + +## Slots + +| Name | Description | +| ------- | ------------------------------------- | +| `left` | Content of the left side of the item | +| `right` | Content of the right side of the item | diff --git a/src/components/pearl-chain-vertical/index.ts b/src/components/pearl-chain-vertical/index.ts new file mode 100644 index 0000000000..3bdf8d4052 --- /dev/null +++ b/src/components/pearl-chain-vertical/index.ts @@ -0,0 +1 @@ +export * from './pearl-chain-vertical'; diff --git a/src/components/pearl-chain-vertical/pearl-chain-vertical.e2e.ts b/src/components/pearl-chain-vertical/pearl-chain-vertical.e2e.ts new file mode 100644 index 0000000000..e8e333102d --- /dev/null +++ b/src/components/pearl-chain-vertical/pearl-chain-vertical.e2e.ts @@ -0,0 +1,13 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbPearlChainVertical } from './pearl-chain-vertical'; + +describe('sbb-pearl-chain', () => { + let element: SbbPearlChainVertical; + + it('renders', async () => { + element = await fixture(html``); + assert.instanceOf(element, SbbPearlChainVertical); + }); +}); diff --git a/src/components/pearl-chain-vertical/pearl-chain-vertical.scss b/src/components/pearl-chain-vertical/pearl-chain-vertical.scss new file mode 100644 index 0000000000..96bcb6f4ad --- /dev/null +++ b/src/components/pearl-chain-vertical/pearl-chain-vertical.scss @@ -0,0 +1,14 @@ +@use '../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-pearl-chain-vertical-width: 100%; +} + +.sbb-pearl-chain-vertical { + display: table; + width: var(--sbb-pearl-chain-vertical-width); +} diff --git a/src/components/pearl-chain-vertical/pearl-chain-vertical.spec.ts b/src/components/pearl-chain-vertical/pearl-chain-vertical.spec.ts new file mode 100644 index 0000000000..1a79acc3e2 --- /dev/null +++ b/src/components/pearl-chain-vertical/pearl-chain-vertical.spec.ts @@ -0,0 +1,23 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../core/testing'; + +import type { SbbPearlChainVertical } from './pearl-chain-vertical'; + +import './pearl-chain-vertical'; + +describe('sbb-pearl-chain-vertical', () => { + it('renders', async () => { + const element = await fixture( + html``, + ); + await waitForLitRender(element); + expect(element).dom.to.be.equal(``); + expect(element).shadowDom.to.be.equal(` +
      + +
      + `); + }); +}); diff --git a/src/components/pearl-chain-vertical/pearl-chain-vertical.stories.tsx b/src/components/pearl-chain-vertical/pearl-chain-vertical.stories.tsx new file mode 100644 index 0000000000..9192e0c443 --- /dev/null +++ b/src/components/pearl-chain-vertical/pearl-chain-vertical.stories.tsx @@ -0,0 +1,772 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './pearl-chain-vertical'; +import '../pearl-chain-vertical-item'; +import '../icon'; + +const lineType: InputType = { + options: ['dotted', 'standard', 'thin'], + control: { type: 'select' }, +}; +const lineColor: InputType = { + options: ['default', 'disruption', 'past', 'walk'], + control: { type: 'select' }, +}; +const bulletType: InputType = { + options: ['default', 'disruption', 'past', 'irrelevant', 'skipped'], + control: { type: 'select' }, +}; +const bulletSize: InputType = { + options: ['start-end', 'stop'], + control: { type: 'select' }, +}; + +const hideLine: InputType = { + control: { + type: 'boolean', + }, +}; + +const minHeight: InputType = { + control: { type: 'number' }, +}; + +const position: InputType = { + control: { type: 'number' }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'PearlChainVerticalItem', + }, +}; + +const defaultArgTypes: ArgTypes = { + lineType, + lineColor, + bulletType, + bulletSize, + hideLine, + minHeight, + position, + disableAnimation, +}; + +const defaultArgs: Args = { + lineType: lineType.options[1], + lineColor: lineColor.options[0], + bulletType: bulletType.options[0], + minHeight: '100', + hideLine: false, + bulletSize: bulletSize.options[0], + position: 0, + disableAnimation: isChromatic(), +}; + +// We need to lie to the compiler in order for the CSS variables to work. +// Remove once https://github.com/alex-kinokon/jsx-dom/pull/90 is merged and released. +const styleHack = (args: Record): Record => + Object.entries(args) + .map(([k, v]) => `${k}: ${v}`) + .join(';') as unknown as Record; + +const Template = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      + slot for content +
      more
      +
      more
      +
      more
      +
      more
      +
      more
      +
      +
      +
      + ); +}; + +const TemplateWithoutContent = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + + + ); +}; + +const TemplateLeftSlot = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      + slot for content +
      +
      +
      + ); +}; + +const TemplateTwoDots = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      + slot for content +
      more
      +
      more
      +
      more
      +
      more
      +
      more
      +
      +
      + +
      + ); +}; + +const TemplateLeftSecondSlot = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      + slot for content +
      more
      +
      more
      +
      more
      +
      more
      +
      more
      +
      +
      + 19:00 +
      +
      + +
      + 20:00 +
      +
      +
      + ); +}; + +const connectionDetailTemplate = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      +
      +
      Station
      +
      Pl. 12
      +
      +
      +
      + + + +
      Direction Station
      +
      + + 1. 2. + + +
      +
      +
      + 19:00 +
      +
      + +
      +
      Station
      +
      Pl. 12
      +
      +
      + 20:00 +
      +
      +
      + ); +}; + +const thirdLevelTemplate = ({ disableAnimation, ...args }): JSX.Element => { + return ( + + +
      + 10:31 +
      +
      + +
      +
      +
      Station
      +
      Pl. 12
      +
      +
      + + 1. 2. + + +
      +
      +
      +
      19:00
      +
      10:31
      +
      +
      + +
      +
      +
      Station
      +
      Pl. 12
      +
      +
      + + 1. 2. + + +
      +
      + +
      +
      19:00
      +
      10:31
      +
      +
      + +
      +
      +
      Station
      +
      Pl. 12
      +
      +
      + +
      +
      19:00
      +
      10:31
      +
      +
      + +
      +
      +
      Station
      +
      Pl. 12
      +
      +
      +
      +
      19:00
      +
      +
      +
      + ); +}; + +const TimetableChange = (): JSX.Element => { + return ( + + +
      +
      +
      09:45
      +
      Pl. 12
      +
      +
      + Footpath +
      +
      +
      +
      +
      + +
      +
      5'
      +
      +
      150 m
      +
      +
      + Departure +
      +
      + +
      +
      +
      09:45
      +
      Pl. 12
      +
      +
      +
      +
      + ); +}; + +export const defaultPearlChainRightSlot: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; +export const defaultPearlChainWithoutContent: StoryObj = { + render: TemplateWithoutContent, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; + +export const defaultPearlChainLeftSlot: StoryObj = { + render: TemplateLeftSlot, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + lineColor: 'disruption', + bulletType: 'disruption', + minHeight: '100', + }, +}; +export const defaultPearlChainTwoDots: StoryObj = { + render: TemplateTwoDots, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + lineColor: 'disruption', + bulletType: 'disruption', + }, +}; +export const defaultPearlChainLeftSecondSlot: StoryObj = { + render: TemplateLeftSecondSlot, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; +export const charcoalPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + bulletType: 'thick', + }, +}; +export const dottedPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + lineType: 'dotted', + lineColor: 'disruption', + bulletType: 'disruption', + bulletSize: 'start-end', + }, +}; +export const thinPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + lineType: 'thin', + lineColor: 'disruption', + bulletType: 'disruption', + bulletSize: 'stop', + }, +}; +export const thickBulletPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + bulletSize: 'stop', + }, +}; +export const thinBulletPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + bulletType: 'irrelevant', + bulletSize: 'stop', + }, +}; +export const crossedBulletPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + bulletType: 'skipped', + lineType: 'dotted', + lineColor: 'disruption', + }, +}; + +export const positionPearlChain: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + position: 75, + }, +}; + +export const connectionDetail: StoryObj = { + render: connectionDetailTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + }, +}; + +export const timetableConnection: StoryObj = { + render: thirdLevelTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + minHeight: '89', + }, +}; + +export const timetableChange: StoryObj = { + render: TimetableChange, +}; + +/** All kinds oft possible slot and bullet combinations */ + +/** additional bullet types */ + +/** additional dot types */ + +/** position */ + +const meta: Meta = { + decorators: [(Story) => ], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'timetable/sbb-pearl-chain-vertical', +}; + +export default meta; diff --git a/src/components/pearl-chain-vertical/pearl-chain-vertical.ts b/src/components/pearl-chain-vertical/pearl-chain-vertical.ts new file mode 100644 index 0000000000..ad6f89e35f --- /dev/null +++ b/src/components/pearl-chain-vertical/pearl-chain-vertical.ts @@ -0,0 +1,29 @@ +import { CSSResult, html, LitElement, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import style from './pearl-chain-vertical.scss?lit&inline'; + +/** + * It can be used as a container for the `sbb-pearl-chain-vertical-item` component. + * + * @slot - The unnamed slot is used for the `sbb-pearl-chain-vertical-item` component. + */ +@customElement('sbb-pearl-chain-vertical') +export class SbbPearlChainVertical extends LitElement { + public static override styles: CSSResult = style; + + protected override render(): TemplateResult { + return html` +
      + +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-pearl-chain-vertical': SbbPearlChainVertical; + } +} diff --git a/src/components/pearl-chain-vertical/readme.md b/src/components/pearl-chain-vertical/readme.md new file mode 100644 index 0000000000..da26f187e1 --- /dev/null +++ b/src/components/pearl-chain-vertical/readme.md @@ -0,0 +1,32 @@ +The `sbb-pearl-chain-vertical` is a wrapper component for a +[sbb-pearl-chain-vertical-item](/docs/timetable-sbb-pearl-chain-vertical-item--docs) component, +which is projected within an unnamed slot. +Please refer to its documentation for more details. + +```json +{ + "lineType": "standard", + "lineColor": "charcoal", + "minHeight": "89", + "hideLine": false, + "bulletType": "thick-bullet", + "bulletSize": "small" +} +``` + +```html + + +
      content
      +
      content
      + +
      +``` + + + +## Slots + +| Name | Description | +| ---- | --------------------------------------------------------------------------- | +| | The unnamed slot is used for the `sbb-pearl-chain-vertical-item` component. | diff --git a/src/components/pearl-chain/index.ts b/src/components/pearl-chain/index.ts new file mode 100644 index 0000000000..4c17045264 --- /dev/null +++ b/src/components/pearl-chain/index.ts @@ -0,0 +1 @@ +export * from './pearl-chain'; diff --git a/src/components/pearl-chain/pearl-chain.e2e.ts b/src/components/pearl-chain/pearl-chain.e2e.ts new file mode 100644 index 0000000000..d79f3ea785 --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.e2e.ts @@ -0,0 +1,13 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbPearlChain } from './pearl-chain'; + +describe('sbb-pearl-chain', () => { + let element: SbbPearlChain; + + it('renders', async () => { + element = await fixture(html``); + assert.instanceOf(element, SbbPearlChain); + }); +}); diff --git a/src/components/pearl-chain/pearl-chain.sample-data.ts b/src/components/pearl-chain/pearl-chain.sample-data.ts new file mode 100644 index 0000000000..b85026471d --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.sample-data.ts @@ -0,0 +1,107 @@ +const past2 = '2022-11-30T12:13:00+01:00'; +const past = '2022-12-04T12:13:00+01:00'; +const future = '2022-12-07T12:11:00+01:00'; +const future2 = '2022-12-11T12:13:00+01:00'; + +const defaultService = { + serviceAlteration: { + cancelled: false, + delayText: 'string', + reachable: true, + unplannedStopPointsText: '', + }, +}; +const cancelledService = { serviceAlteration: { cancelled: true } }; +const delayedService = { serviceAlteration: { delay: true } }; +const isNotReachableService = { serviceAlteration: { reachable: false } }; +const unplannedStopService = { serviceAlteration: { unplannedStopPointsText: 'unplannedStop' } }; +const redirectedService = { serviceAlteration: { redirectedText: 'Ausnahmsweise kein Halt' } }; +const departureNotServiced = { + stopPoints: [{ stopStatus: 'NOT_SERVICED' }, { stopStatus: 'PLANNED' }], +}; +const arrivalNotServiced = { + stopPoints: [{ stopStatus: 'PLANNED' }, { stopStatus: 'NOT_SERVICED' }], +}; + +export const futureLeg: any = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: defaultService, +}; + +export const extendedLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: { + ...defaultService, + notices: [{ name: 'CI', text: { template: "Extended boarding time (45')" } }], + }, +}; + +export const longFutureLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: defaultService, +}; + +export const cancelledLeg: any = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: cancelledService, +}; + +export const progressLeg: any = { + __typename: 'PTRideLeg', + arrival: { time: future }, + departure: { time: past }, + serviceJourney: defaultService, +}; + +export const pastLeg: any = { + __typename: 'PTRideLeg', + arrival: { time: past }, + departure: { time: past2 }, + serviceJourney: defaultService, +}; + +export const delayedLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: delayedService, +}; + +export const notReachableLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: isNotReachableService, +}; + +export const unplannedStopLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: unplannedStopService, +}; + +export const redirectedOnDepartureLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: { + ...redirectedService, + ...departureNotServiced, + }, +}; + +export const redirectedOnArrivalLeg = { + __typename: 'PTRideLeg', + arrival: { time: future2 }, + departure: { time: future }, + serviceJourney: { ...redirectedService, ...arrivalNotServiced }, +}; diff --git a/src/components/pearl-chain/pearl-chain.scss b/src/components/pearl-chain/pearl-chain.scss new file mode 100644 index 0000000000..1b7de557be --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.scss @@ -0,0 +1,218 @@ +@use '../core/styles' as sbb; + +@mixin sbb-pearl-chain-dotted { + background-color: unset; + background-image: linear-gradient(to right, currentcolor 0%, currentcolor 50%, Canvas 50%); + background-repeat: repeat-x; + background-size: calc(2 * var(--sbb-pearl-chain-spacing-small)) var(--sbb-pearl-chain-height); + inset-inline-end: var(--sbb-pearl-chain-height); + + @include sbb.if-forced-colors { + background: unset; + border-block-start: #{sbb.px-to-rem-build(1)} dashed Highlight; + transform: translateY(#{sbb.px-to-rem-build(1)}); + } +} + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + @include sbb.pearl-chain-bullet-variables; + + display: block; + + --sbb-pearl-chain-height: var(--sbb-border-width-2x); + --sbb-pearl-chain-spacing-small: var(--sbb-spacing-fixed-1x); + --sbb-pearl-chain-color: var(--sbb-pearl-chain-bullet-color); + --sbb-pearl-chain-color-disruption: var(--sbb-pearl-chain-bullet-color-disruption); + --sbb-pearl-chain-color-past: var(--sbb-pearl-chain-bullet-color-past); + --sbb-pearl-chain-leg-width: 100%; +} + +.sbb-pearl-chain { + position: relative; + display: flex; + justify-content: space-between; + flex-wrap: nowrap; + color: var(--sbb-pearl-chain-color); + width: 100%; + padding-block: calc( + (var(--sbb-pearl-chain-bullet-size-start-end) - var(--sbb-pearl-chain-height)) / 2 + ); + padding-inline-end: var(--sbb-pearl-chain-bullet-size-start-end); +} + +// first and last bullet +.sbb-pearl-chain__bullet { + @include sbb.pearl-chain-bullet; + @include sbb.pearl-chain-bullet-start-end; + + content: ''; + position: absolute; + background-color: currentcolor; + inset-block-start: 0; + z-index: 3; +} + +.sbb-pearl-chain__bullet:first-of-type { + inset-inline-start: 0; +} + +.sbb-pearl-chain__bullet:last-of-type { + inset-inline-end: 0; +} + +.sbb-pearl-chain__leg { + flex-shrink: 0; + flex-grow: 0; + position: relative; + height: var(--sbb-pearl-chain-height); + border-inline-end: var(--sbb-pearl-chain-spacing-small) solid Canvas; + background-color: currentcolor; + width: var(--sbb-pearl-chain-leg-width); + display: flex; + align-items: center; + + @include sbb.if-forced-colors { + background-color: CanvasText; + + .sbb-pearl-chain--past & { + background-color: GrayText; + } + } +} + +.sbb-pearl-chain__leg:last-of-type { + border: none; +} + +// dot on leg +.sbb-pearl-chain__stop { + @include sbb.pearl-chain-bullet; + @include sbb.pearl-chain-bullet-stop; + + position: relative; + z-index: 2; +} + +.sbb-pearl-chain__bullet--future { + @include sbb.pearl-chain-bullet; +} + +.sbb-pearl-chain__leg--past, +.sbb-pearl-chain--past, +.sbb-pearl-chain__leg--past::after, +.sbb-pearl-chain__leg--progress::after, +.sbb-pearl-chain__leg--progress .sbb-pearl-chain__stop, +.sbb-pearl-chain--progress, +.sbb-pearl-chain__bullet--past { + @include sbb.pearl-chain-bullet-past; + + color: var(--sbb-pearl-chain-color-past); + + @include sbb.if-forced-colors { + background-color: GrayText !important; + } +} + +.sbb-pearl-chain__bullet--progress { + @include sbb.pearl-chain-bullet-past; + + background: var(--sbb-pearl-chain-bullet-color); +} + +.sbb-pearl-chain__bullet--departure-disruption, +.sbb-pearl-chain--arrival-disruption, +.sbb-pearl-chain--departure-disruption, +.sbb-pearl-chain__leg--disruption { + @include sbb.pearl-chain-bullet-disruption; + + color: var(--sbb-pearl-chain-color-disruption); + + @include sbb.if-forced-colors { + color: Highlight; + background: Highlight; + } +} + +.sbb-pearl-chain__leg--disruption .sbb-pearl-chain__stop { + @include sbb.pearl-chain-bullet-disruption; +} + +.sbb-pearl-chain__leg--past .sbb-pearl-chain__stop { + @include sbb.pearl-chain-bullet-past; +} + +.sbb-pearl-chain__leg--disruption::after { + @include sbb-pearl-chain-dotted; +} + +.sbb-pearl-chain__leg--skipped { + color: var(--sbb-pearl-chain-color-disruption); + + &::after { + @include sbb-pearl-chain-dotted; + } +} + +.sbb-pearl-chain__stop--departure-skipped { + @include sbb.pearl-chain-bullet-skipped; + @include sbb.pearl-chain-bullet-stop; +} + +.sbb-pearl-chain--arrival-skipped, +.sbb-pearl-chain--departure-skipped { + @include sbb.pearl-chain-bullet-start-end; + @include sbb.pearl-chain-bullet-skipped; +} + +// line on leg +.sbb-pearl-chain__leg::after { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + background-color: currentcolor; + border-radius: var(--sbb-pearl-chain-height); + z-index: 1; + + @include sbb.if-forced-colors { + background-color: CanvasText; + + .sbb-pearl-chain--past & { + background-color: GrayText; + } + } +} + +.sbb-pearl-chain__leg:last-of-type::after { + inset-inline-end: calc(-1 * var(--sbb-pearl-chain-height)); +} + +.sbb-pearl-chain__leg--progress::after { + background-color: var(--sbb-pearl-chain-color-past); + + // --sbb-pearl-chain-leg-status: defined in .tsx file + width: var(--sbb-pearl-chain-leg-status); +} + +.sbb-pearl-chain__position { + @include sbb.pearl-chain-bullet-position; + + position: absolute; + inset-block-start: -200%; + z-index: 4; + + // --sbb-pearl-chain-status-position: defined in .tsx file + inset-inline-start: var(--sbb-pearl-chain-status-position); +} + +.sbb-pearl-chain__position--no-animation { + animation: unset; +} + +.sbb-screenreaderonly { + @include sbb.screen-reader-only; +} diff --git a/src/components/pearl-chain/pearl-chain.spec.ts b/src/components/pearl-chain/pearl-chain.spec.ts new file mode 100644 index 0000000000..01874f28da --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.spec.ts @@ -0,0 +1,167 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../core/testing'; +import { PtRideLeg } from '../core/timetable'; + +import type { SbbPearlChain } from './pearl-chain'; + +import './pearl-chain'; + +describe('sbb-pearl-chain', () => { + describe('sbb-pearl-chain with one leg', () => { + it('renders component with config', async () => { + const element = await fixture(html``); + element.legs = [ + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T05:00' }, + departure: { time: '2022-08-18T04:00' }, + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(``); + expect(element).shadowDom.to.be.equal(` +
      + +
      + +
      + `); + }); + }); + + describe('sbb-pearl-chain with two legs', () => { + it('renders component with config', async () => { + const element = await fixture(html``); + element.legs = [ + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T05:00' }, + departure: { time: '2022-08-18T04:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + }, + } as PtRideLeg, + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T16:00' }, + departure: { time: '2022-08-18T05:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + }, + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(``); + expect(element).shadowDom.to.be.equal(` +
      + +
      +
      + +
      + +
      + `); + }); + }); + + describe('sbb-pearl-chain with skipped stops', () => { + it('renders component with departure skipped', async () => { + const element = await fixture(html``); + element.legs = [ + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T05:00' }, + departure: { time: '2022-08-18T04:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + }, + } as PtRideLeg, + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T16:00' }, + departure: { time: '2022-08-18T05:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + stopPoints: [ + { + stopStatus: 'NOT_SERVICED', + }, + { + stopStatus: 'PLANNED', + }, + ], + }, + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(``); + expect(element).shadowDom.to.be.equal(` +
      + +
      +
      + +
      + +
      + `); + }); + + it('renders component with arrival skipped', async () => { + const element = await fixture(html``); + element.legs = [ + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T05:00' }, + departure: { time: '2022-08-18T04:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + }, + } as PtRideLeg, + { + __typename: 'PTRideLeg', + arrival: { time: '2022-08-18T16:00' }, + departure: { time: '2022-08-18T05:00' }, + serviceJourney: { + serviceAlteration: { + cancelled: false, + }, + stopPoints: [ + { + stopStatus: 'PLANNED', + }, + { + stopStatus: 'NOT_SERVICED', + }, + ], + }, + } as PtRideLeg, + ]; + await waitForLitRender(element); + expect(element).dom.to.be.equal(``); + expect(element).shadowDom.to.be.equal(` +
      + +
      +
      + +
      + +
      + `); + }); + }); +}); diff --git a/src/components/pearl-chain/pearl-chain.stories.tsx b/src/components/pearl-chain/pearl-chain.stories.tsx new file mode 100644 index 0000000000..5d817d1d96 --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.stories.tsx @@ -0,0 +1,167 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import isChromatic from 'chromatic'; +import { h, type JSX } from 'jsx-dom'; + +import { + cancelledLeg, + progressLeg, + pastLeg, + futureLeg, + longFutureLeg, + redirectedOnDepartureLeg, + redirectedOnArrivalLeg, +} from './pearl-chain.sample-data'; +import readme from './readme.md?raw'; +import './pearl-chain'; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const now: InputType = { + control: { + type: 'date', + }, +}; + +const defaultArgTypes: ArgTypes = { + 'disable-animation': disableAnimation, + 'data-now': now, +}; + +const defaultArgs: Args = { + 'disable-animation': isChromatic(), + 'data-now': new Date('2022-12-01T12:11:00').valueOf(), +}; + +const Template = (args): JSX.Element => { + return ; +}; + +export const NoStops: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [futureLeg], + }, +}; + +export const ManyStops: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [futureLeg, longFutureLeg, futureLeg, futureLeg], + }, +}; + +export const Cancelled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [cancelledLeg], + }, +}; + +export const CancelledManyStops: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [futureLeg, cancelledLeg, futureLeg, cancelledLeg], + }, +}; + +export const withPosition: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [progressLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +export const Past: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [pastLeg, pastLeg], + 'data-now': new Date('2023-11-01T12:11:00').valueOf(), + }, +}; + +export const DepartureStopSkipped: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [pastLeg, progressLeg, longFutureLeg, redirectedOnDepartureLeg, futureLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +export const ArrivalStopSkipped: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [pastLeg, progressLeg, longFutureLeg, redirectedOnArrivalLeg, futureLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +export const FirstStopSkipped: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [redirectedOnDepartureLeg, futureLeg, longFutureLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +export const LastStopSkipped: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [futureLeg, longFutureLeg, redirectedOnArrivalLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +export const Mixed: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + legs: [pastLeg, progressLeg, longFutureLeg, cancelledLeg, futureLeg], + 'data-now': new Date('2022-12-05T12:11:00').valueOf(), + }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + ], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'timetable/sbb-pearl-chain', +}; + +export default meta; diff --git a/src/components/pearl-chain/pearl-chain.ts b/src/components/pearl-chain/pearl-chain.ts new file mode 100644 index 0000000000..e12dcdd1e1 --- /dev/null +++ b/src/components/pearl-chain/pearl-chain.ts @@ -0,0 +1,235 @@ +import { differenceInMinutes, isAfter, isBefore } from 'date-fns'; +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { removeTimezoneFromISOTimeString } from '../core/datetime'; +import { isRideLeg, Leg, PtRideLeg } from '../core/timetable'; + +import style from './pearl-chain.scss?lit&inline'; + +type Status = 'progress' | 'future' | 'past'; + +/** + * It visually displays journey information. + */ +@customElement('sbb-pearl-chain') +export class SbbPearlChain extends LitElement { + public static override styles: CSSResult = style; + + /** + * Define the legs of the pearl-chain. + * Format: + * `{"legs": [{"duration": 25}, ...]}` + * `duration` in minutes. Duration of the leg is relative + * to the total travel time. Example: departure 16:30, change at 16:40, + * arrival at 17:00. So the change should have a duration of 33.33%. + */ + @property({ type: Array }) public legs: (Leg | PtRideLeg)[]; + + /** + * Per default, the current location has a pulsating animation. You can + * disable the animation with this property. + */ + @property({ attribute: 'disable-animation', type: Boolean }) public disableAnimation?: boolean; + + private _now(): number { + const dataNow = +this.dataset?.now; + return isNaN(dataNow) ? Date.now() : dataNow; + } + + private _getAllDuration(legs: PtRideLeg[]): number { + return legs?.reduce( + (sum: number, leg) => + sum + + differenceInMinutes( + removeTimezoneFromISOTimeString(leg.arrival?.time), + removeTimezoneFromISOTimeString(leg.departure?.time), + ), + 0, + ); + } + + private _isAllCancelled(legs: PtRideLeg[]): boolean { + return legs?.every((leg) => leg?.serviceJourney?.serviceAlteration?.cancelled); + } + + private _getRelativeDuration(legs: PtRideLeg[], leg: PtRideLeg): number { + const duration = differenceInMinutes( + removeTimezoneFromISOTimeString(leg.arrival?.time), + removeTimezoneFromISOTimeString(leg.departure?.time), + ); + const allDurations = this._getAllDuration(legs); + + if (allDurations === 0) return 100; + + return (duration / allDurations) * 100; + } + + private _getProgress(start: Date, end: Date): number { + const total = differenceInMinutes(end, start); + const progress = differenceInMinutes(this._now(), start); + + return total && (progress / total) * 100; + } + + private _getStatus(end: Date, start?: Date): Status { + if (start && isBefore(start, this._now()) && isAfter(end, this._now())) { + return 'progress'; + } else if (isBefore(end, this._now())) { + return 'past'; + } + return 'future'; + } + + private _renderPosition(start: Date, end: Date): TemplateResult | undefined { + const currentPosition = this._getProgress(start, end); + if (currentPosition < 0 && currentPosition > 100) return undefined; + + const statusStyle = (): Record => { + return { + '--sbb-pearl-chain-status-position': `${currentPosition}%`, + ...(currentPosition >= 50 ? { transform: `translateX(-100%)` } : {}), + }; + }; + + const animation = this.disableAnimation ? 'sbb-pearl-chain__position--no-animation' : ''; + + return html``; + } + + protected override render(): TemplateResult { + const rideLegs: PtRideLeg[] = this.legs?.filter((leg) => isRideLeg(leg)) as PtRideLeg[]; + + const departureTime = + rideLegs?.length && removeTimezoneFromISOTimeString(rideLegs[0]?.departure?.time); + const arrivalTime = + rideLegs?.length && + removeTimezoneFromISOTimeString(rideLegs[rideLegs?.length - 1].arrival?.time); + + const departureNotServiced = ((): string => { + return rideLegs && + rideLegs[0]?.serviceJourney?.stopPoints && + rideLegs[0]?.serviceJourney?.stopPoints[0].stopStatus === 'NOT_SERVICED' + ? 'sbb-pearl-chain--departure-skipped' + : ''; + })(); + + const arrivalNotServiced = ((): string => { + const lastLeg = rideLegs && rideLegs[rideLegs.length - 1]; + const stops = lastLeg && lastLeg.serviceJourney?.stopPoints; + + return stops && stops[stops.length - 1].stopStatus === 'NOT_SERVICED' + ? 'sbb-pearl-chain--arrival-skipped' + : ''; + })(); + + const departureCancelClass = ((): string => { + return rideLegs && rideLegs[0]?.serviceJourney?.serviceAlteration?.cancelled + ? 'sbb-pearl-chain--departure-disruption' + : ''; + })(); + + const arrivalCancelClass = ((): string => { + return rideLegs && rideLegs[rideLegs.length - 1]?.serviceJourney?.serviceAlteration?.cancelled + ? 'sbb-pearl-chain--arrival-disruption' + : ''; + })(); + + const statusClassDeparture = + rideLegs && departureTime && arrivalTime && !departureCancelClass + ? 'sbb-pearl-chain__bullet--' + this._getStatus(arrivalTime, departureTime) + : ''; + + const statusClassArrival = + rideLegs && arrivalTime && !arrivalCancelClass + ? 'sbb-pearl-chain__bullet--' + this._getStatus(arrivalTime) + : ''; + + if (this._isAllCancelled(rideLegs)) { + return html` +
      + +
      + +
      + `; + } + + return html` +
      + + ${rideLegs?.map((leg: PtRideLeg, index: number) => { + const { stopPoints, serviceAlteration } = leg?.serviceJourney || {}; + + const duration = this._getRelativeDuration(rideLegs, leg); + const departure = removeTimezoneFromISOTimeString(leg.departure?.time); + const arrival = removeTimezoneFromISOTimeString(leg.arrival?.time); + + const isArrivalNotServiced = + stopPoints && stopPoints[stopPoints.length - 1]?.stopStatus === 'NOT_SERVICED'; + const isArrivalPlanned = + stopPoints && stopPoints[stopPoints.length - 1]?.stopStatus === 'PLANNED'; + const isDepartureNotServiced = stopPoints && stopPoints[0]?.stopStatus === 'NOT_SERVICED'; + + const stopPointsBefore = index > 0 && rideLegs[index - 1].serviceJourney.stopPoints; + const isBeforeLegArrivalNotServiced = + stopPointsBefore && + stopPointsBefore[stopPointsBefore.length - 1]?.stopStatus === 'NOT_SERVICED'; + + const skippedLeg = + isArrivalNotServiced || (isDepartureNotServiced && isArrivalPlanned) + ? 'sbb-pearl-chain__leg--skipped' + : ''; + const departureSkippedBullet = + isDepartureNotServiced || isBeforeLegArrivalNotServiced + ? 'sbb-pearl-chain__stop--departure-skipped' + : ''; + + const cancelled = serviceAlteration?.cancelled ? 'sbb-pearl-chain__leg--disruption' : ''; + + const legStatus = + !cancelled && + this._getStatus(departure, arrival) && + 'sbb-pearl-chain__leg--' + this._getStatus(arrival, departure); + + const legStyle = (): Record => { + return { + '--sbb-pearl-chain-leg-width': `${duration}%`, + ...(this._getStatus(arrival, departure) === 'progress' && !cancelled + ? { '--sbb-pearl-chain-leg-status': `${this._getProgress(departure, arrival)}%` } + : {}), + }; + }; + + return html`
      + ${index > 0 && index < rideLegs.length + ? html`` + : nothing} + ${this._getStatus(arrival, departure) === 'progress' && !cancelled + ? this._renderPosition(departure, arrival) + : nothing} +
      `; + })} + +
      + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-pearl-chain': SbbPearlChain; + } +} diff --git a/src/components/pearl-chain/readme.md b/src/components/pearl-chain/readme.md new file mode 100644 index 0000000000..a7aa9a4bcb --- /dev/null +++ b/src/components/pearl-chain/readme.md @@ -0,0 +1,61 @@ +The `sbb-pearl-chain` component displays all parts of a journey, including changes of trains or other kinds of transports. +Also, it is possible to render the current position. + +The `legs` property is mandatory. + +```json +[ + { + "__typename": "PTRideLeg", + "arrival": { + "time": "2022-12-11T12:13:00+01:00" + }, + "departure": { + "time": "2022-12-07T12:11:00+01:00" + }, + "serviceJourney": { + "serviceAlteration": { + "cancelled": false, + "delayText": "string", + "reachable": true, + "unplannedStopPointsText": "" + } + } + }, + { + "__typename": "PTRideLeg", + "arrival": { + "time": "2022-12-11T12:13:00+01:00" + }, + "departure": { + "time": "2022-12-07T12:11:00+01:00" + }, + "serviceJourney": { + "serviceAlteration": { + "cancelled": false, + "delayText": "string", + "reachable": true, + "unplannedStopPointsText": "" + } + } + } +] +``` + +```html + +``` + +## Testing + +To specify a specific date for the current datetime, you can use the `data-now` attribute (timestamp in milliseconds). +This is helpful if you need a specific state of the component. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------ | ------------------- | ------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `legs` | `legs` | public | `(Leg \| PtRideLeg)[]` | | Define the legs of the pearl-chain. Format: `{"legs": \[{"duration": 25}, ...]}` `duration` in minutes. Duration of the leg is relative to the total travel time. Example: departure 16:30, change at 16:40, arrival at 17:00. So the change should have a duration of 33.33%. | +| `disableAnimation` | `disable-animation` | public | `boolean \| undefined` | | Per default, the current location has a pulsating animation. You can disable the animation with this property. | diff --git a/src/components/radio-button/index.ts b/src/components/radio-button/index.ts new file mode 100644 index 0000000000..a59886720b --- /dev/null +++ b/src/components/radio-button/index.ts @@ -0,0 +1,2 @@ +export * from './radio-button'; +export * from './radio-button-group'; diff --git a/src/components/radio-button/radio-button-group/index.ts b/src/components/radio-button/radio-button-group/index.ts new file mode 100644 index 0000000000..6b0e03fa92 --- /dev/null +++ b/src/components/radio-button/radio-button-group/index.ts @@ -0,0 +1 @@ +export * from './radio-button-group'; diff --git a/src/components/radio-button/radio-button-group/radio-button-group.e2e.ts b/src/components/radio-button/radio-button-group/radio-button-group.e2e.ts new file mode 100644 index 0000000000..2812987c2e --- /dev/null +++ b/src/components/radio-button/radio-button-group/radio-button-group.e2e.ts @@ -0,0 +1,160 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing'; +import type { SbbRadioButton } from '../radio-button'; +import '../radio-button'; + +import { SbbRadioButtonGroup } from './radio-button-group'; + +describe('sbb-radio-button-group', () => { + let element: SbbRadioButtonGroup; + + beforeEach(async () => { + element = await fixture(html` + + Value one + Value two + Value three + Value four + + `); + }); + + it('renders', () => { + assert.instanceOf(element, SbbRadioButtonGroup); + }); + + describe('events', () => { + it('selects radio on click', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + const radio = element.querySelector('#sbb-radio-2') as SbbRadioButton; + + expect(firstRadio).to.have.attribute('checked'); + + radio.click(); + await waitForLitRender(element); + + expect(radio).to.have.attribute('checked'); + expect(firstRadio).not.to.have.attribute('checked'); + }); + + it('dispatches event on radio change', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + const checkedRadio = element.querySelector('#sbb-radio-2') as SbbRadioButton; + const changeSpy = new EventSpy('change'); + const inputSpy = new EventSpy('input'); + + checkedRadio.click(); + await waitForCondition(() => changeSpy.events.length === 1); + expect(changeSpy.count).to.be.equal(1); + await waitForCondition(() => inputSpy.events.length === 1); + expect(inputSpy.count).to.be.equal(1); + + firstRadio.click(); + await waitForLitRender(element); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('does not select disabled radio on click', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + const disabledRadio = element.querySelector('#sbb-radio-3') as SbbRadioButton; + + disabledRadio.click(); + await waitForLitRender(element); + + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('preserves radio button disabled state after being disabled from group', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + const secondRadio = element.querySelector('#sbb-radio-2') as SbbRadioButton; + const disabledRadio = element.querySelector('#sbb-radio-3') as SbbRadioButton; + + element.disabled = true; + await waitForLitRender(element); + + disabledRadio.click(); + await waitForLitRender(element); + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + + secondRadio.click(); + await waitForLitRender(element); + expect(secondRadio).not.to.have.attribute('checked'); + + element.disabled = false; + await waitForLitRender(element); + + disabledRadio.click(); + await waitForLitRender(element); + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('selects radio on left arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + + firstRadio.focus(); + await waitForLitRender(element); + + await sendKeys({ down: 'ArrowLeft' }); + await waitForLitRender(element); + + const radio = element.querySelector('#sbb-radio-4'); + expect(radio).to.have.attribute('checked'); + + firstRadio.click(); + await waitForLitRender(element); + + expect(firstRadio).to.have.attribute('checked'); + }); + + it('selects radio on right arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + + firstRadio.focus(); + await sendKeys({ down: 'ArrowRight' }); + + await waitForLitRender(element); + const radio = element.querySelector('#sbb-radio-2'); + + expect(radio).to.have.attribute('checked'); + + firstRadio.click(); + await waitForLitRender(element); + + expect(firstRadio).to.have.attribute('checked'); + }); + + it('wraps around on arrow key navigation', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButton; + const secondRadio = element.querySelector('#sbb-radio-2') as SbbRadioButton; + + secondRadio.click(); + await waitForLitRender(element); + expect(secondRadio).to.have.attribute('checked'); + + secondRadio.focus(); + await waitForLitRender(element); + + await sendKeys({ down: 'ArrowRight' }); + await waitForLitRender(element); + + await sendKeys({ down: 'ArrowRight' }); + await waitForLitRender(element); + + const radio = element.querySelector('#sbb-radio-1'); + expect(radio).to.have.attribute('checked'); + + firstRadio.click(); + await waitForLitRender(element); + + expect(firstRadio).to.have.attribute('checked'); + }); + }); +}); diff --git a/src/components/radio-button/radio-button-group/radio-button-group.scss b/src/components/radio-button/radio-button-group/radio-button-group.scss new file mode 100644 index 0000000000..ff09a2710f --- /dev/null +++ b/src/components/radio-button/radio-button-group/radio-button-group.scss @@ -0,0 +1,59 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +@mixin horizontal-orientation { + --sbb-radio-button-group-orientation: row; +} + +$breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; + +:host { + @include horizontal-orientation; + + --sbb-radio-button-group-width: max-content; + --sbb-radio-button-group-gap: var(--sbb-spacing-fixed-3x) var(--sbb-spacing-fixed-6x); +} + +:host([orientation='vertical']) { + --sbb-radio-button-group-orientation: column; + --sbb-radio-button-group-width: 100%; +} + +:host([data-has-selection-panel]) { + --sbb-radio-button-group-width: 100%; +} + +:host([data-has-selection-panel][orientation='vertical']) { + --sbb-radio-button-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); +} + +@each $breakpoint in $breakpoints { + @include sbb.mq($from: #{$breakpoint}) { + // horizontal-from overrides orientation vertical + :host([orientation='vertical'][horizontal-from='#{$breakpoint}']) { + @include horizontal-orientation; + } + + :host( + [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-panel]) + ) { + --sbb-radio-button-group-width: max-content; + } + } +} + +.sbb-radio-group { + display: flex; + flex-direction: var(--sbb-radio-button-group-orientation); + gap: var(--sbb-radio-button-group-gap); + align-items: flex-start; + width: var(--sbb-radio-button-group-width); +} + +.sbb-radio-group__error { + display: inline-block; + margin-block-start: var(--sbb-spacing-fixed-1x); +} diff --git a/src/components/radio-button/radio-button-group/radio-button-group.spec.ts b/src/components/radio-button/radio-button-group/radio-button-group.spec.ts new file mode 100644 index 0000000000..ea56ba1fd4 --- /dev/null +++ b/src/components/radio-button/radio-button-group/radio-button-group.spec.ts @@ -0,0 +1,23 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './radio-button-group'; + +describe('sbb-radio-button-group', () => { + it('renders', async () => { + const root = await fixture(html``); + + expect(root).dom.to.be.equal( + ` + + + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
      + +
      + `, + ); + }); +}); diff --git a/src/components/radio-button/radio-button-group/radio-button-group.stories.tsx b/src/components/radio-button/radio-button-group/radio-button-group.stories.tsx new file mode 100644 index 0000000000..882d038133 --- /dev/null +++ b/src/components/radio-button/radio-button-group/radio-button-group.stories.tsx @@ -0,0 +1,210 @@ +/** @jsx h */ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './radio-button-group'; +import '../radio-button'; +import '../../form-error'; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const required: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const allowEmptySelection: InputType = { + control: { + type: 'boolean', + }, +}; + +const orientation: InputType = { + control: { + type: 'inline-radio', + }, + options: ['horizontal', 'vertical'], +}; + +const horizontalFrom: InputType = { + control: { + type: 'select', + }, + options: ['unset', 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'], +}; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + value, + required, + disabled, + 'allow-empty-selection': allowEmptySelection, + orientation, + 'horizontal-from': horizontalFrom, + size, + 'aria-label': ariaLabel, +}; + +const defaultArgs: Args = { + value: 'Value two', + required: false, + disabled: false, + 'allow-empty-selection': false, + orientation: orientation.options[0], + 'horizontal-from': undefined, + size: size.options[0], + 'aria-label': undefined, +}; + +const radioButtons = (): JSX.Element[] => [ + Value one, + Value two, + + Value three + , + Value four, +]; + +const DefaultTemplate = (args): JSX.Element => ( + {radioButtons()} +); + +const ErrorMessageTemplate = (args): JSX.Element => { + const sbbFormError = This is a required field.; + + return ( + { + if (event.detail.value) { + sbbFormError.remove(); + } else if (args.required) { + (event.target as HTMLElement).closest('sbb-radio-button-group').append(sbbFormError); + } + }} + > + {radioButtons()} + {args.required && sbbFormError} + + ); +}; + +export const Horizontal: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Vertical: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, orientation: orientation.options[1] }, +}; + +export const VerticalToHorizontal: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + orientation: orientation.options[1], + 'horizontal-from': horizontalFrom.options[4], + }, +}; + +export const HorizontalSizeS: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[1] }, +}; + +export const VerticalSizeS: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, orientation: orientation.options[1], size: size.options[1] }, +}; + +export const Disabled: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const AllowEmptySelection: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, value: undefined, 'allow-empty-selection': true }, +}; + +export const ErrorMessage: StoryObj = { + render: ErrorMessageTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + value: undefined, + required: true, + 'allow-empty-selection': true, + }, +}; + +export const ErrorMessageVertical: StoryObj = { + render: ErrorMessageTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + value: undefined, + required: true, + orientation: orientation.options[1], + 'allow-empty-selection': true, + }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + withActions as Decorator, + ], + parameters: { + actions: { + handles: ['change', 'input'], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-radio-button/sbb-radio-button-group', +}; + +export default meta; diff --git a/src/components/radio-button/radio-button-group/radio-button-group.ts b/src/components/radio-button/radio-button-group/radio-button-group.ts new file mode 100644 index 0000000000..10dc9cb674 --- /dev/null +++ b/src/components/radio-button/radio-button-group/radio-button-group.ts @@ -0,0 +1,326 @@ +import { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { isArrowKeyPressed, getNextElementIndex, interactivityChecker } from '../../core/a11y'; +import { toggleDatasetEntry, setAttribute } from '../../core/dom'; +import { + createNamedSlotState, + HandlerRepository, + namedSlotChangeHandlerAspect, + EventEmitter, + ConnectedAbortController, +} from '../../core/eventing'; +import { SbbHorizontalFrom, SbbOrientation } from '../../core/interfaces'; +import type { + SbbRadioButton, + SbbRadioButtonSize, + SbbRadioButtonStateChange, +} from '../radio-button'; + +import style from './radio-button-group.scss?lit&inline'; + +/** + * It can be used as a container for one or more `sbb-radio-button`. + * + * @slot - Use the unnamed slot to add `sbb-radio-button` elements to the `sbb-radio-button-group`. + * @slot error - Use this to provide a `sbb-form-error` to show an error message. + * @event {CustomEvent} did-change - Emits whenever the `sbb-radio-group` value changes. + * @event {CustomEvent} change - Emits whenever the `sbb-radio-group` value changes. + * @event {CustomEvent} input - Emits whenever the `sbb-radio-group` value changes. + */ +@customElement('sbb-radio-button-group') +export class SbbRadioButtonGroup extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + didChange: 'did-change', + change: 'change', + input: 'input', + } as const; + + /** + * Whether the radios can be deselected. + */ + @property({ attribute: 'allow-empty-selection', type: Boolean }) + public set allowEmptySelection(value: boolean) { + this._allowEmptySelection = value; + this._updateAllowEmptySelection(); + } + public get allowEmptySelection(): boolean { + return this._allowEmptySelection; + } + private _allowEmptySelection: boolean = false; + + /** + * Whether the radio group is disabled. + */ + @property({ type: Boolean }) + public set disabled(value: boolean) { + this._disabled = value; + this._updateDisabled(); + } + public get disabled(): boolean { + return this._disabled; + } + private _disabled: boolean = false; + + /** + * Whether the radio group is required. + */ + @property({ type: Boolean }) + public set required(value: boolean) { + this._required = value; + this._updateRequired(); + } + public get required(): boolean { + return this._required; + } + private _required: boolean = false; + + /** + * The value of the radio group. + */ + @property() public value?: any | null; + + /** + * Size variant, either m or s. + */ + @property() + public set size(value: SbbRadioButtonSize) { + this._size = value; + this._updateSize(); + } + public get size(): SbbRadioButtonSize { + return this._size; + } + private _size: SbbRadioButtonSize = 'm'; + + /** + * Overrides the behaviour of `orientation` property. + */ + @property({ attribute: 'horizontal-from', reflect: true }) + public horizontalFrom?: SbbHorizontalFrom; + + /** + * Radio group's orientation, either horizontal or vertical. + */ + @property({ reflect: true }) + public orientation: SbbOrientation = 'horizontal'; + + /** + * State of listed named slots, by indicating whether any element for a named slot is defined. + */ + @state() private _namedSlots = createNamedSlotState('error'); + + private _hasSelectionPanel: boolean; + private _didLoad = false; + private _abort = new ConnectedAbortController(this); + + private _valueChanged(value: any | undefined): void { + for (const radio of this._radioButtons) { + radio.checked = radio.value === value; + radio.tabIndex = this._getRadioTabIndex(radio); + } + this._setFocusableRadio(); + } + + private _updateDisabled(): void { + for (const radio of this._radioButtons) { + toggleDatasetEntry(radio, 'groupDisabled', this.disabled); + radio.tabIndex = this._getRadioTabIndex(radio); + } + this._setFocusableRadio(); + } + + private _updateRequired(): void { + for (const radio of this._radioButtons) { + toggleDatasetEntry(radio, 'groupRequired', this.required); + } + } + + private _updateSize(): void { + for (const radio of this._radioButtons) { + radio.size = this.size; + } + } + + private _updateAllowEmptySelection(): void { + for (const radio of this._radioButtons) { + radio.allowEmptySelection = this.allowEmptySelection; + } + } + + /** + * Emits whenever the `sbb-radio-group` value changes. + * @deprecated only used for React. Will probably be removed once React 19 is available. + */ + private _didChange: EventEmitter = new EventEmitter(this, SbbRadioButtonGroup.events.didChange); + + /** + * Emits whenever the `sbb-radio-group` value changes. + */ + private _change: EventEmitter = new EventEmitter(this, SbbRadioButtonGroup.events.change); + + /** + * Emits whenever the `sbb-radio-group` value changes. + */ + private _input: EventEmitter = new EventEmitter(this, SbbRadioButtonGroup.events.input); + + private _handlerRepository = new HandlerRepository( + this, + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + ); + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener( + 'state-change', + (e: CustomEvent) => this._onRadioButtonSelect(e), + { + signal, + passive: true, + }, + ); + this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); + this._hasSelectionPanel = !!this.querySelector('sbb-selection-panel'); + toggleDatasetEntry(this, 'hasSelectionPanel', this._hasSelectionPanel); + this._handlerRepository.connect(); + this._updateRadios(this.value); + } + + public override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('value')) { + this._valueChanged(this.value); + } + } + + protected override firstUpdated(): void { + this._didLoad = true; + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + private _onRadioButtonSelect(event: CustomEvent): void { + event.stopPropagation(); + if (event.detail.type !== 'checked' || !this._didLoad) { + return; + } + + if (event.detail.checked) { + this.value = (event.target as HTMLInputElement).value; + this._emitChange(this.value); + } else if (this.allowEmptySelection) { + this.value = this._radioButtons.find((radio) => radio.checked)?.value; + if (!this.value) { + this._emitChange(); + } + } + } + + private _emitChange(value?: string): void { + this._change.emit({ value }); + this._input.emit({ value }); + this._didChange.emit({ value }); + } + + private _updateRadios(initValue?: string): void { + this.value = initValue ?? this._radioButtons.find((radio) => radio.checked)?.value; + + for (const radio of this._radioButtons) { + radio.checked = radio.value === this.value; + radio.size = this.size; + radio.allowEmptySelection = this.allowEmptySelection; + + toggleDatasetEntry(radio, 'groupDisabled', this.disabled); + toggleDatasetEntry(radio, 'groupRequired', this.required); + + radio.tabIndex = this._getRadioTabIndex(radio); + } + + this._setFocusableRadio(); + } + + private get _radioButtons(): SbbRadioButton[] { + return (Array.from(this.querySelectorAll('sbb-radio-button')) as SbbRadioButton[]).filter( + (el) => el.closest('sbb-radio-button-group') === this, + ); + } + + private get _enabledRadios(): SbbRadioButton[] | undefined { + if (!this.disabled) { + return this._radioButtons.filter((r) => !r.disabled && interactivityChecker.isVisible(r)); + } + } + + private _setFocusableRadio(): void { + const checked = this._radioButtons.find((radio) => radio.checked && !radio.disabled); + + const enabledRadios = this._enabledRadios; + if (!checked && enabledRadios?.length) { + enabledRadios[0].tabIndex = 0; + } + } + + private _getRadioTabIndex(radio: SbbRadioButton): number { + return (radio.checked || this._hasSelectionPanel) && !radio.disabled && !this.disabled ? 0 : -1; + } + + private _handleKeyDown(evt: KeyboardEvent): void { + const enabledRadios: SbbRadioButton[] = this._enabledRadios; + + if ( + !enabledRadios || + !enabledRadios.length || + // don't trap nested handling + ((evt.target as HTMLElement) !== this && + (evt.target as HTMLElement).parentElement !== this && + (evt.target as HTMLElement).parentElement.nodeName !== 'SBB-SELECTION-PANEL') + ) { + return; + } + + if (!isArrowKeyPressed(evt)) { + return; + } + + let current: number; + let nextIndex: number; + + if (this._hasSelectionPanel) { + current = enabledRadios.findIndex((e: SbbRadioButton) => e === evt.target); + nextIndex = getNextElementIndex(evt, current, enabledRadios.length); + } else { + const checked: number = enabledRadios.findIndex((radio: SbbRadioButton) => radio.checked); + nextIndex = getNextElementIndex(evt, checked, enabledRadios.length); + enabledRadios[nextIndex].select(); + } + + enabledRadios[nextIndex].focus(); + evt.preventDefault(); + } + + protected override render(): TemplateResult { + setAttribute(this, 'role', 'radiogroup'); + + return html` +
      + this._updateRadios()}> +
      + ${this._namedSlots.error + ? html`
      + +
      ` + : nothing} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-radio-button-group': SbbRadioButtonGroup; + } +} diff --git a/src/components/radio-button/radio-button-group/readme.md b/src/components/radio-button/radio-button-group/readme.md new file mode 100644 index 0000000000..64685642b7 --- /dev/null +++ b/src/components/radio-button/radio-button-group/readme.md @@ -0,0 +1,95 @@ +The `sbb-radio-button-group` is a component which can be used as a wrapper for +a collection of [sbb-radio-button](/docs/components-sbb-radio-button-sbb-radio-button--docs)s, +or, alternatively, for a collection of [sbb-selection-panel](/docs/components-sbb-selection-panel--docs)s. + +```html + + + Option one + Option two + Option three + +``` + +Pressing a `sbb-radio-button` checks it and unchecks the previously selected one, if any. +They can also be controlled programmatically by setting the value property of the parent radio group to the value of the radio. + +Please note that within a `sbb-radio-button-group`, only one `sbb-radio-button` can be selected at a time; +if you need to select more than one item, it is recommended to use the `sbb-checkbox-group` component. + +## States + +The radio group can have different states: + +- can be completely disabled by setting the property `disabled`; +- can be required by setting the property `required`. + +```html + + + ... + + + + + ... + +``` + +In order to deselect a `sbb-radio-button` inside the `sbb-radio-button-group`, +you can use the `allowEmptySelection` property, which will be proxied to the inner `sbb-radio-button` +enabling their deselection (by default, a selected `sbb-radio-button` cannot be deselected). + +```html + ... +``` + +## Style + +The `orientation` property is used to set item orientation. Possible values are `horizontal` (default) and `vertical`. +The optional property `horizontalFrom` can be used in combination with `orientation='vertical'` to +indicate the minimum breakpoint from which the orientation changes to `horizontal`. + +```html + + ... + +``` + +## Events + +Consumers can listen to the native `change`/`input` event on the `sbb-radio-button-group` component +to intercept the selection's change; the current value can be read from `event.detail.value`. + +## Accessibility + +In order to ensure readability for screen-readers, please provide an `aria-label` attribute for the `sbb-radio-button-group`. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| --------------------- | ----------------------- | ------- | -------------------------------- | -------------- | --------------------------------------------------------- | +| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | | Whether the radios can be deselected. | +| `disabled` | `disabled` | public | `boolean` | | Whether the radio group is disabled. | +| `required` | `required` | public | `boolean` | | Whether the radio group is required. | +| `value` | `value` | public | `any \| null \| undefined` | | The value of the radio group. | +| `size` | `size` | public | `SbbRadioButtonSize` | | Size variant, either m or s. | +| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. | +| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Radio group's orientation, either horizontal or vertical. | + +## Events + +| Name | Type | Description | Inherited From | +| ------------ | ------------------- | --------------------------------------------------- | -------------- | +| `did-change` | `CustomEvent` | Emits whenever the `sbb-radio-group` value changes. | | +| `change` | `CustomEvent` | Emits whenever the `sbb-radio-group` value changes. | | +| `input` | `CustomEvent` | Emits whenever the `sbb-radio-group` value changes. | | + +## Slots + +| Name | Description | +| ------- | ---------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-radio-button` elements to the `sbb-radio-button-group`. | +| `error` | Use this to provide a `sbb-form-error` to show an error message. | diff --git a/src/components/radio-button/radio-button/index.ts b/src/components/radio-button/radio-button/index.ts new file mode 100644 index 0000000000..af6ffac816 --- /dev/null +++ b/src/components/radio-button/radio-button/index.ts @@ -0,0 +1 @@ +export * from './radio-button'; diff --git a/src/components/radio-button/radio-button/radio-button.e2e.ts b/src/components/radio-button/radio-button/radio-button.e2e.ts new file mode 100644 index 0000000000..7d041e359f --- /dev/null +++ b/src/components/radio-button/radio-button/radio-button.e2e.ts @@ -0,0 +1,67 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing'; + +import { SbbRadioButton } from './radio-button'; + +describe('sbb-radio-button', () => { + let element: SbbRadioButton; + + beforeEach(async () => { + element = await fixture(html`Value label`); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbRadioButton); + }); + + it('should not render accessibility label about containing state', async () => { + element = element.shadowRoot.querySelector('.sbb-radio-button__expanded-label'); + expect(element).not.to.be.ok; + }); + + it('selects radio on click', async () => { + const stateChange = new EventSpy(SbbRadioButton.events.stateChange); + + element.click(); + await waitForLitRender(element); + + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + }); + + it('does not deselect radio if already checked', async () => { + const stateChange = new EventSpy(SbbRadioButton.events.stateChange); + + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + }); + + it('allows empty selection', async () => { + const stateChange = new EventSpy(SbbRadioButton.events.stateChange); + + element.allowEmptySelection = true; + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + + element.click(); + await waitForLitRender(element); + expect(element).not.to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 2); + expect(stateChange.count).to.be.equal(2); + }); +}); diff --git a/src/components/radio-button/radio-button/radio-button.scss b/src/components/radio-button/radio-button/radio-button.scss new file mode 100644 index 0000000000..0c61e7f52f --- /dev/null +++ b/src/components/radio-button/radio-button/radio-button.scss @@ -0,0 +1,175 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-radio-button-label-color: var(--sbb-color-charcoal-default); + --sbb-radio-button-background-color: var(--sbb-color-white-default); + --sbb-radio-button-inner-circle-color: var(--sbb-color-white-default); + --sbb-radio-button-border-width: var(--sbb-border-width-1x); + --sbb-radio-button-border-style: solid; + --sbb-radio-button-border-color: var(--sbb-color-smoke-default); + --sbb-radio-button-dimension: var(--sbb-size-icon-ui-small); + --sbb-radio-button-inner-circle-dimension: #{sbb.px-to-rem-build(10)}; + --sbb-radio-button-subtext-color: var(--sbb-color-granite-default); + --sbb-radio-button-cursor: pointer; + + // The border in unchecked state should fill the circle. + --sbb-radio-button-background-fake-border-width: calc(var(--sbb-radio-button-dimension) / 2); + + // Align radio button to the first row of the label based on the line-height so that it's vertically + // aligned to the label and sticks to the top if the label breaks into multiple lines + --sbb-radio-button-icon-align: calc( + (1em * var(--sbb-typo-line-height-body-text) - var(--sbb-radio-button-dimension)) / 2 + ); + + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; + + @include sbb.if-forced-colors { + --sbb-radio-button-background-color: Canvas !important; + --sbb-radio-button-border-width: var(--sbb-border-width-2x); + --sbb-radio-button-border-color: ButtonBorder; + } +} + +// Change the focus outline when the input is placed inside of a selection panel +// as the main input element. +:host(:focus-visible[data-is-selection-panel-input]) { + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; + + .sbb-radio-button::after { + content: ''; + position: absolute; + display: block; + inset-block: calc( + (var(--sbb-spacing-responsive-xs) * -1) + var(--sbb-focus-outline-width) - + (var(--sbb-focus-outline-offset) * 2) + ); + inset-inline: calc( + (var(--sbb-spacing-responsive-xxs) * -1) + var(--sbb-focus-outline-width) - + (var(--sbb-focus-outline-offset) * 2) + ); + border: var(--sbb-focus-outline-color) solid var(--sbb-focus-outline-width); + border-radius: calc(var(--sbb-border-radius-4x) + var(--sbb-focus-outline-offset)); + } +} + +:host([checked]) { + --sbb-radio-button-inner-circle-color: var(--sbb-color-red-default); + --sbb-radio-button-background-fake-border-width: calc( + (var(--sbb-radio-button-dimension) - var(--sbb-radio-button-inner-circle-dimension)) / 2 + ); + + @include sbb.if-forced-colors { + --sbb-radio-button-inner-circle-color: Highlight; + --sbb-radio-button-border-color: Highlight; + } +} + +// Disabled definitions have to be after checked definitions +:host(:is([data-group-disabled], [disabled])) { + --sbb-radio-button-background-color: var(--sbb-color-milk-default); + --sbb-radio-button-subtext-color: var(--sbb-color-smoke-default); + --sbb-radio-button-border-style: dashed; + --sbb-radio-button-inner-circle-color: var(--sbb-color-charcoal-default); + --sbb-radio-button-cursor: default; + + @include sbb.if-forced-colors { + --sbb-radio-button-inner-circle-color: GrayText; + --sbb-radio-button-border-color: GrayText; + --sbb-radio-button-border-style: solid; + } +} + +.sbb-radio-button__input { + @include sbb.screen-reader-only; +} + +// One radio button per line +.sbb-radio-button { + @include sbb.text-m--regular; + + display: block; + cursor: var(--sbb-radio-button-cursor); + user-select: none; + position: relative; + color: var(--sbb-radio-button-label-color); + -webkit-tap-highlight-color: transparent; + + :host([size='s']) & { + @include sbb.text-s--regular; + } + + // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. + :host( + :focus-visible:not( + [data-focus-origin='mouse'], + [data-focus-origin='touch'], + [data-is-selection-panel-input] + ) + ) + & { + @include sbb.focus-outline; + + border-radius: calc(var(--sbb-border-radius-4x) - var(--sbb-focus-outline-offset)); + } +} + +slot[name='subtext'] { + display: block; + color: var(--sbb-radio-button-subtext-color); + padding-inline-start: var(--sbb-spacing-fixed-8x); +} + +.sbb-radio-button__label-slot { + display: flex; + align-items: flex-start; + overflow: hidden; + + &::before, + &::after { + content: ''; + flex-shrink: 0; + width: var(--sbb-radio-button-dimension); + height: var(--sbb-radio-button-dimension); + border-radius: 50%; + margin-block-start: var(--sbb-radio-button-icon-align); + + transition: { + duration: var(--sbb-animation-duration-4x); + timing-function: ease; + property: background-color, border; + } + + @include sbb.if-forced-colors { + transition: none; + } + } + + // Unchecked style + &::before { + background: var(--sbb-radio-button-inner-circle-color); + + // The border was used to generate the animation of the radio-button + // The border color acts as background color. + border: var(--sbb-radio-button-background-fake-border-width) solid + var(--sbb-radio-button-background-color); + margin-inline-end: var(--sbb-spacing-fixed-2x); + } + + &::after { + position: absolute; + border: var(--sbb-radio-button-border-width) var(--sbb-radio-button-border-style) + var(--sbb-radio-button-border-color); + } +} + +.sbb-radio-button__expanded-label { + @include sbb.screen-reader-only; +} diff --git a/src/components/radio-button/radio-button/radio-button.spec.ts b/src/components/radio-button/radio-button/radio-button.spec.ts new file mode 100644 index 0000000000..64fab9827c --- /dev/null +++ b/src/components/radio-button/radio-button/radio-button.spec.ts @@ -0,0 +1,26 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './radio-button'; + +describe('sbb-radio-button', () => { + it('renders', async () => { + const root = await fixture(html``); + + expect(root).dom.to.be.equal( + ` + + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + `, + ); + }); +}); diff --git a/src/components/radio-button/radio-button/radio-button.stories.tsx b/src/components/radio-button/radio-button/radio-button.stories.tsx new file mode 100644 index 0000000000..82845f2425 --- /dev/null +++ b/src/components/radio-button/radio-button/radio-button.stories.tsx @@ -0,0 +1,120 @@ +/** @jsx h */ +import type { InputType } from '@storybook/types'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; +import { h, type JSX } from 'jsx-dom'; + +import readme from './readme.md?raw'; +import './radio-button'; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const checked: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + value, + checked, + disabled, + size, + 'aria-label': ariaLabel, +}; + +const defaultArgs: Args = { + value: 'First value', + checked: false, + disabled: false, + size: size.options[0], + 'aria-label': undefined, +}; + +const DefaultTemplate = (args): JSX.Element => Value; + +const MultilineLabelTemplate = (args): JSX.Element => ( + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been + the industry's standard dummy text ever since the 1500s. + +); + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const SizeS: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options[1] }, +}; + +export const Checked: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true }, +}; + +export const Disabled: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const CheckedDisabled: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true, disabled: true }, +}; + +export const MultilineLabel: StoryObj = { + render: MultilineLabelTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true }, +}; + +const meta: Meta = { + decorators: [ + (Story) => ( +
      + +
      + ), + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-radio-button/sbb-radio-button', +}; + +export default meta; diff --git a/src/components/radio-button/radio-button/radio-button.ts b/src/components/radio-button/radio-button/radio-button.ts new file mode 100644 index 0000000000..c5034d4e24 --- /dev/null +++ b/src/components/radio-button/radio-button/radio-button.ts @@ -0,0 +1,294 @@ +import { CSSResult, html, LitElement, nothing, TemplateResult, PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { isValidAttribute, setAttributes } from '../../core/dom'; +import { + createNamedSlotState, + documentLanguage, + HandlerRepository, + languageChangeHandlerAspect, + namedSlotChangeHandlerAspect, + formElementHandlerAspect, + EventEmitter, + ConnectedAbortController, +} from '../../core/eventing'; +import { i18nCollapsed, i18nExpanded } from '../../core/i18n'; +import { + SbbCheckedStateChange, + SbbDisabledStateChange, + SbbStateChange, +} from '../../core/interfaces'; +import { AgnosticMutationObserver } from '../../core/observers'; +import type { SbbRadioButtonGroup } from '../radio-button-group'; + +import style from './radio-button.scss?lit&inline'; + +export type SbbRadioButtonStateChange = Extract< + SbbStateChange, + SbbDisabledStateChange | SbbCheckedStateChange +>; + +export type SbbRadioButtonSize = 's' | 'm'; + +/** Configuration for the attribute to look at if component is nested in a sbb-radio-button-group */ +const radioButtonObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-required', 'data-group-disabled'], +}; + +/** + * It displays a radio button enhanced with the SBB Design. + * + * @slot - Use the unnamed slot to add content to the radio label. + * @slot subtext - Slot used to render a subtext under the label (only visible within a `sbb-selection-panel`). + * @slot suffix - Slot used to render additional content after the label (only visible within a `sbb-selection-panel`). + */ +@customElement('sbb-radio-button') +export class SbbRadioButton extends LitElement { + public static override styles: CSSResult = style; + public static readonly events = { + stateChange: 'state-change', + radioButtonLoaded: 'radio-button-loaded', + } as const; + + /** + * Whether the radio can be deselected. + */ + @property({ attribute: 'allow-empty-selection', type: Boolean }) public allowEmptySelection = + false; + + /** + * Value of radio button. + */ + @property() public value: string; + + /** + * Whether the radio button is disabled. + */ + @property({ reflect: true, type: Boolean }) public disabled = false; + + /** + * Whether the radio button is required. + */ + @property({ type: Boolean }) public required = false; + + /** + * Whether the radio button is checked. + */ + @property({ reflect: true, type: Boolean }) public checked = false; + + /** + * Label size variant, either m or s. + */ + @property({ reflect: true }) public size: SbbRadioButtonSize = 'm'; + + /** + * Whether the component must be set disabled due disabled attribute on sbb-radio-button-group. + */ + @state() private _disabledFromGroup = false; + + /** + * Whether the component must be set required due required attribute on sbb-radio-button-group. + */ + @state() private _requiredFromGroup = false; + + /** + * State of listed named slots, by indicating whether any element for a named slot is defined. + */ + @state() private _namedSlots = createNamedSlotState('subtext', 'suffix'); + + /** + * Whether the input is the main input of a selection panel. + */ + @state() private _isSelectionPanelInput = false; + + /** + * The label describing whether the selection panel is expanded (for screen readers only). + */ + @state() private _selectionPanelExpandedLabel: string; + + @state() private _currentLanguage = documentLanguage(); + + private _selectionPanelElement: HTMLElement; + private _abort = new ConnectedAbortController(this); + private _radioButtonAttributeObserver = new AgnosticMutationObserver( + this._onRadioButtonAttributesChange.bind(this), + ); + + /** + * @internal + * Internal event that emits whenever the state of the radio option + * in relation to the parent selection panel changes. + */ + private _stateChange: EventEmitter = new EventEmitter( + this, + SbbRadioButton.events.stateChange, + { bubbles: true }, + ); + + /** + * @internal + * Internal event that emits when the radio button is loaded. + */ + private _radioButtonLoaded: EventEmitter = new EventEmitter( + this, + SbbRadioButton.events.radioButtonLoaded, + { bubbles: true }, + ); + + private _handleCheckedChange(currentValue: boolean, previousValue: boolean): void { + if (currentValue !== previousValue) { + this._stateChange.emit({ type: 'checked', checked: currentValue }); + this._isSelectionPanelInput && this._updateExpandedLabel(); + } + } + + private _handleDisabledChange(currentValue: boolean, previousValue: boolean): void { + if (currentValue !== previousValue) { + this._stateChange.emit({ type: 'disabled', disabled: currentValue }); + } + } + + private _handleClick(event: Event): void { + event.preventDefault(); + this.select(); + } + + public select(): void { + if (this.disabled || this._disabledFromGroup) { + return; + } + + if (this.allowEmptySelection) { + this.checked = !this.checked; + } else if (!this.checked) { + this.checked = true; + } + } + + private _handlerRepository = new HandlerRepository( + this, + languageChangeHandlerAspect((l) => (this._currentLanguage = l)), + namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), + formElementHandlerAspect, + ); + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('click', (e) => this._handleClick(e), { signal }); + this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); + this._handlerRepository.connect(); + // We can use closest here, as we expect the parent sbb-selection-panel to be in light DOM. + this._selectionPanelElement = this.closest('sbb-selection-panel'); + this._isSelectionPanelInput = + !!this._selectionPanelElement && !this.closest('sbb-selection-panel [slot="content"]'); + this._setupInitialStateAndAttributeObserver(); + this._isSelectionPanelInput && this._radioButtonLoaded.emit(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('checked')) { + this._handleCheckedChange(this.checked, changedProperties.get('checked')); + } + if (changedProperties.has('disabled')) { + this._handleDisabledChange(this.disabled, changedProperties.get('disabled')); + } + } + + protected override firstUpdated(): void { + this._isSelectionPanelInput && this._updateExpandedLabel(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + this._radioButtonAttributeObserver.disconnect(); + } + + private _handleKeyDown(evt: KeyboardEvent): void { + if (evt.code === 'Space') { + this.select(); + } + } + + // Set up the initial disabled/required values and start observe attributes changes. + private _setupInitialStateAndAttributeObserver(): void { + const parentGroup = this.closest('sbb-radio-button-group') as SbbRadioButtonGroup; + if (parentGroup) { + this._requiredFromGroup = isValidAttribute(parentGroup, 'required'); + this._disabledFromGroup = isValidAttribute(parentGroup, 'disabled'); + this.size = parentGroup.size; + } + this._radioButtonAttributeObserver.observe(this, radioButtonObserverConfig); + } + + /** Observe changes on data attributes and set the appropriate values. */ + private _onRadioButtonAttributesChange(mutationsList: MutationRecord[]): void { + for (const mutation of mutationsList) { + if (mutation.attributeName === 'data-group-disabled') { + this._disabledFromGroup = !!isValidAttribute(this, 'data-group-disabled'); + } + if (mutation.attributeName === 'data-group-required') { + this._requiredFromGroup = !!isValidAttribute(this, 'data-group-required'); + } + } + } + + private _updateExpandedLabel(): void { + if (!this._selectionPanelElement.hasAttribute('data-has-content')) { + this._selectionPanelExpandedLabel = ''; + return; + } + + this._selectionPanelExpandedLabel = this.checked + ? ', ' + i18nExpanded[this._currentLanguage] + : ', ' + i18nCollapsed[this._currentLanguage]; + } + + protected override render(): TemplateResult { + const attributes = { + role: 'radio', + 'aria-checked': this.checked?.toString() ?? 'false', + 'aria-required': (this.required || this._requiredFromGroup).toString(), + 'aria-disabled': (this.disabled || this._disabledFromGroup).toString(), + 'data-is-selection-panel-input': this._isSelectionPanelInput, + }; + setAttributes(this, attributes); + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-radio-button': SbbRadioButton; + } +} diff --git a/src/components/radio-button/radio-button/readme.md b/src/components/radio-button/radio-button/readme.md new file mode 100644 index 0000000000..1dd009ca1a --- /dev/null +++ b/src/components/radio-button/radio-button/readme.md @@ -0,0 +1,66 @@ +The `sbb-radio-button` component provides the same functionality +as a native `` enhanced with the SBB Design: use multiple `sbb-radio-button` components +inside a [sbb-radio-button-group](/docs/components-sbb-radio-button-sbb-radio-button-group--docs) component +in order to display a radio input within a group. + +```html + + Option one + Option two + +``` + +## States + +It is possible to display the component in `disabled` or `checked` state by using the self-named properties. + +The component has a `required` property, which can be useful +for setting a custom [sbb-form-error](/docs/components-sbb-form-field-sbb-form-error--docs) message +within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs). + +The `allowEmptySelection` property allows user to deselect the component. + +```html +Option one + +Option two + +Option three + +Option four +``` + +## Style + +The component has two different sizes, which can be changed using the `size` property (`m`, which is the default, and `s`). + +```html +Size +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| --------------------- | ----------------------- | ------- | -------------------- | ------- | ------------------------------------- | +| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. | +| `value` | `value` | public | `string` | | Value of radio button. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the radio button is disabled. | +| `required` | `required` | public | `boolean` | `false` | Whether the radio button is required. | +| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. | +| `size` | `size` | public | `SbbRadioButtonSize` | `'m'` | Label size variant, either m or s. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------- | ------- | ----------- | ---------- | ------ | -------------- | +| `select` | public | | | `void` | | + +## Slots + +| Name | Description | +| --------- | ----------------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the radio label. | +| `subtext` | Slot used to render a subtext under the label (only visible within a `sbb-selection-panel`). | +| `suffix` | Slot used to render additional content after the label (only visible within a `sbb-selection-panel`). | diff --git a/src/components/sbb-accordion/readme.md b/src/components/sbb-accordion/readme.md deleted file mode 100644 index 409fba485c..0000000000 --- a/src/components/sbb-accordion/readme.md +++ /dev/null @@ -1,65 +0,0 @@ -The `sbb-accordion` is a component which acts as a container -for one or more [sbb-expansion-panel](/docs/components-sbb-accordion-sbb-expansion-panel--docs). - -```html - - - Header 1 - Content 1 - - - Header 2 - Content 2 - - -``` - -## Interaction - -The `multi` property, if set, allows having more than one `sbb-expansion-panel` expanded at the same time. - -```html - - ... - -``` - -## Style - -The component has a `titleLevel` property, which is proxied to each inner `sbb-expansion-panel-header`, and can be used -to wrap the header of each `sbb-expansion-panel` in a heading tag; if the property is unset, a `div` is used. - -In the following example, all the `sbb-expansion-panel-header` would be wrapped in a `h3` heading tag. - -```html - - - Header 1 - Content 1 - - ... - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------ | ------------------- | --------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `disableAnimation` | `disable-animation` | Whether the animation should be disabled. | `boolean` | `false` | -| `multi` | `multi` | Whether more than one sbb-expansion-panel can be open at the same time. | `boolean` | `false` | -| `titleLevel` | `title-level` | The heading level for the sbb-expansion-panel-headers within the component. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `undefined` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------------ | -| `"unnamed"` | Use this to add one or more sbb-expansion-panel. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-accordion/sbb-accordion.e2e.ts b/src/components/sbb-accordion/sbb-accordion.e2e.ts deleted file mode 100644 index 27fb0f6ced..0000000000 --- a/src/components/sbb-accordion/sbb-accordion.e2e.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; -import sbbExpansionPanelEvents from '../sbb-expansion-panel/sbb-expansion-panel.events'; - -describe('sbb-accordion', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - Header 1 - Content 1 - - - Header 2 - Content 2 - - - Header 3 - Content 3 - - - `); - - element = await page.find('sbb-accordion'); - await page.waitForChanges(); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('should set accordion context on expansion panel', async () => { - const panels = await page.findAll('sbb-expansion-panel'); - - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[0]).toHaveAttribute('data-accordion'); - expect(panels[1]).toHaveAttribute('data-accordion'); - expect(panels[2]).toHaveAttribute('data-accordion'); - expect(panels[2]).toHaveAttribute('data-accordion-last'); - }); - - it('should set accordion context on expansion panel when removing and adding expansion-panels', async () => { - let panels: E2EElement[]; - await page.waitForChanges(); - - await page.evaluate(() => document.querySelector('sbb-expansion-panel').remove()); - await page.waitForChanges(); - - panels = await page.findAll('sbb-expansion-panel'); - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[1]).toHaveAttribute('data-accordion-last'); - - await page.evaluate(() => document.querySelector('sbb-expansion-panel').remove()); - await page.waitForChanges(); - - const lastRemainingPanel = await page.find('sbb-expansion-panel'); - expect(lastRemainingPanel).toHaveAttribute('data-accordion-first'); - expect(lastRemainingPanel).toHaveAttribute('data-accordion-last'); - - await page.evaluate(() => { - const panel = document.createElement('sbb-expansion-panel'); - document.querySelector('sbb-accordion').append(panel); - }); - await page.waitForChanges(); - - panels = await page.findAll('sbb-expansion-panel'); - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[0]).not.toHaveAttribute('data-accordion-last'); - expect(panels[1]).toHaveAttribute('data-accordion-last'); - }); - - it('should inherit titleLevel prop by panels', async () => { - const panels = await page.findAll('sbb-expansion-panel'); - expect(panels.length).toEqual(3); - expect(panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); - expect(panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); - expect(panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); - }); - - it('should dynamically update titleLevel prop', async () => { - await element.setProperty('titleLevel', '6'); - await page.waitForChanges(); - const panels = await page.findAll('sbb-expansion-panel'); - expect(panels.length).toEqual(3); - expect(panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); - expect(panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); - expect(panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); - }); - - it('should close others when expanding and multi = false', async () => { - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const panelOne: E2EElement = await page.find('#panel-1'); - const headerOne: E2EElement = await page.find('#header-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const headerTwo: E2EElement = await page.find('#header-2'); - const panelThree: E2EElement = await page.find('#panel-3'); - const headerThree: E2EElement = await page.find('#header-3'); - - for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); - } - - await headerTwo.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await headerOne.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await headerThree.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(true); - }); - - it('should not change others when expanding and multi = false', async () => { - await element.setProperty('multi', 'true'); - await page.waitForChanges(); - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const panelOne: E2EElement = await page.find('#panel-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const panelThree: E2EElement = await page.find('#panel-3'); - for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); - } - - await panelTwo.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await panelOne.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await panelThree.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(true); - }); - - it('should close all panels except the first when multi changes from true to false', async () => { - await element.setProperty('multi', 'true'); - await page.waitForChanges(); - const panelOne: E2EElement = await page.find('#panel-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const panelThree: E2EElement = await page.find('#panel-3'); - for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); - } - - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - - await panelTwo.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - - await panelThree.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelThree.getProperty('expanded')).toEqual(true); - - await element.setProperty('multi', 'false'); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(false); - }); -}); diff --git a/src/components/sbb-accordion/sbb-accordion.scss b/src/components/sbb-accordion/sbb-accordion.scss deleted file mode 100644 index 8b5b4f194c..0000000000 --- a/src/components/sbb-accordion/sbb-accordion.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; diff --git a/src/components/sbb-accordion/sbb-accordion.spec.ts b/src/components/sbb-accordion/sbb-accordion.spec.ts deleted file mode 100644 index c5106f537c..0000000000 --- a/src/components/sbb-accordion/sbb-accordion.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SbbAccordion } from './sbb-accordion'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-accordion', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbAccordion], - html: ` - - - Header 1 - Content 1 - - - Header 2 - Content 2 - - - `, - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      - - Header 1 - Content 1 - - - Header 2 - Content 2 - -
      - `); - }); -}); diff --git a/src/components/sbb-accordion/sbb-accordion.stories.tsx b/src/components/sbb-accordion/sbb-accordion.stories.tsx deleted file mode 100644 index 422e440700..0000000000 --- a/src/components/sbb-accordion/sbb-accordion.stories.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/html'; -import { InputType, StoryContext } from '@storybook/types'; -import sbbExpansionPanelEvents from '../sbb-expansion-panel/sbb-expansion-panel.events'; -import { withActions } from '@storybook/addon-actions/decorator'; -import { Decorator } from '@storybook/html'; - -const numberOfPanels: InputType = { - control: { - type: 'number', - }, -}; - -const multi: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Accordion', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Accordion', - }, -}; - -const titleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6, null], - table: { - category: 'Accordion', - }, -}; - -const color: InputType = { - control: { - type: 'inline-radio', - }, - options: ['white', 'milk'], - table: { - category: 'Panel', - }, -}; - -const expanded: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Panel', - }, -}; - -const borderless: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Panel', - }, -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Panel', - }, -}; - -const headerText: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Header', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Header', - }, -}; - -const contentText: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Content', - }, -}; - -const defaultArgTypes: ArgTypes = { - numberOfPanels, - multi, - 'disable-animation': disableAnimation, - 'title-level': titleLevel, - color, - expanded, - borderless, - disabled, - headerText, - iconName, - contentText, -}; - -const defaultArgs: Args = { - numberOfPanels: 3, - multi: false, - 'disable-animation': false, - 'title-level': titleLevel.options[2], - color: color.options[0], - expanded: false, - borderless: false, - disabled: false, - headerText: 'This is the header', - iconName: undefined, - contentText: 'This is the content: "Lorem ipsum dolor sit amet".', -}; - -const createExpansionPanelTemplate = ( - numberOfPanels, - color, - expanded, - borderless, - disabled, - headerText, - iconName, - contentText, -): JSX.Element[] => { - return new Array(numberOfPanels).fill(null).map((_, index) => ( - - - {headerText} {index + 1} - - -

      Content {index + 1}

      - {contentText} -
      -
      - )); -}; - -const Template = ({ - numberOfPanels, - color, - expanded, - borderless, - disabled, - headerText, - iconName, - contentText, - ...args -}): JSX.Element => ( - - {createExpansionPanelTemplate( - numberOfPanels, - color, - expanded, - borderless, - disabled, - headerText, - iconName, - contentText, - )} - -); - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const Milk: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, color: color.options[1] }, -}; - -export const Borderless: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, borderless: true }, -}; - -export const Disabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, disabled: true }, -}; - -export const MilkBorderless: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, color: color.options[1], borderless: true }, -}; - -export const WithIcon: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, iconName: 'swisspass-medium' }, -}; - -export const Expanded: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, expanded: true }, -}; - -export const Multi: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, multi: true }, -}; - -export const NoAnimation: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, 'disable-animation': true }, -}; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.borderless ? '#bdbdbd' : 'var(--sbb-color-white-default)', -}); - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - backgrounds: { - disable: true, - }, - actions: { - handles: [ - sbbExpansionPanelEvents.willOpen, - sbbExpansionPanelEvents.didOpen, - sbbExpansionPanelEvents.willClose, - sbbExpansionPanelEvents.didClose, - ], - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-accordion/sbb-accordion', -}; - -export default meta; diff --git a/src/components/sbb-accordion/sbb-accordion.tsx b/src/components/sbb-accordion/sbb-accordion.tsx deleted file mode 100644 index d8432aa34b..0000000000 --- a/src/components/sbb-accordion/sbb-accordion.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Component, ComponentInterface, Element, h, JSX, Listen, Prop, Watch } from '@stencil/core'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; -import { toggleDatasetEntry } from '../../global/dom'; - -/** - * @slot unnamed - Use this to add one or more sbb-expansion-panel. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-accordion.scss', - tag: 'sbb-accordion', -}) -export class SbbAccordion implements ComponentInterface { - /** The heading level for the sbb-expansion-panel-headers within the component. */ - @Prop() public titleLevel?: InterfaceTitleAttributes['level']; - - /** Whether the animation should be disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; - - /** Whether more than one sbb-expansion-panel can be open at the same time. */ - @Prop() public multi = false; - - @Listen('will-open') - public closePanels(e): void { - if (e.target?.tagName !== 'SBB-EXPANSION-PANEL' || this.multi) { - return; - } - - this._expansionPanels - .filter((panel) => panel !== e.target) - .forEach((panel) => (panel.expanded = false)); - } - - @Watch('multi') - public resetExpansionPanels(newValue: boolean, oldValue: boolean): void { - // If it's changing from "multi = true" to "multi = false", open the first panel and close all the others. - const expansionPanels = this._expansionPanels; - if (expansionPanels.length > 1 && oldValue && !newValue) { - expansionPanels[0].expanded = true; - expansionPanels - .filter((_, index: number) => index > 0) - .forEach((panel) => (panel.expanded = false)); - } - } - - @Watch('titleLevel') - public setTitleLevelOnChildren(): void { - this._expansionPanels.forEach((panel) => (panel.titleLevel = this.titleLevel)); - } - - @Element() private _element!: HTMLElement; - - private get _expansionPanels(): HTMLSbbExpansionPanelElement[] { - return Array.from(this._element.querySelectorAll('sbb-expansion-panel')); - } - - private _setChildrenParameters(): void { - const expansionPanels = this._expansionPanels; - if (!expansionPanels) { - return; - } - - expansionPanels.forEach((panel: HTMLSbbExpansionPanelElement) => { - panel.titleLevel = this.titleLevel; - - toggleDatasetEntry(panel, 'accordionFirst', false); - toggleDatasetEntry(panel, 'accordionLast', false); - - if (this.disableAnimation) { - panel.setAttribute('disable-animation', 'true'); - } else { - panel.removeAttribute('disable-animation'); - } - }); - toggleDatasetEntry(expansionPanels[0], 'accordionFirst', true); - toggleDatasetEntry(expansionPanels[expansionPanels.length - 1], 'accordionLast', true); - } - - public render(): JSX.Element { - return ( -
      - this._setChildrenParameters()}> -
      - ); - } -} diff --git a/src/components/sbb-action-group/readme.md b/src/components/sbb-action-group/readme.md deleted file mode 100644 index 3f58c2116b..0000000000 --- a/src/components/sbb-action-group/readme.md +++ /dev/null @@ -1,139 +0,0 @@ -The `sbb-action-group` component is a generic content container which can contain up to three action items -([sbb-button](/docs/components-sbb-button--docs) or [sbb-link](/docs/components-sbb-link--docs) or other HTML elements) -in various [allocations](#allocations). - -## Style - -### Orientation - -The `orientation` property is used to set item's orientation. -Possible values are `horizontal` (default) and `vertical`. - -The optional property `horizontalFrom` can be used in combination with `orientation='vertical'` to -indicate the minimum breakpoint from which the orientation changes to `horizontal`. - -```html - - Action 1 - Action 2 - - Action 3 - - -``` - -### Button-size and link-size - -The two props `button-size` and `link-size` can be used to override, respectively, the size of the inner `sbb-button` and `sbb-link`. -Default values are `l` for `sbb-button` and `m` for `sbb-link`. - -```html - - Action 1 - - Action 3 - - -``` - -### Align-group and align-self - -The `align-group` property can be used to set the default alignment of the contained elements; -possible values are `start`, `center`, `stretch` and `end`. - -It is also possible to set the `align-self` attribute on action items in order to move them in the -opposite direction to the group; possible values are `start`, `center` or `end`. - -**NOTE**: The `sbb-action-group` will automatically set variant `block` and will sync the `linkSize` - property with nested `sbb-link` and the `buttonSize` property with the nested `sbb-button` - instances. - -```html - - Action 1 - Action 2 - Action 3 - -``` - -## Allocations - -Items can be displayed inside `sbb-action-group` in different allocations. - -If we define the triad x-y-z as the number of elements aligned at the start, at the center and at the end of the component, -and we consider a template like the following one (possibly removing the link for 2-elements allocations): - -```html - - Button 1 - Button 2 - - Link - - -``` - -The values for `align-group` and `align-self` for the various allocations are as follows: - -### Horizontal - -| orientation='horizontal' | align-group | align-self | -|:------------------------:|:-----------:|:------------------:| -| 3-0-0 | start | / | -| 1-1-1 | start | Button 2: 'center' | -| 2-0-1 | start | Link: 'end' | -| 1-0-2 | end | Button 1: 'start' | -| 2-0-0 | start | / | -| 1-0-1 | start | Button 2: 'end' | - -### Vertical - -| orientation='vertical' | align-group | align-self | -|:----------------------:|:-----------:|:----------:| -| 3-0-0 | start | / | -| 2-0-0 | start | / | -| 0-3-0 | center | / | -| 0-2-0 | center | / | -| 0-0-3 | end | / | -| 0-0-2 | end | / | - -| orientation='vertical' (full width) | align-group | align-self | -|:-----------------------------------:|:-----------:|:--------------:| -| 3-0-0 | stretch | Link: 'start' | -| 2-0-0 | stretch | / | -| 0-3-0 | stretch | Link: 'center' | -| 0-2-0 | stretch | / | -| 0-0-3 | stretch | Link: 'end' | -| 0-0-2 | stretch | / | - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------------- | ----------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | -| `alignGroup` | `align-group` | Set the slotted `` children's alignment. | `"center" \| "end" \| "start" \| "stretch"` | `'start'` | -| `buttonSize` | `button-size` | Size of the nested sbb-button instances. This will overwrite the size attribute of nested sbb-button instances. | `"l" \| "m"` | `'l'` | -| `horizontalFrom` | `horizontal-from` | Overrides the behaviour of `orientation` property. | `"large" \| "medium" \| "micro" \| "small" \| "ultra" \| "wide" \| "zero"` | `'medium'` | -| `linkSize` | `link-size` | Size of the nested sbb-link instances. This will overwrite the size attribute of nested sbb-link instances. | `"m" \| "s" \| "xs"` | `'m'` | -| `orientation` | `orientation` | Indicates the orientation of the components inside the ``. | `"horizontal" \| "vertical"` | `'horizontal'` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------------ | -| `"unnamed"` | Slot to render the content inside the container. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-action-group/sbb-action-group.custom.d.ts b/src/components/sbb-action-group/sbb-action-group.custom.d.ts deleted file mode 100644 index 11040c80b9..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.custom.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface InterfaceSbbActionGroupAttributes { - alignGroup: 'start' | 'center' | 'stretch' | 'end'; - horizontalFrom?: 'zero' | 'micro' | 'small' | 'medium' | 'large' | 'wide' | 'ultra'; - orientation: 'horizontal' | 'vertical'; -} diff --git a/src/components/sbb-action-group/sbb-action-group.e2e.ts b/src/components/sbb-action-group/sbb-action-group.e2e.ts deleted file mode 100644 index 01ebdcb8ec..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-action-group', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - Button - - Link - - - `); - element = await page.find('sbb-action-group'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - describe('property sync', () => { - it('should sync default size with sbb-button', async () => { - await page.waitForChanges(); - const links = await page.findAll('sbb-action-group sbb-button'); - expect(links.every((l) => l.getAttribute('size') === 'l')).toBeTruthy(); - }); - - it('should update attributes with button-size="m"', async () => { - element.setAttribute('button-size', 'm'); - await page.waitForChanges(); - const links = await page.findAll('sbb-action-group sbb-button'); - expect(links.every((l) => l.getAttribute('size') === 'm')).toBeTruthy(); - }); - - it('should update attributes with link-size="s"', async () => { - element.setAttribute('link-size', 's'); - await page.waitForChanges(); - const links = await page.findAll('sbb-action-group sbb-link'); - expect(links.every((l) => l.getAttribute('size') === 's')).toBeTruthy(); - }); - - it('should apply variant block to sbb-link', async () => { - await page.waitForChanges(); - const links = await page.findAll('sbb-action-group sbb-link'); - expect(links.every((l) => l.getAttribute('variant') === 'block')).toBeTruthy(); - }); - }); -}); diff --git a/src/components/sbb-action-group/sbb-action-group.scss b/src/components/sbb-action-group/sbb-action-group.scss deleted file mode 100644 index 66cf088887..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.scss +++ /dev/null @@ -1,80 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -$breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; -$vertical-orientations: ( - start: flex-start, - center: center, - stretch: stretch, - end: flex-end, -); -$horizontal-orientations: ( - start: flex-start, - center: center, - stretch: space-between, - end: flex-end, -); - -:host([orientation='horizontal']) { - --sbb-action-group-orientation: row; - --sbb-action-group-align-items: center; - --sbb-action-group-gap: var(--sbb-spacing-fixed-4x); -} - -@each $key, $value in $horizontal-orientations { - :host([orientation='horizontal'][align-group='#{$key}']) { - --sbb-action-group-justify-content: #{$value}; - } -} - -:host([orientation='vertical']) { - --sbb-action-group-orientation: column; - --sbb-action-group-gap: var(--sbb-spacing-fixed-2x); -} - -@each $key, $value in $vertical-orientations { - :host([orientation='vertical'][align-group='#{$key}']) { - --sbb-action-group-align-items: #{$value}; - } -} - -@each $breakpoint in $breakpoints { - @include sbb.mq($from: #{$breakpoint}) { - @each $key, $value in $horizontal-orientations { - // horizontal-from overrides orientation vertical - :host([orientation='vertical'][horizontal-from='#{$breakpoint}'][align-group='#{$key}']) { - --sbb-action-group-orientation: row; - --sbb-action-group-align-items: center; - --sbb-action-group-justify-content: #{$value}; - --sbb-action-group-gap: var(--sbb-spacing-fixed-4x); - } - } - } -} - -.sbb-action-group { - display: flex; - flex-direction: var(--sbb-action-group-orientation); - gap: var(--sbb-action-group-gap); - justify-content: var(--sbb-action-group-justify-content); - align-items: var(--sbb-action-group-align-items); - - ::slotted([align-self='start']) { - margin-inline-end: auto; - } - - ::slotted([align-self='center']) { - margin-inline: auto; - } - - ::slotted([align-self='end']) { - margin-inline-start: auto; - } -} - -::slotted(sbb-link) { - white-space: nowrap; -} diff --git a/src/components/sbb-action-group/sbb-action-group.spec.ts b/src/components/sbb-action-group/sbb-action-group.spec.ts deleted file mode 100644 index 50a5f8d721..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { SbbActionGroup } from './sbb-action-group'; -import { newSpecPage } from '@stencil/core/testing'; -import { AnyHTMLElement } from '@stencil/core/internal'; -import { patchSlotchangeEvent } from '../../global/testing'; - -describe('sbb-action-group', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbActionGroup], - html: ` - - Button - - Link - - - `, - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      - Button - - Link - -
      - `); - }); - - describe('property sync', () => { - const assertButtons = ( - root: AnyHTMLElement, - assertion: (link: HTMLSbbButtonElement) => boolean, - ): boolean => Array.from(root.querySelectorAll('sbb-button')).every(assertion); - - it('should sync default button-size property with sbb-button', async () => { - const { root } = await newSpecPage({ - components: [SbbActionGroup], - html: ` - - Button - - Link - - - `, - }); - patchSlotchangeEvent(root); - - expect(assertButtons(root, (b) => b.size === 'l')).toBeTruthy(); - }); - - it('should sync button-size property with sbb-button', async () => { - const { root } = await newSpecPage({ - components: [SbbActionGroup], - html: ` - - Button - - Link - - - `, - }); - patchSlotchangeEvent(root); - - expect(assertButtons(root, (b) => b.size === 'm')).toBeTruthy(); - }); - - it('should apply block variant to sbb-link', async () => { - const { root } = await newSpecPage({ - components: [SbbActionGroup], - html: ` - - Button - - Link - - - `, - }); - patchSlotchangeEvent(root); - - expect( - Array.from(root.querySelectorAll('sbb-link')).every((l) => l.variant === 'block'), - ).toBeTruthy(); - }); - - it('should sync link-size property with sbb-link', async () => { - const { root } = await newSpecPage({ - components: [SbbActionGroup], - html: ` - - Button - - Link - - - `, - }); - patchSlotchangeEvent(root); - - expect( - Array.from(root.querySelectorAll('sbb-link')).every((l) => l.size === 's'), - ).toBeTruthy(); - }); - }); -}); diff --git a/src/components/sbb-action-group/sbb-action-group.stories.tsx b/src/components/sbb-action-group/sbb-action-group.stories.tsx deleted file mode 100644 index 2f2ce37949..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.stories.tsx +++ /dev/null @@ -1,276 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const secondaryButtonTemplate = (alignSelf): JSX.Element => ( - - Button 1 - -); - -const buttonTemplate = (alignSelf): JSX.Element => ( - Button 2 -); - -const linkTemplate = (alignSelf): JSX.Element => ( - - Link - -); - -const TemplateTwoElements = (alignSelfFirst?, alignSelfSecond?): JSX.Element[] => [ - secondaryButtonTemplate(alignSelfFirst), - buttonTemplate(alignSelfSecond), -]; - -const TemplateThreeElements = ( - alignSelfFirst?, - alignSelfSecond?, - alignSelfThird?, -): JSX.Element[] => [ - ...TemplateTwoElements(alignSelfFirst, alignSelfSecond), - linkTemplate(alignSelfThird), -]; - -const CommonTemplateThreeElementsAllocation = ({ ...args }): JSX.Element => ( - {TemplateThreeElements()} -); - -const CommonTemplateTwoElementsAllocation = ({ ...args }): JSX.Element => ( - {TemplateTwoElements()} -); - -const TemplateHorizontalAllocation111 = ({ ...args }): JSX.Element => ( - {TemplateThreeElements(null, 'center')} -); - -const TemplateHorizontalAllocation201 = ({ ...args }): JSX.Element => ( - {TemplateThreeElements(null, null, 'end')} -); - -const TemplateHorizontalAllocation102 = ({ ...args }): JSX.Element => ( - {TemplateThreeElements('start')} -); - -const TemplateHorizontalAllocation101 = ({ ...args }): JSX.Element => ( - {TemplateTwoElements(null, 'end')} -); - -const TemplateVerticalAllocation300FullWidth = ({ ...args }): JSX.Element => ( - {TemplateThreeElements(null, null, 'start')} -); - -const TemplateVerticalAllocation030FullWidth = ({ ...args }): JSX.Element => ( - {TemplateThreeElements(null, null, 'center')} -); - -const TemplateVerticalAllocation003FullWidth = ({ ...args }): JSX.Element => ( - {TemplateThreeElements(null, null, 'end')} -); - -const buttonSize: InputType = { - control: { - type: 'inline-radio', - }, - options: ['l', 'm'], -}; - -const linkSize: InputType = { - control: { - type: 'inline-radio', - }, - options: ['m', 's', 'xs'], -}; - -const orientation: InputType = { - control: { - type: 'inline-radio', - }, - options: ['horizontal', 'vertical'], -}; - -const horizontalFrom: InputType = { - control: { - type: 'select', - }, - options: ['unset', 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'], -}; - -const alignGroup: InputType = { - control: { - type: 'inline-radio', - }, - options: ['start', 'center', 'stretch', 'end'], -}; - -const basicArgTypes: ArgTypes = { - 'align-group': alignGroup, - orientation, - 'horizontal-from': horizontalFrom, - 'button-size': buttonSize, - 'link-size': linkSize, -}; - -const basicArgs: Args = { - 'align-group': 'start', - orientation: 'horizontal', - 'horizontal-from': 'unset', - 'button-size': buttonSize.options[0], - 'link-size': linkSize.options[0], -}; - -const basicArgsVertical = { - ...basicArgs, - orientation: 'vertical', -}; - -const basicArgsVerticalFullWidth = { - ...basicArgsVertical, - 'align-group': 'stretch', -}; - -export const HorizontalAllocation3_0_0: StoryObj = { - render: CommonTemplateThreeElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const HorizontalAllocation1_1_1: StoryObj = { - render: TemplateHorizontalAllocation111, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const HorizontalAllocation2_0_1: StoryObj = { - render: TemplateHorizontalAllocation201, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const HorizontalAllocation1_0_2: StoryObj = { - render: TemplateHorizontalAllocation102, - argTypes: basicArgTypes, - args: { ...basicArgs, 'align-group': 'end' }, -}; - -export const HorizontalAllocation2_0_0: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const HorizontalAllocation1_0_1: StoryObj = { - render: TemplateHorizontalAllocation101, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const VerticalAllocation3_0_0: StoryObj = { - render: CommonTemplateThreeElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'start' }, -}; - -export const VerticalAllocation2_0_0: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'start' }, -}; - -export const VerticalAllocation0_3_0: StoryObj = { - render: CommonTemplateThreeElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'center' }, -}; - -export const VerticalAllocation0_2_0: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'center' }, -}; - -export const VerticalAllocation0_0_3: StoryObj = { - render: CommonTemplateThreeElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'end' }, -}; - -export const VerticalAllocation0_0_2: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'align-group': 'end' }, -}; - -export const VerticalAllocation3_0_0FullWidth: StoryObj = { - render: TemplateVerticalAllocation300FullWidth, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalAllocation2_0_0FullWidth: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalAllocation0_3_0FullWidth: StoryObj = { - render: TemplateVerticalAllocation030FullWidth, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalAllocation0_2_0FullWidth: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalAllocation0_0_3FullWidth: StoryObj = { - render: TemplateVerticalAllocation003FullWidth, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalAllocation0_0_2FullWidth: StoryObj = { - render: CommonTemplateTwoElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVerticalFullWidth }, -}; - -export const VerticalToHorizontal3_0_0: StoryObj = { - render: CommonTemplateThreeElementsAllocation, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'horizontal-from': 'medium' }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['click'], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-action-group', -}; - -export default meta; diff --git a/src/components/sbb-action-group/sbb-action-group.tsx b/src/components/sbb-action-group/sbb-action-group.tsx deleted file mode 100644 index f8c4129129..0000000000 --- a/src/components/sbb-action-group/sbb-action-group.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Component, Element, h, JSX, Prop, Watch } from '@stencil/core'; -import { InterfaceButtonAttributes } from '../sbb-button/sbb-button.custom'; -import { InterfaceLinkAttributes } from '../sbb-link/sbb-link.custom'; -import { InterfaceSbbActionGroupAttributes } from './sbb-action-group.custom'; - -/** - * @slot unnamed - Slot to render the content inside the container. - */ - -@Component({ - shadow: true, - styleUrl: 'sbb-action-group.scss', - tag: 'sbb-action-group', -}) -export class SbbActionGroup { - /** - * Set the slotted `` children's alignment. - */ - @Prop({ reflect: true }) public alignGroup: InterfaceSbbActionGroupAttributes['alignGroup'] = - 'start'; - - /** - * Overrides the behaviour of `orientation` property. - */ - @Prop({ reflect: true }) - public horizontalFrom?: InterfaceSbbActionGroupAttributes['horizontalFrom'] = 'medium'; - - /** - * Indicates the orientation of the components inside the ``. - */ - @Prop({ reflect: true }) public orientation: InterfaceSbbActionGroupAttributes['orientation'] = - 'horizontal'; - - /** - * Size of the nested sbb-button instances. This will overwrite the size attribute of nested - * sbb-button instances. - */ - @Prop({ reflect: true }) public buttonSize?: InterfaceButtonAttributes['size'] = 'l'; - - /** - * Size of the nested sbb-link instances. This will overwrite the size attribute of nested - * sbb-link instances. - */ - @Prop({ reflect: true }) public linkSize?: InterfaceLinkAttributes['size'] = 'm'; - - @Element() private _element!: HTMLElement; - - @Watch('buttonSize') - public syncButtons(): void { - this._element.querySelectorAll('sbb-button').forEach((b) => (b.size = this.buttonSize)); - } - - @Watch('linkSize') - public syncLinks(): void { - this._element.querySelectorAll('sbb-link').forEach((link) => { - link.variant = 'block'; - link.size = this.linkSize; - }); - } - - public render(): JSX.Element { - return ( -
      - { - this.syncButtons(); - this.syncLinks(); - }} - /> -
      - ); - } -} diff --git a/src/components/sbb-alert-group/readme.md b/src/components/sbb-alert-group/readme.md deleted file mode 100644 index deef26a15e..0000000000 --- a/src/components/sbb-alert-group/readme.md +++ /dev/null @@ -1,72 +0,0 @@ -The `sbb-alert-group` manages the dismissal and accessibility of one or multiple -[sbb-alert](/docs/components-sbb-alert-sbb-alert--docs) and also its visual gap between each other. - -```html - - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - - - Between Berne and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock - construction work will take place. You have to expect changed travel times and changed - connections. - - -``` - -## Interactions - -If all the `sbb-alert`s are dismissed, it's recommended to completely remove the `sbb-alert-group` from DOM. - -You can catch this moment by listening to `empty` event and react accordingly. - -## Accessibility - -By specifying the `accessibility-title` it's possible to add a hidden title to the `sbb-alert-group`. -The heading level can be set via `accessibility-title-level`. - -By default, the `sbb-alert-group` has the role `status` which means that if a new alert arrives, -it will be read out as soon as the user is idle -(equal to [aria-live="polite"](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions)). - -You can change the `role` or `aria-live` attributes to fit your needs. -For example, you can set the `role` to `alert` which implicitly sets `aria-live` to `assertive` -and therefore interrupts screen reader flow, to immediately read out the alert content. - -**Note that with role `alert`, in some combinations of screen readers and browsers not every part of the alert is fully read.** - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `accessibilityTitle` | `accessibility-title` | Title for this alert group which is only visible for screen reader users. | `string` | `undefined` | -| `accessibilityTitleLevel` | `accessibility-title-level` | Level of the accessibility title, will be rendered as heading tag (e.g. h2). Defaults to level 2. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `'2'` | -| `role` | `role` | The role attribute defines how to announce alerts to the user. 'status': sets aria-live to polite and aria-atomic to true. 'alert': sets aria-live to assertive and aria-atomic to true. | `string` | `'status'` | - - -## Events - -| Event | Description | Type | -| ------------------- | ------------------------------------------- | ---------------------------------- | -| `did-dismiss-alert` | Emits when an alert was removed from DOM. | `CustomEvent` | -| `empty` | Emits when `sbb-alert-group` becomes empty. | `CustomEvent` | - - -## Slots - -| Slot | Description | -| ----------------------- | ----------------------------------------------------------------------------- | -| `"accessibility-title"` | title for this sbb-alert-group which is only visible for screen reader users. | -| `"unnamed"` | content slot, should be filled with `sbb-alert` items. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-alert-group/sbb-alert-group.custom.d.ts b/src/components/sbb-alert-group/sbb-alert-group.custom.d.ts deleted file mode 100644 index ef16338ae4..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.custom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InterfaceSbbAlertGroupAttributes { - role: 'alert' | 'status' | string; -} diff --git a/src/components/sbb-alert-group/sbb-alert-group.e2e.ts b/src/components/sbb-alert-group/sbb-alert-group.e2e.ts deleted file mode 100644 index 31190897e3..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.e2e.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import events from './sbb-alert-group.events'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-alert-group', () => { - it('should handle events ond states on interacting with alerts', async () => { - const alertGroupId = 'alertgroup'; - const accessibilityTitle = 'Disruptions'; - const accessibilityTitleLevel = '3'; - - // Given sbb-alert-group with two alerts - const page = await newE2EPage(); - await page.setContent(` - - First - Second - -`); - const didDismissAlertSpy = await page.spyOnEvent(events.didDismissAlert); - const emptySpy = await page.spyOnEvent(events.empty); - - // When rendering initially - await page.waitForChanges(); - - // Then two alerts should be rendered and accessibility title should be displayed - expect((await page.findAll('sbb-alert')).length).toBe(2); - const alertGroupTitle = await page.find('sbb-alert-group >>> .sbb-alert-group__title'); - expect(alertGroupTitle.textContent).toBe(accessibilityTitle); - expect(alertGroupTitle.tagName).toBe(`H${accessibilityTitleLevel}`); - - // When clicking on close button of the first alert - await (await page.find('sbb-alert >>> .sbb-alert__close-button-wrapper sbb-button')).click(); - await page.waitForChanges(); - - // Then one alert should be removed from sbb-alert-group, tabindex should be set to 0, - // focus should be on sbb-alert-group and accessibility title should still be rendered. - // Moreover, didDismissAlert event should have been fired. - expect((await page.findAll('sbb-alert')).length).toBe(1); - expect((await page.find('sbb-alert-group')).tabIndex).toBe(0); - expect(await page.evaluate(() => document.activeElement.id)).toBe(alertGroupId); - expect((await page.find('sbb-alert-group >>> .sbb-alert-group__title')).textContent).toBe( - accessibilityTitle, - ); - expect(didDismissAlertSpy).toHaveReceivedEvent(); - expect(emptySpy).not.toHaveReceivedEvent(); - - // When clicking on close button of the second alert - await (await page.find('sbb-alert >>> .sbb-alert__close-button-wrapper sbb-button')).click(); - await page.waitForChanges(); - - // Then the alert should be removed from sbb-alert-group, tabindex should be set to 0, - // focus should be on sbb-alert-group, accessibility title should be removed and empty event should be fired. - expect((await page.findAll('sbb-alert')).length).toBe(0); - expect((await page.find('sbb-alert-group')).tabIndex).toBe(0); - expect(await page.evaluate(() => document.activeElement.id)).toBe(alertGroupId); - expect(await page.find('sbb-alert-group >>> .sbb-alert-group__title')).toBeNull(); - await waitForCondition(() => didDismissAlertSpy.events.length === 2); - expect(didDismissAlertSpy).toHaveReceivedEventTimes(2); - expect(emptySpy).toHaveReceivedEvent(); - - // When clicking away (simulated by blur event) - (await page.find('sbb-alert-group')).triggerEvent('blur'); - await page.waitForChanges(); - - // Then the active element id should be unset and tabindex should be removed - expect(await page.evaluate(() => document.activeElement.id)).toBe(''); - expect((await page.find('sbb-alert-group')).tabIndex).toBe(-1); - }); - - it('should not trigger empty event after initializing with empty sbb-alert-group', async () => { - // Given empty sbb-alert-group - const page = await newE2EPage(); - const emptySpy = await page.spyOnEvent(events.empty); - - await page.setContent(``); - await page.waitForChanges(); - - // Then no title should be rendered and no empty event fired - expect(await page.find('sbb-alert-group >>> .sbb-alert-group__title')).toBeNull(); - expect(emptySpy).not.toHaveReceivedEvent(); - }); -}); diff --git a/src/components/sbb-alert-group/sbb-alert-group.events.ts b/src/components/sbb-alert-group/sbb-alert-group.events.ts deleted file mode 100644 index 3a92f3b07c..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.events.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didDismissAlert: 'did-dismiss-alert', - empty: 'empty', -}; diff --git a/src/components/sbb-alert-group/sbb-alert-group.scss b/src/components/sbb-alert-group/sbb-alert-group.scss deleted file mode 100644 index c63297c400..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.scss +++ /dev/null @@ -1,26 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-alert-group-gap: var(--sbb-spacing-fixed-3x); - --sbb-alert-group-border-radius: var(--sbb-border-radius-4x); -} - -.sbb-alert-group { - display: flex; - flex-direction: column; - gap: var(--sbb-alert-group-gap); -} - -:host(:focus-visible:not(.sbb-alert-group-empty)) { - @include sbb.focus-outline; - - border-radius: var(--sbb-alert-group-border-radius); -} - -.sbb-alert-group__title { - @include sbb.screen-reader-only; -} diff --git a/src/components/sbb-alert-group/sbb-alert-group.spec.ts b/src/components/sbb-alert-group/sbb-alert-group.spec.ts deleted file mode 100644 index 6a5509d616..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { SbbAlertGroup } from './sbb-alert-group'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-alert-group', () => { - it('should render', async () => { - const { root } = await newSpecPage({ - components: [SbbAlertGroup], - html: ` - - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - - -`, - }); - - // TODO: sbb-alert-group-empty class is wrongly placed in test due to missing slotchange support - expect(root).toEqualHtml(` - - -
      - -
      -
      - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - -
      - `); - }); - - it('should render with slots', async () => { - const { root } = await newSpecPage({ - components: [SbbAlertGroup], - html: ` - - Interruptions - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - - -`, - }); - - // TODO: sbb-alert-group-empty class is wrongly placed in test due to missing slotchange support - expect(root).toEqualHtml(` - - -
      - -
      -
      - - Interruptions - - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - -
      - `); - }); -}); diff --git a/src/components/sbb-alert-group/sbb-alert-group.stories.tsx b/src/components/sbb-alert-group/sbb-alert-group.stories.tsx deleted file mode 100644 index 52a1414af6..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.stories.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import events from './sbb-alert-group.events'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const Template = (args): JSX.Element => ( - - - The rail traffic between Allaman and Morges is interrupted. All trains are cancelled. - - - Between Berne and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock - construction work will take place. You have to expect changed travel times and changed - connections. - - -); - -const accessibilityTitle: InputType = { - control: { - type: 'text', - }, -}; - -const accessibilityTitleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6], -}; - -const role: InputType = { - control: { - type: 'text', - }, -}; - -const ariaLive: InputType = { - control: { - type: 'select', - }, - options: ['off', 'polite', 'assertive'], -}; - -const defaultArgTypes: ArgTypes = { - 'accessibility-title': accessibilityTitle, - 'accessibility-title-level': accessibilityTitleLevel, - role, - 'aria-live': ariaLive, -}; - -const defaultArgs: Args = { - 'accessibility-title': 'Disruptions', - 'accessibility-title-level': accessibilityTitleLevel.options[1], - role: 'status', - 'aria-live': undefined, -}; - -export const multipleAlerts: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: [events.didDismissAlert, events.empty], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-alert/sbb-alert-group', -}; - -export default meta; diff --git a/src/components/sbb-alert-group/sbb-alert-group.tsx b/src/components/sbb-alert-group/sbb-alert-group.tsx deleted file mode 100644 index cd99098aae..0000000000 --- a/src/components/sbb-alert-group/sbb-alert-group.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - Component, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Listen, - Prop, - State, -} from '@stencil/core'; -import { InterfaceSbbAlertGroupAttributes } from './sbb-alert-group.custom'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; - -/** - * @slot unnamed - content slot, should be filled with `sbb-alert` items. - * @slot accessibility-title - title for this sbb-alert-group which is only visible for screen reader users. - */ - -@Component({ - shadow: true, - styleUrl: 'sbb-alert-group.scss', - tag: 'sbb-alert-group', -}) -export class SbbAlertGroup { - /** - * The role attribute defines how to announce alerts to the user. - * - * 'status': sets aria-live to polite and aria-atomic to true. - * 'alert': sets aria-live to assertive and aria-atomic to true. - */ - @Prop({ reflect: true }) - public role: InterfaceSbbAlertGroupAttributes['role'] = 'status'; - - /** Title for this alert group which is only visible for screen reader users. */ - @Prop() public accessibilityTitle: string; - - /** Level of the accessibility title, will be rendered as heading tag (e.g. h2). Defaults to level 2. */ - @Prop() public accessibilityTitleLevel: InterfaceTitleAttributes['level'] = '2'; - - /** Whether the group currently has any alerts. */ - @State() private _hasAlerts: boolean; - - @Element() private _element: HTMLElement; - - /** Emits when an alert was removed from DOM. */ - @Event({ - eventName: 'did-dismiss-alert', - }) - public didDismissAlert: EventEmitter; - - /** Emits when `sbb-alert-group` becomes empty. */ - @Event({ - eventName: 'empty', - }) - public empty: EventEmitter; - - /** - * @internal - */ - @Listen('dismissal-requested') - public removeAlert(event: Event): void { - const target = event.target as HTMLSbbAlertElement; - const hasFocusInsideAlertGroup = document.activeElement === target; - - target.parentNode.removeChild(target); - this.didDismissAlert.emit(target); - - // Restore focus - if (hasFocusInsideAlertGroup) { - // Set tabindex to 0 the make it focusable and afterwards focus it. - // This is done to not completely lose focus after removal of an alert. - // Once the sbb-alert-group was blurred, make the alert group not focusable again. - this._element.tabIndex = 0; - this._element.focus(); - this._element.addEventListener('blur', () => this._element.removeAttribute('tabindex'), { - once: true, - }); - } - } - - private _slotChanged(event: Event): void { - const hadAlerts = this._hasAlerts; - this._hasAlerts = (event.target as HTMLSlotElement).assignedElements().length > 0; - if (!this._hasAlerts && hadAlerts) { - this.empty.emit(); - } - } - - public render(): JSX.Element { - const TITLE_TAG_NAME = `h${this.accessibilityTitleLevel}`; - - return ( - -
      - {this._hasAlerts && ( - - {this.accessibilityTitle} - - )} - this._slotChanged(event)} /> -
      -
      - ); - } -} diff --git a/src/components/sbb-alert/readme.md b/src/components/sbb-alert/readme.md deleted file mode 100644 index 4a0d81d2a0..0000000000 --- a/src/components/sbb-alert/readme.md +++ /dev/null @@ -1,147 +0,0 @@ -The `sbb-alert` is a component which should be used to display important messages to a client. - -Multiple instances of this component can be used within -the [sbb-alert-group](/docs/components-sbb-alert-sbb-alert-group--docs) component. - -## Slots - -The text content is projected using and unnamed slot, while the title uses the slot named `title` or alternatively the `titleContent` property. -The component can optionally display a `sbb-icon` at the component start using the `iconName` property or via custom content using the `icon` slot. - -```html - - Between Bern and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock - construction work will take place. - You have to expect changed travel times and changed connections. - - - - Interruption between Berne and Olten - - Between Bern and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock - construction work will take place. - You have to expect changed travel times and changed connections. - -``` - -## Interactions - -It's possible to place an action, which by clicking navigates somewhere to display more information. -This can be done using the `linkContent` property combined with the `href` one. -The `target` and `rel` property are also configurable via the self-named properties. - -```html - - ... - -``` - -The `sbb-alert` can optionally be hidden by a user, if the `readonly` prop is not set. -Please note that clicking on the close button does not remove it from the DOM, this would be the responsibility -of the library consumer to do it by reacting to the specific event. -See also the [sbb-alert-group](/docs/components-sbb-alert-sbb-alert-group--docs) -which automatically removes an alert after clicking the close button. - -```html - - Between Bern and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock - construction work will take place. - You have to expect changed travel times and changed connections. - -``` - -## Style - -Users can choose between two `size`, `m` (default) and `l`. - -```html - - ... - -``` - -## Accessibility - -Accessibility is mainly done by wrapping the alerts into the `sbb-alert-group`. - -The description text is wrapped into an `

      ` element to guarantee the semantic meaning. - -Avoid slotting block elements (e.g. `

      `) as this violates semantic rules and can have negative effects on screen readers. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `accessibilityLabel` | `accessibility-label` | This will be forwarded as aria-label to the relevant nested element. | `string` | `undefined` | -| `disableAnimation` | `disable-animation` | Whether the fade in animation should be disabled. | `boolean` | `false` | -| `href` | `href` | The href value you want to link to. | `string` | `undefined` | -| `iconName` | `icon-name` | Name of the icon which will be forward to the nested `sbb-icon`. Choose the icons from https://icons.app.sbb.ch. Styling is optimized for icons of type HIM-CUS. | `string` | `undefined` | -| `linkContent` | `link-content` | Content of the link. | `string` | `undefined` | -| `readonly` | `readonly` | Whether the alert is readonly. In readonly mode, there is no dismiss button offered to the user. | `boolean` | `false` | -| `rel` | `rel` | The relationship of the linked URL as space-separated link types. | `string` | `undefined` | -| `size` | `size` | You can choose between `m` or `l` size. | `"l" \| "m"` | `'m'` | -| `target` | `target` | Where to display the linked URL. | `string` | `undefined` | -| `titleContent` | `title-content` | Content of title. | `string` | `undefined` | -| `titleLevel` | `title-level` | Level of title, will be rendered as heading tag (e.g. h3). Defaults to level 3. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `'3'` | - - -## Events - -| Event | Description | Type | -| --------------------- | ------------------------------------------------------------------ | ------------------- | -| `did-present` | Emits when the fade in animation ends and the button is displayed. | `CustomEvent` | -| `dismissal-requested` | Emits when dismissal of an alert was requested. | `CustomEvent` | -| `will-present` | Emits when the fade in animation starts. | `CustomEvent` | - - -## Methods - -### `requestDismissal() => Promise` - -Requests dismissal of the alert. - -#### Returns - -Type: `Promise` - - - - -## Slots - -| Slot | Description | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `"icon"` | Should be a sbb-icon which is displayed next to the title. Styling is optimized for icons of type HIM-CUS. | -| `"title"` | Title content. | -| `"unnamed"` | Content of the alert. | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) -- [sbb-title](../sbb-title) -- [sbb-link](../sbb-link) -- [sbb-divider](../sbb-divider) -- [sbb-button](../sbb-button) - -### Graph -```mermaid -graph TD; - sbb-alert --> sbb-icon - sbb-alert --> sbb-title - sbb-alert --> sbb-link - sbb-alert --> sbb-divider - sbb-alert --> sbb-button - sbb-link --> sbb-icon - sbb-button --> sbb-icon - style sbb-alert fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-alert/sbb-alert.custom.d.ts b/src/components/sbb-alert/sbb-alert.custom.d.ts deleted file mode 100644 index 3afdb3fae2..0000000000 --- a/src/components/sbb-alert/sbb-alert.custom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InterfaceAlertAttributes { - size: 'm' | 'l'; -} diff --git a/src/components/sbb-alert/sbb-alert.e2e.ts b/src/components/sbb-alert/sbb-alert.e2e.ts deleted file mode 100644 index ddb247361e..0000000000 --- a/src/components/sbb-alert/sbb-alert.e2e.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import events from './sbb-alert.events'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-alert', () => { - let alert, page; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - alert = await page.find('sbb-alert'); - expect(alert).toHaveClass('hydrated'); - }); - - // TODO: maybe fix some day. Test just doesn't work for unknown reason. - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should fire animation events', async () => { - page = await newE2EPage(); - - const willPresentSpy = await page.spyOnEvent(events.willPresent); - const didPresentSpy = await page.spyOnEvent(events.didPresent); - - await page.setContent(`Interruption`); - await page.waitForChanges(); - - await waitForCondition(() => willPresentSpy.events.length === 1); - expect(willPresentSpy).toHaveReceivedEventTimes(1); - await waitForCondition(() => didPresentSpy.events.length === 1); - expect(didPresentSpy).toHaveReceivedEventTimes(1); - }); -}); diff --git a/src/components/sbb-alert/sbb-alert.events.ts b/src/components/sbb-alert/sbb-alert.events.ts deleted file mode 100644 index 22d2a2f035..0000000000 --- a/src/components/sbb-alert/sbb-alert.events.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didPresent: 'did-present', - dismissalRequested: 'dismissal-requested', - willPresent: 'will-present', -}; diff --git a/src/components/sbb-alert/sbb-alert.scss b/src/components/sbb-alert/sbb-alert.scss deleted file mode 100644 index 5e5e9c4ee6..0000000000 --- a/src/components/sbb-alert/sbb-alert.scss +++ /dev/null @@ -1,116 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); - --sbb-alert-background-color: var(--sbb-color-midnight-default); - --sbb-alert-border-radius: var(--sbb-border-radius-4x); - --sbb-alert-color: var(--sbb-color-aluminium-default); - --sbb-alert-padding: var(--sbb-spacing-responsive-xxs) var(--sbb-spacing-responsive-xs); - --sbb-alert-icon-size: #{sbb.px-to-rem-build(20)}; - --sbb-alert-close-icon-size: var(--sbb-size-icon-ui-small); - --sbb-alert-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-responsive-xs); - - @include sbb.mq($from: medium) { - --sbb-alert-icon-size: #{sbb.px-to-rem-build(28)}; - } - - @include sbb.if-forced-colors { - // Use outline here to not influence content position. - // Due to overflow hidden of inner elements it's placed on host. - outline: var(--sbb-border-width-1x) solid CanvasText; - border-radius: var(--sbb-alert-border-radius); - } -} - -:host([size='l']) { - --sbb-alert-icon-size: #{sbb.px-to-rem-build(24)}; - - @include sbb.mq($from: medium) { - --sbb-alert-icon-size: #{sbb.px-to-rem-build(34)}; - } -} - -.sbb-alert__transition-wrapper { - transition: height var(--sbb-animation-duration-6x) ease-in; - overflow: hidden; -} - -.sbb-alert { - @include sbb.text-s--regular; - - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: var(--sbb-alert-gap); - min-width: fit-content; - padding: var(--sbb-alert-padding); - overflow: hidden; - color: var(--sbb-alert-color); - background-color: var(--sbb-alert-background-color); - border-radius: var(--sbb-alert-border-radius); - transition: opacity var(--sbb-animation-duration-6x) ease-in; - - @include sbb.mq($from: small) { - grid-template-columns: auto 1fr auto; - align-items: flex-start; - } -} - -.sbb-alert__icon { - display: flex; - align-items: start; - padding-block: var(--sbb-spacing-fixed-1x); - min-width: var(--sbb-alert-icon-size); - - --sbb-icon-svg-width: var(--sbb-alert-icon-size); - --sbb-icon-svg-height: var(--sbb-alert-icon-size); -} - -.sbb-alert__content { - order: 3; - grid-column: 1 / 3; - - @include sbb.mq($from: small) { - order: initial; - grid-column-start: initial; - grid-column-end: initial; - } -} - -.sbb-alert__content-slot { - // Reset paragraph styles - display: inline; - margin: 0; - padding: 0; -} - -.sbb-alert__title { - // Overwrite sbb-title default margin - margin: 0; -} - -.sbb-alert__close-button-wrapper { - display: flex; - justify-content: flex-end; - align-items: center; - height: 100%; -} - -.sbb-alert__close-button { - @include sbb.mq($from: small) { - margin-inline-start: var(--sbb-spacing-responsive-xxs); - } -} - -.sbb-alert__close-button-divider { - display: none; - - @include sbb.mq($from: small) { - display: block; - height: calc(100% - (var(--sbb-spacing-fixed-1x) * 2)); - } -} diff --git a/src/components/sbb-alert/sbb-alert.spec.ts b/src/components/sbb-alert/sbb-alert.spec.ts deleted file mode 100644 index 00f9aec50d..0000000000 --- a/src/components/sbb-alert/sbb-alert.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { SbbAlert } from './sbb-alert'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-alert', () => { - it('should render default properties', async () => { - const { root } = await newSpecPage({ - components: [SbbAlert], - html: 'Alert content', - }); - - expect(root).toEqualHtml(` - - -
      -
      - - - - - - - - Interruption - -

      - -

      -
      - - - - -
      -
      -
      - Alert content -
      - `); - }); - - it('should render customized properties', async () => { - const { root } = await newSpecPage({ - components: [SbbAlert], - html: 'Alert content', - }); - - expect(root).toEqualHtml(` - - -
      -
      - - - - - - - - Interruption - -

      - -

      - - - Show much more - -
      - - - - -
      -
      -
      - Alert content -
      - `); - }); - - it('should hide close button in readonly mode', async () => { - const { root } = await newSpecPage({ - components: [SbbAlert], - html: 'Alert content', - }); - - expect(root.shadowRoot.querySelector('.sbb-alert__close-button-wrapper')).toBeNull(); - }); -}); diff --git a/src/components/sbb-alert/sbb-alert.stories.tsx b/src/components/sbb-alert/sbb-alert.stories.tsx deleted file mode 100644 index f1fcd14478..0000000000 --- a/src/components/sbb-alert/sbb-alert.stories.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/** @jsx h */ -import events from './sbb-alert.events'; -import readme from './readme.md'; -import { h, JSX } from 'jsx-dom'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const Default = ({ 'content-slot-text': contentSlotText, ...args }): JSX.Element => ( - {contentSlotText} -); - -const DefaultWithOtherContent = (args): JSX.Element => { - return ( -
      - -

      Other Content on the page.

      - {!args.readonly && ( -

      - Dismissal event of the alert has to be caught by the consumer and the alert has to be - manually removed from DOM. See `sbb-alert-group` for demonstration. -

      - )} -
      - ); -}; - -const CustomSlots = ({ - 'title-content': titleContent, - 'content-slot-text': contentSlotText, - ...args -}): JSX.Element => ( - - - {titleContent} - {contentSlotText} - -); - -const titleContent: InputType = { - control: { - type: 'text', - }, -}; - -const titleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6], -}; - -const size: InputType = { - control: { - type: 'select', - }, - options: ['m', 'l'], -}; - -const readonly: InputType = { - control: { - type: 'boolean', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, -}; - -const contentSlotText: InputType = { - control: { - type: 'text', - }, -}; - -const linkContent: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Link', - }, -}; - -const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; -const href: InputType = { - options: Object.keys(hrefs), - mapping: hrefs, - control: { - type: 'select', - labels: { - 0: 'sbb.ch', - 1: 'GitHub Lyne Components', - }, - }, - table: { - category: 'Link', - }, -}; - -const target: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Link', - }, -}; - -const rel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Link', - }, -}; - -const accessibilityLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Link', - }, -}; - -const defaultArgTypes: ArgTypes = { - 'title-content': titleContent, - 'title-level': titleLevel, - size, - readonly, - 'disable-animation': disableAnimation, - 'icon-name': iconName, - 'content-slot-text': contentSlotText, - 'link-content': linkContent, - href, - target, - rel, - 'accessibility-label': accessibilityLabel, -}; - -const defaultArgs: Args = { - 'title-content': 'Interruption between Berne and Olten', - 'title-level': 3, - size: size.options[0], - readonly: false, - 'disable-animation': false, - 'icon-name': 'info', - 'content-slot-text': - "Between Berne and Olten from 03.11.2021 to 05.12.2022 each time from 22:30 to 06:00 o'clock construction work will take place. You have to expect changed travel times and changed connections.", - 'link-content': undefined, - href: href.options[0], - target: undefined, - rel: undefined, - 'accessibility-label': undefined, -}; - -export const defaultAlert: StoryObj = { - render: DefaultWithOtherContent, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const sizeL: StoryObj = { - render: Default, - argTypes: defaultArgTypes, - args: { ...defaultArgs, size: 'l' }, -}; - -export const withoutCloseButton: StoryObj = { - render: Default, - argTypes: defaultArgTypes, - args: { ...defaultArgs, readonly: true }, -}; - -export const withDisabledAnimation: StoryObj = { - render: Default, - argTypes: defaultArgTypes, - args: { ...defaultArgs, 'disable-animation': true }, -}; - -export const withoutLink: StoryObj = { - render: Default, - argTypes: defaultArgTypes, - args: { ...defaultArgs, href: undefined }, -}; - -export const withCustomLinkText: StoryObj = { - render: Default, - argTypes: defaultArgTypes, - args: { ...defaultArgs, ['link-content']: 'Follow this link (custom text)' }, -}; - -export const iconAndTitleAsSlot: StoryObj = { - render: CustomSlots, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -// Remove icon name as it has no purpose in slotted variant -delete iconAndTitleAsSlot.argTypes['icon-name']; - -// Remove icon name as it has no purpose in slotted variant -delete iconAndTitleAsSlot.args['icon-name']; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: [events.willPresent, events.didPresent, events.dismissalRequested], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-alert/sbb-alert', -}; - -export default meta; diff --git a/src/components/sbb-alert/sbb-alert.tsx b/src/components/sbb-alert/sbb-alert.tsx deleted file mode 100644 index c201a9f5fd..0000000000 --- a/src/components/sbb-alert/sbb-alert.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { - Component, - Element, - Event, - EventEmitter, - h, - JSX, - Method, - Prop, - ComponentInterface, - Fragment, - State, -} from '@stencil/core'; -import { InterfaceAlertAttributes } from './sbb-alert.custom'; -import { i18nCloseAlert, i18nFindOutMore } from '../../global/i18n'; -import { LinkProperties, LinkTargetType } from '../../global/interfaces'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; -import { - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, -} from '../../global/eventing'; - -/** - * @slot icon - Should be a sbb-icon which is displayed next to the title. Styling is optimized for icons of type HIM-CUS. - * @slot title - Title content. - * @slot unnamed - Content of the alert. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-alert.scss', - tag: 'sbb-alert', -}) -export class SbbAlert implements ComponentInterface, LinkProperties { - /** - * Whether the alert is readonly. - * In readonly mode, there is no dismiss button offered to the user. - */ - @Prop({ reflect: true }) public readonly = false; - - /** You can choose between `m` or `l` size. */ - @Prop({ reflect: true }) public size: InterfaceAlertAttributes['size'] = 'm'; - - /** Whether the fade in animation should be disabled. */ - @Prop() public disableAnimation = false; - - /** - * Name of the icon which will be forward to the nested `sbb-icon`. - * Choose the icons from https://icons.app.sbb.ch. - * Styling is optimized for icons of type HIM-CUS. - */ - @Prop() public iconName?: string; - - /** Content of title. */ - @Prop() public titleContent?: string; - - /** Level of title, will be rendered as heading tag (e.g. h3). Defaults to level 3. */ - @Prop() public titleLevel: InterfaceTitleAttributes['level'] = '3'; - - /** Content of the link. */ - @Prop() public linkContent?: string; - - /** The href value you want to link to. */ - @Prop() public href: string | undefined; - - /** Where to display the linked URL. */ - @Prop() public target: LinkTargetType | string | undefined; - - /** The relationship of the linked URL as space-separated link types. */ - @Prop() public rel: string | undefined; - - /** This will be forwarded as aria-label to the relevant nested element. */ - @Prop() public accessibilityLabel: string | undefined; - - /** Emits when the fade in animation starts. */ - @Event({ - eventName: 'will-present', - }) - public willPresent: EventEmitter; - - /** Emits when the fade in animation ends and the button is displayed. */ - @Event({ - eventName: 'did-present', - }) - public didPresent: EventEmitter; - - /** Emits when dismissal of an alert was requested. */ - @Event({ - eventName: 'dismissal-requested', - }) - public dismissalRequested: EventEmitter; - - @Element() private _element!: HTMLElement; - - @State() private _currentLanguage = documentLanguage(); - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - ); - - private _transitionWrapperElement!: HTMLElement; - private _alertElement!: HTMLElement; - - private _firstRenderingDone = false; - - public connectedCallback(): void { - this._handlerRepository.connect(); - // Skip very first render where the animation elements are not yet ready. - // Presentation is postponed to componentDidRender(). - if (this._transitionWrapperElement) { - this._initFadeInTransitionStyles(); - this._present(); - } - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - public componentDidRender(): void { - // During the very first rendering, the animation elements are only present in componentDidRender. - // So we need to fire the fade in animation later than at connectedCallback(). - if (!this._firstRenderingDone) { - this._present(); - } - this._firstRenderingDone = true; - } - - /** Requests dismissal of the alert. */ - @Method() public async requestDismissal(): Promise { - this.dismissalRequested.emit(); - } - - /** Present the alert. */ - private _present(): Promise { - this.willPresent.emit(); - - if (this.disableAnimation) { - this._onHeightTransitionEnd(); - return; - } - - this._transitionWrapperElement.addEventListener( - 'transitionend', - () => this._onHeightTransitionEnd(), - { - once: true, - }, - ); - this._transitionWrapperElement.style.height = `${this._alertElement.offsetHeight}px`; - } - - private _initFadeInTransitionStyles(): void { - if (this.disableAnimation) { - return; - } - this._transitionWrapperElement.style.height = '0'; - this._alertElement.style.opacity = '0'; - } - - private _onHeightTransitionEnd(): void { - this._transitionWrapperElement.style.removeProperty('height'); - this._alertElement.style.removeProperty('opacity'); - - if (this.disableAnimation) { - this._onOpacityTransitionEnd(); - return; - } - - this._alertElement.addEventListener('transitionend', () => this._onOpacityTransitionEnd(), { - once: true, - }); - } - - private _onOpacityTransitionEnd(): void { - this.didPresent.emit(); - } - - private _linkProperties(): Record { - return { - ['aria-label']: this.accessibilityLabel, - href: this.href, - rel: this.rel, - target: this.target, - }; - } - - public render(): JSX.Element { - return ( -
      { - this._transitionWrapperElement = el; - }} - > -
      { - const isFirstInitialization = !this._alertElement; - - this._alertElement = el; - if (isFirstInitialization) { - this._initFadeInTransitionStyles(); - } - }} - > - - {} - - - - {this.titleContent} - -

      - -

      - {this.href && ( - - - - {this.linkContent ? this.linkContent : i18nFindOutMore[this._currentLanguage]} - - - )} -
      - {!this.readonly && ( - - - this.requestDismissal()} - aria-label={i18nCloseAlert[this._currentLanguage]} - class="sbb-alert__close-button" - /> - - )} -
      -
      - ); - } -} diff --git a/src/components/sbb-autocomplete/readme.md b/src/components/sbb-autocomplete/readme.md deleted file mode 100644 index d96744f98d..0000000000 --- a/src/components/sbb-autocomplete/readme.md +++ /dev/null @@ -1,155 +0,0 @@ -The `sbb-autocomplete` is a component that can be used to display a panel of suggested options connected to a text input. - -It's possible to set the element to which the component's panel will be attached using the `origin` prop, -and the input which will work as a trigger using the `trigger` prop. -Both accept an id or an element reference. - -```html - -
      Another origin
      - - - - - - Option A - Option B - Option C - -``` - -## In `sbb-form-field` - -If the component is used within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs), -it will automatically connect to the native `` as trigger and will display the option panel above or below the `sbb-form-field`. - -```html - - - - - - - - Option 1 - Option 2 - Option 3 - - -``` - -## Style - -### Option highlight - -By default, the autocomplete will highlight the label of the `sbb-option` in the panel, if it matches the typed text. -See the [sbb-option](/docs/components-sbb-option-sbb-option--docs) for more details. - -### Option grouping - -The displayed `sbb-option` can be collected into groups using `sbb-optgroup` element: - -```html - - - - - - - - - Option 1 - ... - - - ... - - - -``` - -## Events - -The `sbb-option` emits the `option-selected` event when selected via user interaction. - -## Keyboard interaction - -The options panel opens on `focus`, `click` or `input` events on the trigger element, or on `ArrowDown` keypress; -it can be closed on backdrop click, or using the `Escape` or `Tab` keys. - -| Keyboard | Action | -|-----------------------|---------------------------------------------------------| -| Down Arrow | Navigate to the next option. Open the panel, if closed. | -| Up Arrow | Navigate to the previous option. | -| Enter | Select the active option. | -| Escape | Close the autocomplete panel. | - -## Accessibility - -The `sbb-autocomplete` implements the [ARIA combobox interaction pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). - -The text input trigger specifies `role="combobox"` while the content of the pop-up applies `role="listbox"`. -Because of this `listbox` pattern, you should not put other interactive controls, such as buttons or checkboxes, inside an autocomplete option. -Nesting interactive controls like this interferes with many assistive technologies. - -The component preserves focus on the input trigger, -using `aria-activedescendant` to support navigation though the autocomplete options. - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ----------- | -| `disableAnimation` | `disable-animation` | Whether the animation is disabled. | `boolean` | `false` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | -| `origin` | `origin` | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, will search for the first 'sbb-form-field' ancestor. | `HTMLElement \| string` | `undefined` | -| `preserveIconSpace` | `preserve-icon-space` | Whether the icon space is preserved when no icon is set. | `boolean` | `undefined` | -| `trigger` | `trigger` | The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. | `HTMLInputElement \| string` | `undefined` | - - -## Events - -| Event | Description | Type | -| ------------ | -------------------------------------------------------------- | ------------------- | -| `did-close` | Emits whenever the autocomplete is closed. | `CustomEvent` | -| `did-open` | Emits whenever the autocomplete is opened. | `CustomEvent` | -| `will-close` | Emits whenever the autocomplete begins the closing transition. | `CustomEvent` | -| `will-open` | Emits whenever the autocomplete starts the opening transition. | `CustomEvent` | - - -## Methods - -### `close() => Promise` - -Closes the autocomplete. - -#### Returns - -Type: `Promise` - - - -### `open() => Promise` - -Opens the autocomplete. - -#### Returns - -Type: `Promise` - - - - -## Slots - -| Slot | Description | -| ----------- | --------------------------------- | -| `"unnamed"` | Use this slot to project options. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts b/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts deleted file mode 100644 index 9ca3ec1eb3..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import events from './sbb-autocomplete.events'; -import optionEvents from '../sbb-option/sbb-option.events'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-autocomplete', () => { - let element: E2EElement, formField: E2EElement, input: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - - 1 - 2 - 3 - - - - `); - - formField = await page.find('sbb-form-field'); - input = await page.find('input'); - element = await page.find('sbb-autocomplete'); - }); - - it('renders and sets the correct attributes', () => { - expect(formField).toHaveClass('hydrated'); - expect(element).toHaveClass('hydrated'); - - expect(element).not.toHaveAttribute('autocomplete-origin-borderless'); - - expect(input).toEqualAttribute('autocomplete', 'off'); - expect(input).toEqualAttribute('role', 'combobox'); - expect(input).toEqualAttribute('aria-autocomplete', 'list'); - expect(input).toEqualAttribute('aria-haspopup', 'listbox'); - expect(input).toEqualAttribute('aria-controls', 'myAutocomplete'); - expect(input).toEqualAttribute('aria-owns', 'myAutocomplete'); - expect(input).toEqualAttribute('aria-expanded', 'false'); - }); - - it('opens and closes with mouse and keyboard', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - - await input.focus(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('true'); - - await element.press('Escape'); - await page.waitForChanges(); - await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await element.press('ArrowDown'); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 2); - expect(didOpenEventSpy).toHaveReceivedEventTimes(2); - expect(input.getAttribute('aria-expanded')).toEqual('true'); - - await element.press('Tab'); - await page.waitForChanges(); - await waitForCondition(() => willCloseEventSpy.events.length === 2); - expect(willCloseEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - await waitForCondition(() => didCloseEventSpy.events.length === 2); - expect(didCloseEventSpy).toHaveReceivedEventTimes(2); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await input.click(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 3); - expect(didOpenEventSpy).toHaveReceivedEventTimes(3); - expect(input.getAttribute('aria-expanded')).toEqual('true'); - - const button = await page.find('button'); - await button.click(); - await waitForCondition(() => willCloseEventSpy.events.length === 3); - expect(willCloseEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - await waitForCondition(() => didCloseEventSpy.events.length === 3); - expect(didCloseEventSpy).toHaveReceivedEventTimes(3); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - }); - - it('select by mouse', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const optionSelectedEventSpy = await page.spyOnEvent(optionEvents.optionSelected); - - await input.focus(); - await page.waitForChanges(); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - - await element.press('ArrowDown'); - await element.press('ArrowDown'); - await page.waitForChanges(); - await element.press('Enter'); - await page.waitForChanges(); - - expect(optionSelectedEventSpy).toHaveReceivedEventTimes(1); - expect(optionSelectedEventSpy.firstEvent.target.id).toBe('option-2'); - }); - - it('opens and select with keyboard', async () => { - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const optionSelectedEventSpy = await page.spyOnEvent(optionEvents.optionSelected); - await input.focus(); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - - await element.press('ArrowDown'); - await page.waitForChanges(); - await element.press('ArrowDown'); - await page.waitForChanges(); - const optOne = await page.find('sbb-autocomplete > sbb-option#option-1'); - expect(await optOne.getProperty('active')).toEqual(false); - expect(await optOne.getProperty('selected')).toEqual(false); - const optTwo = await page.find('sbb-autocomplete > sbb-option#option-2'); - expect(await optTwo.getProperty('active')).toEqual(true); - expect(await optTwo.getProperty('selected')).toEqual(false); - expect(input.getAttribute('aria-activedescendant')).toEqual('option-2'); - - await element.press('Enter'); - await page.waitForChanges(); - await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(await optTwo.getProperty('active')).toEqual(false); - expect(await optTwo.getProperty('selected')).toEqual(true); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - expect(optionSelectedEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - }); - - it('should stay closed when disabled', async () => { - await page.$eval('input', (e) => e.setAttribute('disabled', 'true')); - - await input.focus(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await input.click(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await element.press('ArrowDown'); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - }); - - it('should stay closed when readonly', async () => { - await page.$eval('input', (e) => e.setAttribute('readonly', 'true')); - - await input.focus(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await input.click(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - - await element.press('ArrowDown'); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - }); -}); diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.events.ts b/src/components/sbb-autocomplete/sbb-autocomplete.events.ts deleted file mode 100644 index cf7d67d8ae..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.events.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didClose: 'did-close', - didOpen: 'did-open', - willClose: 'will-close', - willOpen: 'will-open', -}; diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.scss b/src/components/sbb-autocomplete/sbb-autocomplete.scss deleted file mode 100644 index cc55837e99..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.scss +++ /dev/null @@ -1,159 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -// Fixes the gap between the origin and the overlay by creating conjunction -// corners based on the origin element border radius -@include sbb.overlay-gap-fix; - -:host { - @include sbb.options-panel-overlay-variables; - - --sbb-options-panel-internal-z-index: var(--sbb-autocomplete-z-index, var(--sbb-overlay-z-index)); -} - -:host([negative]:not([negative='false'])) { - @include sbb.options-panel-overlay-negative-variables; -} - -:host([data-state='closed']) { - --sbb-options-panel-visibility: hidden; -} - -:host([data-state='opening']) { - --sbb-options-panel-animation-name: open; -} - -:host([data-state='closing']) { - --sbb-options-panel-animation-name: close; -} - -:host([data-state='opened']), -:host([data-state='opening']) { - --sbb-options-panel-gap-fix-opacity: 1; -} - -:host([data-options-panel-position='below']) { - --sbb-options-panel-animation-transform: translateY( - calc((var(--sbb-options-panel-origin-height) / 2) * -1) - ); -} - -:host([data-options-panel-position='above']) { - --sbb-options-panel-options-border-radius: var(--sbb-options-panel-border-radius) - var(--sbb-options-panel-border-radius) 0 0; - --sbb-options-panel-gap-fix-top: var(--sbb-options-panel-max-height); - --sbb-options-panel-gap-fix-transform: rotate(180deg); - --sbb-options-panel-animation-transform: translateY( - calc(var(--sbb-options-panel-origin-height) / 2) - ); -} - -:host([disable-animation]:not([disable-animation='false'])) { - --sbb-options-panel-animation-duration: 0s; -} - -:host([preserve-icon-space]:not([preserve-icon-space='false'])) { - --sbb-option-icon-container-display: block; -} - -::slotted(sbb-divider) { - margin-block: var(--sbb-spacing-fixed-3x); -} - -.sbb-autocomplete__container { - @include sbb.options-panel-overlay-container; -} - -.sbb-autocomplete__gap-fix { - @include sbb.options-panel-overlay-gap; -} - -.sbb-autocomplete__panel { - @include sbb.options-panel-overlay; - - :host([data-options-panel-position='below']) & { - inset-block-start: calc( - var(--sbb-options-panel-position-y) - var(--sbb-options-panel-origin-height) - ); - } - - :host(:is([data-state='opened'], [data-state='opening'])) & { - @include sbb.shadow-level-5-hard; - } - - :host(:is([data-state='opened'], [data-state='opening'])[negative]:not([negative='false'])) & { - @include sbb.shadow-level-5-hard-negative; - } - - &::before { - :host([data-options-panel-position='below']) & { - display: block; - } - } - - &::after { - :host([data-options-panel-position='above']) & { - display: block; - } - } - - /* stylelint-disable-next-line no-descending-specificity */ - &::before, - &::after { - :host(:is([data-state='opened'], [data-state='opening'])[data-option-panel-origin-borderless]) - & { - @include sbb.shadow-level-5-hard; - } - - :host( - :is( - [data-state='opened'], - [data-state='opening'] - )[data-option-panel-origin-borderless][negative]:not([negative='false']) - ) - & { - @include sbb.shadow-level-5-hard-negative; - } - } -} - -.sbb-autocomplete__wrapper { - overflow: hidden; -} - -.sbb-autocomplete__options { - @include sbb.scrollbar-rules; - @include sbb.optionsOverlay; - - @include sbb.if-forced-colors { - border: var(--sbb-border-width-1x) solid CanvasText; - border-top: none; - } -} - -@keyframes open { - from { - transform: var(--sbb-options-panel-animation-transform); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } -} - -@keyframes close { - from { - transform: translateY(0); - opacity: 1; - } - - to { - transform: var(--sbb-options-panel-animation-transform); - opacity: 0; - } -} diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts b/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts deleted file mode 100644 index 5aa731ec78..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { SbbAutocomplete } from './sbb-autocomplete'; -import { newSpecPage } from '@stencil/core/testing'; -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; - -describe('sbb-autocomplete', () => { - it('renders standalone', async () => { - const { root } = await newSpecPage({ - components: [SbbAutocomplete], - html: ` -
      - - - 1 - 2 - - `, - }); - - expect(root).toEqualHtml(` - - -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      - 1 - 2 -
      - `); - }); - - it('renders in form field', async () => { - const { root } = await newSpecPage({ - components: [SbbAutocomplete, SbbFormField], - html: ` - - - - 1 - 2 - - - `, - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      -
      - -
      -
      - -
      -
      - -
      -
      -
      - - - -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      - 1 - 2 -
      -
      - `); - }); -}); diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx b/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx deleted file mode 100644 index ea6e3890ae..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx +++ /dev/null @@ -1,530 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import events from './sbb-autocomplete.events'; -import optionEvents from '../sbb-option/sbb-option.events'; -import readme from './readme.md'; -import { userEvent, within } from '@storybook/testing-library'; -import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; -import isChromatic from 'chromatic'; -import { waitForStablePosition } from '../../global/testing'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; -import { withActions } from '@storybook/addon-actions/decorator'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const negative: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Autocomplete', - }, -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Autocomplete', - }, -}; - -const readonly: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Autocomplete', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Autocomplete', - }, -}; - -const preserveIconSpace: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Autocomplete', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Option', - }, -}; - -const disableOption: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Option', - }, -}; - -const borderless: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Form field', - }, -}; - -const floatingLabel: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Form field', - }, -}; - -const disableGroup: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Option group', - }, -}; - -const defaultArgTypes: ArgTypes = { - // Autocomplete args - negative, - disabled, - readonly, - disableAnimation, - preserveIconSpace, - - // Option args - iconName, - disableOption, - - // Form field args - borderless, - floatingLabel, -}; - -const withGroupsArgTypes: ArgTypes = { - ...defaultArgTypes, - - // Option group args - disableGroup, -}; - -const defaultArgs: Args = { - // Autocomplete args - negative: false, - disabled: false, - readonly: false, - disableAnimation: isChromatic(), - - // Option args - iconName: 'clock-small', - preserveIconSpace: true, - disableOption: false, - - // Form field args - borderless: false, - floatingLabel: false, -}; - -const withGroupsDefaultArgs: Args = { - ...defaultArgs, - - // Option group args - disableGroup: false, -}; - -const aboveDecorator: Decorator = (Story) => ( -
      - -
      -); - -const scrollDecorator: Decorator = (Story) => ( -
      - -
      -); - -// Story interaction executed after the story renders -const playStory = async ({ canvasElement }): Promise => { - const canvas = within(canvasElement); - - await waitForComponentsReady(() => - canvas.getByTestId('form-field').shadowRoot.querySelector('div.sbb-form-field__space-wrapper'), - ); - - await waitForStablePosition(() => canvas.getByTestId('autocomplete-input')); - await userEvent.type(canvas.getByTestId('autocomplete-input'), 'Opt'); - await new Promise((resolve) => setTimeout(resolve, 2000)); -}; - -const createOptionGroup1 = (iconName, disableOption): JSX.Element[] => { - return [ - - Option 1 - , - - Option 2 - , - - - Option 3 - , - ]; -}; -const createOptionGroup2 = (): JSX.Element[] => { - return [ - Option 4, - Option 5, - ]; -}; - -const textBlockStyle: Args = { - position: 'relative', - marginBlockStart: '1rem', - padding: '1rem', - backgroundColor: 'var(--sbb-color-milk-default)', - border: 'var(--sbb-border-width-1x) solid var(--sbb-color-cloud-default)', - borderRadius: 'var(--sbb-border-radius-4x)', - zIndex: '100', -}; - -const codeStyle: Args = { - padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)', - borderRadius: 'var(--sbb-border-radius-4x)', - backgroundColor: 'var(--sbb-color-smoke-alpha-20)', -}; - -const textBlock = (): JSX.Element => ( -
      - This text block has a z-index greater than the form field, but it - must always be covered by the autocomplete overlay. -
      -); - -const Template = (args): JSX.Element => ( -
      - - - - - {createOptionGroup1(args.iconName, args.disableOption)} - {createOptionGroup2()} - - - {textBlock()} -
      -); - -const OptionGroupTemplate = (args): JSX.Element => ( -
      - - - - - - {createOptionGroup1(args.iconName, args.disableOption)} - - {createOptionGroup2()} - - - {textBlock()} -
      -); - -const MixedTemplate = (args): JSX.Element => ( -
      - - - - - - - Option Value - - - {createOptionGroup1(args.iconName, args.disableOption)} - - {createOptionGroup2()} - - - {textBlock()} -
      -); - -const RequiredTemplate = (args): JSX.Element => { - const sbbFormError = This is a required field.; - - return ( -
      - - { - if ((event.currentTarget as HTMLInputElement).value !== '') { - sbbFormError.remove(); - document.getElementById('sbb-autocomplete').classList.remove('sbb-invalid'); - } else { - document.getElementById('sbb-form-field').append(sbbFormError); - document.getElementById('sbb-autocomplete').classList.add('sbb-invalid'); - } - }} - /> - - - - {createOptionGroup1(args.iconName, args.disableOption)} - - {createOptionGroup2()} - - {sbbFormError} - - {textBlock()} -
      - ); -}; - -export const Basic: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, - play: isChromatic() && playStory, -}; - -export const BasicNegative: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, negative: true }, - play: isChromatic() && playStory, -}; - -export const BasicOpenAbove: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, - decorators: [aboveDecorator], - play: isChromatic() && playStory, -}; - -export const Borderless: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, borderless: true }, - play: isChromatic() && playStory, -}; - -export const BorderlessNegative: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, borderless: true, negative: true }, - play: isChromatic() && playStory, -}; - -export const FloatingLabel: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, floatingLabel: true }, - play: isChromatic() && playStory, -}; - -export const WithError: StoryObj = { - render: RequiredTemplate, - argTypes: withGroupsArgTypes, - args: { ...withGroupsDefaultArgs }, - play: isChromatic() && playStory, -}; - -export const WithErrorNegative: StoryObj = { - render: RequiredTemplate, - argTypes: withGroupsArgTypes, - args: { ...withGroupsDefaultArgs, negative: true }, - play: isChromatic() && playStory, -}; - -export const Disabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, disabled: true }, - play: isChromatic() && playStory, -}; - -export const Readonly: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, readonly: true }, - play: isChromatic() && playStory, -}; - -export const BorderlessOpenAbove: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, borderless: true }, - decorators: [aboveDecorator], - play: isChromatic() && playStory, -}; - -export const NoIconSpace: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, preserveIconSpace: false }, - play: isChromatic() && playStory, -}; - -export const Scroll: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, - decorators: [scrollDecorator], - parameters: { - chromatic: { disableSnapshot: true }, - }, -}; - -export const WithOptionGroup: StoryObj = { - render: OptionGroupTemplate, - argTypes: withGroupsArgTypes, - args: { ...withGroupsDefaultArgs }, - play: isChromatic() && playStory, -}; - -export const MixedSingleOptionWithOptionGroup: StoryObj = { - render: MixedTemplate, - argTypes: withGroupsArgTypes, - args: { ...withGroupsDefaultArgs }, - play: isChromatic() && playStory, -}; - -export const MixedSingleOptionWithOptionGroupNegative: StoryObj = { - render: MixedTemplate, - argTypes: withGroupsArgTypes, - args: { ...withGroupsDefaultArgs, negative: true }, - play: isChromatic() && playStory, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - chromatic: { disableSnapshot: false }, - actions: { - handles: [ - events.willOpen, - events.didOpen, - events.didClose, - events.willClose, - 'change', - optionEvents.optionSelected, - ], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-autocomplete', -}; - -export default meta; diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.tsx b/src/components/sbb-autocomplete/sbb-autocomplete.tsx deleted file mode 100644 index 6fe9788230..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.tsx +++ /dev/null @@ -1,550 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Listen, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; -import { - isEventOnElement, - overlayGapFixCorners, - removeAriaComboBoxAttributes, - SbbOverlayState, - setAriaComboBoxAttributes, - setOverlayPosition, -} from '../../global/overlay'; -import { - getDocumentWritingMode, - findReferencedElement, - isSafari, - isValidAttribute, - toggleDatasetEntry, -} from '../../global/dom'; -import { assignId, getNextElementIndex } from '../../global/a11y'; - -let nextId = 0; - -/** - * @slot unnamed - Use this slot to project options. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-autocomplete.scss', - tag: 'sbb-autocomplete', -}) -export class SbbAutocomplete implements ComponentInterface { - /** - * The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. - * If not set, will search for the first 'sbb-form-field' ancestor. - */ - @Prop() public origin: string | HTMLElement; - - /** - * The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. - * By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. - * If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. - */ - @Prop() public trigger: string | HTMLInputElement; - - /** Whether the animation is disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; - - /** Whether the icon space is preserved when no icon is set. */ - @Prop({ reflect: true }) public preserveIconSpace: boolean; - - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - /** The state of the autocomplete. */ - @State() private _state: SbbOverlayState = 'closed'; - - /** Emits whenever the autocomplete starts the opening transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; - - /** Emits whenever the autocomplete is opened. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; - - /** Emits whenever the autocomplete begins the closing transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter; - - /** Emits whenever the autocomplete is closed. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter; - - @Element() private _element!: HTMLElement; - - private _overlay: HTMLElement; - private _optionContainer: HTMLElement; - private _originElement: HTMLElement; - private _triggerElement: HTMLInputElement; - private _triggerEventsController: AbortController; - private _openPanelEventsController: AbortController; - private _overlayId = `sbb-autocomplete-${++nextId}`; - private _activeItemIndex = -1; - private _didLoad = false; - private _isPointerDownEventOnMenu: boolean; - - /** - * On Safari, the aria role 'listbox' must be on the host element, or else VoiceOver won't work at all. - * On the other hand, JAWS and NVDA need the role to be "closer" to the options, or else optgroups won't work. - */ - private _ariaRoleOnHost = isSafari(); - - /** The autocomplete should inherit 'readonly' state from the trigger. */ - private get _readonly(): boolean { - return this._triggerElement && isValidAttribute(this._triggerElement, 'readonly'); - } - - private get _options(): HTMLSbbOptionElement[] { - return Array.from(this._element.querySelectorAll('sbb-option')) as HTMLSbbOptionElement[]; - } - - /** Opens the autocomplete. */ - @Method() - public async open(): Promise { - if ( - this._state !== 'closed' || - !this._overlay || - this._options.length === 0 || - this._readonly - ) { - return; - } - - this._state = 'opening'; - this.willOpen.emit(); - this._setOverlayPosition(); - } - - /** Closes the autocomplete. */ - @Method() - public async close(): Promise { - if (this._state !== 'opened') { - return; - } - - this._state = 'closing'; - this.willClose.emit(); - this._openPanelEventsController.abort(); - } - - /** Removes trigger click listener on trigger change. */ - @Watch('origin') - public resetOriginClickListener( - newValue: string | HTMLElement, - oldValue: string | HTMLElement, - ): void { - if (newValue !== oldValue) { - this._componentSetup(); - } - } - - /** Removes trigger click listener on trigger change. */ - @Watch('trigger') - public resetTriggerClickListener( - newValue: string | HTMLElement, - oldValue: string | HTMLElement, - ): void { - if (newValue !== oldValue) { - this._componentSetup(); - } - } - - /** When an option is selected, update the input value and close the autocomplete. */ - @Listen('option-selection-change') - public async onOptionSelected(event: CustomEvent): Promise { - const target: HTMLSbbOptionElement = event.target as HTMLSbbOptionElement; - if (!target.selected) { - return; - } - - // Deselect the previous options - this._options - .filter((option) => option.id !== target.id && option.selected) - .forEach((option) => (option.selected = false)); - - // Set the option value - this._triggerElement.value = target.value; - - // Manually trigger the change events - this._triggerElement.dispatchEvent(new window.Event('change', { bubbles: true })); - this._triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - - await this.close(); - } - - @Listen('click') - public async onOptionClick(event): Promise { - if (event.target?.tagName !== 'SBB-OPTION' || event.target.disabled) { - return; - } - await this.close(); - } - - public componentDidLoad(): void { - this._componentSetup(); - this._didLoad = true; - } - - public connectedCallback(): void { - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); - - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - } - this._syncNegative(); - - if (this._didLoad) { - this._componentSetup(); - } - } - - @Watch('negative') - private _syncNegative(): void { - this._element - .querySelectorAll('sbb-divider') - .forEach((element) => - this.negative ? element.setAttribute('negative', '') : element.removeAttribute('negative'), - ); - - this._element - .querySelectorAll('sbb-option, sbb-optgroup') - .forEach((element: HTMLElement) => toggleDatasetEntry(element, 'negative', this.negative)); - } - - public disconnectedCallback(): void { - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - } - - private _componentSetup(): void { - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - - this._attachTo(this._getOriginElement()); - this._bindTo(this._getTriggerElement()); - } - - /** - * Retrieve the element where the autocomplete will be attached. - * @returns 'origin' or the first 'sbb-form-field' ancestor. - */ - private _getOriginElement(): HTMLElement { - let result: HTMLElement; - - if (!this.origin) { - result = this._element.closest('sbb-form-field')?.shadowRoot.querySelector('#overlay-anchor'); - } else { - result = findReferencedElement(this.origin); - } - - if (!result) { - throw new Error( - 'Cannot find the origin element. Please specify a valid element or read the "origin" prop documentation', - ); - } - - return result; - } - - /** - * Retrieve the element that will trigger the autocomplete opening. - * @returns 'trigger' or the first 'input' inside the origin element. - */ - private _getTriggerElement(): HTMLInputElement { - if (!this.trigger) { - return this._element.closest('sbb-form-field')?.querySelector('input') as HTMLInputElement; - } - - const result = findReferencedElement(this.trigger); - - if (!result) { - throw new Error( - 'Cannot find the trigger element. Please specify a valid element or read the "trigger" prop documentation', - ); - } - - return result; - } - - private _attachTo(anchorElem: HTMLElement): void { - if (!anchorElem) { - return; - } - - this._originElement = anchorElem; - - toggleDatasetEntry( - this._element, - 'optionPanelOriginBorderless', - this._element.closest('sbb-form-field')?.hasAttribute('borderless'), - ); - } - - private _bindTo(triggerElem: HTMLInputElement): void { - if (!triggerElem) { - return; - } - - // Reset attributes to the old trigger and add them to the new one - this._removeTriggerAttributes(this._triggerElement); - this._setTriggerAttributes(triggerElem); - - this._triggerElement = triggerElem; - - this._setupTriggerEvents(); - } - - private _setupTriggerEvents(): void { - this._triggerEventsController = new AbortController(); - - // Open the overlay on focus, click, input and `ArrowDown` event - this._triggerElement.addEventListener('focus', () => this.open(), { - signal: this._triggerEventsController.signal, - }); - this._triggerElement.addEventListener('click', () => this.open(), { - signal: this._triggerEventsController.signal, - }); - this._triggerElement.addEventListener( - 'input', - async (event) => { - await this.open(); - this._highlightOptions((event.target as HTMLInputElement).value); - }, - { signal: this._triggerEventsController.signal }, - ); - this._triggerElement.addEventListener( - 'keydown', - (event: KeyboardEvent) => this._closedPanelKeyboardInteraction(event), - { signal: this._triggerEventsController.signal }, - ); - } - - // Set overlay position, width and max height - private _setOverlayPosition(): void { - setOverlayPosition(this._overlay, this._originElement, this._optionContainer, this._element); - } - - /** On open/close animation end. - * In rare cases it can be that the animationEnd event is triggered twice. - * To avoid entering a corrupt state, exit when state is not expected. - */ - private _onAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._onOpenAnimationEnd(); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._onCloseAnimationEnd(); - } - } - - private _onOpenAnimationEnd(): void { - this._state = 'opened'; - this._attachOpenPanelEvents(); - this._triggerElement?.setAttribute('aria-expanded', 'true'); - this.didOpen.emit(); - } - - private _onCloseAnimationEnd(): void { - this._state = 'closed'; - this._triggerElement?.setAttribute('aria-expanded', 'false'); - this._resetActiveElement(); - this._optionContainer.scrollTop = 0; - this.didClose.emit(); - } - - private _attachOpenPanelEvents(): void { - this._openPanelEventsController = new AbortController(); - - // Recalculate the overlay position on scroll and window resize - document.addEventListener('scroll', () => this._setOverlayPosition(), { - passive: true, - signal: this._openPanelEventsController.signal, - }); - window.addEventListener('resize', () => this._setOverlayPosition(), { - passive: true, - signal: this._openPanelEventsController.signal, - }); - - // Close autocomplete on backdrop click - window.addEventListener('pointerdown', (ev) => this._pointerDownListener(ev), { - signal: this._openPanelEventsController.signal, - }); - window.addEventListener('pointerup', (ev) => this._closeOnBackdropClick(ev), { - signal: this._openPanelEventsController.signal, - }); - - // Keyboard interactions - this._triggerElement.addEventListener( - 'keydown', - (event: KeyboardEvent) => this._openedPanelKeyboardInteraction(event), - { - signal: this._openPanelEventsController.signal, - }, - ); - } - - // Check if the pointerdown event target is triggered on the menu. - private _pointerDownListener = (event: PointerEvent): void => { - this._isPointerDownEventOnMenu = isEventOnElement(this._overlay, event); - }; - - // If the click is outside the autocomplete, closes the panel. - private _closeOnBackdropClick = async (event: PointerEvent): Promise => { - if ( - !this._isPointerDownEventOnMenu && - !isEventOnElement(this._overlay, event) && - !isEventOnElement(this._originElement, event) - ) { - await this.close(); - } - }; - - private async _closedPanelKeyboardInteraction(event: KeyboardEvent): Promise { - if (this._state !== 'closed') { - return; - } - - switch (event.key) { - case 'Enter': - case 'ArrowDown': - case 'ArrowUp': - await this.open(); - break; - } - } - - private async _openedPanelKeyboardInteraction(event: KeyboardEvent): Promise { - if (this._state !== 'opened') { - return; - } - - switch (event.key) { - case 'Escape': - case 'Tab': - await this.close(); - break; - - case 'Enter': - await this._selectByKeyboard(); - break; - - case 'ArrowDown': - case 'ArrowUp': - this._setNextActiveOption(event); - break; - } - } - - private async _selectByKeyboard(): Promise { - const activeOption = this._options[this._activeItemIndex]; - - if (activeOption) { - await activeOption.setSelectedViaUserInteraction(true); - } - } - - private _setNextActiveOption(event: KeyboardEvent): void { - const filteredOptions = this._options.filter( - (opt) => !isValidAttribute(opt, 'disabled') && !isValidAttribute(opt, 'data-group-disabled'), - ); - - // Get and activate the next active option - const next = getNextElementIndex(event, this._activeItemIndex, filteredOptions.length); - const nextActiveOption = filteredOptions[next]; - nextActiveOption.active = true; - this._triggerElement.setAttribute('aria-activedescendant', nextActiveOption.id); - nextActiveOption.scrollIntoView({ block: 'nearest' }); - - // Reset the previous active option - const lastActiveOption = filteredOptions[this._activeItemIndex]; - if (lastActiveOption) { - lastActiveOption.active = false; - } - - this._activeItemIndex = next; - } - - private _resetActiveElement(): void { - const activeElement = this._options[this._activeItemIndex]; - - if (activeElement) { - activeElement.active = false; - } - this._activeItemIndex = -1; - this._triggerElement.removeAttribute('aria-activedescendant'); - } - - /** Highlight the searched text on the options. */ - private _highlightOptions(searchTerm: string): void { - this._options.forEach((option) => option.highlight(searchTerm)); - } - - private _setTriggerAttributes(element: HTMLInputElement): void { - setAriaComboBoxAttributes(element, this._element.id || this._overlayId, false); - } - - private _removeTriggerAttributes(element: HTMLInputElement): void { - removeAriaComboBoxAttributes(element); - } - - public render(): JSX.Element { - return ( - this._overlayId)} - dir={getDocumentWritingMode()} - > -
      -
      -
      {overlayGapFixCorners()}
      -
      this._onAnimationEnd(event)} - class="sbb-autocomplete__panel" - data-open={this._state === 'opened' || this._state === 'opening'} - ref={(overlayRef) => (this._overlay = overlayRef)} - > -
      -
      (this._optionContainer = containerRef)} - role={!this._ariaRoleOnHost ? 'listbox' : null} - id={!this._ariaRoleOnHost ? this._overlayId : null} - > - -
      -
      -
      -
      -
      - ); - } -} diff --git a/src/components/sbb-breadcrumb-group/readme.md b/src/components/sbb-breadcrumb-group/readme.md deleted file mode 100644 index 6d25dfc254..0000000000 --- a/src/components/sbb-breadcrumb-group/readme.md +++ /dev/null @@ -1,55 +0,0 @@ -The `sbb-breadcrumb-group` component is a container for one or more [sbb-breadcrumb](/docs/components-sbb-breadcrumb-sbb-breadcrumb--docs), -which are meant to represent the hierarchy of visited pages before arriving to the current one. - -```html - - - - Work with us - - - Apply - - -``` - -## Style - -If the width of all the nested `sbb-breadcrumb` exceeds the container width, -only the first and the last breadcrumb are displayed, and a new one with the ellipsis symbol appears between them. -Clicking on this `sbb-breadcrumb` will make the ellipsis disappear and will restore the full list -(the action is not reversible). - -## Accessibility - -It is strongly recommended to place an `aria-label` attribute on the `sbb-breadcrumb-group`, as in the example above, -to describe what context the breadcrumbs have. -Whenever the `sbb-breadcrumb` list within the component is loaded or updated, -the last element of the list receives the attribute `aria-current="page"`. - - - - -## Slots - -| Slot | Description | -| ----------- | --------------------------------------------- | -| `"unnamed"` | Use this to slot the sbb-breadcrumb elements. | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-breadcrumb-group --> sbb-icon - style sbb-breadcrumb-group fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.e2e.ts b/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.e2e.ts deleted file mode 100644 index 9321f1888d..0000000000 --- a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.e2e.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-breadcrumb-group', () => { - describe('without ellipsis', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - One - Two - - `); - - element = await page.find('sbb-breadcrumb-group'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('keyboard navigation', async () => { - const first = await page.find('sbb-breadcrumb-group > sbb-breadcrumb#breadcrumb-0'); - const second = await page.find('sbb-breadcrumb-group > sbb-breadcrumb#breadcrumb-1'); - const third = await page.find('sbb-breadcrumb-group > sbb-breadcrumb#breadcrumb-2'); - - await first.focus(); - await page.keyboard.down('ArrowRight'); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(second.id); - await page.keyboard.down('ArrowRight'); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(third.id); - }); - }); - - describe('with ellipsis', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setViewport({ width: 320, height: 600 }); - await page.setContent(` - - - First - Second - Third - Fourth - Fifth - Sixth - - `); - - element = await page.find('sbb-breadcrumb-group'); - await page.waitForChanges(); - }); - - it('renders', async () => { - const ellipsisBreadcrumb = ` -
    • - - -
    • `; - - const li = await page.findAll('sbb-breadcrumb-group >>> li'); - const slots = await page.findAll('sbb-breadcrumb-group >>> li > slot'); - expect(li).not.toBeNull(); - expect(li.length).toEqual(3); - expect(li[1]).toEqualHtml(ellipsisBreadcrumb); - expect(slots.length).toEqual(2); - expect(slots[0]).toEqualAttribute('name', 'breadcrumb-0'); - expect(slots[1]).toEqualAttribute('name', 'breadcrumb-6'); - }); - - it('keyboard navigation with ellipsis', async () => { - const ellipsisElement = await page.find( - 'sbb-breadcrumb-group >>> #sbb-breadcrumb-group-ellipsis', - ); - const ellipsisBreadcrumb = await page.find( - 'sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis', - ); - const first = await page.find('sbb-breadcrumb-group > sbb-breadcrumb#breadcrumb-0'); - const last = await page.find('sbb-breadcrumb-group > sbb-breadcrumb#breadcrumb-6'); - - expect(ellipsisElement).not.toBeNull(); - expect(ellipsisBreadcrumb).not.toBeNull(); - - await first.focus(); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(first.id); - - await page.keyboard.down('ArrowRight'); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(element.id); - expect( - await page.evaluate( - () => document.getElementById('sbb-breadcrumb-group').shadowRoot.activeElement.id, - ), - ).toEqual(ellipsisBreadcrumb.id); - - await page.keyboard.down('ArrowRight'); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(last.id); - - await page.keyboard.down('ArrowRight'); - expect(await page.evaluate(() => document.activeElement.id)).toEqual(first.id); - }); - - it('expand breadcrumbs with ellipsis', async () => { - let ellipsisElement = await page.find( - 'sbb-breadcrumb-group >>> #sbb-breadcrumb-group-ellipsis', - ); - let ellipsisBreadcrumb = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis'); - expect(ellipsisElement).not.toBeNull(); - expect(ellipsisBreadcrumb).not.toBeNull(); - - const changeSpy = await ellipsisBreadcrumb.spyOnEvent('click'); - await ellipsisBreadcrumb.click(); - await waitForCondition(() => changeSpy.events.length === 1); - - ellipsisElement = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-group-ellipsis'); - ellipsisBreadcrumb = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis'); - expect(ellipsisElement).toBeNull(); - expect(ellipsisBreadcrumb).toBeNull(); - }); - - it('should expand breadcrumbs and focus correctly by keyboard', async () => { - // When pressing space key on ellipsis - const ellipsisBreadcrumb = await page.find( - 'sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis', - ); - await ellipsisBreadcrumb.press('Space'); - await page.waitForChanges(); - - // Then focus should be on first breadcrumb - expect(await page.evaluate(() => document.activeElement.id)).toEqual('breadcrumb-1'); - - // When blurring the focus - await page.evaluate(() => (document.activeElement as HTMLElement).blur()); - - // Then body should be focused - expect(await page.evaluate(() => document.activeElement.tagName)).toEqual('BODY'); - - // When triggering a slotChange by removing a breadcrumb - await page.evaluate(() => document.getElementById('breadcrumb-6').remove()); - await page.waitForChanges(); - - // Then still the body should be focused - expect(await page.evaluate(() => document.activeElement.tagName)).toEqual('BODY'); - }); - - it('should remove expand button when too less breadcrumbs available', async () => { - let ellipsisElement = await page.find( - 'sbb-breadcrumb-group >>> #sbb-breadcrumb-group-ellipsis', - ); - let ellipsisBreadcrumb = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis'); - expect(ellipsisElement).not.toBeNull(); - expect(ellipsisBreadcrumb).not.toBeNull(); - - // Remove every breadcrumb from DOM except the first two - await page.evaluate(() => - Array.from(document.querySelectorAll('sbb-breadcrumb')) - .slice(2) - .forEach((el) => el.remove()), - ); - - await page.waitForChanges(); - - ellipsisElement = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-group-ellipsis'); - ellipsisBreadcrumb = await page.find('sbb-breadcrumb-group >>> #sbb-breadcrumb-ellipsis'); - expect(ellipsisElement).toBeNull(); - expect(ellipsisBreadcrumb).toBeNull(); - }); - }); -}); diff --git a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.scss b/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.scss deleted file mode 100644 index 0f129d5689..0000000000 --- a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.scss +++ /dev/null @@ -1,80 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-breadcrumb-group-wrap: nowrap; - --sbb-breadcrumb-group-visibility: hidden; -} - -:host([data-loaded]) { - --sbb-breadcrumb-group-visibility: visible; -} - -:host([data-state]) { - --sbb-breadcrumb-group-wrap: wrap; -} - -.sbb-breadcrumb-group { - @include sbb.list-reset; - - display: flex; - flex-wrap: var(--sbb-breadcrumb-group-wrap); - column-gap: var(--sbb-spacing-fixed-1x); - visibility: var(--sbb-breadcrumb-group-visibility); -} - -.sbb-breadcrumb-group__item { - flex: 0 0 auto; - display: flex; - column-gap: var(--sbb-spacing-fixed-1x); -} - -.sbb-breadcrumb-group__divider-icon { - color: var(--sbb-color-granite-default); -} - -#sbb-breadcrumb-ellipsis { - --sbb-breadcrumb-group-ellipsis-color: var(--sbb-color-granite-default); - --sbb-breadcrumb-group-ellipsis-background-color: transparent; - --sbb-breadcrumb-group-ellipsis-border-width: var(--sbb-border-width-1x); - --sbb-breadcrumb-group-ellipsis-border-color: var(--sbb-color-silver-default); - - @include sbb.button-reset; - @include sbb.text--bold; - - width: var(--sbb-size-icon-ui-small); - height: var(--sbb-size-icon-ui-small); - border: var(--sbb-breadcrumb-group-ellipsis-border-width) solid - var(--sbb-breadcrumb-group-ellipsis-border-color); - border-radius: 50%; - - // In order to vertically center ..., we use padding with half the font size. - padding-block-end: 0.5em; - cursor: pointer; - color: var(--sbb-breadcrumb-group-ellipsis-color); - background-color: var(--sbb-breadcrumb-group-ellipsis-background-color); - overflow: hidden; - - @include sbb.if-forced-colors { - --sbb-breadcrumb-group-ellipsis-border-width: var(--sbb-border-width-2x); - --sbb-breadcrumb-group-ellipsis-border-color: CanvasText; - } - - @include sbb.hover-mq($hover: true) { - &:hover { - --sbb-breadcrumb-group-ellipsis-color: var(--sbb-color-charcoal-default); - --sbb-breadcrumb-group-ellipsis-background-color: var(--sbb-color-milk-default); - - @include sbb.if-forced-colors { - --sbb-breadcrumb-group-ellipsis-border-color: Highlight; - } - } - } - - &:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch']) { - @include sbb.focus-outline; - } -} diff --git a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.spec.ts b/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.spec.ts deleted file mode 100644 index 7ff5b60104..0000000000 --- a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { SbbBreadcrumbGroup } from './sbb-breadcrumb-group'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-breadcrumb-group', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbBreadcrumbGroup], - html: ` - - - One - Two - - `, - }); - - expect(root).toEqualHtml(` - - -
        -
      1. - - -
      2. -
      3. - - -
      4. -
      5. - -
      6. -
      - -
      - - - - One - - - Two - -
      - `); - }); -}); diff --git a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.stories.tsx b/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.stories.tsx deleted file mode 100644 index 3c07d9d4c9..0000000000 --- a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.stories.tsx +++ /dev/null @@ -1,166 +0,0 @@ -/** @jsx h */ -import { Fragment, h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const addBreadcrumb = (): void => { - const container = document.getElementById('container'); - const breadcrumb = document.createElement('sbb-breadcrumb'); - breadcrumb.setAttribute('href', '/'); - breadcrumb.textContent = 'Breadcrumb ' + container.children.length; - container.append(breadcrumb); -}; - -const removeBreadcrumb = (): void => { - const container = document.getElementById('container'); - if (container.children.length > 1) { - container.removeChild(container.lastElementChild); - } -}; - -const numberOfBreadcrumbs: InputType = { - control: { - type: 'number', - }, -}; - -const text: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Breadcrumb', - }, -}; - -const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; -const href: InputType = { - options: Object.keys(hrefs), - mapping: hrefs, - control: { - type: 'select', - labels: { - 0: 'sbb.ch', - 1: 'GitHub Lyne Components', - }, - }, - table: { - category: 'Breadcrumb', - }, -}; - -const target: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Breadcrumb', - }, -}; - -const rel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Breadcrumb', - }, -}; - -const download: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Breadcrumb', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Breadcrumb', - }, -}; - -const defaultArgTypes: ArgTypes = { - numberOfBreadcrumbs, - text, - href, - target, - rel, - download, - 'icon-name': iconName, -}; - -const defaultArgs: Args = { - numberOfBreadcrumbs: 3, - text: 'Breadcrumb', - href: 'https://github.com/lyne-design-system/lyne-components', - target: '_blank', - rel: undefined, - download: false, - 'icon-name': undefined, -}; - -const breadcrumbTemplate = (args, text: string, i: number): JSX.Element => ( - - {text} {i} - -); - -const createBreadcrumbs = ({ numberOfBreadcrumbs, text, ...args }): JSX.Element[] => [ - , - new Array(numberOfBreadcrumbs - 1) - .fill(undefined) - .map((_, i) => breadcrumbTemplate(args, text, i + 1)), -]; - -const Template = (args): JSX.Element => ( - - - {createBreadcrumbs(args)} - -
      - - -
      -
      -); - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const CollapsedState: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, numberOfBreadcrumbs: 25 }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      Page content
      -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-breadcrumb/sbb-breadcrumb-group', -}; - -export default meta; diff --git a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.tsx b/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.tsx deleted file mode 100644 index 1935721ffc..0000000000 --- a/src/components/sbb-breadcrumb-group/sbb-breadcrumb-group.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, Listen, State } from '@stencil/core'; -import { i18nBreadcrumbEllipsisButtonLabel } from '../../global/i18n'; -import { - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { - getNextElementIndex, - isArrowKeyPressed, - sbbInputModalityDetector, -} from '../../global/a11y'; -import { AgnosticResizeObserver } from '../../global/observers'; - -/** - * @slot unnamed - Use this to slot the sbb-breadcrumb elements. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-breadcrumb-group.scss', - tag: 'sbb-breadcrumb-group', -}) -export class SbbBreadcrumbGroup implements ComponentInterface { - /** Local instance of slotted sbb-breadcrumb elements */ - @State() private _breadcrumbs: HTMLSbbBreadcrumbElement[]; - - @State() private _state?: 'collapsed' | 'manually-expanded'; - - @State() private _loaded = false; - - /** Current document language used for translation of the button label. */ - @State() private _currentLanguage = documentLanguage(); - - @Element() private _element!: HTMLElement; - - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - ); - - private _resizeObserver = new AgnosticResizeObserver(() => this._evaluateCollapsedState()); - - private _markForFocus = false; - - @Listen('keydown') - public handleKeyDown(evt: KeyboardEvent): void { - if ( - !this._breadcrumbs || - // don't trap nested handling - ((evt.target as HTMLElement) !== this._element && - (evt.target as HTMLElement).parentElement !== this._element) - ) { - return; - } - - if (isArrowKeyPressed(evt)) { - if (this._state === 'collapsed') { - return this._focusNextCollapsed(evt); - } - this._focusNext(evt); - } - } - - public connectedCallback(): void { - this._readBreadcrumb(); - this._handlerRepository.connect(); - } - - public componentDidLoad(): void { - this._resizeObserver.observe(this._element); - this._loaded = true; - } - - public componentDidRender(): void { - if (this._markForFocus && sbbInputModalityDetector.mostRecentModality === 'keyboard') { - this._breadcrumbs[1]?.focus(); - - // Reset mark for focus - this._markForFocus = false; - } - } - - public disconnectedCallback(): void { - this._resizeObserver.disconnect(); - this._handlerRepository.disconnect(); - } - - /** Creates and sets an array with only the sbb-breadcrumb children. */ - private _readBreadcrumb(): void { - this._evaluateCollapsedState(); - - const breadcrumbs = Array.from(this._element.children).filter( - (e): e is HTMLSbbBreadcrumbElement => e.tagName === 'SBB-BREADCRUMB', - ); - // If the slotted sbb-breadcrumb instances have not changed, - // we can skip syncing and updating the breadcrumbs reference list. - if ( - this._breadcrumbs && - breadcrumbs.length === this._breadcrumbs.length && - this._breadcrumbs.every((e, i) => breadcrumbs[i] === e) - ) { - return; - } - this._breadcrumbs = breadcrumbs; - this._syncBreadcrumbs(); - } - - /** Apply the aria-current attribute to the last sbb-breadcrumb element. */ - private _syncBreadcrumbs(): void { - this._breadcrumbs.forEach((breadcrumb, index) => { - breadcrumb.removeAttribute('aria-current'); - if (!breadcrumb.id) { - breadcrumb.id = `sbb-breadcrumb-${index}`; - } - }); - this._breadcrumbs[this._breadcrumbs.length - 1]?.setAttribute('aria-current', 'page'); - - // If it is not expandable, reset state - if (this._breadcrumbs.length < 3) { - this._state = undefined; - } - } - - /** - * Sets the focus on the correct element when the ellipsis breadcrumb is displayed and the user is navigating with keyboard's arrows. - */ - private _focusNextCollapsed(evt: KeyboardEvent): void { - const arrayCollapsed: HTMLSbbBreadcrumbElement[] = [ - this._breadcrumbs[0], - this._element.shadowRoot.querySelector( - '#sbb-breadcrumb-ellipsis', - ) as HTMLSbbBreadcrumbElement, - this._breadcrumbs[this._breadcrumbs.length - 1], - ]; - this._focusNext(evt, arrayCollapsed); - } - - private _focusNext( - evt: KeyboardEvent, - breadcrumbs: HTMLSbbBreadcrumbElement[] = this._breadcrumbs, - ): void { - const current: number = breadcrumbs.findIndex( - (e) => e === document.activeElement || e === this._element.shadowRoot.activeElement, - ); - const nextIndex: number = getNextElementIndex(evt, current, breadcrumbs.length); - breadcrumbs[nextIndex]?.focus(); - evt.preventDefault(); - } - - /** - * Note: due to @State annotation on _state, this method triggers a new render; as a consequence, the focus is moved - * to the `body`, so if the ellipsis element has focus, it's asynchronously forced to the first element. - */ - private _expandBreadcrumbs(): void { - this._state = 'manually-expanded'; - this._markForFocus = true; - } - - /** Evaluate if the expanded breadcrumb element fits in page width, otherwise it needs ellipsis */ - private _evaluateCollapsedState(): void { - if (this._element && !this._state && this._element.scrollWidth > this._element.offsetWidth) { - this._state = 'collapsed'; - this._resizeObserver.disconnect(); - } - } - - private _renderCollapsed(): JSX.Element { - for (let i = 0; i < this._breadcrumbs.length; i++) { - if (i === 0 || i === this._breadcrumbs.length - 1) { - this._breadcrumbs[i].setAttribute('slot', `breadcrumb-${i}`); - } else { - this._breadcrumbs[i].removeAttribute('slot'); - } - } - const idFirstElement = this._breadcrumbs[0].id ?? `sbb-breadcrumb-0`; - const idLastElement = - this._breadcrumbs[this._breadcrumbs.length - 1].id ?? - `sbb-breadcrumb-${this._breadcrumbs.length - 1}`; - - return [ -
    • - this._readBreadcrumb()} - /> -
    • , -
    • - - -
    • , -
    • - - this._readBreadcrumb()} - /> -
    • , - ]; - } - - private _renderExpanded(): JSX.Element { - const slotName = (index: number): string => `breadcrumb-${index}`; - - return this._breadcrumbs.map((element: HTMLSbbBreadcrumbElement, index: number) => { - element.setAttribute('slot', slotName(index)); - return ( -
    • - this._readBreadcrumb()} /> - {index !== this._breadcrumbs.length - 1 && ( - - )} -
    • - ); - }); - } - - public render(): JSX.Element { - return ( - -
        - {this._state === 'collapsed' ? this._renderCollapsed() : this._renderExpanded()} -
      - -
      - ); - } -} diff --git a/src/components/sbb-breadcrumb/readme.md b/src/components/sbb-breadcrumb/readme.md deleted file mode 100644 index a7d42dfeee..0000000000 --- a/src/components/sbb-breadcrumb/readme.md +++ /dev/null @@ -1,75 +0,0 @@ -The `sbb-breadcrumb` is a component used to display a link to a page. - -When it's used within the [sbb-breadcrumb-group](/docs/components-sbb-breadcrumb-sbb-breadcrumb-group--docs) component, -it can display the list of the links the user visited to arrive at the current page. - -## Slots - -It is possible to provide a text via an unnamed slot; the component can optionally display a `sbb-icon` -at the component start using the `iconName` property or via custom content using the `icon` slot. -Text and icon are not exclusive and can be used together. - -```html -Contact us - - - - - Info - - -``` - -## Link properties - -It's possible to set all the link related properties (`download`, `href`, `rel` and `target`). - -```html -Info -``` - -## Accessibility - -The `aria-current` property should be used to make the breadcrumb read correctly by screen-readers when the component -is used in the `sbb-breadcrumb-group`. - -By default, the `sbb-breadcrumb-group` component sets `aria-current="page"` on the last slotted `sbb-breadcrumb`. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | -| `download` | `download` | Whether the browser will show the download dialog on click. | `boolean` | `undefined` | -| `href` | `href` | The href value you want to link to. | `string` | `undefined` | -| `iconName` | `icon-name` | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | `string` | `undefined` | -| `rel` | `rel` | The relationship of the linked URL as space-separated link types. | `string` | `undefined` | -| `target` | `target` | Where to display the linked URL. | `string` | `undefined` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------ | -| `"icon"` | Use this to display an icon as breadcrumb. | -| `"unnamed"` | Use this to slot the breadcrumb's text. | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-breadcrumb --> sbb-icon - style sbb-breadcrumb fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-breadcrumb/sbb-breadcrumb.e2e.ts b/src/components/sbb-breadcrumb/sbb-breadcrumb.e2e.ts deleted file mode 100644 index e65b1e5c88..0000000000 --- a/src/components/sbb-breadcrumb/sbb-breadcrumb.e2e.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-breadcrumb', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent('Test'); - - element = await page.find('sbb-breadcrumb'); - }); - - it('renders', async () => { - await page.waitForChanges(); - expect(element).toHaveClass('hydrated'); - }); - - it('dispatches event on click', async () => { - await page.waitForChanges(); - const changeSpy = await page.spyOnEvent('click'); - - await element.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('should receive focus', async () => { - await element.focus(); - await page.waitForChanges(); - - expect(await page.evaluate(() => document.activeElement.id)).toBe('focus-id'); - }); -}); diff --git a/src/components/sbb-breadcrumb/sbb-breadcrumb.scss b/src/components/sbb-breadcrumb/sbb-breadcrumb.scss deleted file mode 100644 index d38c2c5b5e..0000000000 --- a/src/components/sbb-breadcrumb/sbb-breadcrumb.scss +++ /dev/null @@ -1,59 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - display: flex; - - --sbb-breadcrumb-color: var(--sbb-color-granite-default); -} - -@include sbb.hover-mq($hover: true) { - :host(:hover) { - --sbb-breadcrumb-color: var(--sbb-color-charcoal-default); - } -} - -:host(:is(:active, [data-active])) { - --sbb-breadcrumb-color: var(--sbb-color-anthracite-default); -} - -// Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. -:host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) { - @include sbb.focus-outline; - - border-radius: var(--sbb-border-radius-2x); -} - -.sbb-breadcrumb { - @include sbb.text-xs--regular; - @include sbb.link-base; - - display: flex; - cursor: pointer; - gap: var(--sbb-spacing-fixed-2x); - color: var(--sbb-breadcrumb-color); - align-items: center; - overflow: hidden; - - @include sbb.if-forced-colors { - --sbb-breadcrumb-color: ButtonText; - } -} - -.sbb-breadcrumb__icon { - display: flex; - flex-shrink: 0; - cursor: pointer; - color: var(--sbb-breadcrumb-color); -} - -.sbb-breadcrumb__label { - @include sbb.ellipsis; -} - -.sbb-breadcrumb__label--opens-in-new-window { - @include sbb.screen-reader-only; -} diff --git a/src/components/sbb-breadcrumb/sbb-breadcrumb.spec.ts b/src/components/sbb-breadcrumb/sbb-breadcrumb.spec.ts deleted file mode 100644 index 9d34f7ebbd..0000000000 --- a/src/components/sbb-breadcrumb/sbb-breadcrumb.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { SbbBreadcrumb } from './sbb-breadcrumb'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-breadcrumb', () => { - it('renders with text', async () => { - const { root } = await newSpecPage({ - components: [SbbBreadcrumb], - html: 'Breadcrumb', - }); - - expect(root).toEqualHtml(` - - - - - - - . Link target opens in new window. - - - - - Breadcrumb - - `); - }); - - it('renders with icon', async () => { - const { root } = await newSpecPage({ - components: [SbbBreadcrumb], - html: ` - - `, - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - - `); - }); - - it('renders with icon and text', async () => { - const { root } = await newSpecPage({ - components: [SbbBreadcrumb], - html: ` - Home - `, - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - - - Home - - `); - }); - - it('renders as span if no href is provided', async () => { - const { root } = await newSpecPage({ - components: [SbbBreadcrumb], - html: 'Breadcrumb', - }); - - expect(root).toEqualHtml(` - - - - - - - - - Breadcrumb - - `); - }); -}); diff --git a/src/components/sbb-breadcrumb/sbb-breadcrumb.stories.tsx b/src/components/sbb-breadcrumb/sbb-breadcrumb.stories.tsx deleted file mode 100644 index 911b569aba..0000000000 --- a/src/components/sbb-breadcrumb/sbb-breadcrumb.stories.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const text: InputType = { - control: { - type: 'text', - }, -}; - -const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; -const href: InputType = { - options: Object.keys(hrefs), - mapping: hrefs, - control: { - type: 'select', - labels: { - 0: 'sbb.ch', - 1: 'GitHub Lyne Components', - }, - }, - table: { - category: 'Link', - }, -}; - -const target: InputType = { - control: { - type: 'text', - }, -}; - -const rel: InputType = { - control: { - type: 'text', - }, -}; - -const download: InputType = { - control: { - type: 'boolean', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - text, - href, - target, - rel, - download, - 'icon-name': iconName, -}; - -const defaultArgs: Args = { - text: 'Breadcrumb', - href: href.options[0], - target: '_blank', - rel: undefined, - download: false, - 'icon-name': undefined, -}; - -const Template = ({ text, ...args }): JSX.Element => ( - {text} -); - -const SlottedIconTemplate = ({ text, 'icon-name': iconName, ...args }): JSX.Element => ( - - {text} - - -); - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const Icon: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - text: undefined, - 'icon-name': 'house-small', - }, -}; - -export const IconAndText: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - 'icon-name': 'house-small', - }, -}; - -export const SlottedIconAndText: StoryObj = { - render: SlottedIconTemplate, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - 'icon-name': 'globe-small', - text: 'Custom slotted icon', - }, -}; - -export const LongContent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - 'icon-name': 'house-small', - text: 'This label name is so long that it needs ellipsis to fit.', - }, - decorators: [ - (Story) => ( -
      - -
      - ), - ], -}; - -export const NoLink: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, href: undefined, target: undefined }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-breadcrumb/sbb-breadcrumb', -}; - -export default meta; diff --git a/src/components/sbb-breadcrumb/sbb-breadcrumb.tsx b/src/components/sbb-breadcrumb/sbb-breadcrumb.tsx deleted file mode 100644 index 3667e880d9..0000000000 --- a/src/components/sbb-breadcrumb/sbb-breadcrumb.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, Prop, State } from '@stencil/core'; -import { - LinkProperties, - LinkTargetType, - resolveLinkOrStaticRenderVariables, - targetsNewWindow, -} from '../../global/interfaces'; -import { i18nTargetOpensInNewWindow } from '../../global/i18n'; -import { - createNamedSlotState, - documentLanguage, - HandlerRepository, - actionElementHandlerAspect, - languageChangeHandlerAspect, - namedSlotChangeHandlerAspect, -} from '../../global/eventing'; - -/** - * @slot unnamed - Use this to slot the breadcrumb's text. - * @slot icon - Use this to display an icon as breadcrumb. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-breadcrumb.scss', - tag: 'sbb-breadcrumb', -}) -export class SbbBreadcrumb implements ComponentInterface, LinkProperties { - /** The href value you want to link to. */ - @Prop() public href: string | undefined; - - /** Where to display the linked URL. */ - @Prop() public target?: LinkTargetType | string | undefined; - - /** The relationship of the linked URL as space-separated link types. */ - @Prop() public rel?: string | undefined; - - /** Whether the browser will show the download dialog on click. */ - @Prop() public download?: boolean; - - /** - * The icon name we want to use, choose from the small icon variants - * from the ui-icons category from here - * https://icons.app.sbb.ch. - */ - @Prop() public iconName?: string; - - /** State of listed named slots, by indicating whether any element for a named slot is defined. */ - @State() private _namedSlots = createNamedSlotState('icon'); - - @State() private _currentLanguage = documentLanguage(); - - @State() private _hasText = false; - - @Element() private _element!: HTMLElement; - - private _handlerRepository = new HandlerRepository( - this._element, - actionElementHandlerAspect, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - - public connectedCallback(): void { - this._hasText = Array.from(this._element.childNodes).some( - (n) => !(n as Element).slot && n.textContent?.trim(), - ); - this._handlerRepository.connect(); - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - private _onLabelSlotChange(event: Event): void { - this._hasText = (event.target as HTMLSlotElement) - .assignedNodes() - .some((n) => !!n.textContent?.trim()); - } - - public render(): JSX.Element { - const { - tagName: TAG_NAME, - attributes, - hostAttributes, - } = resolveLinkOrStaticRenderVariables(this); - - return ( - - - {(this.iconName || this._namedSlots.icon) && ( - - {this.iconName && } - - )} - {this._hasText && ( - - this._onLabelSlotChange(event)} /> - {targetsNewWindow(this) && ( - - . {i18nTargetOpensInNewWindow[this._currentLanguage]} - - )} - - )} - {!this._hasText && ( - - )} - - - ); - } -} diff --git a/src/components/sbb-button/readme.md b/src/components/sbb-button/readme.md deleted file mode 100644 index c9067c85ac..0000000000 --- a/src/components/sbb-button/readme.md +++ /dev/null @@ -1,150 +0,0 @@ -The `sbb-button` component provides the same functionality as a native ` - `); - - const yearCells: E2EElement[] = await page.findAll( - 'sbb-calendar >>> .sbb-calendar__table-year', - ); - expect(yearCells.length).toEqual(24); - expect(yearCells[0]).toEqualHtml(` - - - - `); - - const selectedYear: E2EElement = await page.find({ text: '2023' }); - expect(selectedYear).toHaveClass('sbb-calendar__selected'); - expect(yearCells[yearCells.length - 1].textContent).toEqual('2039'); - await selectedYear.click(); - await page.waitForChanges(); - - const monthSelection: E2EElement = await page.find( - 'sbb-calendar >>> #sbb-calendar__month-selection', - ); - expect(monthSelection).not.toBeNull(); - expect(monthSelection).toEqualHtml(` - - `); - - const monthCells: E2EElement[] = await page.findAll( - 'sbb-calendar >>> .sbb-calendar__table-month', - ); - expect(monthCells.length).toEqual(12); - expect(monthCells[0]).toEqualHtml(` - - - - `); - await monthCells[0].click(); - await page.waitForChanges(); - - const dayCells: E2EElement[] = await page.findAll('sbb-calendar >>> .sbb-calendar__day'); - expect(dayCells.length).toEqual(31); - }); - - describe('navigation', () => { - it('navigates left via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('ArrowLeft'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('14 1 2023'); - }); - - it('navigates right via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('ArrowRight'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('16 1 2023'); - }); - - it('navigates up via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('ArrowUp'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('8 1 2023'); - }); - - it('navigates down via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('ArrowDown'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('22 1 2023'); - }); - - it('navigates to first day via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('Home'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('1 1 2023'); - }); - - it('navigates to last day via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('End'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('31 1 2023'); - }); - - it('navigates to column start via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('PageUp'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('1 1 2023'); - }); - - it('navigates to column end via keyboard', async () => { - await element.focus(); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('15 1 2023'); - - await element.press('PageDown'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.getAttribute('data-day'); - }), - ).toEqual('29 1 2023'); - }); - }); - - describe('navigation for year view', () => { - beforeEach(async () => { - const yearSelectionButton: E2EElement = await page.find( - 'sbb-calendar >>> #sbb-calendar__date-selection', - ); - const table: E2EElement = await page.find('sbb-calendar >>> table'); - const animationSpy = await table.spyOnEvent('animationend'); - - await yearSelectionButton.click(); - await waitForCondition(() => animationSpy.events.length >= 2); - const selectedYear: E2EElement = await page.find({ text: '2023' }); - await selectedYear.focus(); - }); - - it('navigates left via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('ArrowLeft'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2022'); - }); - - it('navigates right via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('ArrowRight'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2024'); - }); - - it('navigates up via keyboard', async () => { - await page.waitForChanges(); - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('ArrowUp'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2019'); - }); - - it('navigates down via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('ArrowDown'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2027'); - }); - - it('navigates to first day via keyboard', async () => { - await page.waitForChanges(); - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('Home'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2016'); - }); - - it('navigates to last day via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('End'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2039'); - }); - - it('navigates to column start via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('PageUp'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2019'); - }); - - it('navigates to column end via keyboard', async () => { - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2023'); - - await element.press('PageDown'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => { - return document.activeElement.shadowRoot.activeElement.textContent; - }), - ).toEqual('2039'); - }); - }); -}); diff --git a/src/components/sbb-calendar/sbb-calendar.events.ts b/src/components/sbb-calendar/sbb-calendar.events.ts deleted file mode 100644 index 78d2fba3d6..0000000000 --- a/src/components/sbb-calendar/sbb-calendar.events.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - dateSelected: 'date-selected', -}; diff --git a/src/components/sbb-calendar/sbb-calendar.scss b/src/components/sbb-calendar/sbb-calendar.scss deleted file mode 100644 index 643e0e77ba..0000000000 --- a/src/components/sbb-calendar/sbb-calendar.scss +++ /dev/null @@ -1,313 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-calendar-cell-size: #{sbb.px-to-rem-build(40)}; - --sbb-calendar-hover-shift: #{sbb.px-to-rem-build(1)}; - --sbb-calendar-wide-cell-size: #{sbb.px-to-rem-build(70)}; - --sbb-calendar-cell-disabled-color: var(--sbb-color-granite-default); - --sbb-calendar-header-color: var(--sbb-color-granite-default); - --sbb-calendar-cell-background-color: transparent; - --sbb-calendar-cell-padding: #{sbb.px-to-rem-build(2)}; - --sbb-calendar-cell-color: var(--sbb-color-charcoal-default); - --sbb-calendar-cell-selected-color: var(--sbb-color-white-default); - --sbb-calendar-cell-selected-background-color: var(--sbb-color-charcoal-default); - --sbb-calendar-cell-disabled-height: #{sbb.px-to-rem-build(1.5)}; - --sbb-calendar-cell-disabled-width: #{sbb.px-to-rem-build(25.5)}; - --sbb-calendar-cell-transition-duration: var(--sbb-animation-duration-2x); - --sbb-calendar-cell-transition-easing-function: var(--sbb-animation-easing); - --sbb-calendar-tables-gap: var(--sbb-spacing-fixed-10x); - --sbb-calendar-table-animation-shift: #{sbb.px-to-rem-build(0.1)}; - --sbb-calendar-table-animation-duration: 0ms; - --sbb-calendar-table-column-spaces: 12; - --sbb-calendar-control-view-change-height: #{sbb.px-to-rem-build(44)}; - --sbb-calendar-control-view-change-color: var(--sbb-color-charcoal-default); - --sbb-calendar-control-view-change-background: var(--sbb-color-white-default); - - @include sbb.mq($from: micro) { - --sbb-calendar-cell-size: #{sbb.px-to-rem-build(44)}; - --sbb-calendar-wide-cell-size: #{sbb.px-to-rem-build(77)}; - --sbb-calendar-control-view-change-height: #{sbb.px-to-rem-build(48)}; - } - - // We add width definition to host, to make overwriting easy for consumers. - width: max-content; -} - -.sbb-calendar__wrapper { - width: 100%; - display: block; - transition-duration: var(--sbb-calendar-cell-transition-duration); -} - -.sbb-calendar__controls { - width: 100%; - display: inline-flex; - align-items: center; - gap: var(--sbb-spacing-fixed-2x); - margin-block-end: var(--sbb-spacing-fixed-4x); -} - -.sbb-calendar__controls-month { - width: 100%; - display: flex; - gap: var(--sbb-calendar-tables-gap); -} - -#sbb-calendar__controls-previous, -#sbb-calendar__controls-next { - -webkit-tap-highlight-color: transparent; -} - -.sbb-calendar__controls-change-date { - @include sbb.button-reset; - @include sbb.text-s--regular; - - display: flex; - align-items: center; - margin: auto; - height: var(--sbb-calendar-control-view-change-height); - text-transform: capitalize; - cursor: pointer; - padding-inline: var(--sbb-spacing-fixed-5x) var(--sbb-spacing-fixed-2x); - border-radius: var(--sbb-border-radius-infinity); - background-color: var(--sbb-calendar-control-view-change-background); - color: var(--sbb-calendar-control-view-change-color); - transition-duration: var(--sbb-calendar-cell-transition-duration); - transition-timing-function: var(--sbb-calendar-cell-transition-easing-function); - transition-property: background-color, padding-block-end; - - &:disabled { - --sbb-calendar-control-view-change-background: var(--sbb-color-milk-default); - --sbb-calendar-control-view-change-color: var(--sbb-calendar-cell-disabled-color); - - cursor: unset; - } - - &:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch']) { - @include sbb.focus-outline; - - outline-offset: var(--sbb-spacing-fixed-1x); - } - - @include sbb.hover-mq { - &:not(:active, :disabled):hover { - padding-block-end: var(--sbb-calendar-hover-shift); - } - } - - &:not(:disabled):active { - --sbb-calendar-control-view-change-background: var(--sbb-color-milk-default); - } -} - -.sbb-calendar__table-month-view, -.sbb-calendar__table-year-view { - --sbb-calendar-table-column-spaces: 6; -} - -.sbb-calendar__table-container { - display: flex; - gap: var(--sbb-calendar-tables-gap); - - // The padding of the first and last column should not be visible if calendar is stretched. - // Therefore we need a negative inline margin. - // As we don't want to squeeze, the margin should never be greater than zero. - - // Min width is equals to the normal width of the calendar - --sbb-calendar-min-width: calc(7 * var(--sbb-calendar-cell-size)); - - // The overflow variable is equals the difference between the actual width and the min width. - --sbb-calendar-overflow: calc(100% - var(--sbb-calendar-min-width)); - - // The start offset is negative margin which should overlap the parent container. Should never be a positive value. - --sbb-calendar-start-offset: min( - 0px, - -1 * (var(--sbb-calendar-overflow) / var(--sbb-calendar-table-column-spaces)) - ); - --sbb-calendar-margin: var(--sbb-calendar-start-offset); - - :host([data-wide]) & { - --sbb-calendar-min-width: calc( - 2 * 7 * var(--sbb-calendar-cell-size) + var(--sbb-calendar-tables-gap) - ); - --sbb-calendar-margin: calc(0.5 * var(--sbb-calendar-start-offset)); - } - - margin-inline: var(--sbb-calendar-margin); -} - -.sbb-calendar__table { - width: 100%; - border-collapse: collapse; - height: max-content; - - &.sbb-calendar__table-show { - --sbb-calendar-cell-transition-duration: 0ms; - - animation: { - name: show; - duration: var(--sbb-calendar-table-animation-duration); - } - } - - &.sbb-calendar__table-hide { - --sbb-calendar-cell-transition-duration: 0ms; - - animation: { - name: hide; - duration: var(--sbb-calendar-table-animation-duration); - } - } - - :host(:not([data-wide])) & { - // Due to a Safari iOS rendering bug we need to define min-width as well. - // Otherwise, after orientation change, there is a wrong width if placed in an sbb-dialog. - min-width: 100%; - } -} - -.sbb-calendar__table-header { - @include sbb.text-xs--regular; - - color: var(--sbb-calendar-header-color); - width: var(--sbb-calendar-cell-size); - padding: 0; - padding-block-end: var(--sbb-spacing-fixed-4x); -} - -.sbb-calendar__table-data { - position: relative; - padding: 0; - text-align: center; -} - -.sbb-calendar__cell { - @include sbb.button-reset; - @include sbb.text-s--regular; - - height: var(--sbb-calendar-cell-size); - color: var(--sbb-calendar-cell-color); - cursor: pointer; - position: relative; - z-index: 0; - - &::before { - content: ''; - position: absolute; - inset: var(--sbb-calendar-cell-padding); - background-color: var(--sbb-calendar-cell-background-color); - border-radius: 50%; - z-index: -1; - transition-duration: var(--sbb-calendar-cell-transition-duration); - transition-timing-function: var(--sbb-calendar-cell-transition-easing-function); - transition-property: background-color; - } - - @include sbb.hover-mq($hover: true) { - &:not(.sbb-calendar__selected, :active, :disabled):hover { - --sbb-calendar-cell-background-color: var(--sbb-color-milk-default); - - padding-block-end: var(--sbb-calendar-hover-shift); - - &::before { - @include sbb.if-forced-colors { - @include sbb.focus-outline; - } - } - } - } - - &:disabled { - --sbb-calendar-cell-color: var(--sbb-calendar-cell-disabled-color); - - cursor: unset; - } - - &:focus-visible::before { - @include sbb.focus-outline; - } - - &:not(.sbb-calendar__selected, :disabled):active { - --sbb-calendar-cell-background-color: var(--sbb-color-cloud-default); - - &::before { - @include sbb.if-forced-colors { - @include sbb.focus-outline; - } - } - } -} - -.sbb-calendar__day { - border-radius: 50%; - width: var(--sbb-calendar-cell-size); - - &::before { - border-radius: 50%; - } -} - -.sbb-calendar__pill { - width: var(--sbb-calendar-wide-cell-size); - border-radius: var(--sbb-border-radius-infinity); - - &::before { - border-radius: var(--sbb-border-radius-infinity); - } -} - -.sbb-calendar__crossed-out::after { - content: ''; - height: var(--sbb-calendar-cell-disabled-height); - width: var(--sbb-calendar-cell-disabled-width); - position: absolute; - background-color: var(--sbb-calendar-cell-disabled-color); - top: 50%; - left: 50%; - transform: translate(-50%, -50%) rotate(-45deg); -} - -.sbb-calendar__cell-current { - @include sbb.text-s--bold; -} - -.sbb-calendar__selected { - --sbb-calendar-cell-color: var(--sbb-calendar-cell-selected-color); - --sbb-calendar-cell-background-color: var(--sbb-calendar-cell-selected-background-color); - - @include sbb.if-forced-colors { - --sbb-calendar-cell-background-color: ButtonText !important; - } -} - -.sbb-calendar__visually-hidden { - @include sbb.screen-reader-only; -} - -@keyframes show { - from { - opacity: 0; - transform: translateY(var(--sbb-calendar-table-animation-shift)); - } - - to { - opacity: 1; - transform: translateY(0%); - } -} - -@keyframes hide { - from { - opacity: 1; - transform: translateY(0%); - } - - to { - opacity: 0; - transform: translateY(var(--sbb-calendar-table-animation-shift)); - } -} diff --git a/src/components/sbb-calendar/sbb-calendar.spec.ts b/src/components/sbb-calendar/sbb-calendar.spec.ts deleted file mode 100644 index ffdcd4d29d..0000000000 --- a/src/components/sbb-calendar/sbb-calendar.spec.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { SbbCalendar } from './sbb-calendar'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-calendar', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbCalendar], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      - - - January 2023 - -
      - -
      -
      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - Monday - - - Tuesday - - - Wednesday - - - Thursday - - - Friday - - - Saturday - - - Sunday - -
      - -
      - - - - - - - - - - - - - -
      - - - - - - - - - - - - - -
      - - - - - - - - - - - - - -
      - - - - - - - - - - - - - -
      - - - -
      -
      -
      -
      -
      - `); - }); - - it('renders with min and max', async () => { - const page = await newSpecPage({ - components: [SbbCalendar], - html: "", - }); - - const buttonPrevDay = page.root.shadowRoot.querySelector( - "sbb-button[iconname='chevron-small-left-small']", - ); - expect(buttonPrevDay).toHaveAttribute('disabled'); - const buttonNextDay = page.root.shadowRoot.querySelector( - "sbb-button[iconname='chevron-small-right-small']", - ); - expect(buttonNextDay).toHaveAttribute('disabled'); - - const emptyCells = page.root.shadowRoot.querySelectorAll("[data-day='0 1 2023']"); - expect(emptyCells.length).toEqual(6); - - const lastDisabledMinDate = page.root.shadowRoot.querySelector("[data-day='8 1 2023']"); - expect(lastDisabledMinDate).toHaveAttribute('disabled'); - expect(lastDisabledMinDate).toEqualAttribute('aria-disabled', 'true'); - const firstNotDisabledMinDate = page.root.shadowRoot.querySelector("[data-day='9 1 2023']"); - expect(firstNotDisabledMinDate).not.toHaveAttribute('disabled'); - expect(firstNotDisabledMinDate).toEqualAttribute('aria-disabled', 'false'); - - const lastNotDisabledMaxDate = page.root.shadowRoot.querySelector("[data-day='29 1 2023']"); - expect(lastNotDisabledMaxDate).not.toHaveAttribute('disabled'); - expect(lastNotDisabledMaxDate).toEqualAttribute('aria-disabled', 'false'); - const firstDisabledMaxDate = page.root.shadowRoot.querySelector("[data-day='30 1 2023']"); - expect(firstDisabledMaxDate).toHaveAttribute('disabled'); - expect(firstDisabledMaxDate).toEqualAttribute('aria-disabled', 'true'); - }); -}); diff --git a/src/components/sbb-calendar/sbb-calendar.stories.tsx b/src/components/sbb-calendar/sbb-calendar.stories.tsx deleted file mode 100644 index 4d5a93b182..0000000000 --- a/src/components/sbb-calendar/sbb-calendar.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** @jsx h */ -import events from './sbb-calendar.events'; -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import isChromatic from 'chromatic'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const getCalendarAttr = (min, max): Record => { - const attr: Record = {}; - if (min) { - attr.min = new Date(min); - } - if (max) { - attr.max = new Date(max); - } - return attr; -}; - -const Template = ({ min, max, selectedDate, dateFilter, ...args }): JSX.Element => ( - { - calendarRef.dateFilter = dateFilter; - }} - > -); - -const TemplateDynamicWidth = ({ min, max, selectedDate, dateFilter, ...args }): JSX.Element => ( - { - calendarRef.dateFilter = dateFilter; - }} - > -); - -const TemplateFilterFunction = ({ dateFilter, ...args }): JSX.Element => ( - { - calendarRef.dateFilter = dateFilter; - }} - {...args} - > -); - -const wide: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Calendar', - }, -}; - -const selectedDate: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Calendar', - }, -}; - -const min: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Date filters', - }, -}; - -const max: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Date filters', - }, -}; - -const dataNow: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Testing', - }, -}; - -const filterFunctions = [ - () => true, - (d) => d.getDay() !== 6 && d.getDay() !== 0, - (d) => d.getDate() % 2 === 1, - (d) => d.getFullYear() % 2 === 0, - (d) => d.getMonth() > 6, -]; -const dateFilter: InputType = { - options: Object.keys(filterFunctions), - mapping: filterFunctions, - control: { - type: 'select', - labels: { - 0: 'No dateFilter function.', - 1: 'The dateFilter function includes only working days.', - 2: 'The dateFilter function excludes even days.', - 3: 'The dateFilter function excludes odd years.', - 4: 'The dateFilter function excludes months from January to July', - }, - }, - table: { - category: 'Date filters', - }, -}; - -const defaultArgTypes: ArgTypes = { - wide, - selectedDate, - min, - max, - dateFilter, - 'data-now': dataNow, -}; - -const today = new Date(); -today.setDate(today.getDate() >= 15 ? 8 : 18); - -const defaultArgs: Args = { - wide: false, - selectedDate: isChromatic() ? new Date(2023, 0, 20) : today, - dataNow: isChromatic() ? new Date(2023, 0, 12, 0, 0, 0).valueOf() : undefined, -}; - -export const Calendar: StoryObj = { - render: Template, - argTypes: { ...defaultArgTypes }, - args: { ...defaultArgs }, -}; - -export const CalendarWithMinAndMax: StoryObj = { - render: Template, - argTypes: { ...defaultArgTypes }, - args: { - ...defaultArgs, - min: isChromatic() ? new Date(2023, 0, 9) : new Date(today.getFullYear(), today.getMonth(), 5), - max: isChromatic() - ? new Date(2023, 0, 29) - : new Date(today.getFullYear(), today.getMonth(), 29), - }, -}; - -export const CalendarWide: StoryObj = { - render: Template, - argTypes: { ...defaultArgTypes }, - args: { ...defaultArgs, wide: true }, -}; - -export const CalendarFilterFunction: StoryObj = { - render: TemplateFilterFunction, - argTypes: { ...defaultArgTypes, dateFilter }, - args: { - ...defaultArgs, - // Workaround: On Chromatic mapping functions do not work, so assign function directly. - // TODO: Check if condition can be removed after refactoring Chromatic generation @kyubisation - dateFilter: isChromatic() ? filterFunctions[1] : dateFilter.options[2], - }, -}; - -export const CalendarDynamicWidth: StoryObj = { - render: TemplateDynamicWidth, - argTypes: { ...defaultArgTypes }, - args: { ...defaultArgs }, -}; - -export const CalendarWideDynamicWidth: StoryObj = { - render: TemplateDynamicWidth, - argTypes: { ...defaultArgTypes }, - args: { ...defaultArgs, wide: true }, -}; - -const meta: Meta = { - excludeStories: /.*DynamicWidth$/, - decorators: [withActions as Decorator], - parameters: { - actions: { - handles: [events.dateSelected], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-datepicker/sbb-calendar', -}; - -export default meta; diff --git a/src/components/sbb-calendar/sbb-calendar.tsx b/src/components/sbb-calendar/sbb-calendar.tsx deleted file mode 100644 index 53677ffc42..0000000000 --- a/src/components/sbb-calendar/sbb-calendar.tsx +++ /dev/null @@ -1,1207 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - Fragment, - h, - Host, - JSX, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; -import { isArrowKeyOrPageKeysPressed, sbbInputModalityDetector } from '../../global/a11y'; -import { - DateAdapter, - DAYS_PER_ROW, - MONTHS_PER_ROW, - NativeDateAdapter, - YEARS_PER_PAGE, - YEARS_PER_ROW, -} from '../../global/datetime'; -import { isBreakpoint, toggleDatasetEntry } from '../../global/dom'; -import { - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { - i18nCalendarDateSelection, - i18nNextMonth, - i18nNextYear, - i18nNextYearRange, - i18nPreviousMonth, - i18nPreviousYear, - i18nPreviousYearRange, - i18nYearMonthSelection, -} from '../../global/i18n'; -import { CalendarView, Day, Month, Weekday } from './sbb-calendar.custom'; - -/** - * In keyboard navigation, the cell's index and the element's index in its month / year batch must be distinguished; - * this is necessary because the navigation in the vertical direction using some keys is restricted to a single month for days, - * and to a single range of 24 years for years. While the latter is easy to understand, the cell's index is the index - * of the element in the array of all the rendered cells. In non-wide mode, there's no issue because the element index - * is basically the cell's index plus 1 (element with index 0 displays the 1st of month, element with index 1 displays the 2nd and so on). - * In wide mode, the cell's index can go from 0 to 47 for years (two batches of 24 years), and from 0 to a maximum of 61 for days - * (two consecutive months of 31 days, as July-August or December-January), depending on the lengths of the rendered months; - * the element index instead goes from 0 to a max value of 24 for years and 31 for days. - * Moreover, in day view, the index of the first day of the second rendered month and the index of the last rendered day - * can also vary depending on which months are rendered; for July-August they are equals to 31 and 61, while for February-March they are 28 and 58. - * So, some additional parameters are needed, besides the cell's index, to correctly calculate the element to navigate to. - */ -interface CalendarKeyboardNavigationParameters { - /** The element index within its month (or year range). */ - elementIndexForWideMode: number; - /** In wide mode, the index of the first element in the second panel, or, alternatively, the number of elements in the first panel. */ - offsetForWideMode: number; - /** The index of the last element within the element's month (or year range). */ - lastElementIndexForWideMode: number; - /** The number of cells displayed in a single row, depending on the rendered view. */ - verticalOffset: number; -} - -@Component({ - shadow: true, - styleUrl: 'sbb-calendar.scss', - tag: 'sbb-calendar', -}) -export class SbbCalendar implements ComponentInterface { - /** If set to true, two months are displayed. */ - @Prop() public wide = false; - - /** The minimum valid date. Takes Date Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). */ - @Prop() public min: Date | string | number; - - /** The maximum valid date. Takes Date Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). */ - @Prop() public max: Date | string | number; - - /** A function used to filter out dates. */ - @Prop() public dateFilter: (date: Date | null) => boolean = () => true; - - /** The selected date. Takes Date Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). */ - @Prop() public selectedDate: Date | string | number; - - /** Event emitted on date selection. */ - @Event({ eventName: 'date-selected' }) public dateSelected: EventEmitter; - - /** The currently active date. */ - @State() private _activeDate: Date; - - /** The selected date as ISOString. */ - @State() private _selected: string; - - /** The current wide property considering property value and breakpoints. From zero to small `wide` has always to be false. */ - @State() private _wide: boolean; - - /** Minimum value converted to date. */ - @State() private _min: Date; - - /** Maximum value converted to date. */ - @State() private _max: Date; - - @State() private _calendarView: CalendarView = 'day'; - - @State() private _currentLanguage = documentLanguage(); - - @Element() private _element!: HTMLElement; - - private _nextCalendarView: CalendarView = 'day'; - - private _dateAdapter: DateAdapter = new NativeDateAdapter(); - - /** A list of days, in two formats (long and single char). */ - private _weekdays: Weekday[]; - - /** Grid of calendar cells representing the dates of the month. */ - private _weeks: Day[][]; - - /** Grid of calendar cells representing months. */ - private _months: Month[][]; - - /** Grid of calendar cells representing years. */ - private _years: number[][]; - - /** Grid of calendar cells representing years for the wide view. */ - private _nextMonthYears: number[][]; - - /** Grid of calendar cells representing the dates of the next month. */ - private _nextMonthWeeks: Day[][]; - - /** An array containing all the month names in the current language. */ - private _monthNames: string[] = this._dateAdapter.getMonthNames('long'); - - /** A list of buttons corresponding to days, months or years depending on the view. */ - private get _cells(): HTMLButtonElement[] { - return Array.from( - this._element.shadowRoot.querySelectorAll('.sbb-calendar__cell'), - ) as HTMLButtonElement[]; - } - - private _calendarController: AbortController; - - /** The chosen year in the year selection view. */ - private _chosenYear: number; - - /** The chosen month in the year selection view. */ - private _chosenMonth: number; - - /** Whether the focus should be reset on focusCell. */ - private _resetFocus = false; - - private _handlerRepository = new HandlerRepository( - this._element as HTMLElement, - languageChangeHandlerAspect((l) => { - this._currentLanguage = l; - this._monthNames = this._dateAdapter.getMonthNames('long'); - this._months = this._createMonthRows(); - }), - ); - - @Watch('min') - private _convertMinDate(newMin: Date | string | number): void { - this._min = this._dateAdapter.deserializeDate(newMin); - } - - @Watch('max') - private _convertMaxDate(newMax: Date | string | number): void { - this._max = this._dateAdapter.deserializeDate(newMax); - } - - /** Sets the selected date. */ - @Watch('selectedDate') - private _setSelectedDate(selectedDate: Date | null): void { - if ( - !!selectedDate && - (!this._isDayInRange(this._dateAdapter.getISOString(selectedDate)) || - this.dateFilter(selectedDate)) - ) { - this._selected = this._dateAdapter.getISOString(selectedDate); - } else { - this._selected = undefined; - } - } - - /** Resets the active month according to the new state of the calendar. */ - @Method() - @Watch('wide') - public async resetPosition(): Promise { - if (this._calendarView !== 'day') { - this._resetToDayView(); - } - this._setDates(); - this._init(); - } - - public connectedCallback(): void { - this._element.focus = () => { - this._resetFocus = true; - this._focusCell(); - }; - this._handlerRepository.connect(); - this._calendarController = new AbortController(); - window.addEventListener('resize', () => this._init(), { - passive: true, - signal: this._calendarController.signal, - }); - this._convertMinDate(this.min); - this._convertMaxDate(this.max); - this._setDates(); - this._init(); - } - - public componentDidRender(): void { - // The calendar needs to calculate tab-indexes on first render, - // and every time a date is selected or the month view changes. - this._setTabIndex(); - // When changing view to year/month, the tabindex is changed, but the focused element is not, - // so if the navigation is done via keyboard, there's the need - // to call the `_focusCell()` method explicitly to correctly set the focus. - if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { - this._focusCell(); - } - } - - public disconnectedCallback(): void { - this._calendarController.abort(); - this._handlerRepository.disconnect(); - } - - /** Initializes the component. */ - private _init(): void { - this._wide = isBreakpoint('medium') && this.wide; - toggleDatasetEntry(this._element, 'wide', this._wide); - this._setWeekdays(); - this._weeks = this._createWeekRows( - this._dateAdapter.getMonth(this._activeDate), - this._dateAdapter.getYear(this._activeDate), - ); - this._nextMonthWeeks = [[]]; - this._months = this._createMonthRows(); - this._nextMonthYears = [[]]; - this._years = this._createYearRows(); - if (this._wide) { - const nextMonthDate = this._dateAdapter.addCalendarMonths(this._activeDate, 1); - this._nextMonthWeeks = this._createWeekRows( - this._dateAdapter.getMonth(nextMonthDate), - this._dateAdapter.getYear(nextMonthDate), - ); - this._nextMonthYears = this._createYearRows(YEARS_PER_PAGE); - } - } - - /** Focuses on a day cell prioritizing the selected day, the current day, and lastly, the first selectable day. */ - private _focusCell(): void { - if (this._resetFocus) { - this._getFirstFocusable()?.focus(); - this._resetFocus = false; - } - } - - /** Sets the date variables. */ - private _setDates(): void { - const selectedDate: Date = this._dateAdapter.deserializeDate(this.selectedDate); - this._activeDate = selectedDate ?? this._now(); - this._setSelectedDate(selectedDate); - } - - /** Creates the array of weekdays. */ - private _setWeekdays(): void { - const narrowWeekdays: string[] = this._dateAdapter.getDayOfWeekNames('narrow'); - const longWeekdays: string[] = this._dateAdapter.getDayOfWeekNames('long'); - const weekdays: Weekday[] = longWeekdays.map((long: string, i: number) => ({ - long, - narrow: narrowWeekdays[i], - })); - - // Rotates the labels for days of the week based on the configured first day of the week. - const firstDayOfWeek: number = this._dateAdapter.getFirstDayOfWeek(); - this._weekdays = weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); - } - - /** Creates the rows for each week. */ - private _createWeekRows(month: number, year: number): Day[][] { - const daysInMonth: number = this._dateAdapter.getNumDaysInMonth(year, month); - const dateNames: string[] = this._dateAdapter.getDateNames(); - const weeks: Day[][] = [[]]; - const weekOffset = this._dateAdapter.getFirstWeekOffset(year, month); - for (let i = 0, cell = weekOffset; i < daysInMonth; i++, cell++) { - if (cell === DAYS_PER_ROW) { - weeks.push([]); - cell = 0; - } - const date = this._dateAdapter.createDate(year, month, i + 1); - weeks[weeks.length - 1].push({ - value: this._dateAdapter.getISOString(date), - dayValue: dateNames[i], - monthValue: String(month + 1), - yearValue: String(year), - }); - } - return weeks; - } - - /** Creates the rows for the month selection view. */ - private _createMonthRows(): Month[][] { - const shortNames: string[] = this._dateAdapter.getMonthNames('short'); - const months: Month[] = new Array(12).fill(null).map( - (_, i: number): Month => ({ - value: shortNames[i], - longValue: this._monthNames[i], - monthValue: i, - }), - ); - const rows: number = 12 / MONTHS_PER_ROW; - const monthArray: Month[][] = []; - for (let i: number = 0; i < rows; i++) { - monthArray.push(months.slice(MONTHS_PER_ROW * i, MONTHS_PER_ROW * (i + 1))); - } - return monthArray; - } - - /** Creates the rows for the year selection view. */ - private _createYearRows(offset: number = 0): number[][] { - const startValueYearView: number = this._dateAdapter.getStartValueYearView( - this._activeDate, - this._min, - this._max, - ); - const allYears: number[] = new Array(YEARS_PER_PAGE) - .fill(0) - .map((_, i: number) => startValueYearView + offset + i); - const rows: number = YEARS_PER_PAGE / YEARS_PER_ROW; - const yearArray: number[][] = []; - for (let i: number = 0; i < rows; i++) { - yearArray.push(allYears.slice(YEARS_PER_ROW * i, YEARS_PER_ROW * (i + 1))); - } - return yearArray; - } - - /** Checks if date is within the min-max range. */ - private _isDayInRange(date: string): boolean { - if (!this._min && !this._max) { - return true; - } - const isBeforeMin: boolean = - this._dateAdapter.isValid(this._min) && - this._dateAdapter.compareDate(this._min, this._dateAdapter.createDateFromISOString(date)) > 0; - const isAfterMax: boolean = - this._dateAdapter.isValid(this._max) && - this._dateAdapter.compareDate(this._max, this._dateAdapter.createDateFromISOString(date)) < 0; - return !(isBeforeMin || isAfterMax); - } - - private _isMonthInRange(month: number): boolean { - if (!this._min && !this._max) { - return true; - } - const isBeforeMin: boolean = - this._dateAdapter.isValid(this._min) && - this._dateAdapter.getYear(this._min) >= this._chosenYear && - this._dateAdapter.getMonth(this._min) > month; - const isAfterMax: boolean = - this._dateAdapter.isValid(this._max) && - this._dateAdapter.getYear(this._max) <= this._chosenYear && - this._dateAdapter.getMonth(this._max) < month; - return !(isBeforeMin || isAfterMax); - } - - private _isYearInRange(year: number): boolean { - if (!this._min && !this._max) { - return true; - } - const isBeforeMin: boolean = - this._dateAdapter.isValid(this._min) && this._dateAdapter.getYear(this._min) > year; - const isAfterMax: boolean = - this._dateAdapter.isValid(this._max) && this._dateAdapter.getYear(this._max) < year; - return !(isBeforeMin || isAfterMax); - } - - // Implementation adapted from https://github.com/angular/components/blob/main/src/material/datepicker/year-view.ts#L366 - private _isMonthFilteredOut(month: number): boolean { - if (!this.dateFilter) { - return true; - } - - const firstOfMonth = this._dateAdapter.createDate(this._chosenYear, month, 1); - for ( - let date: Date = firstOfMonth; - this._dateAdapter.getMonth(date) == month; - date = this._dateAdapter.addCalendarDays(date, 1) - ) { - if (this.dateFilter(date)) { - return true; - } - } - - return false; - } - - // Implementation adapted from https://github.com/angular/components/blob/main/src/material/datepicker/multi-year-view.ts#L351 - private _isYearFilteredOut(year: number): boolean { - if (!this.dateFilter) { - return true; - } - - const firstOfYear = this._dateAdapter.createDate(year, 0, 1); - for ( - let date: Date = firstOfYear; - this._dateAdapter.getYear(date) == year; - date = this._dateAdapter.addCalendarDays(date, 1) - ) { - if (this.dateFilter(date)) { - return true; - } - } - - return false; - } - - /** Emits the selected date and sets it internally. */ - private _selectDate(day: string): void { - this._chosenMonth = undefined; - this._chosenYear = undefined; - if (this._selected !== day) { - this._selected = day; - this.dateSelected.emit(this._dateAdapter.createDateFromISOString(day)); - } - } - - private _assignActiveDate(date: Date): void { - if (this._min && this._dateAdapter.compareDate(this._min, date) > 0) { - this._activeDate = this._min; - return; - } - if (this._max && this._dateAdapter.compareDate(this._max, date) < 0) { - this._activeDate = this._max; - return; - } - this._activeDate = date; - } - - /** Goes to the month identified by the shift. */ - private _goToDifferentMonth(months: number): void { - this._assignActiveDate(this._dateAdapter.addCalendarMonths(this._activeDate, months)); - this._init(); - } - - private _goToDifferentYear(years: number): void { - this._chosenYear += years; - // Can't use `_assignActiveDate(...)` here, because it will set it to min/max value if argument is out of range - this._activeDate = new Date( - this._chosenYear, - this._dateAdapter.getMonth(this._activeDate), - this._dateAdapter.getDate(this._activeDate), - ); - this._init(); - } - - private _goToDifferentYearRange(years: number): void { - this._assignActiveDate(this._dateAdapter.addCalendarYears(this._activeDate, years)); - this._init(); - } - - private _prevDisabled(prevDate: Date): boolean { - if (!this._min) { - return false; - } - return this._dateAdapter.compareDate(prevDate, this._min) < 0; - } - - private _nextDisabled(nextDate: Date): boolean { - if (!this._max) { - return false; - } - return this._dateAdapter.compareDate(nextDate, this._max) > 0; - } - - /** Checks if the "previous month" button should be disabled. */ - private _previousMonthDisabled(): boolean { - const prevMonth: Date = this._dateAdapter.clone(this._activeDate); - prevMonth.setDate(0); - return this._prevDisabled(prevMonth); - } - - /** Checks if the "next month" button should be disabled. */ - private _nextMonthDisabled(): boolean { - const nextMonth: Date = this._dateAdapter.addCalendarMonths( - this._activeDate, - this._wide ? 2 : 1, - ); - nextMonth.setDate(1); - return this._nextDisabled(nextMonth); - } - - private _previousYearDisabled(): boolean { - const prevYear: Date = this._dateAdapter.clone(this._activeDate); - prevYear.setFullYear(this._dateAdapter.getYear(this._activeDate) - 1, 11, 31); - return this._prevDisabled(prevYear); - } - - private _nextYearDisabled(): boolean { - const nextYear: Date = this._dateAdapter.clone(this._activeDate); - nextYear.setFullYear(this._dateAdapter.getYear(this._activeDate) + 1, 0, 1); - return this._nextDisabled(nextYear); - } - - private _previousYearRangeDisabled(): boolean { - const prevYear: Date = this._dateAdapter.clone(this._activeDate); - prevYear.setFullYear(this._years.flat()[0] - 1, 11, 31); - return this._prevDisabled(prevYear); - } - - private _nextYearRangeDisabled(): boolean { - const lastYearArray: number[] = ( - isBreakpoint('medium') && this.wide ? this._nextMonthYears : this._years - ).flat(); - const lastYear: number = lastYearArray[lastYearArray.length - 1]; - const nextYear: Date = this._dateAdapter.clone(this._activeDate); - nextYear.setFullYear(lastYear + 1, 0, 1); - return this._nextDisabled(nextYear); - } - - private _handleTableBlur(eventTarget: HTMLElement): void { - if (eventTarget?.tagName !== 'BUTTON') { - this._setTabIndex(); - } - } - - private _setTabIndex(): void { - Array.from( - this._element.shadowRoot.querySelectorAll('.sbb-calendar__cell[tabindex="0"]'), - ).forEach((day) => ((day as HTMLElement).tabIndex = -1)); - const firstFocusable = this._getFirstFocusable(); - if (firstFocusable) { - firstFocusable.tabIndex = 0; - } - } - - private _getFirstFocusable(): HTMLButtonElement { - const active = this._selected ? new Date(this._selected) : this._now(); - let firstFocusable = - this._element.shadowRoot.querySelector('.sbb-calendar__selected') ?? - this._element.shadowRoot.querySelector( - `[data-day="${active.getDate()} ${ - this._activeDate.getMonth() + 1 - } ${this._activeDate.getFullYear()}"]`, - ) ?? - this._element.shadowRoot.querySelector(`[data-month="${this._activeDate.getMonth()}"]`) ?? - this._element.shadowRoot.querySelector(`[data-year="${this._activeDate.getFullYear()}"]`); - if (!firstFocusable || (firstFocusable as HTMLButtonElement)?.disabled) { - firstFocusable = this._element.shadowRoot.querySelector( - '.sbb-calendar__cell:not([disabled])', - ); - } - return (firstFocusable as HTMLButtonElement) || null; - } - - private _handleKeyboardEvent(event, day?: Day): void { - if (isArrowKeyOrPageKeysPressed(event)) { - event.preventDefault(); - } - // Gets the currently rendered table's cell; - // they could be days, months or years based on the current selection view. - // If `wide` is true, years are doubled in number and days are (roughly) doubled too, affecting the `index` calculation. - const cells: HTMLButtonElement[] = this._cells; - const index: number = cells.findIndex((e: HTMLButtonElement) => e === event.target); - const nextEl: HTMLButtonElement = this._navigateByKeyboard(event, index, cells, day); - const activeEl: HTMLButtonElement = this._element.shadowRoot.activeElement as HTMLButtonElement; - if (nextEl !== activeEl) { - (nextEl as HTMLButtonElement).tabIndex = 0; - nextEl?.focus(); - (activeEl as HTMLButtonElement).tabIndex = -1; - } - } - - /** - * Gets the index of the element to move to, based on a list of elements (which can be potentially disabled), - * the keyboard input and the position of the current element in the list. - * In the day view, the `day?: Day` parameter is mandatory for calculation, - * while in month and year view it's not due to the fixed amount of rendered cells. - */ - private _navigateByKeyboard( - evt: KeyboardEvent, - index: number, - cells: HTMLButtonElement[], - day?: Day, - ): HTMLButtonElement { - const { - elementIndexForWideMode, - offsetForWideMode, - lastElementIndexForWideMode, - verticalOffset, - }: CalendarKeyboardNavigationParameters = this._calculateParametersForKeyboardNavigation( - cells, - index, - day, - ); - - switch (evt.key) { - case 'ArrowUp': - return this._findNext(cells, index, -verticalOffset); - case 'ArrowDown': - return this._findNext(cells, index, verticalOffset); - case 'ArrowLeft': - return this._findNext(cells, index, -1); - case 'ArrowRight': - return this._findNext(cells, index, 1); - case 'Home': - return this._findFirst(cells, offsetForWideMode); - case 'PageUp': - return this._findFirstOnColumn( - cells, - elementIndexForWideMode, - offsetForWideMode, - verticalOffset, - ); - case 'PageDown': - return this._findLastOnColumn(cells, index, lastElementIndexForWideMode, verticalOffset); - case 'End': - return this._findLast(cells, lastElementIndexForWideMode - 1); - default: - return cells[index]; - } - } - - /** - * Calculates the parameter needed in keyboard navigation. - * Since three views are now available, the function creates and returns the correct parameters for each of them - * by considering the number of cells per each row and the correction for the wide mode. - * @param cells The array of rendered table cells; they are buttons that can represent days, months or years. - * @param index The starting element's index in the cell array. - * @param day (optional) Only in the day view, the day represented by the starting cell. - */ - private _calculateParametersForKeyboardNavigation( - cells: HTMLButtonElement[], - index: number, - day?: Day, - ): CalendarKeyboardNavigationParameters { - switch (this._calendarView) { - case 'day': { - const indexInView = +day.dayValue - 1; - return { - verticalOffset: DAYS_PER_ROW, - elementIndexForWideMode: indexInView, - offsetForWideMode: index - indexInView, - lastElementIndexForWideMode: - index === indexInView - ? this._dateAdapter.getNumDaysInMonth(+day.yearValue, +day.monthValue - 1) - : cells.length, - }; - } - // Month view is not dependent from `wide` value, so some parameters are fixed. - case 'month': { - return { - verticalOffset: MONTHS_PER_ROW, - elementIndexForWideMode: index, - offsetForWideMode: 0, - lastElementIndexForWideMode: 12, - }; - } - case 'year': { - const offset: number = Math.trunc(index / YEARS_PER_PAGE) * YEARS_PER_PAGE; - const indexInView: number = offset === 0 ? index : index - YEARS_PER_PAGE; - return { - verticalOffset: YEARS_PER_ROW, - elementIndexForWideMode: indexInView, - offsetForWideMode: index - indexInView, - lastElementIndexForWideMode: offset === 0 ? YEARS_PER_PAGE : YEARS_PER_PAGE * 2, - }; - } - } - } - - /** - * Gets the next element of the provided array starting from `index` by adding `delta`. - * If the found element is disabled, it continues adding `delta` until it finds an enabled one in the array bounds. - */ - private _findNext(days: HTMLButtonElement[], index: number, delta: number): HTMLButtonElement { - let nextIndex = index + delta; - while (nextIndex < days.length && days[nextIndex]?.disabled) { - nextIndex += delta; - } - return days[nextIndex] ?? days[index]; - } - - /** Find the first enabled element in the provided array. */ - private _findFirst(days: HTMLButtonElement[], firstOfCurrentMonth: number): HTMLButtonElement { - return !days[firstOfCurrentMonth].disabled - ? days[firstOfCurrentMonth] - : this._findNext(days, firstOfCurrentMonth, 1); - } - - /** Find the last enabled element in the provided array. */ - private _findLast(days: HTMLButtonElement[], lastOfCurrentMonth: number): HTMLButtonElement { - return !days[lastOfCurrentMonth].disabled - ? days[lastOfCurrentMonth] - : this._findNext(days, lastOfCurrentMonth, -1); - } - - /** Find the first enabled element in the same column of the provided array. */ - private _findFirstOnColumn( - days: HTMLButtonElement[], - index: number, - offset: number, - verticalOffset: number, - ): HTMLButtonElement { - const nextIndex = (index % verticalOffset) + offset; - return !days[nextIndex].disabled - ? days[nextIndex] - : this._findNext(days, nextIndex, verticalOffset); - } - - /** Find the last enabled element in the same column of the provided array. */ - private _findLastOnColumn( - days: HTMLButtonElement[], - index: number, - offset: number, - verticalOffset: number, - ): HTMLButtonElement { - const nextIndex = index + Math.trunc((offset - index - 1) / verticalOffset) * verticalOffset; - return !days[nextIndex].disabled - ? days[nextIndex] - : this._findNext(days, nextIndex, -verticalOffset); - } - - private _now(): Date { - if (this._hasDataNow()) { - const today = new Date(+this._element.dataset?.now); - today.setHours(0, 0, 0, 0); - return today; - } - return this._dateAdapter.today(); - } - - private _hasDataNow(): boolean { - const dataNow = +this._element.dataset?.now; - return !isNaN(dataNow); - } - - private _resetToDayView(): void { - this._resetFocus = true; - this._activeDate = this._dateAdapter.deserializeDate(this.selectedDate) ?? this._now(); - this._chosenYear = undefined; - this._chosenMonth = undefined; - this._nextCalendarView = 'day'; - this._removeTable(); - } - - /** Render the view for the day selection. */ - private _renderDayView(): JSX.Element { - const nextMonthActiveDate = this._wide - ? this._dateAdapter.addCalendarMonths(this._activeDate, 1) - : undefined; - return ( - -
      - {this._getArrow( - 'left', - () => this._goToDifferentMonth(-1), - i18nPreviousMonth[this._currentLanguage], - this._previousMonthDisabled(), - )} -
      - {this._createLabelForDayView(this._activeDate)} - {this._wide && this._createLabelForDayView(nextMonthActiveDate)} - - {this._createAriaLabelForDayView(this._activeDate, nextMonthActiveDate)} - -
      - {this._getArrow( - 'right', - () => this._goToDifferentMonth(1), - i18nNextMonth[this._currentLanguage], - this._nextMonthDisabled(), - )} -
      -
      - {this._createDayTable(this._weeks)} - {this._wide && this._createDayTable(this._nextMonthWeeks)} -
      -
      - ); - } - - /** Creates the label with the month for the daily view. */ - private _createLabelForDayView(d: Date): JSX.Element { - const monthLabel = `${ - this._monthNames[this._dateAdapter.getMonth(d)] - } ${this._dateAdapter.getYear(d)}`; - return ( - - ); - } - - /** Creates the aria-label for the daily view. */ - private _createAriaLabelForDayView(...dates: Date[]): string { - let monthLabel = ''; - for (const d of dates) { - if (d) { - monthLabel += `${ - this._monthNames[this._dateAdapter.getMonth(d)] - } ${this._dateAdapter.getYear(d)} `; - } - } - return monthLabel; - } - - /** Creates the calendar table for the daily view. */ - private _createDayTable(weeks: Day[][]): JSX.Element { - return ( - this._handleTableBlur(event.relatedTarget as HTMLElement)} - onAnimationEnd={(e) => this._tableAnimationEnd(e)} - > - - {this._createDayTableHeader()} - - {this._createDayTableBody(weeks)} -
      - ); - } - - /** Creates the table header with the month header cells. */ - private _createDayTableHeader(): JSX.Element { - return this._weekdays.map((day: Weekday) => ( - - {day.long} - - - )); - } - - /** Creates the table body with the day cells. For the first row, it also considers the possible day's offset. */ - private _createDayTableBody(weeks: Day[][]): JSX.Element { - const today: string = this._dateAdapter.getISOString(this._now()); - return weeks.map((week: Day[], rowIndex: number) => { - const firstRowOffset: number = DAYS_PER_ROW - week.length; - if (rowIndex === 0 && firstRowOffset) { - return ( - - {[...Array(firstRowOffset).keys()].map(() => ( - - ))} - {this._createDayCells(week, today)} - - ); - } - return {this._createDayCells(week, today)}; - }); - } - - /** Creates the cells for the daily view. */ - private _createDayCells(week: Day[], today: string): JSX.Element { - return week.map((day: Day) => { - const isOutOfRange = !this._isDayInRange(day.value); - const isFilteredOut = !this.dateFilter(this._dateAdapter.createDateFromISOString(day.value)); - const selected: boolean = !!this._selected && day.value === this._selected; - const dayValue = `${day.dayValue} ${day.monthValue} ${day.yearValue}`; - const isToday = day.value === today; - return ( - - - - ); - }); - } - - /** Render the view for the month selection. */ - private _renderMonthView(): JSX.Element { - return ( - -
      - {this._getArrow( - 'left', - () => this._goToDifferentYear(-1), - i18nPreviousYear[this._currentLanguage], - this._previousYearDisabled(), - )} -
      {this._createLabelForMonthView()}
      - {this._getArrow( - 'right', - () => this._goToDifferentYear(1), - i18nNextYear[this._currentLanguage], - this._nextYearDisabled(), - )} -
      -
      - {this._createMonthTable(this._months, this._chosenYear)} - {this._wide && this._createMonthTable(this._months, this._chosenYear + 1, true)} -
      -
      - ); - } - - /** Creates the label with the year for the monthly view. */ - private _createLabelForMonthView(): JSX.Element { - return ( - - - - {this._chosenYear} - - - ); - } - - /** Creates the table for the month selection view. */ - private _createMonthTable(months: Month[][], year: number, shiftRight = false): JSX.Element { - return ( - this._tableAnimationEnd(e)}> - {this._wide && ( - - - - - - )} - - {months.map((row: Month[]) => ( - - {row.map((month: Month) => { - const isOutOfRange = !this._isMonthInRange(month.monthValue); - const isFilteredOut = !this._isMonthFilteredOut(month.monthValue); - const selectedMonth = this._selected - ? this._dateAdapter.getMonth(new Date(this._selected)) - : undefined; - const selectedYear = this._selected - ? this._dateAdapter.getYear(new Date(this._selected)) - : undefined; - const selected: boolean = - !!this._selected && year === selectedYear && month.monthValue === selectedMonth; - - const isCurrentMonth = - year === this._dateAdapter.getYear(this._now()) && - this._dateAdapter.getMonth(this._now()) === month.monthValue; - - return ( - - ); - })} - - ))} - -
      - {year} -
      - -
      - ); - } - - /** Select the month and change the view to day selection. */ - private _onMonthSelection(month: number, year: number, shiftRight: boolean): void { - this._chosenMonth = shiftRight ? month - 1 : month; - this._assignActiveDate(new Date(year, this._chosenMonth, this._activeDate.getDate())); - this._init(); - this._resetFocus = true; - this._nextCalendarView = 'day'; - this._removeTable(); - } - - /** Render the view for the year selection. */ - private _renderYearView(): JSX.Element { - return ( - -
      - {this._getArrow( - 'left', - () => this._goToDifferentYearRange(-YEARS_PER_PAGE), - i18nPreviousYearRange(YEARS_PER_PAGE)[this._currentLanguage], - this._previousYearRangeDisabled(), - )} -
      {this._createLabelForYearView()}
      - {this._getArrow( - 'right', - () => this._goToDifferentYearRange(YEARS_PER_PAGE), - i18nNextYearRange(YEARS_PER_PAGE)[this._currentLanguage], - this._nextYearRangeDisabled(), - )} -
      -
      - {this._createYearTable(this._years)} - {this._wide && this._createYearTable(this._nextMonthYears, true)} -
      -
      - ); - } - - /** Creates the button arrow for all the views. */ - private _getArrow( - direction: 'left' | 'right', - click: () => void, - ariaLabel: string, - disabled: boolean, - ): JSX.Element { - return ( - - ); - } - - /** Creates the label with the year range for the yearly view. */ - private _createLabelForYearView(): JSX.Element { - const firstYear: number = this._years.flat()[0]; - const lastYearArray: number[] = ( - isBreakpoint('medium') && this.wide ? this._nextMonthYears : this._years - ).flat(); - const lastYear: number = lastYearArray[lastYearArray.length - 1]; - const yearLabel = `${firstYear} - ${lastYear}`; - return ( - - - - {yearLabel} - - - ); - } - - /** Creates the table for the year selection view. */ - private _createYearTable(years: number[][], shiftRight = false): JSX.Element { - return ( - this._tableAnimationEnd(e)}> - - {years.map((row: number[]) => ( - - {row.map((year: number) => { - const isOutOfRange = !this._isYearInRange(year); - const isFilteredOut = !this._isYearFilteredOut(year); - const selectedYear = this._selected - ? this._dateAdapter.getYear(new Date(this._selected)) - : undefined; - const selected: boolean = !!this._selected && year === selectedYear; - const isCurrentYear = this._dateAdapter.getYear(this._now()) === year; - return ( - - ); - })} - - ))} - -
      - -
      - ); - } - - /** Select the year and change the view to month selection. */ - private _onYearSelection(year: number, rightSide: boolean): void { - this._chosenYear = rightSide ? year - 1 : year; - this._assignActiveDate( - new Date(this._chosenYear, this._activeDate.getMonth(), this._activeDate.getDate()), - ); - this._resetFocus = true; - this._nextCalendarView = 'month'; - this._removeTable(); - } - - private get _getView(): JSX.Element { - switch (this._calendarView) { - case 'year': - return this._renderYearView(); - case 'month': - return this._renderMonthView(); - case 'day': - default: - return this._renderDayView(); - } - } - - private _tableAnimationEnd(event: AnimationEvent): void { - const table = event.target as HTMLElement; - if (event.animationName === 'hide') { - table.classList.remove('sbb-calendar__table-hide'); - this._calendarView = this._nextCalendarView; - table.classList.add('sbb-calendar__table-show'); - } else if (event.animationName === 'show') { - table.classList.remove('sbb-calendar__table-show'); - } - } - - private _removeTable(): void { - const table = this._element.shadowRoot.querySelectorAll('table'); - table.forEach((e) => e.classList.toggle('sbb-calendar__table-hide')); - return; - } - - public render(): JSX.Element { - return ( - -
      {this._getView}
      -
      - ); - } -} diff --git a/src/components/sbb-card-action/readme.md b/src/components/sbb-card-action/readme.md deleted file mode 100644 index 0134f442fd..0000000000 --- a/src/components/sbb-card-action/readme.md +++ /dev/null @@ -1,64 +0,0 @@ -The `sbb-card-action` is the component used to turn a `sbb-card` into an action. - -```html -Check all the wonderful trips available. -``` - -## Link / button properties - -As the [sbb-link](/docs/components-sbb-link--docs) and the [sbb-button](/docs/components-sbb-button--docs), -the component can be internally rendered as a button or as a link, -depending on the value of the `href` property, so the associated properties are available -(`href`, `target`, `rel` and `download` for link; `type`, `name`, `value` and `form` for button). - -## Accessibility - -It's **important** that a descriptive message is being slotted into the unnamed slot of `sbb-card-action` -as it is used for search engines and screen-reader users. - -```html -Buy a half-fare ticket now -``` - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | ---------- | ------------------------------------------------------------------------------- | --------------------------------- | ----------- | -| `active` | `active` | Whether the card is active. | `boolean` | `false` | -| `download` | `download` | Whether the browser will show the download dialog on click. | `boolean` | `undefined` | -| `form` | `form` | The element to associate the button to it. | `string` | `undefined` | -| `href` | `href` | The href value you want to link to. | `string` | `undefined` | -| `name` | `name` | The name of the button. | `string` | `undefined` | -| `rel` | `rel` | The relationship of the linked URL as space-separated link types. | `string` | `undefined` | -| `target` | `target` | Where to display the linked URL. | `string` | `undefined` | -| `type` | `type` | Default behaviour of the button. | `"button" \| "reset" \| "submit"` | `undefined` | -| `value` | `value` | The value associated with button `name` when it's submitted with the form data. | `string` | `undefined` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------- | -| `"unnamed"` | Slot to render a descriptive label / title of the action (important!). This is relevant for SEO and screen readers. | - - -## Dependencies - -### Used by - - - [sbb-timetable-row](../sbb-timetable-row) - -### Graph -```mermaid -graph TD; - sbb-timetable-row --> sbb-card-action - style sbb-card-action fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-card-action/sbb-card-action.e2e.ts b/src/components/sbb-card-action/sbb-card-action.e2e.ts deleted file mode 100644 index e42cf94ae2..0000000000 --- a/src/components/sbb-card-action/sbb-card-action.e2e.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -// As tests don't work in specs at all (missing :is support in Jest), we moved all tests to e2e. - -describe('sbb-card-action', () => { - let element: E2EElement, page: E2EPage; - - it('should render an sbb-card-action as a link opening in a new window', async () => { - page = await newE2EPage(); - await page.setContent( - ` - - Follow me - Content text - `, - ); - const card = await page.find('sbb-card'); - - await page.waitForChanges(); - - expect(card).toHaveAttribute('data-has-action'); - expect(card).not.toHaveAttribute('data-has-active-action'); - expect(card).toEqualAttribute('data-action-role', 'link'); - expect(await card.find('sbb-card-action')).toEqualHtml(` - - - - - - . Link target opens in new window. - - - - Follow me - - `); - }); - - it('should render an sbb-card-action as a button which is active', async () => { - page = await newE2EPage(); - await page.setContent( - `Click meContent`, - ); - const card = await page.find('sbb-card'); - await page.waitForChanges(); - - expect(card).toHaveAttribute('data-has-action'); - expect(card).toHaveAttribute('data-has-active-action'); - expect(card).toEqualAttribute('data-action-role', 'button'); - expect(await card.find('sbb-card-action')).toEqualHtml(` - - - - - - - - - Click me - - `); - }); - - it('should correctly toggle active state', async () => { - page = await newE2EPage(); - await page.setContent( - `Click meContent`, - ); - const card = await page.find('sbb-card'); - await page.waitForChanges(); - - expect(card).not.toHaveAttribute('data-has-active-action'); - - (await card.find('sbb-card-action')).setAttribute('active', ''); - await page.waitForChanges(); - - expect(card).toHaveAttribute('data-has-active-action'); - }); - - it('should remove data properties from host', async () => { - page = await newE2EPage(); - await page.setContent( - `Click me`, - ); - const card = await page.find('sbb-card'); - - await page.waitForChanges(); - - expect(card).toHaveAttribute('data-has-action'); - expect(card).toHaveAttribute('data-has-active-action'); - expect(card).toEqualAttribute('data-action-role', 'button'); - - // Remove action from DOM - await page.evaluate(() => document.querySelector('sbb-card-action').remove()); - await page.waitForChanges(); - - expect(card).not.toHaveAttribute('data-has-action'); - expect(card).not.toHaveAttribute('data-has-active-action'); - expect(card).not.toEqualAttribute('data-action-role', 'button'); - }); - - it('should detect added button in slotted content to update focusable elements', async () => { - page = await newE2EPage(); - await page.setContent( - `Click me`, - ); - await page.waitForChanges(); - expect(await page.find('button')).toHaveAttribute('data-card-focusable'); - - // Add a second button in content - await page.evaluate(() => - document - .getElementById('content') - .insertBefore(document.createElement('button'), document.querySelector('button')), - ); - - // Both buttons should be marked as focusable - const buttons = await page.findAll('button'); - expect(buttons.length).toBe(2); - expect(buttons.every((el) => el.getAttribute('data-card-focusable') !== null)).toBe(true); - - // Remove all buttons - await page.evaluate(() => document.querySelectorAll('button').forEach((el) => el.remove())); - await page.waitForChanges(); - - // Card should not have marker anymore - expect((await page.findAll('button')).length).toBe(0); - }); - - it('should detect added second element of slot to update focusable elements', async () => { - page = await newE2EPage(); - await page.setContent( - `Click me`, - ); - await page.waitForChanges(); - - // Add a button to slot - await page.evaluate(() => - document - .querySelector('sbb-card') - .insertBefore(document.createElement('button'), document.getElementById('content')), - ); - await page.waitForChanges(); - - // Button should be marked as focusable - expect(await page.find('button')).toHaveAttribute('data-card-focusable'); - }); - - it('should detect focusable elements when action was added at later point', async () => { - page = await newE2EPage(); - await page.setContent(``); - await page.waitForChanges(); - - // Add a sbb-card-action - await page.evaluate(() => - document.querySelector('sbb-card').appendChild(document.createElement('sbb-card-action')), - ); - await page.waitForChanges(); - - // Button should be marked as focusable - expect(await page.find('button')).toHaveAttribute('data-card-focusable'); - }); - - describe('events', () => { - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent( - 'CardContent', - ); - - element = await page.find('sbb-card-action'); - }); - - it('dispatches event on click', async () => { - await page.waitForChanges(); - const changeSpy = await page.spyOnEvent('click'); - - await element.click(); - - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('should dispatch click event on pressing Enter', async () => { - const changeSpy = await page.spyOnEvent('click'); - await element.press('Enter'); - expect(changeSpy).toHaveReceivedEvent(); - }); - - it('should dispatch click event on pressing Space', async () => { - const changeSpy = await page.spyOnEvent('click'); - await element.press(' '); - expect(changeSpy).toHaveReceivedEvent(); - }); - - it('should dispatch click event on pressing Enter with href', async () => { - element.setAttribute('href', 'test'); - await page.waitForChanges(); - - const changeSpy = await page.spyOnEvent('click'); - await element.press('Enter'); - expect(changeSpy).toHaveReceivedEvent(); - }); - - it('should not dispatch click event on pressing Space with href', async () => { - element.setAttribute('href', 'test'); - await page.waitForChanges(); - - const changeSpy = await page.spyOnEvent('click'); - await element.press(' '); - expect(changeSpy).not.toHaveReceivedEvent(); - }); - - it('should receive focus', async () => { - await element.focus(); - await page.waitForChanges(); - - expect(await page.evaluate(() => document.activeElement.id)).toBe('focus-id'); - }); - }); -}); diff --git a/src/components/sbb-card-action/sbb-card-action.scss b/src/components/sbb-card-action/sbb-card-action.scss deleted file mode 100644 index a8e759963e..0000000000 --- a/src/components/sbb-card-action/sbb-card-action.scss +++ /dev/null @@ -1,30 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; - position: absolute; - inset: 0; -} - -.sbb-card-action { - display: block; - position: absolute; - inset: 0; - border-radius: var(--sbb-card-border-radius); - cursor: pointer; - - // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. - :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { - @include sbb.focus-outline; - } -} - -.sbb-card-action__label { - @include sbb.screen-reader-only; -} diff --git a/src/components/sbb-card-action/sbb-card-action.stories.tsx b/src/components/sbb-card-action/sbb-card-action.stories.tsx deleted file mode 100644 index d852e90f5e..0000000000 --- a/src/components/sbb-card-action/sbb-card-action.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj } from '@storybook/html'; - -const Template = (): JSX.Element => ( - - `sbb-card-action` is an invisible action element. See `sbb-card` examples to see it in action. - -); - -export const SbbCardAction: StoryObj = { - render: Template, -}; - -const meta: Meta = { - parameters: { - chromatic: { disableSnapshot: true }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-card/sbb-card-action', -}; - -export default meta; diff --git a/src/components/sbb-card-action/sbb-card-action.tsx b/src/components/sbb-card-action/sbb-card-action.tsx deleted file mode 100644 index 294257564f..0000000000 --- a/src/components/sbb-card-action/sbb-card-action.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Fragment, - h, - Host, - JSX, - Prop, - State, - Watch, -} from '@stencil/core'; -import { i18nTargetOpensInNewWindow } from '../../global/i18n'; -import { - ButtonType, - LinkButtonProperties, - LinkButtonRenderVariables, - LinkTargetType, - resolveRenderVariables, - targetsNewWindow, -} from '../../global/interfaces'; -import { IS_FOCUSABLE_QUERY } from '../../global/a11y'; -import { toggleDatasetEntry } from '../../global/dom'; -import { - documentLanguage, - HandlerRepository, - actionElementHandlerAspect, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { AgnosticMutationObserver } from '../../global/observers'; - -/** - * @slot unnamed - Slot to render a descriptive label / title of the action (important!). This is relevant for SEO and screen readers. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-card-action.scss', - tag: 'sbb-card-action', -}) -export class SbbCardAction implements ComponentInterface, LinkButtonProperties { - /** Whether the card is active. */ - @Prop({ reflect: true }) public active = false; - - /** The href value you want to link to. */ - @Prop({ reflect: true }) public href: string | undefined; - - /** Where to display the linked URL. */ - @Prop() public target?: LinkTargetType | string | undefined; - - /** The relationship of the linked URL as space-separated link types. */ - @Prop() public rel?: string | undefined; - - /** Whether the browser will show the download dialog on click. */ - @Prop() public download?: boolean | undefined; - - /** Default behaviour of the button. */ - @Prop() public type: ButtonType | undefined; - - /** The name of the button. */ - @Prop({ reflect: true }) public name: string | undefined; - - /** The element to associate the button to it. */ - @Prop() public form?: string | undefined; - - /** The value associated with button `name` when it's submitted with the form data. */ - @Prop() public value?: string | undefined; - - @State() private _currentLanguage = documentLanguage(); - - @Watch('active') - public onActiveChange(): void { - if (this._card) { - toggleDatasetEntry(this._card, 'hasActiveAction', this.active); - } - } - - @Element() private _element!: HTMLElement; - - private _abortController = new AbortController(); - private _card: HTMLSbbCardElement | null = null; - private _cardMutationObserver = new AgnosticMutationObserver(() => - this._checkForSlottedActions(), - ); - - private _handlerRepository = new HandlerRepository( - this._element, - actionElementHandlerAspect, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - ); - - public constructor() { - // Set slot name as early as possible - this._element.setAttribute('slot', 'action'); - } - - public connectedCallback(): void { - this._abortController = new AbortController(); - - this._card = this._element.closest('sbb-card'); - if (this._card) { - toggleDatasetEntry(this._card, 'hasAction', true); - toggleDatasetEntry(this._card, 'hasActiveAction', this.active); - - this._checkForSlottedActions(); - this._cardMutationObserver.observe(this._card, { - childList: true, - subtree: true, - }); - } - - this._handlerRepository.connect(); - } - - public disconnectedCallback(): void { - if (this._card) { - toggleDatasetEntry(this._card, 'hasAction', false); - toggleDatasetEntry(this._card, 'hasActiveAction', false); - toggleDatasetEntry(this._card, 'actionRole', false); - this._card - .querySelectorAll(`[data-card-focusable]`) - .forEach((el) => el.removeAttribute('data-card-focusable')); - this._card = null; - } - this._handlerRepository.disconnect(); - this._cardMutationObserver.disconnect(); - this._abortController.abort(); - } - - private _checkForSlottedActions(): void { - const cardFocusableAttributeName = 'data-card-focusable'; - - this._card - .querySelectorAll(`[${cardFocusableAttributeName}]:not(${IS_FOCUSABLE_QUERY})`) - .forEach((el) => el.removeAttribute(cardFocusableAttributeName)); - - this._card - .querySelectorAll( - `${IS_FOCUSABLE_QUERY}:not([${cardFocusableAttributeName}], sbb-card-action)`, - ) - .forEach((el) => el.setAttribute(cardFocusableAttributeName, '')); - } - - public render(): JSX.Element { - const { - tagName: TAG_NAME, - attributes, - hostAttributes, - }: LinkButtonRenderVariables = resolveRenderVariables(this); - - if (this._card) { - this._card.dataset.actionRole = hostAttributes.role; - } - - return ( - - - - - {targetsNewWindow(this) && ( - . {i18nTargetOpensInNewWindow[this._currentLanguage]} - )} - - - - ); - } -} diff --git a/src/components/sbb-card-badge/readme.md b/src/components/sbb-card-badge/readme.md deleted file mode 100644 index 6a00f50df6..0000000000 --- a/src/components/sbb-card-badge/readme.md +++ /dev/null @@ -1,56 +0,0 @@ -The `sbb-card-badge` can contain some information like prices or discounts, -and can be used in [sbb-card](/docs/components-sbb-card-sbb-card--docs) or -[sbb-selection-panel](/docs/components-sbb-selection-panel--docs). - -To achieve the correct spacing between elements inside the card badge, we recommend to use `span`-elements. -All content parts are presented with a predefined gap in between. - -```html - - - % - from CHF - 19.99 - - Card content... - -``` - -## Accessibility - -It's recommended to place an `aria-label` on `sbb-card-badge` to describe the displayed information in a full sentence, -as in the example above. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------- | --------- | ------------------------ | ----------------------- | ------------ | -| `color` | `color` | Color of the card badge. | `"charcoal" \| "white"` | `'charcoal'` | - - -## Slots - -| Slot | Description | -| ----------- | --------------------------------------------------------------------------------------------------- | -| `"unnamed"` | Content of the badge. Content parts should be wrapped in `` tags to achieve correct spacings. | - - -## Dependencies - -### Used by - - - [sbb-timetable-row](../sbb-timetable-row) - -### Graph -```mermaid -graph TD; - sbb-timetable-row --> sbb-card-badge - style sbb-card-badge fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-card-badge/sbb-card-badge.custom.d.ts b/src/components/sbb-card-badge/sbb-card-badge.custom.d.ts deleted file mode 100644 index 10ff94f82c..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.custom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InterfaceSbbCardBadgeAttributes { - color: 'charcoal' | 'white'; -} diff --git a/src/components/sbb-card-badge/sbb-card-badge.e2e.ts b/src/components/sbb-card-badge/sbb-card-badge.e2e.ts deleted file mode 100644 index cdfa37642f..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-card-badge', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-card-badge'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-card-badge/sbb-card-badge.scss b/src/components/sbb-card-badge/sbb-card-badge.scss deleted file mode 100644 index 5ae1875fd9..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.scss +++ /dev/null @@ -1,89 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-card-badge-gap: var(--sbb-spacing-fixed-2x); -} - -:host([color='white']) { - --sbb-card-badge-color: var(--sbb-color-charcoal-default); - --sbb-card-badge-background-color: var(--sbb-color-white-default); - --sbb-card-badge-border-color: var(--sbb-color-aluminium-default); -} - -:host([color='charcoal']) { - --sbb-card-badge-color: var(--sbb-color-white-default); - --sbb-card-badge-background-color: var(--sbb-color-charcoal-default); - --sbb-card-badge-border-color: transparent; -} - -.sbb-card-badge-wrapper { - display: flex; - position: relative; - height: fit-content; - justify-content: end; - - // Divider line to content - @include sbb.if-forced-colors { - &::after { - content: ''; - display: block; - position: absolute; - inset: 0; - border-block-end: var(--sbb-border-width-1x) solid CanvasText; - } - } -} - -.sbb-card-badge { - position: relative; - display: flex; - inset-block-start: 0; - inset-inline-end: 0; - padding-inline: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-3x); -} - -.sbb-card-badge-content { - @include sbb.text-xxs--bold; - - position: relative; - display: flex; - align-items: center; - gap: var(--sbb-card-badge-gap); - color: var(--sbb-card-badge-color); -} - -.sbb-card-badge-background { - content: ''; - display: block; - position: absolute; - inset: 0; - background-color: var(--sbb-card-badge-background-color); - border-end-start-radius: var(--sbb-border-radius-4x); - - // Increase size to avoid looking cut. - margin-inline-end: calc(var(--sbb-spacing-fixed-3x) * -1); - transform: skew(16deg, 0deg); - - :host([dir='rtl']) & { - transform: skew(-16deg, 0deg); - } - - // Set border inline to the badge - &::before { - content: ''; - display: block; - position: absolute; - inset: 0; - border-block-end: var(--sbb-border-width-1x) solid var(--sbb-card-badge-border-color); - border-inline-start: var(--sbb-border-width-1x) solid var(--sbb-card-badge-border-color); - border-end-start-radius: var(--sbb-border-radius-4x); - - @include sbb.if-forced-colors { - border: none; - } - } -} diff --git a/src/components/sbb-card-badge/sbb-card-badge.spec.ts b/src/components/sbb-card-badge/sbb-card-badge.spec.ts deleted file mode 100644 index baa9da1172..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SbbCardBadge } from './sbb-card-badge'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-card-badge', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbCardBadge], - html: '', - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - - `); - }); -}); diff --git a/src/components/sbb-card-badge/sbb-card-badge.stories.tsx b/src/components/sbb-card-badge/sbb-card-badge.stories.tsx deleted file mode 100644 index 1e08554502..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.stories.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': - context.args.color === 'charcoal' - ? 'var(--sbb-color-white-default)' - : 'var(--sbb-color-charcoal-default)', -}); - -const ariaLabel: InputType = { - control: { - type: 'text', - }, -}; - -const color: InputType = { - control: { - type: 'inline-radio', - }, - options: ['charcoal', 'white'], -}; - -const defaultArgTypes: ArgTypes = { - 'aria-label': ariaLabel, - color, -}; - -const defaultArgs: Args = { - 'aria-label': 'Super saver sales ticket price starts at CHF 92.50 Black Friday Special', - color: color.options[0], -}; - -const Template = (args): JSX.Element => ( - - % - from CHF - 92.50 - - Special - - -); - -export const Charcoal: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - }, -}; - -export const White: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[1], - }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-card/sbb-card-badge', -}; - -export default meta; diff --git a/src/components/sbb-card-badge/sbb-card-badge.tsx b/src/components/sbb-card-badge/sbb-card-badge.tsx deleted file mode 100644 index aad05a7d42..0000000000 --- a/src/components/sbb-card-badge/sbb-card-badge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, Prop } from '@stencil/core'; -import { InterfaceSbbCardBadgeAttributes } from './sbb-card-badge.custom'; -import { toggleDatasetEntry, getDocumentWritingMode } from '../../global/dom'; - -/** - * @slot unnamed - Content of the badge. - * Content parts should be wrapped in `` tags to achieve correct spacings. - */ - -@Component({ - shadow: true, - styleUrl: 'sbb-card-badge.scss', - tag: 'sbb-card-badge', -}) -export class SbbCardBadge implements ComponentInterface { - /** Color of the card badge. */ - @Prop({ reflect: true }) public color: InterfaceSbbCardBadgeAttributes['color'] = 'charcoal'; - - @Element() private _element!: HTMLElement; - - public constructor() { - // Set slot name as early as possible - this._element.setAttribute('slot', 'badge'); - } - - private _parentElement?: HTMLElement; - - public connectedCallback(): void { - this._parentElement = this._element.parentElement; - toggleDatasetEntry(this._parentElement, 'hasCardBadge', true); - } - - public disconnectedCallback(): void { - toggleDatasetEntry(this._parentElement, 'hasCardBadge', false); - this._parentElement = undefined; - } - - public render(): JSX.Element { - return ( - - - - - - - - - - - ); - } -} diff --git a/src/components/sbb-card/readme.md b/src/components/sbb-card/readme.md deleted file mode 100644 index 78762ead79..0000000000 --- a/src/components/sbb-card/readme.md +++ /dev/null @@ -1,115 +0,0 @@ -The `sbb-card` component is a generic content container; its task is to contain content related to a single subject. - -```html -Card content -``` - -## Slots - -The content is projected in an unnamed slot. -It's possible to use the component together with the `sbb-card-badge` and the `sbb-card-action`. - -### With `sbb-card-badge` - -The `sbb-card-badge` component can be used to display a badge in the upper right corner. -The badge is hidden with card sizes are `xs` or `s`. -For API details, see the [sbb-card-badge](/docs/components-sbb-card-sbb-card-badge--docs) docs. - -```html - - - % - from CHF - 19.99 - - Card content - -``` - -### With `sbb-card-action` - -To add an action to a card, add a `sbb-card-action` to the main slot. -With the `sbb-card-action` all the card area becomes clickable. -For API details (mainly accessibility), see the [sbb-card-action](/docs/components-sbb-card-sbb-card-action--docs) docs. - -```html - - Check all the wonderful trips available. - Buy trips - -``` - -## Style - -It's possible to choose among seven different values for the `size` property (from `xs` to `xxxl`, default `m`); -the choice mainly affects the content's padding. - -```html -Card content -Card content -Card content -Card content -Card content -Card content -Card content -``` - -The component has four different values to choose from for the `color` property; default is `white`. - -```html -Card content -Card content -Card content -``` - -## Accessibility - -Normally, a `sbb-card` should be a single action, however, it's possible to place other interactive elements -in the card content. Interactive content will automatically be detected and made accessible to click / focus. -In cases where there should be only a visual button or link inside the card content without a different action, the -`is-static` attribute should be set (e.g. ``). - -### Windows High Contrast Notes - -In high contrast mode, all the content of a link or a button receives a specific color which overrides every other color. - -However, as the content of the card is not directly inside the button or link, -this does not happen when the slotted content has a specific color set. -To improve coloring, it's needed to manually define styles for Window high contrast mode (setting `LinkText` or `ButtonText`). - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------- | --------- | -------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | -| `color` | `color` | Option to set the component's background color. | `"milk" \| "transparent-bordered" \| "transparent-bordered-dashed" \| "white"` | `'white'` | -| `size` | `size` | Size variant, either xs, s, m, l, xl, xxl or xxxl. | `"l" \| "m" \| "s" \| "xl" \| "xs" \| "xxl" \| "xxxl"` | `'m'` | - - -## Slots - -| Slot | Description | -| ----------- | ----------------------------------- | -| `"action"` | Slot to render ``. | -| `"badge"` | Slot to render ``. | -| `"unnamed"` | Slot to render the content. | - - -## Dependencies - -### Used by - - - [sbb-timetable-row](../sbb-timetable-row) - -### Graph -```mermaid -graph TD; - sbb-timetable-row --> sbb-card - style sbb-card fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-card/sbb-card.custom.d.ts b/src/components/sbb-card/sbb-card.custom.d.ts deleted file mode 100644 index b2ae51fe88..0000000000 --- a/src/components/sbb-card/sbb-card.custom.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface InterfaceSbbCardAttributes { - size: 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl'; - color: 'white' | 'milk' | 'transparent-bordered' | 'transparent-bordered-dashed'; -} diff --git a/src/components/sbb-card/sbb-card.e2e.ts b/src/components/sbb-card/sbb-card.e2e.ts deleted file mode 100644 index 0941132dab..0000000000 --- a/src/components/sbb-card/sbb-card.e2e.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-card', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-card'); - expect(element).toHaveClass('hydrated'); - }); - - it('should render with sbb-card-badge', async () => { - page = await newE2EPage(); - await page.setContent( - ` - -

      Title

      - Content text - - % - from CHF - 19.99 - -
      -`, - ); - const card = await page.find('sbb-card'); - - expect( - await page.evaluate(() => - getComputedStyle( - document.querySelector('sbb-card').shadowRoot.querySelector('.sbb-card__badge-wrapper'), - ).getPropertyValue('display'), - ), - ).not.toBe('none'); - expect(card).toHaveAttribute('data-has-card-badge'); - expect(card).toEqualHtml(` - - - - - - - - - - - - -

      Title

      - Content text - - % - from CHF - 19.99 - -
      - `); - }); - - it('should render without sbb-card-badge', async () => { - page = await newE2EPage(); - await page.setContent( - ` - -

      Title

      - Content text -
      `, - ); - const card = await page.find('sbb-card'); - - expect( - await page.evaluate(() => - getComputedStyle( - document.querySelector('sbb-card').shadowRoot.querySelector('.sbb-card__badge-wrapper'), - ).getPropertyValue('display'), - ), - ).toBe('none'); - expect(card).not.toHaveAttribute('data-has-card-badge'); - }); -}); diff --git a/src/components/sbb-card/sbb-card.scss b/src/components/sbb-card/sbb-card.scss deleted file mode 100644 index 5528b91973..0000000000 --- a/src/components/sbb-card/sbb-card.scss +++ /dev/null @@ -1,128 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -// # Stacking of action -// To support content with actions that should be accessible, we have to stack the action and content like following: -// The content is basically on top of the action, but is made pointer transparent with `pointer-events: none`. -// All focusable elements inside the main slot will receive `pointer-events: all` which makes them pointer accessible. - -:host { - @include sbb.card-variables; -} - -:host([color='milk']) { - @include sbb.card-variables--milk; -} - -:host([color='transparent-bordered']) { - @include sbb.card-variables--transparent-bordered; -} - -:host([color='transparent-bordered-dashed']) { - @include sbb.card-variables--transparent-bordered-dashed; -} - -:host([data-has-active-action]) { - @include sbb.card-variables--active; -} - -:host([size='xs']) { - @include sbb.card-variables--xs; -} - -:host([size='s']) { - @include sbb.card-variables--s; -} - -:host([size='m']) { - @include sbb.card-variables--m; -} - -:host([size='m'][data-has-card-badge]) { - @include sbb.card-variables--m-has-badge; -} - -:host([size='l']) { - @include sbb.card-variables--l; -} - -:host([size='l'][data-has-card-badge]) { - @include sbb.card-variables--l-has-badge; -} - -:host([size='xl']) { - @include sbb.card-variables--xl; -} - -:host([size='xl'][data-has-card-badge]) { - @include sbb.card-variables--xl-has-badge; -} - -:host([size='xxl']) { - @include sbb.card-variables--xxl; -} - -:host([size='xxxl']) { - @include sbb.card-variables--xxxl; -} - -:host([data-has-action]) { - @include sbb.if-forced-colors { - --sbb-title-text-color-normal-override: var(--sbb-card-color); - } -} - -:host([data-has-action]:not([data-has-active-action]):hover) { - @include sbb.card--hover('.sbb-card'); -} - -:host([data-has-action][data-action-role='button']) { - @include sbb.card-variables--button; -} - -:host([data-has-action][data-action-role='link']) { - @include sbb.card-variables--link; -} - -.sbb-card { - @include sbb.card; - - width: 100%; - height: 100%; -} - -.sbb-card__wrapper { - @include sbb.card--wrapper; - - :host([data-has-action]) & { - pointer-events: none; - transform: translateY(var(--sbb-card-hover-shift)); - transition: transform var(--sbb-card-animation-duration) var(--sbb-card-animation-easing); - } -} - -.sbb-card__badge-wrapper { - overflow: hidden; - position: absolute; - inset: 0; - inset-block-end: unset; - border-start-start-radius: var(--sbb-card-border-radius); - border-start-end-radius: var(--sbb-card-border-radius); - - :host([data-has-action]) & { - pointer-events: none; - } - - :host(:not([data-has-card-badge])) & { - display: none; - } -} - -// We remove upper margin from all titles -// as we do not expect multiple titles to be used inside a card. -::slotted(sbb-title) { - margin-block-start: 0; -} diff --git a/src/components/sbb-card/sbb-card.spec.ts b/src/components/sbb-card/sbb-card.spec.ts deleted file mode 100644 index c9b6e3e4eb..0000000000 --- a/src/components/sbb-card/sbb-card.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; -import { SbbCard } from './sbb-card'; - -const cardBadgeWrapperSelector = '.sbb-card__badge-wrapper'; -const hasBadgeDataAttributeName = 'data-has-card-badge'; - -describe('sbb-card', () => { - it('should not render sbb-card-badge for small sizes', async () => { - // Note: for easier testing, we add the slot="badge" - // to which would not be needed in real. - const { root } = await newSpecPage({ - components: [SbbCard], - html: ` - -

      Title

      - Content text - - % - from CHF - 19.99 - -
      `, - }); - - expect(root.shadowRoot.querySelector(cardBadgeWrapperSelector)).toBeFalsy(); - expect(root).not.toHaveAttribute(hasBadgeDataAttributeName); - }); -}); diff --git a/src/components/sbb-card/sbb-card.stories.tsx b/src/components/sbb-card/sbb-card.stories.tsx deleted file mode 100644 index 7fb3b6b721..0000000000 --- a/src/components/sbb-card/sbb-card.stories.tsx +++ /dev/null @@ -1,481 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; -import { StoryContext } from '@storybook/html'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': - context.args.color === 'white' || context.args.color === 'transparent-bordered-dashed' - ? 'var(--sbb-color-milk-default)' - : context.args.color === 'milk' - ? 'var(--sbb-color-white-default)' - : '--sbb-color-platinum-default', -}); - -const ContentText = (): JSX.Element => ( - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec porttitor blandit odio, ut - blandit libero cursus vel. Nunc eu congue mauris. Quisque sed facilisis leo. Curabitur - malesuada, nibh ac blandit vehicula, urna sem scelerisque magna, sed tincidunt neque arcu ac - justo. - -); - -const Content = (): JSX.Element[] => [Example text, ContentText()]; - -const Template = ({ size, color }): JSX.Element => ( - {Content()} -); - -const TemplateWithBadge = ({ size, color }): JSX.Element => ( - - - % - from CHF - 19.99 - - {Content()} - -); - -const TemplateCardAction = ({ size, color, label, ...args }): JSX.Element => ( - - {label} - {Content()} - -); - -const TemplateCardActionFixedHeight = ({ size, color, label, ...args }): JSX.Element => ( - - {label} - {Content()} - -); - -const TemplateCardActionWithBadge = ({ size, color, label, ...args }): JSX.Element => ( - - {label} - - % - from CHF - 19.99 - - {Content()} - -); - -const TemplateCardActionMultipleCards = (args): JSX.Element => ( -
      - {TemplateCardActionWithBadge(args)} - {TemplateCardActionWithBadge({ ...args, active: true })} - {TemplateCardActionWithBadge(args)} - {TemplateCardActionWithBadge(args)} -
      -); - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl'], -}; - -const color: InputType = { - control: { - type: 'inline-radio', - }, - options: ['white', 'milk', 'transparent-bordered', 'transparent-bordered-dashed'], -}; - -const active: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Card Action', - }, -}; - -const label: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action', - }, -}; - -const hrefs = ['https://www.sbb.ch', 'https://github.com/lyne-design-system/lyne-components']; -const href: InputType = { - options: Object.keys(hrefs), - mapping: hrefs, - control: { - type: 'select', - labels: { - 0: 'sbb.ch', - 1: 'GitHub Lyne Components', - }, - }, - table: { - category: 'Card Action Link', - }, -}; - -const download: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Card Action Link', - }, -}; - -const target: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action Link', - }, -}; - -const rel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action Link', - }, -}; - -const name: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action Button', - }, -}; - -const type: InputType = { - control: { - type: 'select', - }, - options: ['button', 'reset', 'submit'], - table: { - category: 'Card Action Button', - }, -}; - -const form: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action Button', - }, -}; - -const value: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Card Action Button', - }, -}; - -const defaultArgTypes: ArgTypes = { - size, - color, -}; - -const defaultArgTypesAction: ArgTypes = { - ...defaultArgTypes, - active, - label, - href, - download, - target, - rel, - name, - type, - form, - value, -}; - -const defaultArgs: Args = { - size: 'm', - color: color.options[0], -}; - -const defaultArgsAction = { - ...defaultArgs, - active: false, - label: 'Click this card to follow the action.', - href: href.options[1], - download: false, - target: '_blank', - rel: undefined, - name: undefined, - type: undefined, - form: undefined, - value: undefined, -}; - -const defaultArgsButton = { - ...defaultArgsAction, - href: undefined, - download: undefined, - target: undefined, - name: 'Button name', - type: type.options[0], - form: 'form-name', - value: 'Value', -}; - -export const ColorWhite: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - }, -}; - -export const ColorMilk: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[1], - }, -}; - -export const ColorTransparent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[2], - }, -}; - -export const ColorTransparentBorderedDashed: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[3], - }, -}; - -export const SizeXS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[0], - }, -}; - -export const SizeS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[1], - }, -}; - -export const SizeM: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[2], - }, -}; - -export const SizeL: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[3], - }, -}; - -export const SizeXL: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[4], - }, -}; - -export const SizeXXL: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[5], - }, -}; - -export const SizeXXXL: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[6], - }, -}; - -export const SizeMWithBadge: StoryObj = { - render: TemplateWithBadge, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[2], - }, -}; - -export const SizeLWithBadge: StoryObj = { - render: TemplateWithBadge, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[3], - }, -}; - -export const SizeXLWithBadge: StoryObj = { - render: TemplateWithBadge, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[4], - }, -}; - -export const SizeXXLWithBadge: StoryObj = { - render: TemplateWithBadge, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[5], - }, -}; - -export const SizeXXXLWithBadge: StoryObj = { - render: TemplateWithBadge, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[6], - }, -}; - -export const Link: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsAction }, -}; - -export const Button: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsButton }, -}; - -export const ButtonActive: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsButton, active: true }, -}; - -export const ButtonActiveMilk: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { - ...defaultArgsButton, - color: color.options[1], - active: true, - }, -}; - -export const ButtonActiveTransparentBordered: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { - ...defaultArgsButton, - color: color.options[2], - active: true, - }, -}; - -export const ButtonActiveTransparentBorderedDashed: StoryObj = { - render: TemplateCardAction, - argTypes: defaultArgTypesAction, - args: { - ...defaultArgsButton, - color: color.options[3], - active: true, - }, -}; - -export const ButtonWithSbbBadge: StoryObj = { - render: TemplateCardActionWithBadge, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsButton }, -}; - -export const LinkWithSbbBadge: StoryObj = { - render: TemplateCardActionWithBadge, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsAction }, -}; - -export const LinkActiveWithSbbBadge: StoryObj = { - render: TemplateCardActionWithBadge, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsAction, active: true }, -}; - -export const FixedHeight: StoryObj = { - render: TemplateCardActionFixedHeight, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsButton }, -}; - -export const Multiple: StoryObj = { - render: TemplateCardActionMultipleCards, - argTypes: defaultArgTypesAction, - args: { ...defaultArgsAction }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['click'], - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-card/sbb-card', -}; - -export default meta; diff --git a/src/components/sbb-card/sbb-card.tsx b/src/components/sbb-card/sbb-card.tsx deleted file mode 100644 index 9d0290b427..0000000000 --- a/src/components/sbb-card/sbb-card.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Component, ComponentInterface, h, JSX, Prop } from '@stencil/core'; -import { InterfaceSbbCardAttributes } from './sbb-card.custom'; - -/** - * @slot unnamed - Slot to render the content. - * @slot badge - Slot to render ``. - * @slot action - Slot to render ``. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-card.scss', - tag: 'sbb-card', -}) -export class SbbCard implements ComponentInterface { - /** Size variant, either xs, s, m, l, xl, xxl or xxxl. */ - @Prop({ reflect: true }) public size?: InterfaceSbbCardAttributes['size'] = 'm'; - - /** Option to set the component's background color. */ - @Prop({ reflect: true }) public color: InterfaceSbbCardAttributes['color'] = 'white'; - - /** - * It is used internally to show the ``. - * - * @returns True whether size is equal to m, l, xl or xxl. - */ - private _isBadgeVisible(): boolean { - return ['m', 'l', 'xl', 'xxl', 'xxxl'].includes(this.size); - } - - public render(): JSX.Element { - return ( - - - - - - {this._isBadgeVisible() && ( - - - - )} - - ); - } -} diff --git a/src/components/sbb-checkbox-group/readme.md b/src/components/sbb-checkbox-group/readme.md deleted file mode 100644 index 5d9a18a083..0000000000 --- a/src/components/sbb-checkbox-group/readme.md +++ /dev/null @@ -1,104 +0,0 @@ -The `sbb-checkbox-group` component is used as a container for one or multiple -[sbb-checkbox](/docs/components-sbb-checkbox-sbb-checkbox--docs) components, -or, alternatively, for a collection of [sbb-selection-panel](/docs/components-sbb-selection-panel--docs). - -```html - - Label 1 - Label 2 - Label 3 - - - - - - Value - - - CHF - 40.00 - - - - -``` - -## Slots - -The content is projected in an unnamed slot. - -The component can display one or more [sbb-form-error](/docs/components-sbb-form-field-sbb-form-error--docs) components -right below the `sbb-checkbox-group` using the `error` slot. - -```html - - Label 1 - Label 2 - Label 3 - You must accept all the terms and conditions. - -``` - -## States - -It is possible to mark the entire group as disabled or required using the properties `disabled` and `required`. - -```html - - - ... - - - - - ... - -``` - -## Style - -The `orientation` property is used to set item orientation. -Possible values are `horizontal` (default) and `vertical`. -The optional property `horizontalFrom` can be used in combination with `orientation='vertical'` to -indicate the minimum breakpoint from which the orientation changes to `horizontal`. - -```html - - ... - -``` - -The component has a `size` property too, which can be used to change the size of all the inner `sbb-checkbox`. -Two values are available, `s` and `m`, which is the default - -```html - - ... - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------------- | ----------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------- | -| `disabled` | `disabled` | Whether the checkbox group is disabled. | `boolean` | `false` | -| `horizontalFrom` | `horizontal-from` | Overrides the behaviour of `orientation` property. | `"large" \| "medium" \| "micro" \| "small" \| "ultra" \| "wide" \| "zero"` | `undefined` | -| `orientation` | `orientation` | Indicates the orientation of the checkboxes inside the ``. | `"horizontal" \| "vertical"` | `'horizontal'` | -| `required` | `required` | Whether the checkbox group is required. | `boolean` | `false` | -| `size` | `size` | Size variant, either m or s. | `"m" \| "s"` | `'m'` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------------------------------------- | -| `"error"` | Slot used to render the inside the . | -| `"unnamed"` | Slot used to render the inside the . | - - ----------------------------------------------- - - diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.custom.ts b/src/components/sbb-checkbox-group/sbb-checkbox-group.custom.ts deleted file mode 100644 index 88eec1ae14..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.custom.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface InterfaceSbbCheckboxGroupAttributes { - horizontalFrom?: 'zero' | 'micro' | 'small' | 'medium' | 'large' | 'wide' | 'ultra'; - orientation: 'horizontal' | 'vertical'; - size: 'm' | 's'; -} diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.e2e.ts b/src/components/sbb-checkbox-group/sbb-checkbox-group.e2e.ts deleted file mode 100644 index 9df593f011..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.e2e.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-checkbox-group', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - Label 1 - Label 2 - Label 3 - - `); - element = await page.find('sbb-checkbox-group'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('disabled status is inherited', async () => { - element.setAttribute('disabled', 'true'); - await page.waitForChanges(); - expect(element).toEqualAttribute('disabled', 'true'); - const checkboxOne = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-1'); - expect(checkboxOne.getAttribute('data-group-disabled')).not.toBeNull(); - const checkboxTwo = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-2'); - expect(checkboxTwo.getAttribute('data-group-disabled')).not.toBeNull(); - expect(checkboxTwo.getAttribute('disabled')).not.toBeNull(); - const checkboxThree = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-3'); - expect(checkboxThree.getAttribute('data-group-disabled')).not.toBeNull(); - element.removeAttribute('disabled'); - await page.waitForChanges(); - expect(checkboxTwo.getAttribute('data-group-disabled')).toBeNull(); - expect(checkboxTwo.getAttribute('disabled')).not.toBeNull(); - }); - - it('disabled status prevents changes', async () => { - const checkboxOne = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-1'); - const checkboxTwo = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-2'); - const checkboxThree = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-3'); - const checkboxes = [checkboxOne, checkboxTwo, checkboxThree]; - checkboxes.forEach((check: E2EElement) => expect(check).toEqualAttribute('checked', null)); - element.setAttribute('disabled', 'true'); - await page.waitForChanges(); - expect(element).toEqualAttribute('disabled', 'true'); - for (const check of checkboxes) { - await check.click(); - expect(check).toEqualAttribute('checked', null); - } - element.removeAttribute('disabled'); - await page.waitForChanges(); - for (const check of checkboxes) { - await check.click(); - } - expect(checkboxOne).toEqualAttribute('checked', ''); - expect(checkboxTwo).toEqualAttribute('checked', null); - expect(checkboxThree).toEqualAttribute('checked', ''); - }); - - it('required status', async () => { - element.setAttribute('required', 'true'); - await page.waitForChanges(); - expect(element).toEqualAttribute('required', 'true'); - const checkboxOne = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-1'); - expect(checkboxOne.getAttribute('data-group-required')).not.toBeNull(); - const checkboxTwo = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-2'); - expect(checkboxTwo.getAttribute('data-group-required')).not.toBeNull(); - const checkboxThree = await page.find('sbb-checkbox-group > sbb-checkbox#checkbox-3'); - expect(checkboxThree.getAttribute('data-group-required')).not.toBeNull(); - }); -}); diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.scss b/src/components/sbb-checkbox-group/sbb-checkbox-group.scss deleted file mode 100644 index 69916219c8..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -@mixin horizontal-orientation { - --sbb-checkbox-group-orientation: row; - --sbb-checkbox-group-checkbox-width: auto; -} - -$breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; - -:host { - @include horizontal-orientation; - - --sbb-checkbox-group-width: max-content; - --sbb-checkbox-group-gap: var(--sbb-spacing-fixed-3x) var(--sbb-spacing-fixed-6x); -} - -:host([orientation='vertical']) { - --sbb-checkbox-group-orientation: column; - --sbb-checkbox-group-width: 100%; - --sbb-checkbox-group-checkbox-width: 100%; -} - -:host([data-has-selection-panel]) { - --sbb-checkbox-group-width: 100%; -} - -:host([data-has-selection-panel][orientation='vertical']) { - --sbb-checkbox-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); -} - -@each $breakpoint in $breakpoints { - @include sbb.mq($from: #{$breakpoint}) { - // horizontal-from overrides orientation vertical - :host([orientation='vertical'][horizontal-from='#{$breakpoint}']) { - @include horizontal-orientation; - } - - :host( - [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-panel]) - ) { - --sbb-checkbox-group-width: max-content; - } - } -} - -.sbb-checkbox-group { - display: flex; - flex-direction: var(--sbb-checkbox-group-orientation); - gap: var(--sbb-checkbox-group-gap); - align-items: flex-start; - width: var(--sbb-checkbox-group-width); -} - -.sbb-checkbox-group__error { - display: inline-block; - margin-block-start: var(--sbb-spacing-fixed-1x); -} - -::slotted(sbb-checkbox) { - width: var(--sbb-checkbox-group-checkbox-width); -} diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.spec.ts b/src/components/sbb-checkbox-group/sbb-checkbox-group.spec.ts deleted file mode 100644 index 26ec767c56..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SbbCheckboxGroup } from './sbb-checkbox-group'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-checkbox-group', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckboxGroup], - html: ` - - Label 1 - Label 2 - Label 3 - - `, - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      - Label 1 - Label 2 - Label 3 -
      - `); - }); -}); diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.stories.tsx b/src/components/sbb-checkbox-group/sbb-checkbox-group.stories.tsx deleted file mode 100644 index cee0a6fc6f..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.stories.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/** @jsx h */ -import { Fragment, h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const longLabelText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt -quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et -lacus sollicitudin, quis malesuada lorem vehicula. Suspendisse at augue quis tellus vulputate tempor. Vivamus urna -velit, varius nec est ac, mollis efficitur lorem. Quisque non nisl eget massa interdum tempus. Praesent vel feugiat -metus.`; - -const checkboxes = (checked, disabledSingle, iconName, iconPlacement, label): JSX.Element[] => [ - - {label} 1 - , - - {label} 2 - , - - {label} 3 - , -]; - -const DefaultTemplate = ({ - checked, - disabledSingle, - iconName, - iconPlacement, - label, - ...args -}): JSX.Element => ( - - {checkboxes(checked, disabledSingle, iconName, iconPlacement, label)} - -); - -const ErrorMessageTemplate = ({ - checked, - disabledSingle, - iconName, - iconPlacement, - label, - ...args -}): JSX.Element => ( - - {checkboxes(checked, disabledSingle, iconName, iconPlacement, label)} - {args.required && This is a required field.} - -); - -let selectedCheckboxes = ['checkbox-1']; - -const childCheck = (event): void => { - if (event.target.checked) { - selectedCheckboxes.push(event.target.value); - } else { - selectedCheckboxes.splice(selectedCheckboxes.indexOf(event.target.value), 1); - } - document - .getElementById('parent') - .setAttribute('indeterminate', String(selectedCheckboxes.length === 1)); - document - .getElementById('parent') - .setAttribute('checked', String(selectedCheckboxes.length === 2)); -}; - -const parentCheck = (event): void => { - if (event.target.checked) { - selectedCheckboxes = ['checkbox-1', 'checkbox-2']; - } else { - selectedCheckboxes = []; - } - document.getElementById('checkbox-1').setAttribute('checked', event.target.checked); - document.getElementById('checkbox-2').setAttribute('checked', event.target.checked); -}; - -const IndeterminateGroupTemplate = ({ - disabledSingle, - iconName, - iconPlacement, - label, - ...args -}): JSX.Element => ( - -
      -
      Check/uncheck all the children checkboxes and the parent will be checked/unchecked.
      -
      Check a single child and the parent will be indeterminate.
      -
      - - parentCheck(event)} - icon-name={iconName} - icon-placement={iconPlacement} - > - Parent checkbox - - childCheck(event)} - icon-name={iconName} - icon-placement={iconPlacement} - disabled={disabledSingle} - style={{ 'margin-inline-start': '2rem' }} - > - {label} option 1 - - childCheck(event)} - icon-name={iconName} - icon-placement={iconPlacement} - style={{ 'margin-inline-start': '2rem' }} - > - {label} option 2 - - -
      -); - -const disabled: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Checkbox group', - }, -}; - -const required: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Checkbox group', - }, -}; - -const orientation: InputType = { - control: { - type: 'inline-radio', - }, - options: ['horizontal', 'vertical'], - table: { - category: 'Checkbox group', - }, -}; - -const horizontalFrom: InputType = { - control: { - type: 'select', - }, - options: ['unset', 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'], - table: { - category: 'Checkbox group', - }, -}; - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['m', 's'], - table: { - category: 'Checkbox group', - }, -}; - -const checked: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Checkbox', - }, -}; - -const disabledSingle: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Checkbox', - }, -}; - -const label: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Checkbox', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Checkbox', - }, -}; - -const iconPlacement: InputType = { - control: { - type: 'select', - }, - options: ['start', 'end'], - table: { - category: 'Checkbox', - }, -}; - -const basicArgTypes: ArgTypes = { - disabled, - required, - orientation, - 'horizontal-from': horizontalFrom, - size, - label, - checked, - disabledSingle, - iconName, - iconPlacement, -}; - -const basicArgs: Args = { - disabled: false, - required: false, - orientation: orientation.options[0], - 'horizontal-from': undefined, - size: size.options[1], - label: 'Label', - checked: true, - disabledSingle: false, - iconName: undefined, - iconPlacement: undefined, -}; - -const basicArgsVertical = { - ...basicArgs, - orientation: orientation.options[1], -}; - -const iconStart: Args = { - iconName: 'tickets-class-small', - iconPlacement: iconPlacement.options[0], -}; - -const iconEnd: Args = { - iconName: 'tickets-class-small', - iconPlacement: iconPlacement.options[1], -}; - -export const horizontal: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const vertical: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical }, -}; - -export const verticalToHorizontal: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'horizontal-from': 'medium' }, -}; - -export const horizontalSizeM: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, size: 'm' }, -}; - -export const horizontalDisabled: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, disabled: true, disabledSingle: true }, -}; - -export const verticalDisabled: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, disabled: true, disabledSingle: true }, -}; - -export const horizontalIconStart: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, ...iconStart }, -}; - -export const verticalIconStart: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconStart }, -}; - -export const horizontalIconEnd: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, ...iconEnd }, -}; - -export const verticalIconEnd: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconEnd }, -}; - -export const verticalIconEndLongLabel: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconEnd, label: longLabelText }, -}; - -export const horizontalWithSbbFormError: StoryObj = { - render: ErrorMessageTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, required: true }, -}; - -export const verticalWithSbbFormError: StoryObj = { - render: ErrorMessageTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, required: true }, -}; - -export const indeterminateGroup: StoryObj = { - render: IndeterminateGroupTemplate, - argTypes: { ...basicArgTypes }, - args: { ...basicArgsVertical }, -}; - -delete indeterminateGroup.args.checked; -delete indeterminateGroup.argTypes.checked; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['change', 'input'], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-checkbox/sbb-checkbox-group', -}; - -export default meta; diff --git a/src/components/sbb-checkbox-group/sbb-checkbox-group.tsx b/src/components/sbb-checkbox-group/sbb-checkbox-group.tsx deleted file mode 100644 index cdaf6f545c..0000000000 --- a/src/components/sbb-checkbox-group/sbb-checkbox-group.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - h, - Host, - JSX, - Listen, - Prop, - State, - Watch, -} from '@stencil/core'; -import { InterfaceSbbCheckboxGroupAttributes } from './sbb-checkbox-group.custom'; -import { isArrowKeyPressed, getNextElementIndex, interactivityChecker } from '../../global/a11y'; -import { toggleDatasetEntry, isValidAttribute } from '../../global/dom'; -import { - createNamedSlotState, - HandlerRepository, - namedSlotChangeHandlerAspect, -} from '../../global/eventing'; - -/** - * @slot unnamed - Slot used to render the inside the . - * @slot error - Slot used to render the inside the . - */ - -@Component({ - shadow: true, - styleUrl: 'sbb-checkbox-group.scss', - tag: 'sbb-checkbox-group', -}) -export class SbbCheckboxGroup implements ComponentInterface { - /** - * Whether the checkbox group is disabled. - */ - @Prop() public disabled = false; - - /** - * Whether the checkbox group is required. - */ - @Prop() public required = false; - - /** - * Size variant, either m or s. - */ - @Prop() public size: InterfaceSbbCheckboxGroupAttributes['size'] = 'm'; - - /** - * Overrides the behaviour of `orientation` property. - */ - @Prop({ reflect: true }) - public horizontalFrom?: InterfaceSbbCheckboxGroupAttributes['horizontalFrom']; - - /** - * Indicates the orientation of the checkboxes inside the ``. - */ - @Prop({ reflect: true }) public orientation: InterfaceSbbCheckboxGroupAttributes['orientation'] = - 'horizontal'; - - /** - * State of listed named slots, by indicating whether any element for a named slot is defined. - */ - @State() private _namedSlots = createNamedSlotState('error'); - - @Element() private _element!: HTMLElement; - - private _handlerRepository = new HandlerRepository( - this._element, - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - - @Watch('disabled') - public updateDisabled(): void { - for (const checkbox of this._checkboxes) { - toggleDatasetEntry(checkbox, 'groupDisabled', this.disabled); - } - } - - @Watch('required') - public updateRequired(): void { - for (const checkbox of this._checkboxes) { - toggleDatasetEntry(checkbox, 'groupRequired', this.required); - } - } - - @Watch('size') - public updateSize(): void { - for (const checkbox of this._checkboxes) { - checkbox.size = this.size; - } - } - - public connectedCallback(): void { - toggleDatasetEntry( - this._element, - 'hasSelectionPanel', - !!this._element.querySelector('sbb-selection-panel'), - ); - this._handlerRepository.connect(); - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - @Listen('keydown') - public handleKeyDown(evt: KeyboardEvent): void { - const enabledCheckboxes: HTMLSbbCheckboxElement[] = this._checkboxes.filter( - (checkbox: HTMLSbbCheckboxElement) => - !isValidAttribute(checkbox, 'disabled') && interactivityChecker.isVisible(checkbox), - ); - - if ( - !enabledCheckboxes || - // don't trap nested handling - ((evt.target as HTMLElement) !== this._element && - (evt.target as HTMLElement).parentElement !== this._element && - (evt.target as HTMLElement).parentElement.nodeName !== 'SBB-SELECTION-PANEL') - ) { - return; - } - - if (isArrowKeyPressed(evt)) { - const current: number = enabledCheckboxes.findIndex( - (e: HTMLSbbCheckboxElement) => e === evt.target, - ); - const nextIndex: number = getNextElementIndex(evt, current, enabledCheckboxes.length); - enabledCheckboxes[nextIndex]?.focus(); - } - } - - private _updateCheckboxes(): void { - const checkboxes = this._checkboxes; - - for (const checkbox of checkboxes) { - checkbox.size = this.size; - toggleDatasetEntry(checkbox, 'groupDisabled', this.disabled); - toggleDatasetEntry(checkbox, 'groupRequired', this.required); - } - } - - private get _checkboxes(): HTMLSbbCheckboxElement[] { - return ( - Array.from(this._element.querySelectorAll('sbb-checkbox')) as HTMLSbbCheckboxElement[] - ).filter((el) => el.closest('sbb-checkbox-group') === this._element); - } - - public render(): JSX.Element { - return ( - -
      - this._updateCheckboxes()} /> -
      - {this._namedSlots.error && ( -
      - -
      - )} -
      - ); - } -} diff --git a/src/components/sbb-checkbox/readme.md b/src/components/sbb-checkbox/readme.md deleted file mode 100644 index 99f48a87a0..0000000000 --- a/src/components/sbb-checkbox/readme.md +++ /dev/null @@ -1,121 +0,0 @@ -The `sbb-checkbox` component provides the same functionality as a native `` enhanced with the SBB Design. - -## Slots - -It is possible to provide a label via an unnamed slot; the component can optionally display a `sbb-icon` using -the `iconName` property or via custom SVG using the `icon` slot. -The icon can be placed before or after the label based on the value of the `iconPlacement` property (default: `end`). - -```html -Example - -Icon - -Icon at start -``` - -## States - -The component could be checked or not depending on the value of the `checked` attribute. - -```html -Checked state -``` - -It has a third state too, which is set if the `indeterminate` property is true. -This is useful when multiple dependent checkboxes are used -(e.g., a parent which is checked only if all the children are checked, otherwise is in indeterminate state). -Clicking on a `sbb-checkbox` in this state sets `checked` to `true` and `indeterminate` to false. - -```html -Indeterminate state -``` - -The component can be displayed in `disabled` or `required` state by using the self-named properties. - -```html -Required - -Disabled -``` - -## Style - -The component has two `size`, named `s` (default) and `m`. - -```html -Size -``` - -## Events - -Consumers can listen to the native `change` event on the `sbb-checkbox` component to intercept the input's change; -the current state can be read from `event.target.checked`, while the value from `event.target.value`. - -## Accessibility - -The component uses an internal `` element to provide an accessible experience. - -This internal checkbox receives focus and is automatically labeled by the text content of the `sbb-checkbox` element. -Avoid adding other interactive controls into the content of `sbb-checkbox`, as this degrades the experience for users of assistive technology. - -Always provide an accessible label via `aria-label` for checkboxes without descriptive text content. -If you don't want the label to appear next to the checkbox, you can use `aria-label` to specify an appropriate label. - -```html - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| --------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------- | -| `checked` | `checked` | Whether the checkbox is checked. | `boolean` | `false` | -| `disabled` | `disabled` | Whether the checkbox is disabled. | `boolean` | `false` | -| `iconName` | `icon-name` | The icon name we want to use, choose from the small icon variants from the ui-icons category from https://icons.app.sbb.ch (optional). | `string` | `undefined` | -| `iconPlacement` | `icon-placement` | The label position relative to the labelIcon. Defaults to end | `"end" \| "start"` | `'end'` | -| `indeterminate` | `indeterminate` | Whether the checkbox is indeterminate. | `boolean` | `false` | -| `required` | `required` | Whether the checkbox is required. | `boolean` | `false` | -| `size` | `size` | Label size variant, either m or s. | `"m" \| "s"` | `'m'` | -| `value` | `value` | Value of checkbox. | `string` | `undefined` | - - -## Events - -| Event | Description | Type | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| `didChange` | **[DEPRECATED]** only used for React. Will probably be removed once React 19 is available.

      | `CustomEvent` | -| `sbb-checkbox-loaded` | Internal event that emits when the input element is loaded. | `CustomEvent` | - - -## Slots - -| Slot | Description | -| ----------- | ----------------------------------------------------------------------------------------------- | -| `"icon"` | Slot used to render the checkbox icon (disabled inside a selection panel). | -| `"subtext"` | Slot used to render a subtext under the label (only visible within a selection panel). | -| `"suffix"` | Slot used to render additional content after the label (only visible within a selection panel). | -| `"unnamed"` | Slot used to render the checkbox label's text. | - - -## Dependencies - -### Depends on - -- [sbb-visual-checkbox](../sbb-visual-checkbox) -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-checkbox --> sbb-visual-checkbox - sbb-checkbox --> sbb-icon - style sbb-checkbox fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-checkbox/sbb-checkbox.custom.d.ts b/src/components/sbb-checkbox/sbb-checkbox.custom.d.ts deleted file mode 100644 index 5afbf20b75..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.custom.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type CheckboxStateChange = CheckboxStateChangeChecked | CheckboxStateChangeDisabled; - -export interface CheckboxStateChangeDisabled { - type: 'disabled'; - disabled: boolean; -} - -export interface CheckboxStateChangeChecked { - type: 'checked'; - checked: boolean; -} - -export interface InterfaceSbbCheckboxAttributes { - size: 'm' | 's'; - iconPlacement?: 'start' | 'end'; -} diff --git a/src/components/sbb-checkbox/sbb-checkbox.e2e.ts b/src/components/sbb-checkbox/sbb-checkbox.e2e.ts deleted file mode 100644 index a94887306b..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.e2e.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-checkbox', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(''); - element = await page.find('sbb-checkbox'); - }); - - it('should render', async () => { - element = await page.find('sbb-checkbox'); - expect(element).toHaveClass('hydrated'); - }); - - it('should not render accessibility label containing expanded state', async () => { - element = await page.find('sbb-checkbox >>> .sbb-checkbox__expanded-label'); - expect(element).toBeFalsy(); - }); - - describe('events', () => { - it('emit event on click', async () => { - await page.waitForChanges(); - const changeSpy = await page.spyOnEvent('change'); - await element.click(); - expect(changeSpy).toHaveReceivedEvent(); - }); - - it('emit event on keypress', async () => { - await page.waitForChanges(); - const changeSpy = await page.spyOnEvent('change'); - await element.press('Tab'); - await element.press('Space'); - await page.waitForChanges(); - expect(changeSpy).toHaveReceivedEvent(); - }); - }); - - describe('indeterminate', () => { - it('should set indeterminate to false after checked', async () => { - page = await newE2EPage(); - await page.setContent('Label'); - element = await page.find('sbb-checkbox'); - await page.waitForChanges(); - - expect(await element.getProperty('checked')).toBe(false); - expect(await element.getProperty('indeterminate')).toBe(true); - - await element.click(); - await page.waitForChanges(); - - expect(await element.getProperty('checked')).toBe(true); - expect(await element.getProperty('indeterminate')).toBeFalsy(); - }); - - it('should update indeterminate state of input', async () => { - await page.waitForChanges(); - - expect(await element.getProperty('indeterminate')).toBeFalsy(); - - element.setProperty('indeterminate', true); - await page.waitForChanges(); - - expect(await element.getProperty('indeterminate')).toBe(true); - }); - }); - - it('should prevent scrolling on space bar press', async () => { - page = await newE2EPage(); - await page.setContent( - `
      -
      - -
      -
      `, - ); - element = await page.find('sbb-checkbox'); - expect(element).not.toHaveAttribute('checked'); - expect(await page.evaluate(() => document.querySelector('#scroll-context').scrollTop)).toBe(0); - - await element.press(' '); - await page.waitForChanges(); - - expect(element).toHaveAttribute('checked'); - expect(await page.evaluate(() => document.querySelector('#scroll-context').scrollTop)).toBe(0); - }); -}); diff --git a/src/components/sbb-checkbox/sbb-checkbox.events.ts b/src/components/sbb-checkbox/sbb-checkbox.events.ts deleted file mode 100644 index 2b5485d0a9..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.events.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didChange: 'didChange', - sbbCheckboxLoaded: 'sbb-checkbox-loaded', - stateChange: 'state-change', -}; diff --git a/src/components/sbb-checkbox/sbb-checkbox.scss b/src/components/sbb-checkbox/sbb-checkbox.scss deleted file mode 100644 index d6f48c43d1..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.scss +++ /dev/null @@ -1,143 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-checkbox-dimension: var(--sbb-size-icon-ui-small); - --sbb-checkbox-label-color: var(--sbb-color-charcoal-default); - --sbb-checkbox-cursor: pointer; - --sbb-checkbox-subtext-color: var(--sbb-color-granite-default); - --sbb-checkbox-label-gap: var(--sbb-spacing-fixed-2x); - - display: inline-block; - - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; -} - -:host(:is([data-group-disabled], [disabled]:not([disabled='false']))) { - --sbb-checkbox-label-color: var(--sbb-color-charcoal-default); - --sbb-checkbox-cursor: default; - --sbb-checkbox-subtext-color: var(--sbb-color-smoke-default); -} - -:host([data-is-selection-panel-input]) { - --sbb-checkbox-label-gap: 0; -} - -slot[name='subtext'] { - display: block; - color: var(--sbb-checkbox-subtext-color); - padding-inline-start: var(--sbb-spacing-fixed-8x); -} - -.sbb-checkbox-wrapper { - display: flex; - - @include sbb.zero-width-space; - - // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. - :host( - :focus-visible:not( - [data-focus-origin='mouse'], - [data-focus-origin='touch'], - [data-is-selection-panel-input] - ) - ) - & { - @include sbb.focus-outline; - - border-radius: calc(var(--sbb-border-radius-4x) - var(--sbb-focus-outline-offset)); - } -} - -.sbb-checkbox { - @include sbb.text-s--regular; - - position: relative; - display: block; - width: 100%; - color: var(--sbb-checkbox-label-color); - cursor: var(--sbb-checkbox-cursor); - user-select: none; - -webkit-tap-highlight-color: transparent; - - :host([size='m']) & { - @include sbb.text-m--regular; - } -} - -.sbb-checkbox__inner { - display: flex; - align-items: start; - gap: var(--sbb-spacing-fixed-2x); - - // Change the focus outline when the input is placed inside of a selection panel - // as the main input element. - :host( - [data-is-selection-panel-input]:focus-visible:not( - [data-focus-origin='mouse'], - [data-focus-origin='touch'] - ) - ) - & { - &::before { - content: ''; - position: absolute; - display: block; - inset-block: calc( - (var(--sbb-spacing-responsive-xs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - inset-inline: calc( - (var(--sbb-spacing-responsive-xxs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - border: var(--sbb-focus-outline-color) solid var(--sbb-focus-outline-width); - border-radius: calc(var(--sbb-border-radius-4x) + var(--sbb-focus-outline-offset)); - } - } -} - -input[type='checkbox'] { - @include sbb.invisible-container-overlay; -} - -.sbb-checkbox__aligner, -.sbb-checkbox__label--icon { - display: flex; - align-items: center; - height: calc(1em * var(--sbb-typo-line-height-body-text)); -} - -.sbb-checkbox__label--icon { - :host([icon-placement='end']) & { - margin-left: auto; - } -} - -.sbb-checkbox__label { - display: flex; - gap: var(--sbb-checkbox-label-gap); - color: var(--sbb-checkbox-label-color); - - // Fix for Chrome and Safari, they approximate 23.8px to 23px for line-height - line-height: max((1em * var(--sbb-typo-line-height-body-text)), var(--sbb-checkbox-dimension)); - - :host([icon-placement='start']) & { - flex-direction: row-reverse; - justify-content: flex-end; - } - - :host([icon-placement='end']) & { - justify-content: flex-start; - flex-grow: 1; - } -} - -.sbb-checkbox__expanded-label { - @include sbb.screen-reader-only; -} diff --git a/src/components/sbb-checkbox/sbb-checkbox.spec.ts b/src/components/sbb-checkbox/sbb-checkbox.spec.ts deleted file mode 100644 index 1d7df46332..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; -import { SbbCheckbox } from './sbb-checkbox'; - -describe('sbb-checkbox', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckbox], - html: 'Label', - }); - - expect(root).toEqualHtml(` - - - - - - - Label - - `); - }); - - describe('icon position', () => { - it('start', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckbox], - html: 'Label', - }); - - expect(root).toEqualHtml(` - - - - - - - Label - - `); - }); - }); - - describe('state', () => { - it('checked', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckbox], - html: 'Label', - }); - - expect(root).toEqualHtml(` - - - - - - - Label - - `); - }); - - it('indeterminate', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckbox], - html: 'Label', - }); - - const input = root.shadowRoot.querySelector('input'); - expect(input.indeterminate).toBe(true); - - expect(root).toEqualHtml(` - - - - - - - Label - `); - }); - - it('unchecked disabled', async () => { - const { root } = await newSpecPage({ - components: [SbbCheckbox], - html: 'Label', - }); - expect(root).toEqualHtml(` - - - - - - - Label - - `); - }); - }); -}); diff --git a/src/components/sbb-checkbox/sbb-checkbox.stories.tsx b/src/components/sbb-checkbox/sbb-checkbox.stories.tsx deleted file mode 100644 index 1c7676f586..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.stories.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const longLabelText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt -quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et -lacus sollicitudin, quis malesuada lorem vehicula. Suspendisse at augue quis tellus vulputate tempor. Vivamus urna -velit, varius nec est ac, mollis efficitur lorem. Quisque non nisl eget massa interdum tempus. Praesent vel feugiat -metus. Donec pharetra odio at turpis bibendum, vel commodo dui vulputate. Aenean congue nec nisl vel bibendum. -Praesent sit amet lorem augue. Suspendisse ornare a justo sagittis fermentum.`; - -/* ************************************************* */ -/* Storybook controls */ -/* ************************************************* */ - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['m', 's'], -}; - -const checked: InputType = { - control: { - type: 'boolean', - }, -}; - -const indeterminate: InputType = { - control: { - type: 'boolean', - }, -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, -}; - -const label: InputType = { - control: { - type: 'text', - }, -}; - -const value: InputType = { - control: { - type: 'text', - }, -}; - -const icon: InputType = { - control: { - type: 'text', - }, -}; - -const iconPlacement: InputType = { - control: { - type: 'select', - }, - options: ['start', 'end'], -}; - -const ariaLabel: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - size, - checked, - indeterminate, - disabled, - label, - value, - 'icon-name': icon, - 'icon-placement': iconPlacement, - 'aria-label': ariaLabel, -}; - -const defaultArgs: Args = { - size: size.options[1], - checked: false, - indeterminate: false, - disabled: false, - label: 'Label', - value: 'Value', - 'icon-name': undefined, - 'icon-placement': undefined, - 'aria-label': undefined, -}; - -/* ************************************************* */ -/* Storybook templates */ -/* ************************************************* */ - -const Template = ({ label, ...args }): JSX.Element => ( - {label} -); - -export const defaultUnchecked: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - }, -}; -export const defaultChecked: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - checked: true, - }, -}; -export const defaultIndeterminate: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - indeterminate: true, - }, -}; -export const sizeM: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[0], - }, -}; -export const longLabel: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - label: longLabelText, - }, -}; -export const withIconEnd: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - 'icon-name': 'tickets-class-small', - }, -}; -export const checkedWithIconStart: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - checked: true, - 'icon-name': 'tickets-class-small', - 'icon-placement': iconPlacement.options[0], - }, -}; -export const disabledChecked: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - disabled: true, - checked: true, - }, -}; -export const disabledUnchecked: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - disabled: true, - }, -}; -export const disabledIndeterminate: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - disabled: true, - indeterminate: true, - }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['change', 'input'], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-checkbox/sbb-checkbox', -}; - -export default meta; diff --git a/src/components/sbb-checkbox/sbb-checkbox.tsx b/src/components/sbb-checkbox/sbb-checkbox.tsx deleted file mode 100644 index c390c69d19..0000000000 --- a/src/components/sbb-checkbox/sbb-checkbox.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - Component, - Prop, - h, - JSX, - Element, - State, - ComponentInterface, - Listen, - EventEmitter, - Event, - Watch, - Host, -} from '@stencil/core'; -import { CheckboxStateChange, InterfaceSbbCheckboxAttributes } from './sbb-checkbox.custom'; -import { i18nCollapsed, i18nExpanded } from '../../global/i18n'; -import { isValidAttribute } from '../../global/dom'; -import { - createNamedSlotState, - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, - namedSlotChangeHandlerAspect, - formElementHandlerAspect, - getEventTarget, - forwardEventToHost, -} from '../../global/eventing'; -import { AgnosticMutationObserver } from '../../global/observers'; - -/** Configuration for the attribute to look at if component is nested in a sbb-checkbox-group */ -const checkboxObserverConfig: MutationObserverInit = { - attributeFilter: ['data-group-required', 'data-group-disabled'], -}; - -/** - * @slot unnamed - Slot used to render the checkbox label's text. - * @slot icon - Slot used to render the checkbox icon (disabled inside a selection panel). - * @slot subtext - Slot used to render a subtext under the label (only visible within a selection panel). - * @slot suffix - Slot used to render additional content after the label (only visible within a selection panel). - */ -@Component({ - shadow: true, - styleUrl: 'sbb-checkbox.scss', - tag: 'sbb-checkbox', -}) -export class SbbCheckbox implements ComponentInterface { - /** Value of checkbox. */ - @Prop() public value?: string; - - /** Whether the checkbox is disabled. */ - @Prop({ reflect: true }) public disabled = false; - - /** Whether the checkbox is required. */ - @Prop() public required = false; - - /** Whether the checkbox is indeterminate. */ - @Prop({ reflect: true, mutable: true }) public indeterminate = false; - - /** - * The icon name we want to use, choose from the small icon variants from the ui-icons category - * from https://icons.app.sbb.ch (optional). - */ - @Prop() public iconName?: string; - - /** The label position relative to the labelIcon. Defaults to end */ - @Prop({ reflect: true }) public iconPlacement: InterfaceSbbCheckboxAttributes['iconPlacement'] = - 'end'; - - /** Whether the checkbox is checked. */ - @Prop({ mutable: true, reflect: true }) public checked = false; - - /** Label size variant, either m or s. */ - @Prop({ reflect: true, mutable: true }) public size: InterfaceSbbCheckboxAttributes['size'] = 'm'; - - /** Whether the component must be set disabled due disabled attribute on sbb-checkbox-group. */ - @State() private _disabledFromGroup = false; - - /** Whether the component must be set required due required attribute on sbb-checkbox-group. */ - @State() private _requiredFromGroup = false; - - /** State of listed named slots, by indicating whether any element for a named slot is defined. */ - @State() private _namedSlots = createNamedSlotState('icon', 'subtext', 'suffix'); - - @State() private _currentLanguage = documentLanguage(); - - /** Whether the input is the main input of a selection panel. */ - @State() private _isSelectionPanelInput = false; - - /** The label describing whether the selection panel is expanded (for screen readers only). */ - @State() private _selectionPanelExpandedLabel: string; - - private _checkbox: HTMLInputElement; - private _selectionPanelElement: HTMLElement; - - /** MutationObserver on data attributes. */ - private _checkboxAttributeObserver = new AgnosticMutationObserver( - this._onCheckboxAttributesChange.bind(this), - ); - - @Element() private _element!: HTMLElement; - - /** - * @deprecated only used for React. Will probably be removed once React 19 is available. - */ - @Event({ bubbles: true, cancelable: true }) public didChange: EventEmitter; - - /** - * @internal - * Internal event that emits whenever the state of the checkbox - * in relation to the parent selection panel changes. - */ - @Event({ - bubbles: true, - eventName: 'state-change', - }) - public stateChange: EventEmitter; - - /** - * Internal event that emits when the input element is loaded. - */ - @Event({ - bubbles: true, - eventName: 'sbb-checkbox-loaded', - }) - public sbbCheckboxLoaded: EventEmitter; - - @Watch('checked') - public handleCheckedChange(currentValue: boolean, previousValue: boolean): void { - if (this._isSelectionPanelInput && currentValue !== previousValue) { - this.stateChange.emit({ type: 'checked', checked: currentValue }); - this._updateExpandedLabel(); - } - } - - @Watch('disabled') - public handleDisabledChange(currentValue: boolean, previousValue: boolean): void { - if (this._isSelectionPanelInput && currentValue !== previousValue) { - this.stateChange.emit({ type: 'disabled', disabled: currentValue }); - } - } - - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - formElementHandlerAspect, - ); - - // Set up the initial disabled/required values and start observe attributes changes. - private _setupInitialStateAndAttributeObserver(): void { - const parentGroup = this._element.closest('sbb-checkbox-group'); - if (parentGroup) { - this._requiredFromGroup = isValidAttribute(parentGroup, 'required'); - this._disabledFromGroup = isValidAttribute(parentGroup, 'disabled'); - this.size = parentGroup.size; - } - this._checkboxAttributeObserver.observe(this._element, checkboxObserverConfig); - } - - /** Observe changes on data attributes and set the appropriate values. */ - private _onCheckboxAttributesChange(mutationsList: MutationRecord[]): void { - for (const mutation of mutationsList) { - if (mutation.attributeName === 'data-group-disabled') { - this._disabledFromGroup = !!isValidAttribute(this._element, 'data-group-disabled'); - } - if (mutation.attributeName === 'data-group-required') { - this._requiredFromGroup = !!isValidAttribute(this._element, 'data-group-required'); - } - } - } - - public connectedCallback(): void { - // We can use closest here, as we expect the parent sbb-selection-panel to be in light DOM. - this._selectionPanelElement = this._element.closest('sbb-selection-panel'); - this._isSelectionPanelInput = - !!this._selectionPanelElement && - !this._element.closest('sbb-selection-panel [slot="content"]'); - this._handlerRepository.connect(); - this._setupInitialStateAndAttributeObserver(); - this._isSelectionPanelInput && this.sbbCheckboxLoaded.emit(); - } - - public componentDidLoad(): void { - this._isSelectionPanelInput && this._updateExpandedLabel(); - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - this._checkboxAttributeObserver.disconnect(); - } - - @Listen('click') - public handleClick(event: Event): void { - if (!this.disabled && !this._disabledFromGroup && getEventTarget(event) === this._element) { - this._checkbox.click(); - } - } - - @Listen('keyup') - public handleKeyup(event: KeyboardEvent): void { - // The native checkbox input toggles state on keyup with space. - if (!this.disabled && !this._disabledFromGroup && event.key === ' ') { - // The toggle needs to happen after the keyup event finishes, so we schedule - // it to be triggered after the current event loop. - setTimeout(() => this._checkbox.click()); - } - } - - public handleChangeEvent(event: Event): void { - forwardEventToHost(event, this._element); - this.didChange.emit(); - } - - /** - * Method triggered on checkbox input event. - * If not indeterminate, inverts the value; otherwise sets checked to true. - */ - public handleInputEvent(): void { - if (this.indeterminate) { - this.checked = true; - this.indeterminate = false; - } else { - this.checked = this._checkbox?.checked ?? false; - } - } - - private _updateExpandedLabel(): void { - if (!this._selectionPanelElement.hasAttribute('data-has-content')) { - this._selectionPanelExpandedLabel = ''; - return; - } - - this._selectionPanelExpandedLabel = this.checked - ? ', ' + i18nExpanded[this._currentLanguage] - : ', ' + i18nCollapsed[this._currentLanguage]; - } - - public render(): JSX.Element { - const attributes = { - role: 'checkbox', - 'aria-checked': this.indeterminate ? 'mixed' : this.checked?.toString() ?? 'false', - 'aria-required': (this.required || this._requiredFromGroup).toString(), - 'aria-disabled': (this.disabled || this._disabledFromGroup).toString(), - 'data-is-selection-panel-input': this._isSelectionPanelInput, - ...(this.disabled || this._disabledFromGroup ? undefined : { tabIndex: '0' }), - }; - return ( - - - - - - ); - } -} diff --git a/src/components/sbb-chip/readme.md b/src/components/sbb-chip/readme.md deleted file mode 100644 index 8448ccdf3a..0000000000 --- a/src/components/sbb-chip/readme.md +++ /dev/null @@ -1,40 +0,0 @@ -The `sbb-chip` is a visual component used to display compact information, like a filter's name or a tag. - -```html -On sale -``` - -## Style - -It's possible to choose among three different values for the `size` property (`s`, `xs` and `xxs`, which is the default), -and four different values for the `color` property (`charcoal`, `granite`, `white` and `milk`, which is the default). - -```html -Label - -Label - -Label -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------- | --------- | ------------------ | ---------------------------------------------- | -------- | -| `color` | `color` | Color of the chip. | `"charcoal" \| "granite" \| "milk" \| "white"` | `'milk'` | -| `size` | `size` | Size of the chip. | `"s" \| "xs" \| "xxs"` | `'xxs'` | - - -## Slots - -| Slot | Description | -| ----------- | --------------------------- | -| `"unnamed"` | Content / Label of the chip | - - ----------------------------------------------- - - diff --git a/src/components/sbb-chip/sbb-chip.custom.d.ts b/src/components/sbb-chip/sbb-chip.custom.d.ts deleted file mode 100644 index 104908568a..0000000000 --- a/src/components/sbb-chip/sbb-chip.custom.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface InterfaceSbbChipAttributes { - size: 's' | 'xs' | 'xxs'; - color: 'milk' | 'charcoal' | 'white' | 'granite'; -} diff --git a/src/components/sbb-chip/sbb-chip.e2e.ts b/src/components/sbb-chip/sbb-chip.e2e.ts deleted file mode 100644 index d6f5daccaa..0000000000 --- a/src/components/sbb-chip/sbb-chip.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-chip', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent('Label'); - - element = await page.find('sbb-chip'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-chip/sbb-chip.scss b/src/components/sbb-chip/sbb-chip.scss deleted file mode 100644 index 27c2710fb9..0000000000 --- a/src/components/sbb-chip/sbb-chip.scss +++ /dev/null @@ -1,47 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - display: inline-block; -} - -:host([color='milk']) { - @include sbb.chip-variables--color-milk; -} - -:host([color='charcoal']) { - @include sbb.chip-variables--color-charcoal; -} - -:host([color='white']) { - @include sbb.chip-variables--color-white; -} - -:host([color='granite']) { - @include sbb.chip-variables--color-granite; -} - -:host([size='xxs']) { - @include sbb.chip-variables--size-xxs; -} - -:host([size='xs']) { - @include sbb.chip-variables--size-xs; -} - -:host([size='s']) { - @include sbb.chip-variables--size-s; -} - -.sbb-chip { - @include sbb.chip-rules; - - display: flex; -} - -.sbb-chip__text-wrapper { - @include sbb.chip-rules-ellipsis; -} diff --git a/src/components/sbb-chip/sbb-chip.spec.ts b/src/components/sbb-chip/sbb-chip.spec.ts deleted file mode 100644 index c95c259c54..0000000000 --- a/src/components/sbb-chip/sbb-chip.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SbbChip } from './sbb-chip'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-chip', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbChip], - html: 'Label', - }); - - expect(root).toEqualHtml(` - - - - - - - - - Label - - `); - }); -}); diff --git a/src/components/sbb-chip/sbb-chip.stories.tsx b/src/components/sbb-chip/sbb-chip.stories.tsx deleted file mode 100644 index 80ed603a6f..0000000000 --- a/src/components/sbb-chip/sbb-chip.stories.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': - context.args.color === 'milk' || context.args.color === 'white' - ? 'var(--sbb-color-granite-default)' - : 'var(--sbb-color-white-default)', -}); - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['xxs', 'xs', 's'], -}; - -const color: InputType = { - control: { - type: 'inline-radio', - }, - options: ['milk', 'charcoal', 'white', 'granite'], -}; - -const label: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - color, - size, - label, -}; - -const defaultArgs: Args = { - size: size.options[0], - color: color.options[0], - label: 'Label', -}; - -const Template = ({ label, ...args }): JSX.Element => {label}; -const TemplateFixedWidth = ({ label, ...args }): JSX.Element => ( - - {label} - -); - -export const MilkXXS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - }, -}; - -export const MilkXS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[1], - }, -}; - -export const MilkS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - size: size.options[2], - }, -}; - -export const Charcoal: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[1], - }, -}; - -export const White: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[2], - }, -}; - -export const Granite: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - color: color.options[3], - }, -}; - -export const FixedWidth: StoryObj = { - render: TemplateFixedWidth, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - }, -}; - -export const FixedWidthLongLabel: StoryObj = { - render: TemplateFixedWidth, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - label: 'This is a very long label which will be cut.', - }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-chip', -}; - -export default meta; diff --git a/src/components/sbb-chip/sbb-chip.tsx b/src/components/sbb-chip/sbb-chip.tsx deleted file mode 100644 index 1f3d8838f5..0000000000 --- a/src/components/sbb-chip/sbb-chip.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, ComponentInterface, h, JSX, Prop } from '@stencil/core'; -import { InterfaceSbbChipAttributes } from './sbb-chip.custom'; - -/** - * @slot unnamed - Content / Label of the chip - */ -@Component({ - shadow: true, - styleUrl: 'sbb-chip.scss', - tag: 'sbb-chip', -}) -export class SbbChip implements ComponentInterface { - /** Size of the chip. */ - @Prop({ reflect: true }) - public size: InterfaceSbbChipAttributes['size'] = 'xxs'; - - /** Color of the chip. */ - @Prop({ reflect: true }) - public color: InterfaceSbbChipAttributes['color'] = 'milk'; - - public render(): JSX.Element { - return ( - - - - - - ); - } -} diff --git a/src/components/sbb-clock/readme.md b/src/components/sbb-clock/readme.md deleted file mode 100644 index 68001421c4..0000000000 --- a/src/components/sbb-clock/readme.md +++ /dev/null @@ -1,20 +0,0 @@ -The `sbb-clock` component displays an analog clock face in the style of the classic SBB station clock. - -It mimics its behavior too, completing a rotation in approximately 58.5 seconds, -then it briefly pauses at the clock top before starting a new rotation. - -```html - -``` - -## Testing - -To specify a specific date for the current datetime, you can use the `data-now` attribute (timestamp in milliseconds). -This is helpful if you need a specific state of the component. - - - - ----------------------------------------------- - - diff --git a/src/components/sbb-clock/sbb-clock.e2e.ts b/src/components/sbb-clock/sbb-clock.e2e.ts deleted file mode 100644 index 3bc65c088d..0000000000 --- a/src/components/sbb-clock/sbb-clock.e2e.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-clock', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-clock'); - - expect(element).toEqualHtml(` - - -
      - - - - - - - - - - - - - - - - - - - - - - - - - - -
      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-clock/sbb-clock.scss b/src/components/sbb-clock/sbb-clock.scss deleted file mode 100644 index d5d939ef62..0000000000 --- a/src/components/sbb-clock/sbb-clock.scss +++ /dev/null @@ -1,121 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-clock-hours-animation-start-angle: 0deg; - --sbb-clock-seconds-animation-start-angle: 0deg; - --sbb-clock-hours-animation-duration: 0s; - --sbb-clock-seconds-animation-duration: 0s; - --sbb-clock-animation-play-state: paused; -} - -.sbb-clock { - position: relative; - width: 100%; - height: 100%; - aspect-ratio: 1 / 1; - overflow: hidden; - contain: content; -} - -.sbb-clock__face, -.sbb-clock__hand-hours, -.sbb-clock__hand-minutes, -.sbb-clock__hand-seconds { - position: absolute; - inset: 0; - transform-origin: center center; - transform: rotateZ(0deg); - transform-style: preserve-3d; - backface-visibility: hidden; -} - -.sbb-clock__hand-minutes { - @supports not (aspect-ratio: 1 / 1) { - transform-origin: 50% 49.625%; - } - - transition: transform 0.2s cubic-bezier(0.4, 2.08, 0.55, 0.44); -} - -.sbb-clock__hand-hours { - animation-name: rotate; - animation-duration: 43200s; - animation-iteration-count: infinite; - animation-timing-function: linear; - animation-play-state: var(--sbb-clock-animation-play-state); -} - -.sbb-clock__hand-hours--initial-hour { - animation-name: hand-hours-animation-initial-hour; - animation-duration: var(--sbb-clock-hours-animation-duration); - animation-play-state: var(--sbb-clock-animation-play-state); - animation-iteration-count: 1; -} - -.sbb-clock__hand-minutes--no-transition { - transition: none; -} - -.sbb-clock__hand-seconds { - animation-name: hand-seconds-animation; - animation-duration: 60s; - animation-timing-function: linear; - animation-play-state: var(--sbb-clock-animation-play-state); - animation-iteration-count: infinite; - fill: var(--sbb-color-red-default); -} - -.sbb-clock__hand-seconds--initial-minute { - animation-name: hand-seconds-animation-initial-minute; - animation-duration: var(--sbb-clock-seconds-animation-duration); - animation-play-state: var(--sbb-clock-animation-play-state); - animation-iteration-count: 1; -} - -:is(.sbb-clock__hand-hours, .sbb-clock__hand-minutes, .sbb-clock__hand-seconds) { - :host(:not([data-initialized])) & { - display: none; - } -} - -@keyframes rotate { - 100% { - transform: rotateZ(360deg); - } -} - -@keyframes hand-hours-animation-initial-hour { - 0% { - transform: rotateZ(var(--sbb-clock-hours-animation-start-angle)); - } - - 100% { - transform: rotateZ(360deg); - } -} - -@keyframes hand-seconds-animation { - 0% { - transform: rotateZ(0deg); - } - - 97.5%, - 100% { - transform: rotateZ(360deg); - } -} - -@keyframes hand-seconds-animation-initial-minute { - 0% { - transform: rotateZ(var(--sbb-clock-seconds-animation-start-angle)); - } - - 97.5%, - 100% { - transform: rotateZ(360deg); - } -} diff --git a/src/components/sbb-clock/sbb-clock.spec.ts b/src/components/sbb-clock/sbb-clock.spec.ts deleted file mode 100644 index e290a90e81..0000000000 --- a/src/components/sbb-clock/sbb-clock.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SbbClock } from './sbb-clock'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-clock', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbClock], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      - - - - -
      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-clock/sbb-clock.stories.tsx b/src/components/sbb-clock/sbb-clock.stories.tsx deleted file mode 100644 index fc87755689..0000000000 --- a/src/components/sbb-clock/sbb-clock.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import isChromatic from 'chromatic'; -import readme from './readme.md'; -import type { Meta, StoryObj } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const dataNow: InputType = { - control: { - type: 'date', - }, -}; - -const Template = (args): JSX.Element => ; - -export const Default: StoryObj = { - render: Template, - argTypes: { 'data-now': dataNow }, - args: { 'data-now': undefined }, -}; - -export const Paused: StoryObj = { - render: Template, - argTypes: { 'data-now': dataNow }, - args: { 'data-now': new Date('2023-01-24T10:10:30+01:00').valueOf() }, -}; - -/** - * Stop the clock for Chromatic visual regression tests - * and set time to given time - */ -if (isChromatic()) { - Default.args = { - 'data-now': new Date('2023-01-24T10:10:30+01:00').valueOf(), - }; -} - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - ], - parameters: { - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-clock', -}; - -export default meta; diff --git a/src/components/sbb-clock/sbb-clock.tsx b/src/components/sbb-clock/sbb-clock.tsx deleted file mode 100644 index ba1df8a673..0000000000 --- a/src/components/sbb-clock/sbb-clock.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, State } from '@stencil/core'; - -import clockFaceSVG from './assets/sbb_clock_face.svg'; -import clockHandleHoursSVG from './assets/sbb_clock_hours.svg'; -import clockHandleMinutesSVG from './assets/sbb_clock_minutes.svg'; -import clockHandleSecondsSVG from './assets/sbb_clock_seconds.svg'; - -/** Number of hours on the clock face. */ -const TOTAL_HOURS_ON_CLOCK_FACE = 12; - -/** Number of minutes on the clock face. */ -const TOTAL_MINUTES_ON_CLOCK_FACE = 60; - -/** Number of seconds on the clock face. */ -const TOTAL_SECONDS_ON_CLOCK_FACE = 60; - -/** Timeout for the clock start. */ -const INITIAL_TIMEOUT_DURATION = 50; - -/** Angle in a single rotation. */ -const FULL_ANGLE = 360; - -/** Angle between two consecutive hours: 360/12, means a full rotation / number of hours in a rotation. */ -const HOURS_ANGLE = 30; - -/** Angle between two consecutive minutes: 360/60, means a full rotation / number of minutes in one hour. */ -const MINUTES_ANGLE = 6; - -/** Angle between two consecutive seconds for SBB clock custom behavior. */ -const SBB_SECONDS_ANGLE = 360 / 58.5; - -/** Number of seconds in a minute. */ -const SECONDS_IN_A_MINUTE = 60; - -/** Number of seconds in an hour. */ -const SECONDS_IN_AN_HOUR = 3600; - -const ADD_EVENT_LISTENER_OPTIONS: AddEventListenerOptions = { - once: true, - passive: true, -}; - -@Component({ - shadow: true, - styleUrl: 'sbb-clock.scss', - tag: 'sbb-clock', -}) -export class SbbClock implements ComponentInterface { - /** If it's false, the clock's hands are hidden; it's set to true when calculations are ready. */ - @State() private _isInitialized = false; - - /** Reference to the host element. */ - @Element() private _element: HTMLElement; - - /** Reference to the hour hand. */ - private _clockHandHours: HTMLElement; - - /** Reference to the minute hand. */ - private _clockHandMinutes: HTMLElement; - - /** Reference to the second hand. */ - private _clockHandSeconds: HTMLElement; - - /** Hours value for the current date. */ - private _hours: number; - - /** Minutes value for the current date. */ - private _minutes: number; - - /** Seconds value for the current date. */ - private _seconds: number; - - /** Callback function for hours hand. */ - private _moveHoursHandFn = (): void => this._moveHoursHand(); - - /** Callback function for minutes hand. */ - private _moveMinutesHandFn = (): void => this._moveMinutesHand(); - - /** Move the minutes hand every minute. */ - private _handMovement: ReturnType; - - private _handlePageVisibilityChange(): void { - if (document.visibilityState === 'hidden') { - this._stopClock(); - } else if (!this._hasDataNow()) { - this._startClock(); - } - } - - private _addEventListeners(): void { - document.addEventListener('visibilitychange', () => this._handlePageVisibilityChange(), false); - } - - private _removeEventListeners(): void { - document.removeEventListener( - 'visibilitychange', - () => this._handlePageVisibilityChange(), - false, - ); - this._clockHandHours?.removeEventListener('animationend', this._moveHoursHandFn); - this._clockHandSeconds?.removeEventListener('animationend', this._moveMinutesHandFn); - clearInterval(this._handMovement); - } - - private _removeHoursAnimationStyles(): void { - this._clockHandHours?.classList.remove('sbb-clock__hand-hours--initial-hour'); - this._element.style.removeProperty('--sbb-clock-hours-animation-start-angle'); - this._element.style.removeProperty('--sbb-clock-hours-animation-duration'); - } - - private _removeSecondsAnimationStyles(): void { - this._clockHandSeconds?.classList.remove('sbb-clock__hand-seconds--initial-minute'); - this._clockHandMinutes?.classList.remove('sbb-clock__hand-minutes--no-transition'); - this._element.style.removeProperty('--sbb-clock-seconds-animation-start-angle'); - this._element.style.removeProperty('--sbb-clock-seconds-animation-duration'); - } - - /** Given the current date, calculates the hh/mm/ss values and the hh/mm/ss left to the next midnight. */ - private _assignCurrentTime(): void { - const date = this._now(); - this._hours = date.getHours() % 12; - this._minutes = date.getMinutes(); - this._seconds = date.getSeconds(); - } - - /** Set the starting position for the three hands on the clock face. */ - private _setHandsStartingPosition(): void { - this._assignCurrentTime(); - const remainingSeconds = TOTAL_SECONDS_ON_CLOCK_FACE - this._seconds; - const remainingMinutes = TOTAL_MINUTES_ON_CLOCK_FACE - this._minutes; - const remainingHours = TOTAL_HOURS_ON_CLOCK_FACE - this._hours; - - let hoursAnimationDuration = 0; - let hasRemainingMinutesOrSeconds = 0; - - if (remainingSeconds > 0) { - hoursAnimationDuration += remainingSeconds; - hasRemainingMinutesOrSeconds = 1; - } - - if (remainingMinutes > 0) { - hoursAnimationDuration += - (remainingMinutes - hasRemainingMinutesOrSeconds) * SECONDS_IN_A_MINUTE; - hasRemainingMinutesOrSeconds = 1; - } - - if (remainingHours > 0) { - hoursAnimationDuration += - (remainingHours - hasRemainingMinutesOrSeconds) * SECONDS_IN_AN_HOUR; - } - - if (this._clockHandSeconds) { - this._clockHandSeconds.style.animation = ''; - } - - this._element.style.setProperty( - '--sbb-clock-hours-animation-start-angle', - `${Math.ceil(this._hours * HOURS_ANGLE + this._minutes / 2)}deg`, - ); - this._element.style.setProperty( - '--sbb-clock-hours-animation-duration', - `${hoursAnimationDuration}s`, - ); - this._element.style.setProperty( - '--sbb-clock-seconds-animation-start-angle', - `${Math.ceil(this._seconds * SBB_SECONDS_ANGLE)}deg`, - ); - this._element.style.setProperty( - '--sbb-clock-seconds-animation-duration', - `${remainingSeconds}s`, - ); - - this._setMinutesHand(); - - this._clockHandSeconds?.classList.add('sbb-clock__hand-seconds--initial-minute'); - this._clockHandHours?.classList.add('sbb-clock__hand-hours--initial-hour'); - this._element.style.setProperty('--sbb-clock-animation-play-state', 'running'); - - this._isInitialized = true; - } - - /** Set the starting position for the minutes hand. */ - private _setMinutesHand(): void { - this._clockHandMinutes?.style.setProperty( - 'transform', - `rotateZ(${Math.ceil(this._minutes * MINUTES_ANGLE)}deg)`, - ); - } - - /** Move the hours hand to the next value. */ - private _moveHoursHand(): void { - this._removeHoursAnimationStyles(); - - let hoursAngle = Math.ceil(this._hours * HOURS_ANGLE + this._minutes / 2); - - if (hoursAngle >= FULL_ANGLE) { - hoursAngle -= FULL_ANGLE; - } - - this._clockHandHours?.style.setProperty('transform', `rotateZ(${hoursAngle}deg)`); - } - - /** Move the minutes hand to the next value. */ - private _moveMinutesHand(): void { - this._clockHandSeconds?.removeEventListener('animationend', this._moveMinutesHandFn); - - this._removeSecondsAnimationStyles(); - - this._addMinutesAndSetHands(); - - this._handMovement = setInterval( - () => this._addMinutesAndSetHands(), - TOTAL_SECONDS_ON_CLOCK_FACE * 1000, - ); - } - - private _addMinutesAndSetHands(): void { - this._minutes++; - this._setMinutesHand(); - } - - /** Stops the clock by removing all the animations. */ - private _stopClock(): void { - clearInterval(this._handMovement); - - if (this._hasDataNow()) { - this._setHandsStartingPosition(); - this._clockHandSeconds?.classList.add('sbb-clock__hand-seconds--initial-minute'); - this._clockHandHours?.classList.add('sbb-clock__hand-hours--initial-hour'); - } else { - this._removeSecondsAnimationStyles(); - this._removeHoursAnimationStyles(); - } - - this._clockHandHours?.removeEventListener('animationend', this._moveHoursHandFn); - this._clockHandSeconds?.removeEventListener('animationend', this._moveMinutesHandFn); - - this._clockHandMinutes?.classList.add('sbb-clock__hand-minutes--no-transition'); - - this._element.style.setProperty('--sbb-clock-animation-play-state', 'paused'); - } - - /** Starts the clock by defining the hands starting position then starting the animations. */ - private _startClock(): void { - this._clockHandHours?.addEventListener( - 'animationend', - this._moveHoursHandFn, - ADD_EVENT_LISTENER_OPTIONS, - ); - this._clockHandSeconds?.addEventListener( - 'animationend', - this._moveMinutesHandFn, - ADD_EVENT_LISTENER_OPTIONS, - ); - - setTimeout(() => this._setHandsStartingPosition(), INITIAL_TIMEOUT_DURATION); - } - - private _hasDataNow(): boolean { - const dataNow = +this._element.dataset?.now; - return !isNaN(dataNow); - } - - private _now(): Date { - if (this._hasDataNow()) { - return new Date(+this._element.dataset?.now); - } - return new Date(); - } - - public componentDidLoad(): void { - this._addEventListeners(); - - if (this._hasDataNow()) { - this._stopClock(); - } else { - this._startClock(); - } - } - - public disconnectedCallback(): void { - this._removeEventListeners(); - } - - public render(): JSX.Element { - const hostAttributes = { 'data-initialized': this._isInitialized }; - return ( - -
      - - { - this._clockHandHours = el; - }} - /> - { - this._clockHandMinutes = el; - }} - /> - { - this._clockHandSeconds = el; - }} - /> -
      -
      - ); - } -} diff --git a/src/components/sbb-datepicker-next-day/readme.md b/src/components/sbb-datepicker-next-day/readme.md deleted file mode 100644 index 8edecb07d0..0000000000 --- a/src/components/sbb-datepicker-next-day/readme.md +++ /dev/null @@ -1,58 +0,0 @@ -The `sbb-datepicker-next-day` is a component closely connected to the [sbb-datepicker](/docs/components-sbb-datepicker-sbb-datepicker--docs); -when the two are used together, the `sbb-datepicker-next-day` can be used to choose -the date after the selected date, or tomorrow's date if the date-picker's input has no defined value. - -The components can be connected using the `datePicker` property, which accepts the id of the `sbb-datepicker`, -or directly its reference. - -```html - - - -``` - -## In `sbb-form-field` - -If the two components are used within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs), -they are automatically linked and the `sbb-datepicker-next-day` will be projected in the `suffix` slot of the `sbb-form-field`; -otherwise, they can be connected using the `datePicker` property as described above. - -The `sbb-datepicker-next-day` has an internal disabled state, which is set looking at the `sbb-datepicker`'s input: -if it is disabled, or if the selected date is equal to the input's `max` attribute, the component is disabled. - -```html - - - - - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------ | ------------- | ----------------------------------------- | ----------------------- | ----------- | -| `datePicker` | `date-picker` | Datepicker reference. | `HTMLElement \| string` | `undefined` | -| `name` | `name` | The name attribute to use for the button. | `string` | `undefined` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-datepicker-next-day --> sbb-icon - style sbb-datepicker-next-day fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.e2e.ts b/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.e2e.ts deleted file mode 100644 index 2fe6b79da0..0000000000 --- a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.e2e.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-datepicker-next-day', () => { - describe('standalone', () => { - it('renders', async () => { - const page: E2EPage = await newE2EPage({ - html: '', - }); - const element: E2EElement = await page.find('sbb-datepicker-next-day'); - expect(element).toHaveClass('hydrated'); - }); - }); - - describe('with picker', () => { - it('renders and click', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - - `, - }); - const element: E2EElement = await page.find('sbb-datepicker-next-day'); - const input: E2EElement = await page.find('input'); - await page.waitForChanges(); - expect(element).toHaveClass('hydrated'); - expect(await input.getProperty('value')).toEqual('Sa, 31.12.2022'); - - const changeSpy = await input.spyOnEvent('change'); - const blurSpy = await input.spyOnEvent('blur'); - await element.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(blurSpy).toHaveReceivedEventTimes(1); - - expect(await input.getProperty('value')).toEqual('Su, 01.01.2023'); - }); - }); - - describe('in form field', () => { - let element: E2EElement, input: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - - - - `); - element = await page.find('sbb-datepicker-next-day'); - input = await page.find('input'); - await page.waitForChanges(); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('click', async () => { - expect(await input.getProperty('value')).toEqual('Sa, 21.01.2023'); - const changeSpy = await input.spyOnEvent('change'); - const blurSpy = await input.spyOnEvent('blur'); - await element.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(blurSpy).toHaveReceivedEventTimes(1); - expect(await input.getProperty('value')).toEqual('Su, 22.01.2023'); - }); - - it('disabled due max value equals to value', async () => { - page = await newE2EPage(); - await page.setContent(` - - - - - - `); - input = await page.find('input'); - await page.waitForChanges(); - - expect(await input.getProperty('value')).toEqual('Sa, 21.01.2023'); - await page.waitForChanges(); - - expect( - await page.evaluate(() => - document.querySelector('sbb-datepicker-next-day').hasAttribute('data-disabled'), - ), - ).toEqual(true); - - await element.click(); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('Sa, 21.01.2023'); - }); - - it('disabled due disabled picker', async () => { - expect(await input.getProperty('value')).toEqual('Sa, 21.01.2023'); - await page.evaluate(() => document.querySelector('input').setAttribute('disabled', '')); - - await page.waitForChanges(); - - expect(element).toHaveAttribute('data-disabled'); - await element.click(); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('Sa, 21.01.2023'); - }); - }); -}); diff --git a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.scss b/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.scss deleted file mode 100644 index 6e6a012e6e..0000000000 --- a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.scss +++ /dev/null @@ -1,20 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - display: flex; - - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; -} - -@include sbb.icon-button('.sbb-datepicker-next-day', 'sbb-icon'); - -.sbb-datepicker-next-day { - margin: auto; - -webkit-tap-highlight-color: transparent; -} diff --git a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.spec.ts b/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.spec.ts deleted file mode 100644 index 3a3a187e14..0000000000 --- a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { SbbDatepickerNextDay } from './sbb-datepicker-next-day'; -import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; -import { SbbDatepicker } from '../sbb-datepicker/sbb-datepicker'; - -describe('sbb-datepicker-next-day', () => { - it('renders', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepickerNextDay], - html: '', - }); - - expect(page.root).toEqualHtml(` - - - - - - - - `); - }); - - it('renders with datepicker and input disabled', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerNextDay], - html: ` - - - - - - `, - }); - - const element: HTMLSbbDatepickerNextDayElement = - page.doc.querySelector('sbb-datepicker-next-day'); - expect(element).toHaveAttribute('data-disabled'); - }); - - it('renders with datepicker and input readonly', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerNextDay], - html: ` - - - - - - `, - }); - - const element: HTMLSbbDatepickerNextDayElement = - page.doc.querySelector('sbb-datepicker-next-day'); - expect(element).toHaveAttribute('data-disabled'); - }); -}); diff --git a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.stories.tsx b/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.stories.tsx deleted file mode 100644 index 851f46bed5..0000000000 --- a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.stories.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, Decorator, StoryContext } from '@storybook/html'; -import { InputType } from '@storybook/types'; -import { Args, ArgTypes } from '@storybook/html'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const defaultArgTypes: ArgTypes = { - negative, -}; - -const defaultArgs: Args = { - negative: false, -}; - -const StandaloneTemplate = (args, picker = null): JSX.Element => ( - -); - -const PickerAndButtonTemplate = (args): JSX.Element => ( -
      - - - {StandaloneTemplate(args, 'datepicker')} -
      -); - -const FormFieldTemplate = (args): JSX.Element => ( - - - - {StandaloneTemplate(args)} - -); - -const EmptyFormFieldTemplate = (args): JSX.Element => ( - - - - {StandaloneTemplate(args)} - -); - -export const Standalone: StoryObj = { - render: StandaloneTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const StandaloneNegative: StoryObj = { - render: StandaloneTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs, negative: true }, -}; - -export const WithPicker: StoryObj = { - render: PickerAndButtonTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const InFormField: StoryObj = { - render: FormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const EmptyFormField: StoryObj = { - render: EmptyFormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['click', 'change'], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-datepicker/sbb-datepicker-next-day', -}; - -export default meta; diff --git a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.tsx b/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.tsx deleted file mode 100644 index aaca822ec3..0000000000 --- a/src/components/sbb-datepicker-next-day/sbb-datepicker-next-day.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - h, - Host, - JSX, - Listen, - Prop, - State, - Watch, -} from '@stencil/core'; -import { i18nNextDay, i18nSelectNextDay, i18nToday } from '../../global/i18n'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { - datepickerControlRegisteredEvent, - findNextAvailableDate, - getDatePicker, - InputUpdateEvent, -} from '../sbb-datepicker/sbb-datepicker.helper'; -import { DateAdapter, NativeDateAdapter } from '../../global/datetime'; -import { - documentLanguage, - HandlerRepository, - actionElementHandlerAspect, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { isValidAttribute, toggleDatasetEntry } from '../../global/dom'; - -@Component({ - shadow: true, - styleUrl: 'sbb-datepicker-next-day.scss', - tag: 'sbb-datepicker-next-day', -}) -export class SbbDatepickerNextDay implements ComponentInterface, ButtonProperties { - /** The name attribute to use for the button. */ - @Prop({ reflect: true }) public name: string | undefined; - - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - /** Datepicker reference. */ - @Prop() public datePicker?: string | HTMLElement; - - @Element() private _element!: HTMLSbbDatepickerNextDayElement; - - /** Whether the component is disabled due date equals to max date. */ - @State() private _disabled = false; - - /** Whether the component is disabled due date-picker's input disabled. */ - @State() private _inputDisabled = false; - - /** The maximum date as set in the date-picker's input. */ - @State() private _max: string | number; - - @State() private _currentLanguage = documentLanguage(); - - private _handlerRepository = new HandlerRepository( - this._element as HTMLElement, - actionElementHandlerAspect, - languageChangeHandlerAspect((l) => { - this._currentLanguage = l; - this._setAriaLabel(); - }), - ); - - private _datePickerElement: HTMLSbbDatepickerElement; - - private _dateAdapter: DateAdapter = new NativeDateAdapter(); - - private _datePickerController: AbortController; - - @Watch('datePicker') - public async findDatePicker( - newValue: string | HTMLElement, - oldValue: string | HTMLElement, - ): Promise { - if (newValue !== oldValue) { - await this._init(this.datePicker); - } - } - - @Listen('click') - public async handleClick(): Promise { - if (!this._datePickerElement || isValidAttribute(this._element, 'data-disabled')) { - return; - } - const startingDate: Date = (await this._datePickerElement.getValueAsDate()) ?? this._now(); - const date: Date = findNextAvailableDate( - startingDate, - this._datePickerElement.dateFilter, - this._dateAdapter, - this._max, - ); - if (this._dateAdapter.compareDate(date, startingDate) !== 0) { - await this._datePickerElement.setValueAsDate(date); - } - } - - public async connectedCallback(): Promise { - this._handlerRepository.connect(); - this._syncUpstreamProperties(); - await this._init(this.datePicker); - } - - private _syncUpstreamProperties(): void { - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - - // We can't use getInputElement of SbbFormField as async awaiting is not supported in connectedCallback. - // We here only have to look for input. - const inputElement = formField.querySelector('input'); - - if (inputElement) { - this._inputDisabled = - isValidAttribute(inputElement, 'disabled') || isValidAttribute(inputElement, 'readonly'); - } - } - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - this._datePickerController?.abort(); - } - - private async _init(picker?: string | HTMLElement): Promise { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - this._datePickerElement = getDatePicker(this._element, picker); - if (!this._datePickerElement) { - return; - } - await this._setDisabledState(this._datePickerElement); - await this._setAriaLabel(); - - this._datePickerElement.addEventListener( - 'change', - (event: Event) => { - this._setDisabledState(event.target as HTMLSbbDatepickerElement); - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.addEventListener( - 'datePickerUpdated', - (event: Event) => { - this._setDisabledState(event.target as HTMLSbbDatepickerElement); - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.addEventListener( - 'inputUpdated', - (event: CustomEvent) => { - this._inputDisabled = event.detail.disabled || event.detail.readonly; - if (this._max !== event.detail.max) { - this._max = event.detail.max; - this._setDisabledState(this._datePickerElement); - } - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - - this._datePickerElement.dispatchEvent(datepickerControlRegisteredEvent); - } - - private async _setDisabledState(datepicker: HTMLSbbDatepickerElement): Promise { - const pickerValueAsDate: Date = await datepicker.getValueAsDate(); - - if (!pickerValueAsDate) { - this._disabled = true; - return; - } - - const nextDate: Date = findNextAvailableDate( - pickerValueAsDate, - datepicker.dateFilter, - this._dateAdapter, - this._max, - ); - this._disabled = this._dateAdapter.compareDate(nextDate, pickerValueAsDate) === 0; - } - - private _hasDataNow(): boolean { - if (!this._datePickerElement) { - return false; - } - const dataNow = +this._datePickerElement.dataset?.now; - return !isNaN(dataNow); - } - - private _now(): Date { - if (this._hasDataNow()) { - const today = new Date(+this._datePickerElement.dataset?.now); - today.setHours(0, 0, 0, 0); - return today; - } - return this._dateAdapter.today(); - } - - private async _setAriaLabel(): Promise { - const currentDate = await this._datePickerElement.getValueAsDate(); - - if (!currentDate) { - this._element.setAttribute('aria-label', i18nNextDay[this._currentLanguage]); - return; - } - - const currentDateString = - this._dateAdapter.today().toDateString() === currentDate.toDateString() - ? i18nToday[this._currentLanguage].toLowerCase() - : this._dateAdapter.getAccessibilityFormatDate(currentDate); - - this._element.setAttribute( - 'aria-label', - i18nSelectNextDay(currentDateString)[this._currentLanguage], - ); - } - - public render(): JSX.Element { - toggleDatasetEntry(this._element, 'disabled', this._disabled || this._inputDisabled); - const { hostAttributes } = resolveButtonRenderVariables({ - ...this, - disabled: isValidAttribute(this._element, 'data-disabled'), - }); - - return ( - - - - - - ); - } -} diff --git a/src/components/sbb-datepicker-previous-day/readme.md b/src/components/sbb-datepicker-previous-day/readme.md deleted file mode 100644 index d905d9fe00..0000000000 --- a/src/components/sbb-datepicker-previous-day/readme.md +++ /dev/null @@ -1,58 +0,0 @@ -The `sbb-datepicker-previous-day` is a component closely connected to the [sbb-datepicker](/docs/components-sbb-datepicker-sbb-datepicker--docs); -when the two are used together, the `sbb-datepicker-previous-day` can be used to choose -the date before the selected date, or yesterday's date if the date-picker's input has no defined value. - -The components can be connected using the `datePicker` property, which accepts the id of the `sbb-datepicker`, -or directly its reference. - -```html - - - -``` - -## In `sbb-form-field` - -If the two components are used within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs), -they are automatically linked and the `sbb-datepicker-previous-day` will be projected in the `prefix` slot of the `sbb-form-field`; -otherwise, they can be connected using the `datePicker` property as described above. - -The `sbb-datepicker-previous-day` has an internal disabled state, which is set looking at the `sbb-datepicker`'s input: -if it is disabled, or if the selected date is equal to the input's `min` attribute, the component is disabled. - -```html - - - - - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------ | ------------- | ----------------------------------------- | ----------------------- | ----------- | -| `datePicker` | `date-picker` | Datepicker reference. | `HTMLElement \| string` | `undefined` | -| `name` | `name` | The name attribute to use for the button. | `string` | `undefined` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-datepicker-previous-day --> sbb-icon - style sbb-datepicker-previous-day fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.e2e.ts b/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.e2e.ts deleted file mode 100644 index aaaadfb77e..0000000000 --- a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.e2e.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-datepicker-previous-day', () => { - describe('standalone', () => { - it('renders', async () => { - const page: E2EPage = await newE2EPage({ - html: '', - }); - const element: E2EElement = await page.find('sbb-datepicker-previous-day'); - expect(element).toHaveClass('hydrated'); - }); - }); - - describe('with picker', () => { - it('renders and click', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - - `, - }); - const element: E2EElement = await page.find('sbb-datepicker-previous-day'); - const input: E2EElement = await page.find('input'); - await page.waitForChanges(); - expect(element).toHaveClass('hydrated'); - expect(await input.getProperty('value')).toEqual('Su, 01.01.2023'); - - const changeSpy = await input.spyOnEvent('change'); - const blurSpy = await input.spyOnEvent('blur'); - await element.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(blurSpy).toHaveReceivedEventTimes(1); - - expect(await input.getProperty('value')).toEqual('Sa, 31.12.2022'); - }); - }); - - describe('in form field', () => { - let element: E2EElement, input: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - - - - `); - element = await page.find('sbb-datepicker-previous-day'); - input = await page.find('input'); - await page.waitForChanges(); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('click', async () => { - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - const changeSpy = await input.spyOnEvent('change'); - const blurSpy = await input.spyOnEvent('blur'); - await element.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(blurSpy).toHaveReceivedEventTimes(1); - expect(await input.getProperty('value')).toEqual('Th, 19.01.2023'); - }); - - it('disabled due min equals to value', async () => { - page = await newE2EPage(); - await page.setContent(` - - - - - - `); - input = await page.find('input'); - await page.waitForChanges(); - - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - expect( - await page.evaluate(() => - document.querySelector('sbb-datepicker-previous-day').getAttribute('data-disabled'), - ), - ).toEqual(''); - - await element.click(); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - }); - - it('disabled due disabled picker', async () => { - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - await page.evaluate(() => document.querySelector('input').setAttribute('disabled', '')); - await page.waitForChanges(); - - expect(element).toHaveAttribute('data-disabled'); - await element.click(); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - }); - }); -}); diff --git a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.scss b/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.scss deleted file mode 100644 index 9279a7ea24..0000000000 --- a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.scss +++ /dev/null @@ -1,20 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - display: flex; - - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; -} - -@include sbb.icon-button('.sbb-datepicker-previous-day', 'sbb-icon'); - -.sbb-datepicker-previous-day { - margin: auto; - -webkit-tap-highlight-color: transparent; -} diff --git a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.spec.ts b/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.spec.ts deleted file mode 100644 index c33734ef58..0000000000 --- a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { SbbDatepickerPreviousDay } from './sbb-datepicker-previous-day'; -import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; -import { SbbDatepicker } from '../sbb-datepicker/sbb-datepicker'; - -describe('sbb-datepicker-previous-day', () => { - it('renders', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepickerPreviousDay], - html: '', - }); - - expect(page.root).toEqualHtml(` - - - - - - - - `); - }); - - it('renders with datepicker and input disabled', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerPreviousDay], - html: ` - - - - - - `, - }); - - const element: HTMLSbbDatepickerPreviousDayElement = page.doc.querySelector( - 'sbb-datepicker-previous-day', - ); - expect(element).toHaveAttribute('data-disabled'); - }); - - it('renders with datepicker and input readonly', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerPreviousDay], - html: ` - - - - - - `, - }); - - const element: HTMLSbbDatepickerPreviousDayElement = page.doc.querySelector( - 'sbb-datepicker-previous-day', - ); - expect(element).toHaveAttribute('data-disabled'); - }); -}); diff --git a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.stories.tsx b/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.stories.tsx deleted file mode 100644 index adff834bf2..0000000000 --- a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.stories.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, Decorator, StoryContext } from '@storybook/html'; -import { InputType } from '@storybook/types'; -import { Args, ArgTypes } from '@storybook/html'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const defaultArgTypes: ArgTypes = { - negative, -}; - -const defaultArgs: Args = { - negative: false, -}; - -const StandaloneTemplate = (args, picker = null): JSX.Element => ( - -); - -const PickerAndButtonTemplate = (args): JSX.Element => ( -
      - {StandaloneTemplate(args, 'datepicker')} - - -
      -); - -const FormFieldTemplate = (args): JSX.Element => ( - - - - {StandaloneTemplate(args)} - -); - -const EmptyFormFieldTemplate = (args): JSX.Element => ( - - - - {StandaloneTemplate(args)} - -); - -export const Standalone: StoryObj = { - render: StandaloneTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const StandaloneNegative: StoryObj = { - render: StandaloneTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs, negative: true }, -}; - -export const WithPicker: StoryObj = { - render: PickerAndButtonTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const InFormField: StoryObj = { - render: FormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const EmptyFormField: StoryObj = { - render: EmptyFormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: ['click', 'change'], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-datepicker/sbb-datepicker-previous-day', -}; - -export default meta; diff --git a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.tsx b/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.tsx deleted file mode 100644 index c4d106c988..0000000000 --- a/src/components/sbb-datepicker-previous-day/sbb-datepicker-previous-day.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - h, - Host, - JSX, - Listen, - Prop, - State, - Watch, -} from '@stencil/core'; -import { i18nPreviousDay, i18nSelectPreviousDay, i18nToday } from '../../global/i18n'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { - datepickerControlRegisteredEvent, - findPreviousAvailableDate, - getDatePicker, - InputUpdateEvent, -} from '../sbb-datepicker/sbb-datepicker.helper'; -import { DateAdapter, NativeDateAdapter } from '../../global/datetime'; -import { - documentLanguage, - HandlerRepository, - actionElementHandlerAspect, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { isValidAttribute, toggleDatasetEntry } from '../../global/dom'; - -@Component({ - shadow: true, - styleUrl: 'sbb-datepicker-previous-day.scss', - tag: 'sbb-datepicker-previous-day', -}) -export class SbbDatepickerPreviousDay implements ComponentInterface, ButtonProperties { - /** The name attribute to use for the button. */ - @Prop({ reflect: true }) public name: string | undefined; - - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - /** Datepicker reference. */ - @Prop() public datePicker?: string | HTMLElement; - - @Element() private _element!: HTMLSbbDatepickerPreviousDayElement; - - /** Whether the component is disabled due date equals to min date. */ - @State() private _disabled = false; - - /** Whether the component is disabled due date-picker's input disabled. */ - @State() private _inputDisabled = false; - - /** The minimum date as set in the date-picker's input. */ - @State() private _min: string | number; - - @State() private _currentLanguage = documentLanguage(); - - private _handlerRepository = new HandlerRepository( - this._element as HTMLElement, - actionElementHandlerAspect, - languageChangeHandlerAspect((l) => { - this._currentLanguage = l; - this._setAriaLabel(); - }), - ); - - private _datePickerElement: HTMLSbbDatepickerElement; - - private _dateAdapter: DateAdapter = new NativeDateAdapter(); - - private _datePickerController: AbortController; - - @Watch('datePicker') - public async findDatePicker( - newValue: string | HTMLElement, - oldValue: string | HTMLElement, - ): Promise { - if (newValue !== oldValue) { - await this._init(this.datePicker); - } - } - - @Listen('click') - public async handleClick(): Promise { - if (!this._datePickerElement || isValidAttribute(this._element, 'data-disabled')) { - return; - } - const startingDate: Date = (await this._datePickerElement.getValueAsDate()) ?? this._now(); - const date: Date = findPreviousAvailableDate( - startingDate, - this._datePickerElement.dateFilter, - this._dateAdapter, - this._min, - ); - if (this._dateAdapter.compareDate(date, startingDate) !== 0) { - await this._datePickerElement.setValueAsDate(date); - } - } - - public async connectedCallback(): Promise { - this._handlerRepository.connect(); - this._syncUpstreamProperties(); - await this._init(this.datePicker); - } - - private _syncUpstreamProperties(): void { - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - - // We can't use getInputElement of SbbFormField as async awaiting is not supported in connectedCallback. - // We here only have to look for input. - const inputElement = formField.querySelector('input'); - - if (inputElement) { - this._inputDisabled = - isValidAttribute(inputElement, 'disabled') || isValidAttribute(inputElement, 'readonly'); - } - } - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - this._datePickerController?.abort(); - } - - private async _init(picker?: string | HTMLElement): Promise { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - this._datePickerElement = getDatePicker(this._element, picker); - if (!this._datePickerElement) { - return; - } - await this._setDisabledState(this._datePickerElement); - await this._setAriaLabel(); - - this._datePickerElement.addEventListener( - 'change', - (event: Event) => { - this._setDisabledState(event.target as HTMLSbbDatepickerElement); - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.addEventListener( - 'datePickerUpdated', - (event: Event) => { - this._setDisabledState(event.target as HTMLSbbDatepickerElement); - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.addEventListener( - 'inputUpdated', - (event: CustomEvent) => { - this._inputDisabled = event.detail.disabled || event.detail.readonly; - if (this._min !== event.detail.min) { - this._min = event.detail.min; - this._setDisabledState(this._datePickerElement); - } - this._setAriaLabel(); - }, - { signal: this._datePickerController.signal }, - ); - - this._datePickerElement.dispatchEvent(datepickerControlRegisteredEvent); - } - - private async _setDisabledState(datepicker: HTMLSbbDatepickerElement): Promise { - const pickerValueAsDate: Date = await datepicker.getValueAsDate(); - - if (!pickerValueAsDate) { - this._disabled = true; - return; - } - - const previousDate: Date = findPreviousAvailableDate( - pickerValueAsDate, - datepicker.dateFilter, - this._dateAdapter, - this._min, - ); - this._disabled = this._dateAdapter.compareDate(previousDate, pickerValueAsDate) === 0; - } - - private _hasDataNow(): boolean { - if (!this._datePickerElement) { - return false; - } - const dataNow = +this._datePickerElement.dataset?.now; - return !isNaN(dataNow); - } - - private _now(): Date { - if (this._hasDataNow()) { - const today = new Date(+this._datePickerElement.dataset?.now); - today.setHours(0, 0, 0, 0); - return today; - } - return this._dateAdapter.today(); - } - - private async _setAriaLabel(): Promise { - const currentDate = await this._datePickerElement.getValueAsDate(); - - if (!currentDate) { - this._element.setAttribute('aria-label', i18nPreviousDay[this._currentLanguage]); - return; - } - - const currentDateString = - this._dateAdapter.today().toDateString() === currentDate.toDateString() - ? i18nToday[this._currentLanguage].toLowerCase() - : this._dateAdapter.getAccessibilityFormatDate(currentDate); - - this._element.setAttribute( - 'aria-label', - i18nSelectPreviousDay(currentDateString)[this._currentLanguage], - ); - } - - public render(): JSX.Element { - toggleDatasetEntry(this._element, 'disabled', this._disabled || this._inputDisabled); - const { hostAttributes } = resolveButtonRenderVariables({ - ...this, - disabled: isValidAttribute(this._element, 'data-disabled'), - }); - - return ( - - - - - - ); - } -} diff --git a/src/components/sbb-datepicker-toggle/readme.md b/src/components/sbb-datepicker-toggle/readme.md deleted file mode 100644 index 9f10463e69..0000000000 --- a/src/components/sbb-datepicker-toggle/readme.md +++ /dev/null @@ -1,81 +0,0 @@ -The `sbb-datepicker-toggle` is a component -closely connected to the [sbb-datepicker](/docs/components-sbb-datepicker-sbb-datepicker--docs). - -When the two are used together, the `sbb-datepicker-toggle` can be used to link the `sbb-datepicker` -to a [sbb-calendar](/docs/components-sbb-datepicker-sbb-calendar--docs): -a change in the latter, like selecting a date, is propagated to the former; and conversely, changes in the `sbb-datepicker` -properties, or in the date-picker's input attributes, are propagated to the `sbb-calendar` to modify its appearance. - -The components can be connected using the `datePicker` property, which accepts the id of the `sbb-datepicker`, -or directly its reference. - -```html - - - -``` - -## In `sbb-form-field` - -If the two components are used within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs), -they are automatically linked and the `sbb-datepicker-toggle` will be projected in the `prefix` slot of the `sbb-form-field`; -otherwise, they can be connected using the `datePicker` property as described above. - -```html - - - - - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------ | ------------------- | ---------------------------------- | ----------------------- | ----------- | -| `datePicker` | `date-picker` | Datepicker reference. | `HTMLElement \| string` | `undefined` | -| `disableAnimation` | `disable-animation` | Whether the animation is disabled. | `boolean` | `false` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | - - -## Methods - -### `open() => Promise` - -Opens the calendar. - -#### Returns - -Type: `Promise` - - - - -## Dependencies - -### Depends on - -- [sbb-tooltip-trigger](../sbb-tooltip-trigger) -- [sbb-tooltip](../sbb-tooltip) -- [sbb-calendar](../sbb-calendar) - -### Graph -```mermaid -graph TD; - sbb-datepicker-toggle --> sbb-tooltip-trigger - sbb-datepicker-toggle --> sbb-tooltip - sbb-datepicker-toggle --> sbb-calendar - sbb-tooltip-trigger --> sbb-icon - sbb-tooltip --> sbb-button - sbb-button --> sbb-icon - sbb-calendar --> sbb-icon - sbb-calendar --> sbb-button - style sbb-datepicker-toggle fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.e2e.ts b/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.e2e.ts deleted file mode 100644 index 80d28e7468..0000000000 --- a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.e2e.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; - -describe('sbb-datepicker-toggle', () => { - it('renders standalone', async () => { - const page: E2EPage = await newE2EPage({ - html: '', - }); - await page.waitForChanges(); - - const element: E2EElement = await page.find('sbb-datepicker-toggle'); - const tooltipTrigger: E2EElement = await page.find( - 'sbb-datepicker-toggle >>> sbb-tooltip-trigger', - ); - expect(element).toHaveClass('hydrated'); - expect(tooltipTrigger).toHaveAttribute('disabled'); - }); - - it('renders and opens tooltip with picker', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - - `, - }); - const element: E2EElement = await page.find('sbb-datepicker-toggle'); - const didOpenEventSpy = await element.spyOnEvent('did-open'); - const tooltipTrigger: E2EElement = await page.find( - 'sbb-datepicker-toggle >>> sbb-tooltip-trigger', - ); - const tooltip: E2EElement = await page.find('sbb-datepicker-toggle >>> sbb-tooltip'); - await page.waitForChanges(); - expect(element).toHaveClass('hydrated'); - expect(tooltipTrigger).not.toHaveAttribute('disabled'); - expect(tooltip).toEqualAttribute('data-state', 'closed'); - - await tooltipTrigger.click(); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - - expect(tooltip).toEqualAttribute('data-state', 'opened'); - }); - - it('renders and opens tooltip programmatically', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - - `, - }); - const element: E2EElement = await page.find('sbb-datepicker-toggle'); - const didOpenEventSpy = await element.spyOnEvent('did-open'); - const tooltipTrigger: E2EElement = await page.find( - 'sbb-datepicker-toggle >>> sbb-tooltip-trigger', - ); - const tooltip: E2EElement = await page.find('sbb-datepicker-toggle >>> sbb-tooltip'); - await page.waitForChanges(); - expect(element).toHaveClass('hydrated'); - expect(tooltipTrigger).not.toHaveAttribute('disabled'); - expect(tooltip).toEqualAttribute('data-state', 'closed'); - - await page.evaluate(() => - (document.querySelector('sbb-datepicker-toggle') as HTMLSbbDatepickerToggleElement).open(), - ); - - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - - expect(tooltip).toEqualAttribute('data-state', 'opened'); - }); - - it('renders in form field, open calendar and change date', async () => { - const page: E2EPage = await newE2EPage(); - await page.setContent(` - - - - - - `); - await page.waitForChanges(); - const tooltip: E2EElement = await page.find('sbb-datepicker-toggle >>> sbb-tooltip'); - expect(tooltip).toEqualAttribute('data-state', 'closed'); - const element: E2EElement = await page.find('sbb-datepicker-toggle'); - const input: E2EElement = await page.find('input'); - const didOpenEventSpy = await element.spyOnEvent('did-open'); - const changeSpy = await input.spyOnEvent('change'); - const blurSpy = await input.spyOnEvent('blur'); - expect(element).toHaveClass('hydrated'); - - const tooltipTrigger: E2EElement = await page.find( - 'sbb-datepicker-toggle >>> sbb-tooltip-trigger', - ); - await tooltipTrigger.click(); - await page.waitForChanges(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(tooltip).toEqualAttribute('data-state', 'opened'); - - const calendar: E2EElement = await page.find('sbb-datepicker-toggle >>> sbb-calendar'); - await calendar.triggerEvent('date-selected', { - detail: new Date('2022-01-01'), - }); - await page.waitForChanges(); - - expect(await input.getProperty('value')).toEqual('Sa, 01.01.2022'); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(blurSpy).toHaveReceivedEventTimes(1); - }); -}); diff --git a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.scss b/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.scss deleted file mode 100644 index 9e37c259fc..0000000000 --- a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.scss +++ /dev/null @@ -1,22 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-datepicker-control-radius: var(--sbb-border-radius-4x); - --sbb-datepicker-control-color: var(--sbb-color-black-default); -} - -sbb-tooltip-trigger { - color: var(--sbb-datepicker-control-color); - height: var(--sbb-form-field-height); - display: flex; - align-items: center; - -webkit-tap-highlight-color: transparent; -} - -sbb-icon { - color: var(--sbb-color-graphite-default); -} diff --git a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.spec.ts b/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.spec.ts deleted file mode 100644 index 5797af1e87..0000000000 --- a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { SbbDatepickerToggle } from './sbb-datepicker-toggle'; -import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; -import { SbbDatepicker } from '../sbb-datepicker/sbb-datepicker'; - -describe('sbb-datepicker-toggle', () => { - it('renders', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepickerToggle], - html: '', - }); - - expect(page.root).toEqualHtml(` - - - - - - - - - `); - }); - - describe('renders in form-field', () => { - it('renders in form-field', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerToggle], - html: ` - - - - - - `, - }); - const element: HTMLSbbDatepickerToggleElement = - page.doc.querySelector('sbb-datepicker-toggle'); - expect(element).toEqualHtml(` - - - - - - - - - `); - }); - - it('renders in disabled form-field', async () => { - const page = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerToggle], - html: ` - - - - - - `, - }); - const element: HTMLSbbDatepickerToggleElement = - page.doc.querySelector('sbb-datepicker-toggle'); - expect(element).toEqualHtml(` - - - - - - - - - `); - }); - - it('renders in form-field with calendar parameters', async () => { - const page = await newSpecPage({ - components: [SbbFormField, SbbDatepicker, SbbDatepickerToggle], - html: ` - - - - - - `, - }); - const element: HTMLSbbDatepickerToggleElement = - page.doc.querySelector('sbb-datepicker-toggle'); - expect(element).toEqualHtml(` - - - - - - - - - `); - }); - }); -}); diff --git a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.stories.tsx b/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.stories.tsx deleted file mode 100644 index 3a457b33f3..0000000000 --- a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.stories.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { userEvent, within } from '@storybook/testing-library'; -import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; -import { waitForStablePosition } from '../../global/testing'; -import isChromatic from 'chromatic'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const defaultArgTypes: ArgTypes = { - 'disable-animation': disableAnimation, - negative, -}; - -const defaultArgs: Args = { - 'disable-animation': isChromatic(), - negative: false, -}; - -// Story interaction executed after the story renders -const playStory = async ({ canvasElement }): Promise => { - const canvas = within(canvasElement); - const queryTrigger = (): HTMLSbbTooltipTriggerElement => - canvas.getByTestId('toggle').shadowRoot.querySelector('sbb-tooltip-trigger'); - - await waitForComponentsReady(queryTrigger); - - await waitForStablePosition(queryTrigger); - - const toggle = queryTrigger(); - userEvent.click(toggle); -}; - -const StandaloneTemplate = (picker, args): JSX.Element => ( - -); - -const PickerAndButtonTemplate = (args): JSX.Element => ( -
      - {StandaloneTemplate('datepicker', args)} - - -
      -); - -const FormFieldTemplate = ({ negative, ...args }): JSX.Element => ( - - - - {StandaloneTemplate(null, args)} - -); - -export const WithPicker: StoryObj = { - render: PickerAndButtonTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, - play: isChromatic() && playStory, -}; - -export const InFormField: StoryObj = { - render: FormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, - play: isChromatic() && playStory, -}; - -export const InFormFieldNegative: StoryObj = { - render: FormFieldTemplate, - argTypes: defaultArgTypes, - args: { ...defaultArgs, negative: true }, - play: isChromatic() && playStory, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - chromatic: { disableSnapshot: false }, - actions: { - handles: ['click'], - }, - backgrounds: { - disable: true, - }, - docs: { - story: { inline: false, iframeHeight: '600px' }, - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-datepicker/sbb-datepicker-toggle', -}; - -export default meta; diff --git a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.tsx b/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.tsx deleted file mode 100644 index 64a37e6344..0000000000 --- a/src/components/sbb-datepicker-toggle/sbb-datepicker-toggle.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - h, - Host, - JSX, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; -import { SbbCalendarCustomEvent } from '../../components'; -import { i18nShowCalendar } from '../../global/i18n'; -import { - datepickerControlRegisteredEvent, - getDatePicker, - InputUpdateEvent, -} from '../sbb-datepicker/sbb-datepicker.helper'; -import { - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { sbbInputModalityDetector } from '../../global/a11y'; -import { isValidAttribute } from '../../global/dom'; - -@Component({ - shadow: true, - styleUrl: 'sbb-datepicker-toggle.scss', - tag: 'sbb-datepicker-toggle', -}) -export class SbbDatepickerToggle implements ComponentInterface { - /** Datepicker reference. */ - @Prop() public datePicker?: string | HTMLElement; - - /** Whether the animation is disabled. */ - @Prop() public disableAnimation = false; - - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - @Element() private _element!: HTMLSbbDatepickerToggleElement; - - @State() private _triggerElement: HTMLElement; - - @State() private _disabled = false; - - @State() private _min: string | number; - - @State() private _max: string | number; - - @State() private _currentLanguage = documentLanguage(); - - private _datePickerElement: HTMLSbbDatepickerElement; - - private _calendarElement: HTMLSbbCalendarElement; - - private _datePickerController: AbortController; - - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - ); - - @Watch('datePicker') - public async findDatePicker( - newValue: string | HTMLElement, - oldValue: string | HTMLElement, - ): Promise { - if (newValue !== oldValue) { - await this._init(this.datePicker); - } - } - - /** - * Opens the calendar. - */ - @Method() - public async open(): Promise { - if (!this._triggerElement) { - this._triggerElement = this._element.shadowRoot.querySelector('sbb-tooltip-trigger'); - } - this._triggerElement.click(); - } - - public async connectedCallback(): Promise { - this._handlerRepository.connect(); - await this._init(this.datePicker); - - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - } - } - - public disconnectedCallback(): void { - this._datePickerController?.abort(); - this._handlerRepository.disconnect(); - } - - private async _init(datePicker?: string | HTMLElement): Promise { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - this._datePickerElement = getDatePicker(this._element, datePicker); - if (!this._datePickerElement) { - return; - } - - this._datePickerElement?.addEventListener( - 'inputUpdated', - (event: CustomEvent) => { - this._datePickerElement = event.target as HTMLSbbDatepickerElement; - this._disabled = event.detail.disabled || event.detail.readonly; - this._min = event.detail.min; - this._max = event.detail.max; - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement?.addEventListener( - 'change', - (event: Event) => this._datePickerChanged(event), - { - signal: this._datePickerController.signal, - }, - ); - this._datePickerElement?.addEventListener( - 'datePickerUpdated', - (event: Event) => - this._configureCalendar(this._calendarElement, event.target as HTMLSbbDatepickerElement), - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.dispatchEvent(datepickerControlRegisteredEvent); - } - - private _configureCalendar( - calendar: HTMLSbbCalendarElement, - datepicker: HTMLSbbDatepickerElement, - ): void { - calendar.wide = datepicker?.wide; - calendar.dateFilter = datepicker?.dateFilter; - } - - private async _datePickerChanged(event: Event): Promise { - this._datePickerElement = event.target as HTMLSbbDatepickerElement; - this._calendarElement.selectedDate = await this._datePickerElement.getValueAsDate(); - } - - private async _assignCalendar(calendar: HTMLSbbCalendarElement): Promise { - if (this._calendarElement && this._calendarElement === calendar) { - return; - } - this._calendarElement = calendar; - if (!this._datePickerElement || !this._calendarElement.resetPosition) { - return; - } - this._calendarElement.selectedDate = await this._datePickerElement.getValueAsDate(); - this._configureCalendar(this._calendarElement, this._datePickerElement); - await this._calendarElement.resetPosition(); - } - - private _hasDataNow(): boolean { - if (!this._datePickerElement) { - return false; - } - const dataNow = +this._datePickerElement.dataset?.now; - return !isNaN(dataNow); - } - - private _now(): Date { - if (this._hasDataNow()) { - const today = new Date(+this._datePickerElement.dataset?.now); - today.setHours(0, 0, 0, 0); - return today; - } - return undefined; - } - - public render(): JSX.Element { - return ( - - { - this._triggerElement = trigger; - }} - data-icon-small - /> - this._calendarElement.resetPosition()} - onDid-open={() => { - sbbInputModalityDetector.mostRecentModality === 'keyboard' && - this._calendarElement.focus(); - }} - trigger={this._triggerElement} - disableAnimation={this.disableAnimation} - hide-close-button={true} - > - this._assignCalendar(calendar)} - min={this._min} - max={this._max} - wide={this._datePickerElement?.wide} - dateFilter={this._datePickerElement?.dateFilter} - onDate-selected={async (d: SbbCalendarCustomEvent) => { - const newDate = new Date(d.detail); - this._calendarElement.selectedDate = newDate; - await this._datePickerElement.setValueAsDate(newDate); - }} - /> - - - ); - } -} diff --git a/src/components/sbb-datepicker/readme.md b/src/components/sbb-datepicker/readme.md deleted file mode 100644 index d5e7b175de..0000000000 --- a/src/components/sbb-datepicker/readme.md +++ /dev/null @@ -1,153 +0,0 @@ -The `sbb-datepicker` is a component which can be used together with a native `` element -to display the typed value as a formatted date (default: `dd.MM.yyyy`). - -The component allows the insertion of up to 10 numbers, possibly with separators like `.`, `-`, `` ``, `,` or `/`, -then automatically formats the value as date and displays it. -It also exposes methods to get / set the value formatted as Date. - -The component and the native `input` can be connected using the `input` property, -which accepts the id of the native input, or directly its reference. - -```html - - -``` - -## In `sbb-form-field` - -If the `sbb-datepicker` is used within a [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs) with a native input, -they are automatically linked; the component sets the input placeholder and the input's type as `text`, -then reads the `disabled`, `readonly`, `min` and `max` attributes from the input and emits then as payload of the `inputUpdated` event. - -It's possible to remove unwanted dates from selection using the `dateFilter` function, however, this should **not** -be used as a replacement for the `min` and `max` properties will most likely result in a significant loss of performance. - -It's also possible to display a two-months view using the `wide` property. - -```html - - - - -``` - -```html - - - - - - - - -``` - -## Events - -If the input's value changes, it is formatted then a `change` event is emitted with the new value. -If it's an invalid date, the `data-sbb-invalid` attribute is added to the input. -The component also listens for changes in its two properties, `wide` and `dateFilter`, and emits a `datePickerUpdated` event when changed. - -Consumers can listen to the native `change` and `input` events on the `sbb-datepicker` component to intercept date changes, -the current value can be read from the async method `event.target.getValueAsDate()`. -To set the value programmatically, it's recommended to use the `setValueAsDate()` method of the `sbb-datepicker`. - -Each time the user changes the date by using the calendar, or the next and previous day arrow, or by using the `setValueAsDate()` method, -a `blur` event is fired on the input to ensure compatibility with any framework that relies on that event to update the current state. - -## Custom date formats - -Using a combination of the `dateParser` and `format` properties, it's possible to configure the datepicker -to accept date formats other than the default `EE, dd.mm.yyyy`. -In the following example the datepicker is set to accept dates in the format `yyyy-mm-dd`. -In particular, `dateParser` is the function that the component uses internally to decode strings and parse them into `Date` objects, -while the `format` function is the one that the component uses internally to display a given `Date` object as a string. - -```ts -// datePicker is a HTMLSbbDatepickerElement -datePicker.dateParser = (value: string) => { - // You should implement some kind of input validation - if (!value || !isValid(value)) { - return undefined; - } - - return new Date(value); -}; - -datePicker.format = (value: Date) => { - if (!value) { - return ''; - } - - const offset = value.getTimezoneOffset(); - value = new Date(yourDate.getTime() - (offset * 60 * 1000)); - return yourDate.toISOString().split('T')[0]; -}; -``` - -Usually these functions need to be changed together, although in simple cases where the default `dateParser` might still work properly -(e.g., in case we wanted to accept the format `dd.mm.yyyy`), it's possible to provide just the `format` function. -For custom `format` functions is recommended to use the `Intl.DateTimeFormat` API, as it's done in the default implementation. - - - -## Validation Change - -Whenever the validation state changes (e.g., a valid value becomes invalid or vice-versa), the `validationChange` event is emitted. - -## Testing - -To specify a specific date for the current datetime, you can use the `data-now` attribute (timestamp in milliseconds). -This is helpful if you need a specific state of the component. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------ | --------- | ----------------------------------------------------------------- | ------------------------- | ------------ | -| `dateFilter` | -- | A function used to filter out dates. | `(date: Date) => boolean` | `() => true` | -| `dateParser` | -- | A function used to parse string value into dates. | `(value: string) => Date` | `undefined` | -| `format` | -- | A function used to format dates into the preferred string format. | `(date: Date) => string` | `undefined` | -| `input` | `input` | Reference of the native input connected to the datepicker. | `HTMLElement \| string` | `undefined` | -| `wide` | `wide` | If set to true, two months are displayed | `boolean` | `false` | - - -## Events - -| Event | Description | Type | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `change` | | `CustomEvent` | -| `datePickerUpdated` | Notifies that the attributes of the datepicker have changes. | `CustomEvent` | -| `didChange` | **[DEPRECATED]** only used for React. Will probably be removed once React 19 is available.

      | `CustomEvent` | -| `inputUpdated` | Notifies that the attributes of the input connected to the datepicker have changes. | `CustomEvent` | -| `validationChange` | Emits whenever the internal validation state changes. | `CustomEvent` | - - -## Methods - -### `getValueAsDate() => Promise` - -Gets the input value with the correct date format. - -#### Returns - -Type: `Promise` - - - -### `setValueAsDate(date: Date | number | string) => Promise` - -Set the input value to the correctly formatted value. - -#### Returns - -Type: `Promise` - - - - ----------------------------------------------- - - diff --git a/src/components/sbb-datepicker/sbb-datepicker.e2e.ts b/src/components/sbb-datepicker/sbb-datepicker.e2e.ts deleted file mode 100644 index 9c0612abc8..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.e2e.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; -import { i18nDateChangedTo } from '../../global/i18n'; - -describe('sbb-datepicker', () => { - it('renders', async () => { - const page: E2EPage = await newE2EPage({ html: '' }); - const element: E2EElement = await page.find('sbb-datepicker'); - expect(element).toHaveClass('hydrated'); - }); - - it('renders and formats date', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - `, - }); - - const input: E2EElement = await page.find('input'); - - expect(await input.getProperty('value')).toEqual('Su, 01.01.2023'); - }); - - it('renders and interprets iso string date', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - `, - }); - - const input: E2EElement = await page.find('input'); - - expect(await input.getProperty('value')).toEqual('Mo, 20.12.2021'); - }); - - it('renders and interprets timestamp', async () => { - const page: E2EPage = await newE2EPage({ - html: ` - - - `, - }); - - const input: E2EElement = await page.find('input'); - - expect(await input.getProperty('value')).toEqual('Su, 12.07.2020'); - }); - - const commonBehaviorTest: (template: string) => void = (template: string) => { - let element: E2EElement, input: E2EElement, button: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(template); - element = await page.find('sbb-datepicker'); - input = await page.find('input'); - button = await page.find('button'); - await page.waitForChanges(); - }); - - it('renders and emit event on value change', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20/01/2023'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2023'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and interpret two digit year correctly in 2000s', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20/01/12'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(await input.getProperty('value')).toEqual('Fr, 20.01.2012'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and interpret two digit year correctly in 1900s', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20/01/99'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(await input.getProperty('value')).toEqual('We, 20.01.1999'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and detects missing month error', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20..2012'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(input).toHaveAttribute('data-sbb-invalid'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and detects missing year error', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20.05.'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(input).toHaveAttribute('data-sbb-invalid'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and detects invalid month error', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('20.00.2012'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(input).toHaveAttribute('data-sbb-invalid'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders and detects invalid day error', async () => { - const changeSpy = await element.spyOnEvent('change'); - await input.type('00.05.2020'); - await button.focus(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(input).toHaveAttribute('data-sbb-invalid'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('renders with errors when typing letters', async () => { - expect(await input.getProperty('value')).toEqual(''); - await input.focus(); - await input.type('invalid'); - await input.press('Enter'); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('invalid'); - expect(input).toHaveAttribute('data-sbb-invalid'); - }); - - it('renders and emits event when input parameter changes', async () => { - const datePickerUpdatedSpy = await page.spyOnEvent('datePickerUpdated'); - const picker = await page.find('sbb-datepicker'); - picker.setProperty('wide', true); - await page.waitForChanges(); - await waitForCondition(() => datePickerUpdatedSpy.events.length === 1); - expect(datePickerUpdatedSpy).toHaveReceivedEventTimes(1); - picker.setProperty('dateFilter', () => null); - await page.waitForChanges(); - await waitForCondition(() => datePickerUpdatedSpy.events.length === 2); - expect(datePickerUpdatedSpy).toHaveReceivedEventTimes(2); - }); - - it('renders and interprets date with custom parse and format functions', async () => { - const changeSpy = await element.spyOnEvent('change'); - - await page.evaluate(() => { - const localDatepicker = document.querySelector('sbb-datepicker'); - localDatepicker.dateParser = (s) => { - s = s.replace(/\D/g, ' ').trim(); - const date = s.split(' '); - return new Date(new Date().getFullYear(), +date[1] - 1, +date[0]); - }; - localDatepicker.format = (d) => { - //Intl.DateTimeFormat API is not available in test environment. - const weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; - const weekday = weekdays[d.getDay()]; - const date = `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart( - 2, - '0', - )}`; - return `${weekday}, ${date}`; - }; - }); - - await page.waitForChanges(); - await input.type('7.8'); - await input.press('Enter'); - await waitForCondition(() => changeSpy.events.length === 1); - await page.waitForChanges(); - expect(await input.getProperty('value')).toEqual('Mo, 07.08'); - expect(changeSpy).toHaveReceivedEventTimes(1); - }); - - it('should emit validation change event', async () => { - let validationChangeSpy = await element.spyOnEvent('validationChange'); - - // When entering 99 - await input.focus(); - await input.type('20'); - await input.press('Tab'); - - // Then validation event should emit with false - expect(validationChangeSpy).toHaveFirstReceivedEventDetail({ valid: false }); - expect(input).toHaveAttribute('data-sbb-invalid'); - - // When adding valid date - await input.focus(); - await input.press('.'); - await input.press('Tab'); - - // Then validation event should not be emitted a second time - expect(validationChangeSpy).toHaveReceivedEventTimes(1); - expect(input).toHaveAttribute('data-sbb-invalid'); - - // Reset event spy - validationChangeSpy = await element.spyOnEvent('validationChange'); - - // When adding missing parts of a valid date - await input.focus(); - await input.type('8.23'); - await input.press('Tab'); - - // Then validation event should be emitted with true - expect(validationChangeSpy).toHaveFirstReceivedEventDetail({ valid: true }); - expect(input).not.toHaveAttribute('data-sbb-invalid'); - }); - - it('should interpret valid values and set accessibility labels', async () => { - const testCases = [ - { - value: '5.5.0', - interpretedAs: 'Fr, 05.05.2000', - accessibilityValue: 'Friday, 05.05.2000', - }, - { - value: '8.2.98', - interpretedAs: 'Su, 08.02.1998', - accessibilityValue: 'Sunday, 08.02.1998', - }, - { - value: '31-12-2020', - interpretedAs: 'Th, 31.12.2020', - accessibilityValue: 'Thursday, 31.12.2020', - }, - { - value: '5 5 21', - interpretedAs: 'We, 05.05.2021', - accessibilityValue: 'Wednesday, 05.05.2021', - }, - { - value: '3/7/26', - interpretedAs: 'Fr, 03.07.2026', - accessibilityValue: 'Friday, 03.07.2026', - }, - { - value: '1.12.2019', - interpretedAs: 'Su, 01.12.2019', - accessibilityValue: 'Sunday, 01.12.2019', - }, - { - value: '6\\1\\2020', - interpretedAs: 'Mo, 06.01.2020', - accessibilityValue: 'Monday, 06.01.2020', - }, - { - value: '5,5,2012', - interpretedAs: 'Sa, 05.05.2012', - accessibilityValue: 'Saturday, 05.05.2012', - }, - ]; - - for (const testCase of testCases) { - // Clear input - await page.evaluate( - () => ((document.getElementById('datepicker-input') as HTMLInputElement).value = ''), - ); - - await input.type(testCase.value); - await input.press('Tab'); - expect(await input.getProperty('value')).toEqual(testCase.interpretedAs); - const paragraphElement = await page.find('sbb-datepicker >>> p'); - expect(paragraphElement.innerText).toBe( - `${i18nDateChangedTo['en']} ${testCase.accessibilityValue}`, - ); - } - }); - - it('should not touch invalid values', async () => { - const testCases = [ - { value: '.12.2020', interpretedAs: '.12.2020' }, - { value: '24..1995', interpretedAs: '24..1995' }, - { value: '24.12.', interpretedAs: '24.12.' }, - { value: '34.06.2020', interpretedAs: '34.06.2020' }, - { value: '24.15.2014', interpretedAs: '24.15.2014' }, - { value: 'invalid', interpretedAs: 'invalid' }, - ]; - - for (const testCase of testCases) { - // Clear input - await page.evaluate( - () => ((document.getElementById('datepicker-input') as HTMLInputElement).value = ''), - ); - - await input.type(testCase.value); - await input.press('Tab'); - expect(await input.getProperty('value')).toEqual(testCase.interpretedAs); - const paragraphElement = await page.find('sbb-datepicker >>> p'); - expect(paragraphElement.innerText).toBe(''); - } - }); - }; - - describe('with input', () => { - const template = ` - - - - `; - - it('renders', async () => { - const page: E2EPage = await newE2EPage(); - await page.setContent(template); - expect(await page.find('sbb-datepicker')).toHaveClass('hydrated'); - expect(await page.find('input')).toEqualHtml( - '', - ); - }); - - commonBehaviorTest(template); - }); - - describe('with form-field', () => { - const template = ` - - - - - - `; - - it('renders', async () => { - const page: E2EPage = await newE2EPage(); - await page.setContent(template); - expect(await page.find('sbb-datepicker')).toHaveClass('hydrated'); - expect(await page.find('input')).toEqualHtml( - '', - ); - }); - - commonBehaviorTest(template); - }); -}); diff --git a/src/components/sbb-datepicker/sbb-datepicker.events.ts b/src/components/sbb-datepicker/sbb-datepicker.events.ts deleted file mode 100644 index c8b90acd32..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.events.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - change: 'change', - datePickerUpdated: 'datePickerUpdated', - didChange: 'didChange', - inputUpdated: 'inputUpdated', - validationChange: 'validationChange', -}; diff --git a/src/components/sbb-datepicker/sbb-datepicker.helper.spec.ts b/src/components/sbb-datepicker/sbb-datepicker.helper.spec.ts deleted file mode 100644 index b86126ce69..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.helper.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { SbbDatepicker } from './sbb-datepicker'; -import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { - findNextAvailableDate, - findPreviousAvailableDate, - getAvailableDate, - getDatePicker, - isDateAvailable, -} from './sbb-datepicker.helper'; -import { NativeDateAdapter } from '../../global/datetime'; -import { findInput } from '../../global/dom'; - -describe('getDatePicker', () => { - it('returns the datepicker if no trigger', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepicker], - html: ` - - - - - - `, - }); - const picker: HTMLSbbDatepickerElement = page.doc.querySelector('sbb-datepicker'); - const elementNext: HTMLSbbDatepickerNextDayElement = - page.doc.querySelector('sbb-datepicker-next-day'); - expect(getDatePicker(elementNext)).toEqual(picker); - }); - - it('returns the datepicker if its id is passed as trigger', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepicker], - html: ` - - - - `, - }); - const picker: HTMLSbbDatepickerElement = page.doc.querySelector('#picker'); - const elementPrevious: HTMLSbbDatepickerPreviousDayElement = page.doc.querySelector( - 'sbb-datepicker-previous-day', - ); - expect(getDatePicker(elementPrevious, 'picker')).toEqual(picker); - }); -}); - -describe('getInput', () => { - it('returns the input if no trigger', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepicker], - html: ` - - - - - `, - }); - const element: HTMLSbbDatepickerElement = page.doc.querySelector('sbb-datepicker'); - const input: HTMLInputElement = page.doc.querySelector('input'); - expect(findInput(element)).toEqual(input); - }); - - it('returns the input if its id is passed as trigger', async () => { - const page: SpecPage = await newSpecPage({ - components: [SbbDatepicker], - html: ` - - - - `, - }); - const picker: HTMLSbbDatepickerElement = page.doc.querySelector('sbb-datepicker'); - const input: HTMLInputElement = page.doc.querySelector('input'); - expect(findInput(picker, 'input')).toEqual(input); - }); -}); - -describe('getAvailableDate', () => { - it('with dateFilter', async () => { - const availableDate: Date = getAvailableDate( - new Date(2024, 0, 1, 0, 0, 0, 0), - 1, - (d: Date) => d.getDay() === 1, - new NativeDateAdapter(), - ); - expect(availableDate.getTime()).toEqual(new Date(2024, 0, 8, 0, 0, 0, 0).getTime()); - }); - - it('without dateFilter', async () => { - const availableDate: Date = getAvailableDate( - new Date(2024, 0, 1, 0, 0, 0, 0), - 1, - () => true, - new NativeDateAdapter(), - ); - expect(availableDate.getTime()).toEqual(new Date(2024, 0, 2, 0, 0, 0, 0).getTime()); - }); -}); - -describe('findPreviousAvailableDate', () => { - it('get date without dateFilter and without min', async () => { - const availableDate: Date = findPreviousAvailableDate( - new Date(2023, 1, 26, 0, 0, 0, 0), - null, - new NativeDateAdapter(), - null, - ); - expect(availableDate.getTime()).toEqual(new Date(2023, 1, 25, 0, 0, 0, 0).getTime()); - }); - - it('get date without dateFilter and with current date equal to min date', async () => { - const date = new Date(2023, 1, 26, 0, 0, 0, 0); - const availableDate: Date = findPreviousAvailableDate( - date, - null, - new NativeDateAdapter(), - date.valueOf() / 1000, - ); - expect(availableDate.getTime()).toEqual(date.getTime()); - }); - - it('get date with dateFilter and min', async () => { - const minDate = new Date(2023, 1, 26, 0, 0, 0, 0); - const availableDate: Date = findPreviousAvailableDate( - new Date(2023, 1, 28, 0, 0, 0, 0), - (d: Date) => d.getDate() !== 27, - new NativeDateAdapter(), - minDate.valueOf() / 1000, - ); - expect(availableDate.getTime()).toEqual(minDate.getTime()); - }); -}); - -describe('findNextAvailableDate', () => { - it('get date without max and without dateFilter', async () => { - const availableDate: Date = findNextAvailableDate( - new Date(2023, 1, 26, 0, 0, 0, 0), - null, - new NativeDateAdapter(), - null, - ); - expect(availableDate.getTime()).toEqual(new Date(2023, 1, 27, 0, 0, 0, 0).getTime()); - }); - - it('get date without dateFilter with current date equal to max date', async () => { - const date: Date = new Date(2023, 1, 26, 0, 0, 0, 0); - const availableDate: Date = findNextAvailableDate( - date, - null, - new NativeDateAdapter(), - date.valueOf() / 1000, - ); - expect(availableDate.getTime()).toEqual(date.getTime()); - }); - - it('get date with dateFilter and max', async () => { - const maxDate = new Date(2023, 1, 28, 0, 0, 0, 0); - const availableDate: Date = findNextAvailableDate( - new Date(2023, 1, 26, 0, 0, 0, 0), - (d: Date) => d.getDate() !== 27, - new NativeDateAdapter(), - maxDate.valueOf() / 1000, - ); - expect(availableDate.getTime()).toEqual(maxDate.getTime()); - }); -}); - -describe('isDateAvailable', () => { - describe('invalid', () => { - it('get invalid date with min', async () => { - expect( - isDateAvailable( - new Date('2023-02-20'), - null, - new Date('2023-02-26').valueOf() / 1000, - null, - ), - ).toBeFalsy(); - }); - - it('get invalid date with max', async () => { - expect( - isDateAvailable( - new Date('2023-02-28'), - null, - null, - new Date('2023-02-26').valueOf() / 1000, - ), - ).toBeFalsy(); - }); - - it('get invalid date with dateFilter', async () => { - expect( - isDateAvailable( - new Date('2023-02-28'), - (d: Date) => d.getTime() > new Date('2024-12-31').valueOf(), - null, - null, - ), - ).toBeFalsy(); - }); - }); - - describe('valid', function () { - it('get valid date without dateFilter, min and max', async () => { - expect(isDateAvailable(new Date('2023-02-25'), null, null, null)).toBeTruthy(); - }); - - it('get valid date with min', async () => { - expect( - isDateAvailable( - new Date('2023-02-20'), - null, - new Date('2023-02-01').valueOf() / 1000, - null, - ), - ).toBeTruthy(); - }); - - it('get valid date with max', async () => { - expect( - isDateAvailable( - new Date('2023-02-28'), - null, - null, - new Date('2023-03-31').valueOf() / 1000, - ), - ).toBeTruthy(); - }); - - it('get invalid date with dateFilter', async () => { - expect( - isDateAvailable( - new Date('2023-02-28'), - (d: Date) => d.getTime() > new Date('2022-01-01').valueOf(), - null, - null, - ), - ).toBeTruthy(); - }); - }); -}); diff --git a/src/components/sbb-datepicker/sbb-datepicker.helper.ts b/src/components/sbb-datepicker/sbb-datepicker.helper.ts deleted file mode 100644 index 0c7b13c482..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.helper.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { DateAdapter, NativeDateAdapter } from '../../global/datetime'; -import { findReferencedElement } from '../../global/dom'; - -export interface InputUpdateEvent { - disabled: boolean; - readonly: boolean; - min: string | number; - max: string | number; -} - -/** - * Given a SbbDatepickerPreviousDay, a SbbDatepickerNextDay or a SbbDatepickerToggle component, - * it returns the related SbbDatepicker reference, if exists. - * @param element The element potentially connected to the SbbDatepicker. - * @param trigger The id or the reference of the SbbDatePicker. - */ -export function getDatePicker( - element: - | HTMLSbbDatepickerPreviousDayElement - | HTMLSbbDatepickerNextDayElement - | HTMLSbbDatepickerToggleElement, - trigger?: string | HTMLElement, -): HTMLSbbDatepickerElement { - if (!trigger) { - const parent = element.closest('sbb-form-field'); - return parent?.querySelector('sbb-datepicker') as HTMLSbbDatepickerElement; - } - - return findReferencedElement(trigger); -} - -/** - * Returns the first available date before or after a given one, considering the SbbDatepicker `dateFilter` property. - * @param date The starting date for calculations. - * @param delta The number of days to add/subtract from the starting one. - * @param dateFilter The dateFilter function from the SbbDatepicker. - * @param dateAdapter The adapter class. - */ -export function getAvailableDate( - date: Date, - delta: number, - dateFilter: (date: Date) => boolean, - dateAdapter: DateAdapter, -): Date { - let availableDate = dateAdapter.addCalendarDays(date, delta); - - if (dateFilter) { - while (!dateFilter(availableDate)) { - availableDate = dateAdapter.addCalendarDays(availableDate, delta); - } - } - - return availableDate; -} - -/** - * Calculates the first available date before the given one, - * considering the SbbDatepicker `dateFilter` property and `min` parameter (e.g. from the self-named input's attribute). - * @param date The starting date for calculations. - * @param dateFilter The dateFilter function from the SbbDatepicker. - * @param dateAdapter The adapter class. - * @param min The minimum value to consider in calculations. - */ -export function findPreviousAvailableDate( - date: Date, - dateFilter: (date: Date) => boolean, - dateAdapter: DateAdapter, - min: string | number, -): Date { - const previousDate = getAvailableDate(date, -1, dateFilter, dateAdapter); - const dateMin: Date = dateAdapter.deserializeDate(min); - - if ( - !dateMin || - (dateAdapter.isValid(dateMin) && dateAdapter.compareDate(previousDate, dateMin) >= 0) - ) { - return previousDate; - } - return date; -} - -/** - * Calculates the first available date after the given one, - * considering the SbbDatepicker `dateFilter` property and `max` parameter (e.g. from the self-named input's attribute). - * @param date The starting date for calculations. - * @param dateFilter The dateFilter function from the SbbDatepicker. - * @param dateAdapter The adapter class. - * @param max The maximum value to consider in calculations. - */ -export function findNextAvailableDate( - date: Date, - dateFilter: (date: Date) => boolean, - dateAdapter: DateAdapter, - max: string | number, -): Date { - const nextDate = getAvailableDate(date, 1, dateFilter, dateAdapter); - const dateMax: Date = dateAdapter.deserializeDate(max); - - if ( - !dateMax || - (dateAdapter.isValid(dateMax) && dateAdapter.compareDate(nextDate, dateMax) <= 0) - ) { - return nextDate; - } - return date; -} - -/** - * Checks if the provided date is a valid one, considering the SbbDatepicker `dateFilter` property - * and `min` and `max` parameters (e.g. from the self-named input's attributes). - * @param date The starting date for calculations. - * @param dateFilter The dateFilter function from the SbbDatepicker. - * @param min The minimum value to consider in calculations. - * @param max The maximum value to consider in calculations. - */ -export function isDateAvailable( - date: Date, - dateFilter: (date: Date) => boolean, - min: string | number, - max: string | number, -): boolean { - const dateAdapter: DateAdapter = new NativeDateAdapter(); - const dateMin: Date = dateAdapter.deserializeDate(min); - const dateMax: Date = dateAdapter.deserializeDate(max); - - if ( - (dateAdapter.isValid(dateMin) && dateAdapter.compareDate(date, dateMin) < 0) || - (dateAdapter.isValid(dateMax) && dateAdapter.compareDate(date, dateMax) > 0) - ) { - return false; - } - - return dateFilter ? dateFilter(date) : true; -} - -export const datepickerControlRegisteredEvent = new CustomEvent('datepicker-control-registered', { - bubbles: false, - composed: true, -}); diff --git a/src/components/sbb-datepicker/sbb-datepicker.scss b/src/components/sbb-datepicker/sbb-datepicker.scss deleted file mode 100644 index dd8b94aeb0..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use '../../global/styles' as sbb; - -:host { - @include sbb.screen-reader-only; -} diff --git a/src/components/sbb-datepicker/sbb-datepicker.spec.ts b/src/components/sbb-datepicker/sbb-datepicker.spec.ts deleted file mode 100644 index 29410caef1..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { SbbDatepicker } from './sbb-datepicker'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-datepicker', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbDatepicker], - html: '', - }); - - expect(root).toEqualHtml(` - - -

      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-datepicker/sbb-datepicker.stories.tsx b/src/components/sbb-datepicker/sbb-datepicker.stories.tsx deleted file mode 100644 index 386ddfdfcb..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.stories.tsx +++ /dev/null @@ -1,523 +0,0 @@ -/** @jsx h */ -import { Fragment, h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { userEvent, within } from '@storybook/testing-library'; -import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; -import { waitForStablePosition } from '../../global/testing'; -import { withActions } from '@storybook/addon-actions/decorator'; -import isChromatic from 'chromatic'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; -import events from './sbb-datepicker.events'; -import { StoryContext } from '@storybook/html'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const value: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const readonly: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const required: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const form: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const min: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const max: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Input datepicker attribute', - }, -}; - -const wide: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Datepicker attribute', - }, -}; - -const filterFunctions = [ - () => true, - (d) => d.getDay() !== 6 && d.getDay() !== 0, - (d) => d.getDate() % 2 === 1, - (d) => d.getFullYear() % 2 === 0, - (d) => d.getMonth() > 6, -]; -const dateFilter: InputType = { - options: Object.keys(filterFunctions), - mapping: filterFunctions, - control: { - type: 'select', - labels: { - 0: 'No dateFilter function.', - 1: 'The dateFilter function includes only working days.', - 2: 'The dateFilter function excludes even days.', - 3: 'The dateFilter function excludes odd years.', - 4: 'The dateFilter function excludes months from January to July', - }, - }, - table: { - category: 'Datepicker attribute', - }, -}; - -const handlingFunctions = [ - { dateParser: undefined, format: undefined }, - { - dateParser: (s) => new Date(s), - format: (d) => - `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( - d.getDate(), - ).padStart(2, '0')}`, - }, - { - dateParser: (s) => - new Date(+s.substring(4, s.length), +s.substring(2, 4) - 1, +s.substring(0, 2)), - format: (d) => - `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart( - 2, - '0', - )}${d.getFullYear()}`, - }, -]; -const dateHandling: InputType = { - name: 'Date Handling', - description: - 'Change the default date handling option with a combination of `dateParser` and `format` properties.', - options: Object.keys(filterFunctions), - mapping: handlingFunctions, - control: { - type: 'select', - labels: { - 0: 'Default', - 1: 'ISO String (YYYY-MM-DD)', - 2: 'Business (DDMMYY)', - }, - }, - table: { - category: 'Datepicker attribute', - }, -}; - -const ariaLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Datepicker attribute', - }, -}; - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['m', 'l'], - table: { - category: 'Form-field attribute', - }, -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Form-field attribute', - }, -}; - -const label: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Form-field attribute', - }, -}; - -const optional: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Form-field attribute', - }, -}; - -const borderless: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Form-field attribute', - }, -}; - -const dataNow: InputType = { - control: { - type: 'date', - }, - table: { - category: 'Testing', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Testing', - }, -}; - -const basicArgTypes: ArgTypes = { - value, - form, - disabled, - readonly, - required, - min, - max, - wide, - dateFilter, - dateHandling, - 'aria-label': ariaLabel, - 'data-now': dataNow, - disableAnimation, -}; - -const basicArgs: Args = { - value: `15.02.2023`, - form: undefined, - disabled: false, - readonly: false, - required: false, - min: undefined, - max: undefined, - wide: false, - dateFilter: dateFilter.options[0], - dateHandling: dateHandling.options[0], - 'aria-label': undefined, - disableAnimation: isChromatic(), - dataNow: isChromatic() ? new Date(2023, 0, 12, 0, 0, 0).valueOf() : undefined, -}; - -const formFieldBasicArgsTypes: ArgTypes = { - ...basicArgTypes, - label, - size, - negative, - optional, - borderless, -}; - -const formFieldBasicArgs = { - ...basicArgs, - label: 'Label', - size: size.options[0], - negative: false, - optional: false, - borderless: false, -}; - -const getInputAttributes = (min, max): Record => { - const attr: Record = {}; - if (min) { - attr.min = new Date(min).getTime() / 1000; - } - if (max) { - attr.max = new Date(max).getTime() / 1000; - } - return attr; -}; - -// Story interaction executed after the story renders -const playStory = async ({ canvasElement }): Promise => { - const canvas = within(canvasElement); - - await waitForComponentsReady(() => - canvas.getByTestId('toggle').shadowRoot.querySelector('sbb-tooltip-trigger'), - ); - - await waitForStablePosition(() => - canvas.getByTestId('toggle').shadowRoot.querySelector('sbb-tooltip-trigger'), - ); - - const toggle = await canvas.getByTestId('toggle').shadowRoot.querySelector('sbb-tooltip-trigger'); - userEvent.click(toggle); -}; - -const changeEventHandler = async (event): Promise => { - const div = document.createElement('div'); - div.innerText = `valueAsDate is: ${await event.target.getValueAsDate()}.`; - document.getElementById('container-value').append(div); -}; - -const Template = ({ - min, - max, - wide, - dateFilter, - 'data-now': dataNow, - disableAnimation, - ...args -}): JSX.Element => { - return ( - -
      - - - - { - calendarRef.dateFilter = dateFilter; - }} - wide={wide} - onChange={(event) => changeEventHandler(event)} - data-now={dataNow} - > - -
      -
      - Change date to get the latest value: -
      -
      - ); -}; - -const TemplateFormField = ({ - min, - max, - label, - optional, - borderless, - size, - negative, - wide, - dateFilter, - dateHandling, - 'data-now': dataNow, - disableAnimation, - ...args -}): JSX.Element => { - return ( - - - - - - - { - calendarRef.dateFilter = dateFilter; - calendarRef.dateParser = dateHandling.dateParser; - calendarRef.format = dateHandling.format; - }} - wide={wide} - onChange={(event) => changeEventHandler(event)} - data-now={dataNow} - > - -
      - Change date to get the latest value: -
      -
      - ); -}; - -export const InFormField: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs }, - play: isChromatic() && playStory, -}; - -export const InFormFieldDisabled: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, disabled: true }, -}; - -export const InFormFieldReadonly: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, readonly: true }, -}; - -export const InFormFieldNegative: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, negative: true }, -}; - -export const InFormFieldDisabledNegative: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, disabled: true, negative: true }, -}; - -export const InFormFieldReadonlyNegative: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, readonly: true, negative: true }, -}; - -export const InFormFieldWide: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, wide: true }, -}; - -export const InFormFieldWithMinAndMax: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { - ...formFieldBasicArgs, - min: new Date(2023, 1, 8), - max: new Date(2023, 1, 22), - }, -}; - -export const InFormFieldWithDateFilter: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, dateFilter: dateFilter.options[1] }, -}; - -export const InFormFieldWithDateParser: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, value: '2023-02-12', dateHandling: dateHandling.options[1] }, -}; - -export const InFormFieldLarge: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, size: size.options[1] }, -}; - -export const InFormFieldOptional: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, optional: true }, -}; - -export const InFormFieldBorderless: StoryObj = { - render: TemplateFormField, - argTypes: { ...formFieldBasicArgsTypes }, - args: { ...formFieldBasicArgs, borderless: true }, -}; - -export const WithoutFormField: StoryObj = { - render: Template, - argTypes: { ...basicArgTypes }, - args: { ...basicArgs }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - chromatic: { disableSnapshot: false }, - actions: { - handles: ['input', 'change', events.validationChange], - }, - backgrounds: { - disable: true, - }, - docs: { - story: { inline: false, iframeHeight: '600px' }, - - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-datepicker/sbb-datepicker', -}; - -export default meta; diff --git a/src/components/sbb-datepicker/sbb-datepicker.tsx b/src/components/sbb-datepicker/sbb-datepicker.tsx deleted file mode 100644 index d974b045a0..0000000000 --- a/src/components/sbb-datepicker/sbb-datepicker.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - JSX, - Listen, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; -import { i18nDatePickerPlaceholder, i18nDateChangedTo } from '../../global/i18n'; -import { InputUpdateEvent, isDateAvailable } from './sbb-datepicker.helper'; -import { DateAdapter } from '../../global/datetime'; -import { findInput, isValidAttribute, toggleDatasetEntry } from '../../global/dom'; -import { - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { AgnosticMutationObserver } from '../../global/observers'; -import { readConfig } from '../../global/config'; -import { ValidationChangeEvent } from '../../global/interfaces'; - -const FORMAT_DATE = - /(^0?[1-9]?|[12]?[0-9]?|3?[01]?)[.,\\/\-\s](0?[1-9]?|1?[0-2]?)?[.,\\/\-\s](\d{1,4}$)?/; - -@Component({ - shadow: true, - styleUrl: 'sbb-datepicker.scss', - tag: 'sbb-datepicker', -}) -export class SbbDatepicker implements ComponentInterface { - /** If set to true, two months are displayed */ - @Prop() public wide = false; - - /** A function used to filter out dates. */ - @Prop() public dateFilter: (date: Date | null) => boolean = () => true; - - /** A function used to parse string value into dates. */ - @Prop() public dateParser?: (value: string) => Date | undefined; - - /** A function used to format dates into the preferred string format. */ - @Prop() public format?: (date: Date) => string; - - /** Reference of the native input connected to the datepicker. */ - @Prop() public input?: string | HTMLElement; - - /** Host element */ - @Element() private _element!: HTMLSbbDatepickerElement; - - /** - * @deprecated only used for React. Will probably be removed once React 19 is available. - */ - @Event({ bubbles: true, cancelable: true }) public didChange: EventEmitter; - - @Event({ bubbles: true }) public change: EventEmitter; - - /** Notifies that the attributes of the input connected to the datepicker have changes. */ - @Event({ bubbles: true, cancelable: true }) public inputUpdated: EventEmitter; - - /** Notifies that the attributes of the datepicker have changes. */ - @Event({ bubbles: true, cancelable: true }) public datePickerUpdated: EventEmitter; - - /** Emits whenever the internal validation state changes. */ - @Event() public validationChange: EventEmitter; - - @State() private _inputElement: HTMLInputElement | null; - - @State() private _currentLanguage = documentLanguage(); - - @Watch('input') - public findInput(newValue: string | HTMLElement, oldValue: string | HTMLElement): void { - if (newValue !== oldValue) { - this._inputElement = findInput(this._element, this.input); - } - } - - @Watch('wide') - @Watch('dateFilter') - public datepickerPropChanged(newValue: any, oldValue: any): void { - if (newValue !== oldValue) { - this.datePickerUpdated.emit(); - } - } - - @Watch('_inputElement') - public registerInputElement(newValue: HTMLInputElement, oldValue: HTMLInputElement): void { - if (newValue !== oldValue) { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - - if (!this._inputElement) { - return; - } - - this._inputObserver?.disconnect(); - this._inputObserver.observe(this._inputElement, { - attributeFilter: ['disabled', 'readonly', 'min', 'max', 'value'], - }); - - this._inputElement.type = 'text'; - - if (!this._inputElement.placeholder) { - this._inputElement.placeholder = i18nDatePickerPlaceholder[this._currentLanguage]; - } - - this._inputElement.addEventListener( - 'change', - async (event: Event) => { - if (!(event instanceof CustomEvent)) { - await this._valueChanged(event); - } - }, - { - signal: this._datePickerController.signal, - }, - ); - } - } - - /** Gets the input value with the correct date format. */ - @Method() public async getValueAsDate(): Promise { - return this._parse(this._inputElement?.value); - } - - /** Set the input value to the correctly formatted value. */ - @Method() public async setValueAsDate(date: Date | number | string): Promise { - const parsedDate = date instanceof Date ? date : new Date(date); - await this._formatAndUpdateValue(this._inputElement.value, parsedDate); - /* Emit blur event when value is changed programmatically to notify - frameworks that rely on that event to update form status. */ - this._inputElement.dispatchEvent(new FocusEvent('blur', { composed: true })); - } - - @Listen('datepicker-control-registered') - private _onInputPropertiesChange(mutationsList?: MutationRecord[]): void { - this.inputUpdated.emit({ - disabled: this._inputElement?.disabled, - readonly: this._inputElement?.readOnly, - min: this._inputElement?.min, - max: this._inputElement?.max, - }); - - if (mutationsList && Array.from(mutationsList).some((e) => e.attributeName === 'value')) { - this._inputElement.value = this._getValidValue(this._inputElement?.getAttribute('value')); - } - } - - private _datePickerController: AbortController; - - private _inputObserver = new AgnosticMutationObserver(this._onInputPropertiesChange.bind(this)); - - private _dateAdapter: DateAdapter = readConfig().datetime.dateAdapter; - - private _statusContainer: HTMLParagraphElement | null; - - private _handlerRepository = new HandlerRepository( - this._element as HTMLElement, - languageChangeHandlerAspect(async (l) => { - this._currentLanguage = l; - if (this._inputElement) { - this._inputElement.placeholder = i18nDatePickerPlaceholder[this._currentLanguage]; - const valueAsDate = await this.getValueAsDate(); - this._inputElement.value = this._format(valueAsDate); - } - }), - ); - - public connectedCallback(): void { - this._handlerRepository.connect(); - this._inputElement = findInput(this._element, this.input); - if (this._inputElement) { - this._inputElement.value = this._getValidValue(this._inputElement.value); - } - } - - public disconnectedCallback(): void { - this._inputObserver?.disconnect(); - this._datePickerController?.abort(); - this._handlerRepository.disconnect(); - } - - private _parseAndFormatValue(value: string): string { - const d = this._parse(value); - return !this._dateAdapter.isValid(d) ? value : this._format(d); - } - - private _createAndComposeDate(value: string | number | Date): string { - const date = new Date(value); - return this._format(date); - } - - private async _valueChanged(event): Promise { - await this._formatAndUpdateValue(event.target.value, this._parse(event.target.value)); - } - - /** Applies the correct format to values and triggers event dispatch. */ - private _formatAndUpdateValue(value: string, valueAsDate: Date): void { - if (this._inputElement) { - this._inputElement.value = !this._dateAdapter.isValid(valueAsDate) - ? value - : this._format(valueAsDate); - - const isEmptyOrValid = - !value || - (!!valueAsDate && - isDateAvailable( - valueAsDate, - this._element.dateFilter, - this._inputElement?.min, - this._inputElement?.max, - )); - const wasValid = !isValidAttribute(this._inputElement, 'data-sbb-invalid'); - toggleDatasetEntry(this._inputElement, 'sbbInvalid', !isEmptyOrValid); - if (wasValid !== isEmptyOrValid) { - this.validationChange.emit({ valid: isEmptyOrValid }); - } - this._emitChange(valueAsDate); - } - } - - /** Emits the change event. */ - private _emitChange(date: Date): void { - this._setAriaLiveMessage(date); - - this.change.emit(); - this.didChange.emit(); - - if (this._inputElement) { - this._inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - this._inputElement.dispatchEvent(new CustomEvent('change', { bubbles: true })); - } - } - - private _getValidValue(value: string): string { - if (!value) { - return ''; - } - - const match: RegExpMatchArray = value.match(FORMAT_DATE); - - if (match?.index === 0) { - return this._parseAndFormatValue(value); - } else if (Number.isInteger(+value)) { - return this._createAndComposeDate(+value); - } else if (this._dateAdapter.isValid(new Date(value))) { - return this._createAndComposeDate(value); - } - - return value; - } - - private _parse(value: string): Date | undefined { - return this.dateParser ? this.dateParser(value) : this._dateAdapter.parseDate(value); - } - - private _format(date: Date): string { - return this.format ? this.format(date) : this._dateAdapter.format(date); - } - - private _setAriaLiveMessage(date: Date): void { - const ariaLiveFormatter = new Intl.DateTimeFormat(`${this._currentLanguage}-CH`, { - weekday: 'long', - year: 'numeric', - month: 'numeric', - day: 'numeric', - }); - - this._statusContainer.innerText = date - ? `${i18nDateChangedTo[this._currentLanguage]} ${ariaLiveFormatter.format(date)}` - : ''; - } - - public render(): JSX.Element { - return

      (this._statusContainer = ref)}>

      ; - } -} diff --git a/src/components/sbb-dialog/readme.md b/src/components/sbb-dialog/readme.md deleted file mode 100644 index 74d2bb2fc8..0000000000 --- a/src/components/sbb-dialog/readme.md +++ /dev/null @@ -1,164 +0,0 @@ -The `sbb-dialog` component provides a way to present content on top of the app's content. -It offers the following features: - -- creates a backdrop for disabling interaction below the modal; -- disables scrolling of the page content while open; -- manages focus properly by setting it on the first focusable element; -- can have a header and a footer, both of which are optional; -- can host a [sbb-action-group](/docs/components-sbb-action-group--docs) component in the footer; -- has a close button, which is always visible; -- can display a back button next to the title; -- adds the appropriate ARIA roles automatically. - -```html - - Dialog content. - -``` - -## Slots - -The content is projected in an unnamed slot, while the dialog's title can be provided via the `titleContent` property or via slot `name="title"`. -It's also possible to display buttons in the component's footer using the `action-group` slot with the `sbb-action-group` component. - -**NOTE**: -- The component will automatically set size `m` on slotted `sbb-action-group`; -- If the title is not present, the footer will not be displayed even if provided; -- If the title is not present, the dialog will be displayed in fullscreen mode with the close button in the content section along with the back button -(if visible, see [next paragraph](#interaction)). - -```html - - Dialog content. - - - - - My dialog title - - Dialog content. - - Abort - Confirm - - -``` - -## Interactions - -In order to show the dialog, you need to call the `open(event?: PointerEvent)` method on the `sbb-dialog` component. -It is necessary to pass the event object to the `open()` method to allow the dialog to detect -whether it has been opened by click or keyboard, so that the focus can be better handled. - -```html - - - Dialog content. -
      ...
      -
      - - -``` - -To dismiss the dialog, you need to get a reference to the `sbb-dialog` element and call -the `close(result?: any, target?: HTMLElement)` method, which will close the dialog element and -emit a close event with an optional result as a payload. - -The component can also be dismissed by clicking on the close button, clicking on the backdrop, pressing the `Esc` key, -or, if an element within the `sbb-dialog` has the `sbb-dialog-close` attribute, by clicking on it. - -You can also set the property `titleBackButton` to display the back button in the title section -(or content section, if title is omitted) which will emit the event `request-back-action` when clicked. - -## Style - -It's possible to display the component in `negative` variant using the self-named property. - -The default `z-index` of the component is set to `1000`; to specify a custom stack order, the -`z-index` can be changed by defining the CSS variable `--sbb-dialog-z-index`. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------------- | --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `accessibilityBackLabel` | `accessibility-back-label` | This will be forwarded as aria-label to the back button element. | `string` | `undefined` | -| `accessibilityCloseLabel` | `accessibility-close-label` | This will be forwarded as aria-label to the close button element. | `string` | `undefined` | -| `accessibilityLabel` | `accessibility-label` | This will be forwarded as aria-label to the relevant nested element. | `string` | `undefined` | -| `backdropAction` | `backdrop-action` | Backdrop click action. | `"close" \| "none"` | `'close'` | -| `disableAnimation` | `disable-animation` | Whether the animation is enabled. | `boolean` | `false` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | -| `titleBackButton` | `title-back-button` | Whether a back button is displayed next to the title. | `boolean` | `false` | -| `titleContent` | `title-content` | Dialog title. | `string` | `undefined` | -| `titleLevel` | `title-level` | Level of title, will be rendered as heading tag (e.g. h1). Defaults to level 1. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `'1'` | - - -## Events - -| Event | Description | Type | -| --------------------- | -------------------------------------------------------- | ------------------- | -| `did-close` | Emits whenever the dialog is closed. | `CustomEvent` | -| `did-open` | Emits whenever the dialog is opened. | `CustomEvent` | -| `request-back-action` | Emits whenever the back button is clicked. | `CustomEvent` | -| `will-close` | Emits whenever the dialog begins the closing transition. | `CustomEvent` | -| `will-open` | Emits whenever the dialog starts the opening transition. | `CustomEvent` | - - -## Methods - -### `close(result?: any, target?: HTMLElement) => Promise` - -Closes the dialog element. - -#### Returns - -Type: `Promise` - - - -### `open() => Promise` - -Opens the dialog element. - -#### Returns - -Type: `Promise` - - - - -## Slots - -| Slot | Description | -| ---------------- | ------------------------------------------------------- | -| `"action-group"` | Use this slot to display an action group in the footer. | -| `"title"` | Use this slot to provide a title. | -| `"unnamed"` | Use this slot to provide the dialog content. | - - -## Dependencies - -### Depends on - -- [sbb-button](../sbb-button) -- [sbb-title](../sbb-title) - -### Graph -```mermaid -graph TD; - sbb-dialog --> sbb-button - sbb-dialog --> sbb-title - sbb-button --> sbb-icon - style sbb-dialog fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-dialog/sbb-dialog.e2e.ts b/src/components/sbb-dialog/sbb-dialog.e2e.ts deleted file mode 100644 index 8f2d669b09..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.e2e.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import events from './sbb-dialog.events'; -import { waitForCondition } from '../../global/testing'; -import { i18nDialog } from '../../global/i18n'; - -describe('sbb-dialog', () => { - let element: E2EElement, ariaLiveRef: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setViewport({ width: 900, height: 600 }); - await page.setContent(` - - Dialog content. -
      Action group
      -
      - `); - element = await page.find('sbb-dialog'); - ariaLiveRef = await page.find('sbb-dialog >>> span.sbb-screen-reader-only'); - await page.waitForChanges(); - }); - - it('renders', () => { - expect(element).toHaveClass('hydrated'); - }); - - it('opens the dialog', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - }); - - it('closes the dialog', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - expect(ariaLiveRef.innerText.trim()).toBe(`${i18nDialog.en}, Title`); - - await element.callMethod('close'); - await page.waitForChanges(); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didClose.events.length === 1); - expect(didClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'closed'); - expect(ariaLiveRef.innerText).toBe(''); - }); - - it('closes the dialog on backdrop click', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - // Simulate backdrop click - await page.mouse.click(1, 1); - await page.waitForChanges(); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didClose.events.length === 1); - expect(didClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'closed'); - }); - - it('does not close the dialog on backdrop click', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - - await element.setProperty('backdropAction', 'none'); - await page.waitForChanges(); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - // Simulate backdrop click - await page.mouse.click(1, 1); - await page.waitForChanges(); - - expect(willClose).toHaveReceivedEventTimes(0); - await page.waitForChanges(); - - expect(didClose).toHaveReceivedEventTimes(0); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - }); - - it('closes the dialog on close button click', async () => { - const closeButton = await page.find('sbb-dialog >>> [sbb-dialog-close]'); - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - closeButton.triggerEvent('click'); - await page.waitForChanges(); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didClose.events.length === 1); - expect(didClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'closed'); - }); - - it('closes the dialog on Esc key press', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - await page.keyboard.down('Tab'); - await page.waitForChanges(); - - await page.keyboard.down('Escape'); - await page.waitForChanges(); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didClose.events.length === 1); - expect(didClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'closed'); - }); - - it('does not have the fullscreen attribute', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - await page.waitForChanges(); - expect(element).not.toHaveAttribute('data-fullscreen'); - }); - - it('renders in fullscreen mode if no title is provided', async () => { - page = await newE2EPage(); - await page.setContent(` - - Dialog content. -
      Action group
      -
      - `); - element = await page.find('sbb-dialog'); - ariaLiveRef = await page.find('sbb-dialog >>> span.sbb-screen-reader-only'); - - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - await page.waitForChanges(); - expect(element).toHaveAttribute('data-fullscreen'); - expect(ariaLiveRef.innerText.trim()).toBe(`${i18nDialog.en}`); - }); - - it('closes stacked dialogs one by one on ESC key pressed', async () => { - page = await newE2EPage(); - await page.setContent(` - - Dialog content. -
      Action group
      -
      - - - Stacked dialog. - - `); - element = await page.find('sbb-dialog'); - - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - await page.waitForChanges(); - - const stackedDialog = await page.find('#stacked-dialog'); - - await stackedDialog.callMethod('open'); - await page.waitForChanges(); - - expect(stackedDialog).toEqualAttribute('data-state', 'opened'); - - await page.keyboard.down('Tab'); - await page.waitForChanges(); - - await page.keyboard.down('Escape'); - await page.waitForChanges(); - - expect(stackedDialog).toEqualAttribute('data-state', 'closed'); - expect(element).toEqualAttribute('data-state', 'opened'); - - await page.keyboard.down('Tab'); - await page.waitForChanges(); - - await page.keyboard.down('Escape'); - await page.waitForChanges(); - - expect(stackedDialog).toEqualAttribute('data-state', 'closed'); - expect(element).toEqualAttribute('data-state', 'closed'); - }); - - it('does not close the dialog on other overlay click', async () => { - page = await newE2EPage(); - await page.setViewport({ width: 900, height: 600 }); - await page.setContent(` - - Dialog content. -
      Action group
      - - Dialog content. -
      Action group
      -
      -
      - `); - element = await page.find('sbb-dialog'); - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - const willClose = await page.spyOnEvent(events.willClose); - const didClose = await page.spyOnEvent(events.didClose); - const innerElement = await page.find('sbb-dialog > sbb-dialog'); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - - await innerElement.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 2); - expect(willOpen).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 2); - expect(didOpen).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - - expect(innerElement).toEqualAttribute('data-state', 'opened'); - - // Simulate a click on the inner dialog's backdrop - await page.mouse.click(1, 1); - await page.waitForChanges(); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didClose.events.length === 1); - expect(didClose).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(innerElement).toEqualAttribute('data-state', 'closed'); - expect(element).toEqualAttribute('data-state', 'opened'); - }); - - it('should remove ariaLiveRef content on any click interaction', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - expect(ariaLiveRef.innerText.trim()).toBe(`${i18nDialog.en}, Title`); - - await element.press('Tab'); - await page.waitForChanges(); - - expect(ariaLiveRef.innerText).toBe(''); - }); - - it('should remove ariaLiveRef content on any keyboard interaction', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - expect(ariaLiveRef.innerText.trim()).toBe(`${i18nDialog.en}, Title`); - - await element.click(); - await page.waitForChanges(); - - expect(ariaLiveRef.innerText).toBe(''); - }); - - it('should announce accessibility label in ariaLiveRef if explicitly set', async () => { - const willOpen = await page.spyOnEvent(events.willOpen); - const didOpen = await page.spyOnEvent(events.didOpen); - - await element.setProperty('accessibilityLabel', 'Special Dialog'); - await element.callMethod('open'); - await page.waitForChanges(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(element).toEqualAttribute('data-state', 'opened'); - expect(ariaLiveRef.innerText.trim()).toBe(`${i18nDialog.en}, Special Dialog`); - }); -}); diff --git a/src/components/sbb-dialog/sbb-dialog.events.ts b/src/components/sbb-dialog/sbb-dialog.events.ts deleted file mode 100644 index c9a2e450f4..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.events.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - backClick: 'request-back-action', - didClose: 'did-close', - didOpen: 'did-open', - willClose: 'will-close', - willOpen: 'will-open', -}; diff --git a/src/components/sbb-dialog/sbb-dialog.scss b/src/components/sbb-dialog/sbb-dialog.scss deleted file mode 100644 index 42d7ab8579..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.scss +++ /dev/null @@ -1,286 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - @include sbb.scrollbar-variables; - - --sbb-dialog-padding-inline: var(--sbb-spacing-fixed-5x); - --sbb-dialog-color: var(--sbb-color-black-default); - --sbb-dialog-background-color: var(--sbb-color-white-default); - --sbb-dialog-width: 100%; - --sbb-dialog-height: 100%; - --sbb-dialog-max-width: 100%; - --sbb-dialog-max-width-default: #{sbb.px-to-rem-build(892)}; - --sbb-dialog-max-height: 100%; - --sbb-dialog-inset: 0 auto auto 0; - --sbb-dialog-border-radius: var(--sbb-border-radius-8x); - --sbb-dialog-padding-block: var(--sbb-spacing-responsive-s); - --sbb-dialog-animation-duration: var(--sbb-animation-duration-6x); - --sbb-dialog-animation-easing: ease; - --sbb-dialog-pointer-events: none; - --sbb-dialog-backdrop-visibility: hidden; - --sbb-dialog-backdrop-pointer-events: none; - --sbb-dialog-backdrop-color: transparent; - --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s) 0; - --sbb-dialog-footer-border: var(--sbb-border-width-1x) solid var(--sbb-color-cloud-default); - - position: fixed; - inset: var(--sbb-dialog-inset); - z-index: var(--sbb-dialog-z-index, var(--sbb-overlay-z-index)); - - @include sbb.mq($from: micro) { - --sbb-dialog-padding-inline: var(--sbb-spacing-fixed-6x); - } - - @include sbb.mq($from: small) { - --sbb-dialog-padding-inline: var(--sbb-spacing-fixed-12x); - } - - @include sbb.mq($from: medium) { - --sbb-dialog-padding-inline: var(--sbb-spacing-responsive-s); - --sbb-dialog-max-width: min( - calc(100vw - var(--sbb-spacing-fixed-30x) * 2), - var(--sbb-dialog-max-width-default) - ); - --sbb-dialog-max-height: calc(100vh - var(--sbb-spacing-fixed-16x)); - } -} - -:host(:is([data-state='opened'], [data-state='opening'])) { - --sbb-dialog-pointer-events: all; - - @include sbb.mq($from: medium) { - --sbb-dialog-backdrop-visibility: visible; - --sbb-dialog-backdrop-pointer-events: all; - --sbb-dialog-backdrop-color: var(--sbb-color-milk-default); - } -} - -:host([data-fullscreen]) { - --sbb-dialog-backdrop-color: transparent; - --sbb-dialog-max-width: 100%; - --sbb-dialog-max-height: 100%; -} - -:host([negative]:not([negative='false'])) { - @include sbb.scrollbar-variables--color-negative; - - --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); - --sbb-dialog-color: var(--sbb-color-white-default); - --sbb-dialog-background-color: var(--sbb-color-midnight-default); - --sbb-dialog-footer-border: none; -} - -:host([disable-animation]:not([disable-animation='false'])) { - --sbb-dialog-animation-duration: 0s; -} - -:host([data-fullscreen]:not([negative]:not([negative='false']))) { - --sbb-dialog-background-color: var(--sbb-color-milk-default); -} - -:host([data-overflows]:not([data-fullscreen])) { - --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s); -} - -:host([data-overflows]:not([data-fullscreen], [negative]:not([negative='false']))) { - --sbb-dialog-footer-border: none; -} - -:host(:not([data-state='closed'])) { - --sbb-dialog-inset: 0; -} - -.sbb-dialog__container { - pointer-events: var(--sbb-dialog-pointer-events); - display: flex; - align-items: center; - position: fixed; - inset: var(--sbb-dialog-inset); - - // Dialog backdrop (not visible on mobile) - &::before { - content: ''; - visibility: var(--sbb-dialog-backdrop-visibility); - pointer-events: var(--sbb-dialog-backdrop-pointer-events); - position: fixed; - inset: var(--sbb-dialog-inset); - background-color: var(--sbb-dialog-backdrop-color); - transition: { - duration: var(--sbb-dialog-animation-duration); - timing-function: var(--sbb-dialog-animation-easing); - property: background-color, visibility; - } - } -} - -.sbb-dialog { - display: none; - position: absolute; - inset-inline: 0; - margin: auto; - padding: 0; - border: none; - width: var(--sbb-dialog-width); - height: var(--sbb-dialog-height); - max-width: var(--sbb-dialog-max-width); - max-height: var(--sbb-dialog-max-height); - overflow: auto; - color: var(--sbb-dialog-color); - background-color: var(--sbb-dialog-background-color); - - :host(:not([data-state='closed'])) & { - display: block; - - animation: { - name: open; - duration: var(--sbb-dialog-animation-duration); - timing-function: ease; - } - } - - :host([data-fullscreen]) & { - border-radius: 0; - } - - :host([data-state='closing']) & { - pointer-events: none; - animation-name: close; - } - - @include sbb.if-forced-colors { - outline: var(--sbb-border-width-1x) solid CanvasText; - } - - @include sbb.mq($from: medium) { - border-radius: var(--sbb-dialog-border-radius); - overflow: hidden; - - :host(:not([data-fullscreen])) & { - height: fit-content; - } - } -} - -.sbb-dialog__wrapper { - display: flex; - flex-direction: column; - width: var(--sbb-dialog-width); - height: var(--sbb-dialog-height); - max-width: var(--sbb-dialog-max-width); - max-height: var(--sbb-dialog-max-height); - outline: none; - position: fixed; - word-break: break-word; - - @include sbb.mq($from: medium) { - position: sticky; - inset-block-start: 0; - height: auto; - } -} - -.sbb-dialog__header { - display: flex; - pointer-events: none; - gap: var(--sbb-spacing-fixed-6x); - align-items: start; - justify-content: space-between; - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-dialog-header-padding-block); - background-color: var(--sbb-dialog-background-color); - z-index: var(--sbb-dialog-z-index, var(--sbb-overlay-z-index)); - - * { - pointer-events: all; - } - - :host([data-fullscreen]) & { - position: fixed; - width: var(--sbb-dialog-width); - background-color: transparent; - padding-inline: var(--sbb-spacing-responsive-xs); - padding-block-start: var(--sbb-spacing-responsive-xs); - } - - @include sbb.mq($from: medium) { - border-radius: var(--sbb-dialog-border-radius) var(--sbb-dialog-border-radius) 0 0; - } -} - -.sbb-dialog__title { - flex: 1; - overflow: hidden; - align-self: center; - - // Overwrite sbb-title default margin - margin: 0; -} - -.sbb-dialog__close { - margin-inline-start: auto; -} - -.sbb-dialog__content { - @include sbb.scrollbar-rules; - - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-dialog-padding-block); - overflow: auto; - - :host([data-fullscreen]) & { - padding-block-start: var(--sbb-spacing-fixed-20x); - padding-inline: var(--sbb-layout-base-offset-responsive); - height: 100vh; - } -} - -.sbb-dialog__footer { - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-spacing-responsive-s); - margin-block-start: auto; - background-color: var(--sbb-dialog-background-color); - border-block-start: var(--sbb-dialog-footer-border); -} - -// stylelint-disable selector-not-notation -:is(.sbb-dialog__header, .sbb-dialog__footer) { - :host([data-overflows]:not([data-fullscreen], [negative]:not([negative='false']))) & { - @include sbb.shadow-level-9-soft; - } -} -// stylelint-enable selector-not-notation - -.sbb-screen-reader-only { - @include sbb.screen-reader-only; -} - -// It is necessary to use animations with keyframes instead of transitions in order not to alter -// the default `display: block` of the modal otherwise it causes several problems, -// especially for accessibility. -@keyframes open { - from { - opacity: 0; - transform: translateY(var(--sbb-spacing-fixed-4x)); - } - - to { - opacity: 1; - transform: translateY(0%); - } -} - -@keyframes close { - from { - opacity: 1; - transform: translateY(0%); - } - - to { - opacity: 0; - transform: translateY(var(--sbb-spacing-fixed-4x)); - } -} diff --git a/src/components/sbb-dialog/sbb-dialog.spec.ts b/src/components/sbb-dialog/sbb-dialog.spec.ts deleted file mode 100644 index cb90d91c0a..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SbbDialog } from './sbb-dialog'; -import { newSpecPage } from '@stencil/core/testing'; -import { i18nCloseDialog } from '../../global/i18n'; - -describe('sbb-dialog', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbDialog], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      -
      -
      - - -
      -
      - -
      -
      -
      -
      - -
      -
      - `); - }); -}); diff --git a/src/components/sbb-dialog/sbb-dialog.stories.tsx b/src/components/sbb-dialog/sbb-dialog.stories.tsx deleted file mode 100644 index 9df4e08dba..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.stories.tsx +++ /dev/null @@ -1,432 +0,0 @@ -/** @jsx h */ -import events from './sbb-dialog.events'; -import { Fragment, h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import sampleImages from '../../global/images'; -import isChromatic from 'chromatic'; -import { userEvent, within } from '@storybook/testing-library'; -import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; -import { waitForStablePosition } from '../../global/testing'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -// Story interaction executed after the story renders -const playStory = async ({ canvasElement }): Promise => { - const canvas = within(canvasElement); - - await waitForComponentsReady(() => - canvas.getByTestId('dialog').shadowRoot.querySelector('.sbb-dialog'), - ); - - await waitForStablePosition(() => canvas.getByTestId('dialog-trigger')); - - const button = canvas.getByTestId('dialog-trigger'); - await userEvent.click(button); -}; - -const titleContent: InputType = { - control: { - type: 'text', - }, -}; - -const titleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6], -}; - -const titleBackButton: InputType = { - control: { - type: 'boolean', - }, -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const accessibilityLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const accessibilityCloseLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const accessibilityBackLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, -}; - -const backdropAction: InputType = { - control: { - type: 'select', - }, - options: ['close', 'none'], -}; - -const basicArgTypes: ArgTypes = { - 'title-content': titleContent, - 'title-level': titleLevel, - 'title-back-button': titleBackButton, - negative, - 'accessibility-label': accessibilityLabel, - 'accessibility-close-label': accessibilityCloseLabel, - 'accessibility-back-label': accessibilityBackLabel, - 'disable-animation': disableAnimation, - 'backdrop-action': backdropAction, -}; - -const basicArgs: Args = { - 'title-content': 'A describing title of the dialog', - 'title-level': undefined, - 'title-back-button': true, - negative: false, - 'accessibility-label': undefined, - 'accessibility-close-label': undefined, - 'accessibility-back-label': undefined, - 'disable-animation': isChromatic(), - 'backdrop-action': backdropAction.options[0], -}; - -const openDialog = (_event, id): void => { - const dialog = document.getElementById(id) as HTMLSbbDialogElement; - dialog.open(); -}; - -const onFormDialogClose = (dialog): void => { - dialog.addEventListener('will-close', (event) => { - if (event.detail) { - document.getElementById( - 'returned-value-message', - ).innerHTML = `${event.detail.returnValue.message?.value}`; - document.getElementById( - 'returned-value-animal', - ).innerHTML = `${event.detail.returnValue.animal?.value}`; - } - }); -}; - -const triggerButton = (dialogId): JSX.Element => ( - openDialog(event, dialogId)} - > - Open dialog - -); - -const actionGroup = (negative): JSX.Element => ( - - - Link - - - Cancel - - - Confirm - - -); - -const codeStyle: Args = { - padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)', - marginInline: 'var(--sbb-spacing-fixed-2x)', - borderRadius: 'var(--sbb-border-radius-4x)', - backgroundColor: 'var(--sbb-color-smoke-alpha-20)', -}; - -const formDetailsStyle: Args = { - marginTop: 'var(--sbb-spacing-fixed-4x)', - padding: 'var(--sbb-spacing-fixed-4x)', - borderRadius: 'var(--sbb-border-radius-8x)', - backgroundColor: 'var(--sbb-color-milk-default)', -}; - -const formStyle: Args = { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: 'var(--sbb-spacing-fixed-4x)', -}; - -const DefaultTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-1')} - -

      - Dialog content -

      - {actionGroup(args.negative)} -
      -
      -); - -const SlottedTitleTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-2')} - - - - The Catcher in the Rye - -

      - “What really knocks me out is a book that, when you're all done reading it, you wish the - author that wrote it was a terrific friend of yours and you could call him up on the phone - whenever you felt like it. That doesn't happen much, though.” ― J.D. Salinger, The Catcher - in the Rye -

      - {actionGroup(args.negative)} -
      -
      -); - -const LongContentTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-3')} - - Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his face - like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo saw that - Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be clad in - elven-mail, and a star shone on his breast. They spoke together, and then suddenly it seemed - to Frodo that Arwen turned towards him, and the light of her eyes fell on him from afar and - pierced his heart. - - He stood still enchanted, while the sweet syllables of the elvish song fell like clear jewels - of blended word and melody. 'It is a song to Elbereth,'' said Bilbo. 'They will sing that, and - other songs of the Blessed Realm, many times tonight. Come on!’ —J.R.R. Tolkien, The Lord of - the Rings: The Fellowship of the Ring, “Many Meetings” - {actionGroup(args.negative)} - - -); - -const FormTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-4')} -
      -
      -
      - Your message: Hello 👋 -
      -
      - Your favorite animal: Red Panda -
      -
      -
      - onFormDialogClose(dialog)} - > -
      - Submit the form below to close the dialog box using the - close(result?: any, target?: HTMLElement) - method and returning the form values to update the details. -
      - e.preventDefault()}> - - - - - - - - Update details - - -
      -
      -); - -const NoFooterTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-5')} - -

      - “What really knocks me out is a book that, when you're all done reading it, you wish the - author that wrote it was a terrific friend of yours and you could call him up on the phone - whenever you felt like it. That doesn't happen much, though.” ― J.D. Salinger, The Catcher - in the Rye -

      -
      -
      -); - -const FullScreenTemplate = (args): JSX.Element => ( - - {triggerButton('my-dialog-6')} - - - Many Meetings - - Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his face - like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo saw that - Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be clad in - elven-mail, and a star shone on his breast. They spoke together, and then suddenly it seemed - to Frodo that Arwen turned towards him, and the light of her eyes fell on him from afar and - pierced his heart. - - He stood still enchanted, while the sweet syllables of the elvish song fell like clear jewels - of blended word and melody. 'It is a song to Elbereth,'' said Bilbo. 'They will sing that, and - other songs of the Blessed Realm, many times tonight. Come on!’ —J.R.R. Tolkien, The Lord of - the Rings: The Fellowship of the Ring, “Many Meetings” - {actionGroup(args.negative)} - - -); - -export const Default: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: basicArgs, - play: isChromatic() && playStory, -}; - -export const Negative: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { - ...basicArgs, - negative: true, - }, - play: isChromatic() && playStory, -}; - -export const AllowBackdropClick: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, 'backdrop-action': backdropAction.options[1] }, - play: isChromatic() && playStory, -}; - -export const SlottedTitle: StoryObj = { - render: SlottedTitleTemplate, - argTypes: basicArgTypes, - args: { - ...basicArgs, - 'title-content': undefined, - 'title-back-button': false, - }, - play: isChromatic() && playStory, -}; - -export const LongContent: StoryObj = { - render: LongContentTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() && playStory, -}; - -export const Form: StoryObj = { - render: FormTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() && playStory, -}; - -export const NoFooter: StoryObj = { - render: NoFooterTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() && playStory, -}; - -export const FullScreen: StoryObj = { - render: FullScreenTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, 'title-content': undefined }, - play: isChromatic() && playStory, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - chromatic: { disableSnapshot: false }, - actions: { - handles: [ - events.willOpen, - events.didOpen, - events.willClose, - events.didClose, - events.backClick, - ], - }, - backgrounds: { - disable: true, - }, - docs: { - story: { inline: false, iframeHeight: '600px' }, - extractComponentDescription: () => readme, - }, - layout: 'fullscreen', - }, - title: 'components/sbb-dialog', -}; - -export default meta; diff --git a/src/components/sbb-dialog/sbb-dialog.tsx b/src/components/sbb-dialog/sbb-dialog.tsx deleted file mode 100644 index 387c806137..0000000000 --- a/src/components/sbb-dialog/sbb-dialog.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Method, - Prop, - State, -} from '@stencil/core'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; -import { i18nCloseDialog, i18nDialog, i18nGoBack } from '../../global/i18n'; -import { FocusTrap, IS_FOCUSABLE_QUERY, setModalityOnNextFocus } from '../../global/a11y'; -import { ScrollHandler, toggleDatasetEntry, isValidAttribute, hostContext } from '../../global/dom'; -import { - createNamedSlotState, - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, - namedSlotChangeHandlerAspect, -} from '../../global/eventing'; -import { AgnosticResizeObserver } from '../../global/observers'; -import { applyInertMechanism, removeInertMechanism, SbbOverlayState } from '../../global/overlay'; - -// A global collection of existing dialogs -const dialogRefs: HTMLSbbDialogElement[] = []; -let nextId = 0; - -/** - * @slot unnamed - Use this slot to provide the dialog content. - * @slot title - Use this slot to provide a title. - * @slot action-group - Use this slot to display an action group in the footer. - */ - -@Component({ - shadow: true, - styleUrl: 'sbb-dialog.scss', - tag: 'sbb-dialog', -}) -export class SbbDialog implements ComponentInterface { - /** - * Dialog title. - */ - @Prop() public titleContent: string; - - /** - * Level of title, will be rendered as heading tag (e.g. h1). Defaults to level 1. - */ - @Prop() public titleLevel: InterfaceTitleAttributes['level'] = '1'; - - /** - * Whether a back button is displayed next to the title. - */ - @Prop() public titleBackButton = false; - - /** - * Backdrop click action. - */ - @Prop() public backdropAction: 'close' | 'none' = 'close'; - - /** - * Negative coloring variant flag. - */ - @Prop({ reflect: true }) public negative = false; - - /** - * This will be forwarded as aria-label to the relevant nested element. - */ - @Prop() public accessibilityLabel: string | undefined; - - /** - * This will be forwarded as aria-label to the close button element. - */ - @Prop() public accessibilityCloseLabel: string | undefined; - - /** - * This will be forwarded as aria-label to the back button element. - */ - @Prop() public accessibilityBackLabel: string | undefined; - - /** - * Whether the animation is enabled. - */ - @Prop({ reflect: true }) public disableAnimation = false; - - /** - * State of listed named slots, by indicating whether any element for a named slot is defined. - */ - @State() private _namedSlots = createNamedSlotState('title', 'action-group'); - - @State() private _currentLanguage = documentLanguage(); - - /* - * The state of the dialog. - */ - private set _state(state: SbbOverlayState) { - this._element.dataset.state = state; - } - private get _state(): SbbOverlayState { - return this._element.dataset.state as SbbOverlayState; - } - - private _dialogContentResizeObserver = new AgnosticResizeObserver(() => - this._setOverflowAttribute(), - ); - - private _ariaLiveRef: HTMLElement; - private _ariaLiveRefToggle = false; - - /** - * Emits whenever the dialog starts the opening transition. - */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; - - /** - * Emits whenever the dialog is opened. - */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; - - /** - * Emits whenever the dialog begins the closing transition. - */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter; - - /** - * Emits whenever the dialog is closed. - */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter; - - /** - * Emits whenever the back button is clicked. - */ - @Event({ - bubbles: true, - composed: true, - eventName: 'request-back-action', - }) - public backClick: EventEmitter; - - private _dialog: HTMLDivElement; - private _dialogWrapperElement: HTMLElement; - private _dialogContentElement: HTMLElement; - private _dialogCloseElement: HTMLElement; - private _dialogController: AbortController; - private _windowEventsController: AbortController; - private _focusTrap = new FocusTrap(); - private _scrollHandler = new ScrollHandler(); - private _returnValue: any; - private _isPointerDownEventOnDialog: boolean; - private _dialogId = `sbb-dialog-${nextId++}`; - - // Last element which had focus before the dialog was opened. - private _lastFocusedElement?: HTMLElement; - - @Element() private _element!: HTMLElement; - - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - - /** - * Opens the dialog element. - */ - @Method() - public async open(): Promise { - if (this._state !== 'closed' || !this._dialog) { - return; - } - this._lastFocusedElement = document.activeElement as HTMLElement; - this.willOpen.emit(); - this._state = 'opening'; - // Add this dialog to the global collection - dialogRefs.push(this._element as HTMLSbbDialogElement); - this._setOverflowAttribute(); - // Disable scrolling for content below the dialog - this._scrollHandler.disableScroll(); - } - - /** - * Closes the dialog element. - */ - @Method() - public async close(result?: any, target?: HTMLElement): Promise { - if (this._state !== 'opened') { - return; - } - - this._returnValue = result; - this._dialogCloseElement = target; - this.willClose.emit({ returnValue: this._returnValue, closeTarget: this._dialogCloseElement }); - this._state = 'closing'; - this._removeAriaLiveRefContent(); - } - - // Closes the dialog on "Esc" key pressed. - private async _onKeydownEvent(event: KeyboardEvent): Promise { - if (this._state !== 'opened') { - return; - } - - if (event.key === 'Escape') { - await dialogRefs[dialogRefs.length - 1].close(); - return; - } - } - - public connectedCallback(): void { - this._handlerRepository.connect(); - this._state = this._state || 'closed'; - this._dialogController = new AbortController(); - - // Close dialog on backdrop click - this._element.addEventListener('pointerdown', this._pointerDownListener, { - signal: this._dialogController.signal, - }); - this._element.addEventListener('pointerup', this._closeOnBackdropClick, { - signal: this._dialogController.signal, - }); - - if (this._state === 'opened') { - applyInertMechanism(this._element); - } - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - this._dialogController?.abort(); - this._windowEventsController?.abort(); - this._focusTrap.disconnect(); - this._removeInstanceFromGlobalCollection(); - removeInertMechanism(); - } - - private _removeInstanceFromGlobalCollection(): void { - dialogRefs.splice(dialogRefs.indexOf(this._element as HTMLSbbDialogElement), 1); - } - - private _attachWindowEvents(): void { - this._windowEventsController = new AbortController(); - // Remove dialog label as soon as it is not needed anymore to prevent accessing it with browse mode. - window.addEventListener( - 'keydown', - async (event: KeyboardEvent) => { - this._removeAriaLiveRefContent(); - await this._onKeydownEvent(event); - }, - { - signal: this._windowEventsController.signal, - }, - ); - window.addEventListener('click', () => this._removeAriaLiveRefContent(), { - signal: this._windowEventsController.signal, - }); - } - - // Check if the pointerdown event target is triggered on the dialog. - private _pointerDownListener = (event: PointerEvent): void => { - if (this.backdropAction !== 'close') { - return; - } - - this._isPointerDownEventOnDialog = event - .composedPath() - .filter((e): e is HTMLElement => e instanceof window.HTMLElement) - .some((target) => target.tagName === this._dialogId); - }; - - // Close dialog on backdrop click. - private _closeOnBackdropClick = async (event: PointerEvent): Promise => { - if (this.backdropAction !== 'close') { - return; - } - - if ( - !this._isPointerDownEventOnDialog && - !event - .composedPath() - .filter((e): e is HTMLElement => e instanceof window.HTMLElement) - .some((target) => target.id === this._dialogId) - ) { - await this.close(); - } - }; - - // Close the dialog on click of any element that has the 'sbb-dialog-close' attribute. - private async _closeOnSbbDialogCloseClick(event: Event): Promise { - const target = event.target as HTMLElement; - - if (target.hasAttribute('sbb-dialog-close') && !isValidAttribute(target, 'disabled')) { - // Check if the target is a submission element within a form and return the form, if present - const closestForm = - target.getAttribute('type') === 'submit' - ? (hostContext('form', target) as HTMLFormElement) - : undefined; - await this.close(closestForm, target); - } - } - - // Wait for dialog transition to complete. - // In rare cases it can be that the animationEnd event is triggered twice. - // To avoid entering a corrupt state, exit when state is not expected. - private _onDialogAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this.didOpen.emit(); - applyInertMechanism(this._element); - this._setDialogFocus(); - // Use timeout to read label after focused element - setTimeout(() => this._setAriaLiveRefContent()); - this._focusTrap.trap(this._element); - this._dialogContentResizeObserver.observe(this._dialogContentElement); - this._attachWindowEvents(); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; - this._dialogWrapperElement.querySelector('.sbb-dialog__content').scrollTo(0, 0); - removeInertMechanism(); - setModalityOnNextFocus(this._lastFocusedElement); - // Manually focus last focused element - this._lastFocusedElement?.focus(); - this.didClose.emit({ returnValue: this._returnValue, closeTarget: this._dialogCloseElement }); - this._windowEventsController?.abort(); - this._focusTrap.disconnect(); - this._dialogContentResizeObserver.disconnect(); - this._removeInstanceFromGlobalCollection(); - // Enable scrolling for content below the dialog if no dialog is open - !dialogRefs.length && this._scrollHandler.enableScroll(); - } - } - - private _setAriaLiveRefContent(): void { - this._ariaLiveRefToggle = !this._ariaLiveRefToggle; - - // Take accessibility label or current string in title section - const label = - this.accessibilityLabel || - (this._element.shadowRoot.querySelector('.sbb-dialog__title') as HTMLElement)?.innerText; - - // If the text content remains the same, on VoiceOver the aria-live region is not announced a second time. - // In order to support reading on every opening, we toggle an invisible space. - this._ariaLiveRef.innerText = `${i18nDialog[this._currentLanguage]}${ - label ? `, ${label}` : '' - }${this._ariaLiveRefToggle ? ' ' : ''}`; - } - - private _removeAriaLiveRefContent(): void { - this._ariaLiveRef.innerText = ''; - } - - // Set focus on the first focusable element. - private _setDialogFocus(): void { - const firstFocusable = this._element.shadowRoot.querySelector( - IS_FOCUSABLE_QUERY, - ) as HTMLElement; - setModalityOnNextFocus(firstFocusable); - firstFocusable.focus(); - } - - private _setOverflowAttribute(): void { - toggleDatasetEntry( - this._element, - 'overflows', - this._dialogContentElement.scrollHeight > this._dialogContentElement.clientHeight, - ); - } - - public render(): JSX.Element { - const hasTitle = !!this.titleContent || this._namedSlots['title']; - const hasActionGroup = this._namedSlots['action-group'] && hasTitle; - - const closeButton = ( - - ); - - const backButton = ( - this.backClick.emit()} - > - ); - - const dialogHeader = ( -
      - {this.titleBackButton && backButton} - {hasTitle && ( - - {this.titleContent} - - )} - {closeButton} -
      - ); - - const dialogFooter = ( - - ); - - return ( - -
      -
      (this._dialog = dialogRef)} - onAnimationEnd={(event: AnimationEvent) => this._onDialogAnimationEnd(event)} - class="sbb-dialog" - id={this._dialogId} - > - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
      this._closeOnSbbDialogCloseClick(event)} - ref={(dialogWrapperRef) => (this._dialogWrapperElement = dialogWrapperRef)} - class="sbb-dialog__wrapper" - > - {dialogHeader} -
      (this._dialogContentElement = dialogContent)} - > - -
      - {hasActionGroup && dialogFooter} -
      -
      -
      - (this._ariaLiveRef = el)} - > -
      - ); - } -} diff --git a/src/components/sbb-divider/readme.md b/src/components/sbb-divider/readme.md deleted file mode 100644 index 84a6ca307e..0000000000 --- a/src/components/sbb-divider/readme.md +++ /dev/null @@ -1,51 +0,0 @@ -The `sbb-divider` is used to visually divide sections. - -## Style - -Based on the `orientation` property, the `sbb-divider` can be displayed vertically or horizontally. - -It's also possible to display the component in `negative` variant using the self-named property. - -```html - - - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------- | ------------- | --------------------------------------------------------------------------------------------- | ---------------------------- | -------------- | -| `negative` | `negative` | Negative coloring variant flag | `boolean` | `false` | -| `orientation` | `orientation` | Orientation property with possible values 'horizontal' \| 'vertical'. Defaults to horizontal. | `"horizontal" \| "vertical"` | `'horizontal'` | - - -## Dependencies - -### Used by - - - [sbb-alert](../sbb-alert) - - [sbb-journey-summary](../sbb-journey-summary) - - [sbb-navigation-section](../sbb-navigation-section) - - [sbb-notification](../sbb-notification) - - [sbb-optgroup](../sbb-optgroup) - - [sbb-selection-panel](../sbb-selection-panel) - -### Graph -```mermaid -graph TD; - sbb-alert --> sbb-divider - sbb-journey-summary --> sbb-divider - sbb-navigation-section --> sbb-divider - sbb-notification --> sbb-divider - sbb-optgroup --> sbb-divider - sbb-selection-panel --> sbb-divider - style sbb-divider fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-divider/sbb-divider.custom.d.ts b/src/components/sbb-divider/sbb-divider.custom.d.ts deleted file mode 100644 index 3e5326cc4e..0000000000 --- a/src/components/sbb-divider/sbb-divider.custom.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface InterfaceSbbDividerAttributes { - orientation?: 'horizontal' | 'vertical'; - negative?: boolean; -} diff --git a/src/components/sbb-divider/sbb-divider.e2e.ts b/src/components/sbb-divider/sbb-divider.e2e.ts deleted file mode 100644 index 458c05af0c..0000000000 --- a/src/components/sbb-divider/sbb-divider.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; - -describe('sbb-divider', () => { - let element, page; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-divider'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-divider/sbb-divider.scss b/src/components/sbb-divider/sbb-divider.scss deleted file mode 100644 index 26d3ea39fd..0000000000 --- a/src/components/sbb-divider/sbb-divider.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-divider-color: var(--sbb-color-cloud-default); - --sbb-divider-border-width: var(--sbb-border-width-1x); -} - -:host([orientation='vertical']) { - height: 100%; -} - -:host([negative]:not([negative='false'])) { - --sbb-divider-color: var(--sbb-color-iron-default); -} - -.sbb-divider { - :host([orientation='horizontal']) & { - border-top: var(--sbb-divider-border-width) solid var(--sbb-divider-color); - } - - :host([orientation='vertical']) & { - height: 100%; - border-left: var(--sbb-divider-border-width) solid var(--sbb-divider-color); - } -} diff --git a/src/components/sbb-divider/sbb-divider.spec.ts b/src/components/sbb-divider/sbb-divider.spec.ts deleted file mode 100644 index dcf5404748..0000000000 --- a/src/components/sbb-divider/sbb-divider.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { SbbDivider } from './sbb-divider'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-divider', () => { - it('should render with default values', async () => { - const { root } = await newSpecPage({ - components: [SbbDivider], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      -
      - `); - }); - - it('should render with orientation horizontal', async () => { - const { root } = await newSpecPage({ - components: [SbbDivider], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      -
      - `); - }); - - it('should render with orientation vertical', async () => { - const { root } = await newSpecPage({ - components: [SbbDivider], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-divider/sbb-divider.stories.tsx b/src/components/sbb-divider/sbb-divider.stories.tsx deleted file mode 100644 index 4625759408..0000000000 --- a/src/components/sbb-divider/sbb-divider.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj, ArgTypes, Args, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-charcoal-default)' - : 'var(--sbb-color-white-default)', -}); - -const Template = (args): JSX.Element => ( -
      - -
      -); - -const orientation: InputType = { - control: { - type: 'select', - }, - options: ['horizontal', 'vertical'], -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Appearance', - }, -}; - -const defaultArgTypes: ArgTypes = { - orientation, - negative, -}; - -const defaultArgs: Args = { - orientation: orientation.options[0], - negative: false, -}; - -export const dividerHorizontal: StoryObj = { - render: Template, - args: { ...defaultArgs }, - argTypes: defaultArgTypes, -}; - -export const dividerVertical: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - orientation: 'vertical', - }, -}; - -export const dividerNegative: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - negative: true, - }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-divider', -}; - -export default meta; diff --git a/src/components/sbb-divider/sbb-divider.tsx b/src/components/sbb-divider/sbb-divider.tsx deleted file mode 100644 index 3d4c813f00..0000000000 --- a/src/components/sbb-divider/sbb-divider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, h, Host, JSX, Prop } from '@stencil/core'; -import { InterfaceSbbDividerAttributes } from './sbb-divider.custom'; - -@Component({ - shadow: true, - styleUrl: 'sbb-divider.scss', - tag: 'sbb-divider', -}) -export class SbbDivider { - /** Negative coloring variant flag */ - @Prop({ reflect: true }) public negative?: boolean = false; - - /** Orientation property with possible values 'horizontal' | 'vertical'. Defaults to horizontal. */ - @Prop({ reflect: true }) public orientation?: InterfaceSbbDividerAttributes['orientation'] = - 'horizontal'; - - public render(): JSX.Element { - return ( - -
      -
      - ); - } -} diff --git a/src/components/sbb-expansion-panel-content/readme.md b/src/components/sbb-expansion-panel-content/readme.md deleted file mode 100644 index 985f5ce617..0000000000 --- a/src/components/sbb-expansion-panel-content/readme.md +++ /dev/null @@ -1,36 +0,0 @@ -The `sbb-expansion-panel-content` is a component which acts as a container for any element -that needs to be displayed in a [sbb-expansion-panel](/docs/components-sbb-accordion-sbb-expansion-panel--docs). - -```html - -

      Lorem ipsum dolor sit amet, consectetur adipiscing elit.

      -

      - - Donec porttitor blandit odio, ut blandit libero cursus vel. - - - Nunc eu congue mauris. Quisque sed facilisis leo. Curabitur malesuada, nibh ac - blandit vehicula, urna sem scelerisque magna, sed tincidunt neque arcu ac justo. - -

      -
      -``` - -## Style - -When it's used in combination with a `sbb-expansion-panel-header` with an icon displayed via slot or `iconName` property, -the `sbb-expansion-panel-content` receives a padding on the left side in order to align it with the header label. - - - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------------------ | -| `"unnamed"` | Slot to render the content in the sbb-expansion-panel. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts deleted file mode 100644 index 00bfab6416..0000000000 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-expansion-panel-content', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent('Content'); - - element = await page.find('sbb-expansion-panel-content'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.scss b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.scss deleted file mode 100644 index f9ac5a2f15..0000000000 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-expansion-panel-content-padding-inline: var(--sbb-spacing-fixed-6x); - --sbb-expansion-panel-content-padding-inline-start: var( - --sbb-expansion-panel-content-padding-inline - ); -} - -:host([data-icon-space]) { - @include sbb.mq($from: micro) { - // The space taken by the icon in the sbb-expansion-panel-header must be considered here to correctly calculate the padding value; - // this is the sum of the default padding, plus the icon space plus the gap between the icon and the header title. - --sbb-expansion-panel-content-padding-inline-start: calc( - var(--sbb-expansion-panel-content-padding-inline) + var(--sbb-expansion-panel-icon-size) + - var(--sbb-expansion-panel-title-gap) - ); - } -} - -.sbb-expansion-panel-content { - padding-block-end: var(--sbb-spacing-responsive-s); - padding-inline: var(--sbb-expansion-panel-content-padding-inline-start) - var(--sbb-expansion-panel-content-padding-inline); -} - -::slotted(:is(p, sbb-title):first-child) { - margin-block-start: 0; -} diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts deleted file mode 100644 index 7208d2e7f1..0000000000 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SbbExpansionPanelContent } from './sbb-expansion-panel-content'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-expansion-panel-content', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelContent], - html: 'Content', - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      - Content -
      - `); - }); - - it('renders expanded', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelContent], - html: 'Content', - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      - Content -
      - `); - }); -}); diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx deleted file mode 100644 index 0e272c05d4..0000000000 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj } from '@storybook/html'; - -const Template = (): JSX.Element => ( - - `sbb-expansion-panel-content` is an element to be only used together with `sbb-expansion-panel`. - See `sbb-expansion-panel` examples to see it in action. - -); - -export const ExpansionPanelContent: StoryObj = { - render: Template, -}; - -const meta: Meta = { - parameters: { - chromatic: { disableSnapshot: true }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-accordion/sbb-expansion-panel-content', -}; - -export default meta; diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx deleted file mode 100644 index 0c6674e5b8..0000000000 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, ComponentInterface, h, Host, JSX } from '@stencil/core'; - -/** - * @slot unnamed - Slot to render the content in the sbb-expansion-panel. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel-content.scss', - tag: 'sbb-expansion-panel-content', -}) -export class SbbExpansionPanelContent implements ComponentInterface { - public render(): JSX.Element { - return ( - -
      - -
      -
      - ); - } -} diff --git a/src/components/sbb-expansion-panel-header/readme.md b/src/components/sbb-expansion-panel-header/readme.md deleted file mode 100644 index 9c4a060e41..0000000000 --- a/src/components/sbb-expansion-panel-header/readme.md +++ /dev/null @@ -1,77 +0,0 @@ -The `sbb-expansion-panel-header` is a component which is meant to be used as a header -in the [sbb-expansion-panel](/docs/components-sbb-accordion-sbb-expansion-panel--docs), -acting as a control for an expanding / collapsing content, like a native `` tag. - - -```html -Header -``` - -## Slots - -The component is internally rendered as a button, and it is possible to provide text via an unnamed slot. -On the left side, a toggle icon is displayed; it flips based on the host's `aria-expanded` property. - -The component can optionally display a `sbb-icon` at the component start using the `iconName` -property or via custom content using the `icon` slot. -If using the SBB icons, the icon should be a medium size icon. - -```html -Header -``` - -## States - -The component can be displayed in `disabled` state using the self-named property. - -```html -Header -``` - -## Events - -When the element is clicked, the `toggle-expanded` event is emitted. - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | -| `disabled` | `disabled` | Whether the button is disabled. | `boolean` | `undefined` | -| `iconName` | `icon-name` | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | `string` | `undefined` | - - -## Events - -| Event | Description | Type | -| ----------------- | ----------- | ------------------ | -| `toggle-expanded` | | `CustomEvent` | - - -## Slots - -| Slot | Description | -| ----------- | ------------------------------------------ | -| `"icon"` | Slot used to render the panel header icon. | -| `"unnamed"` | Slot used to render the panel header text. | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-expansion-panel-header --> sbb-icon - style sbb-expansion-panel-header fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts deleted file mode 100644 index 872fbb8a41..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-expansion-panel-header', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(`Header`); - element = await page.find('sbb-expansion-panel-header'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('should emit event on click', async () => { - const spy = await page.spyOnEvent('toggle-expanded'); - await element.click(); - expect(spy).toHaveReceivedEvent(); - }); - - it('should not emit event on click if disabled', async () => { - page = await newE2EPage(); - await page.setContent( - `Header`, - ); - element = await page.find('sbb-expansion-panel-header'); - const spy = await page.spyOnEvent('toggle-expanded'); - await element.click(); - expect(spy).not.toHaveReceivedEvent(); - }); -}); diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts deleted file mode 100644 index aeb450e7e3..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - toggleExpanded: 'toggle-expanded', -}; diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.scss b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.scss deleted file mode 100644 index edd5d5dcf5..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.scss +++ /dev/null @@ -1,76 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-expansion-panel-header-cursor: pointer; - --sbb-expansion-panel-header-text-color: var(--sbb-color-charcoal-default); - --sbb-expansion-panel-header-gap: var(--sbb-spacing-fixed-6x); - --sbb-expansion-panel-header-padding-block: var(--sbb-spacing-responsive-xs); - --sbb-expansion-panel-header-padding-inline: var(--sbb-spacing-fixed-6x); - - @include sbb.if-forced-colors { - --sbb-expansion-panel-header-text-color: ButtonText; - } -} - -:host([disabled]:not([disabled='false'])) { - --sbb-expansion-panel-header-cursor: default; - --sbb-expansion-panel-header-text-color: var(--sbb-color-granite-default); - - @include sbb.if-forced-colors { - --sbb-expansion-panel-header-text-color: GrayText; - } -} - -:host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) { - @include sbb.focus-outline; - - outline-offset: var(--sbb-spacing-fixed-1x); - border-radius: var(--sbb-expansion-panel-border-radius); -} - -.sbb-expansion-panel-header { - @include sbb.text-l--regular; - - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--sbb-expansion-panel-header-gap); - width: 100%; - padding: var(--sbb-expansion-panel-header-padding-block) - var(--sbb-expansion-panel-header-padding-inline); - text-align: start; - cursor: var(--sbb-expansion-panel-header-cursor); - color: var(--sbb-expansion-panel-header-text-color); - -webkit-tap-highlight-color: transparent; -} - -.sbb-expansion-panel-header__title, -.sbb-expansion-panel-header__toggle, -.sbb-expansion-panel-header__icon { - display: flex; -} - -.sbb-expansion-panel-header__icon { - width: var(--sbb-expansion-panel-icon-size); - - --sbb-icon-svg-width: var(--sbb-expansion-panel-icon-size); - --sbb-icon-svg-height: var(--sbb-expansion-panel-icon-size); -} - -.sbb-expansion-panel-header__title { - align-items: center; - gap: var(--sbb-expansion-panel-title-gap); - overflow: hidden; -} - -.sbb-expansion-panel-header__toggle-icon { - transition: transform var(--sbb-animation-duration-4x); - - :host([aria-expanded]:not([aria-expanded='false'])) & { - transform: rotate(-180deg); - } -} diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts deleted file mode 100644 index 3a5d7e4b25..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { SbbExpansionPanelHeader } from './sbb-expansion-panel-header'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-expansion-panel-header', () => { - it('renders collapsed', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: 'Header', - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - Header - - `); - }); - - it('renders with icon', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: 'Header', - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - - - - - - Header - - `); - }); - - it('renders with slotted icon', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: ` - - - Header - - `, - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - - - - - - Header - - `); - }); -}); diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx deleted file mode 100644 index aaed18aa6a..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryObj } from '@storybook/html'; - -const Template = (): JSX.Element => ( - - `sbb-expansion-panel-header` is an element to be only used together with `sbb-expansion-panel`. - See `sbb-expansion-panel` examples to see it in action. - -); -export const ExpansionPanelHeader: StoryObj = { - render: Template, -}; - -const meta: Meta = { - parameters: { - chromatic: { disableSnapshot: true }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-accordion/sbb-expansion-panel-header', -}; - -export default meta; diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx deleted file mode 100644 index 9dfe313323..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Prop, - State, -} from '@stencil/core'; -import { - actionElementHandlerAspect, - createNamedSlotState, - HandlerRepository, - namedSlotChangeHandlerAspect, -} from '../../global/eventing'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { toggleDatasetEntry } from '../../global/dom'; - -/** - * @slot icon - Slot used to render the panel header icon. - * @slot unnamed - Slot used to render the panel header text. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel-header.scss', - tag: 'sbb-expansion-panel-header', -}) -export class SbbExpansionPanelHeader implements ButtonProperties, ComponentInterface { - /** - * The icon name we want to use, choose from the small icon variants - * from the ui-icons category from here - * https://icons.app.sbb.ch. - */ - @Prop() public iconName?: string; - - /** Whether the button is disabled. */ - @Prop({ reflect: true }) public disabled: boolean; - - @Element() private _element!: HTMLElement; - - /** State of listed named slots, by indicating whether any element for a named slot is defined. */ - @State() private _namedSlots = createNamedSlotState('icon'); - - @Event({ - bubbles: true, - eventName: 'toggle-expanded', - }) - public toggleExpanded: EventEmitter; - - private _handlerRepository = new HandlerRepository( - this._element, - actionElementHandlerAspect, - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - - public connectedCallback(): void { - this._handlerRepository.connect(); - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - private _emitExpandedEvent(): void { - if (!this.disabled) { - this.toggleExpanded.emit(); - } - } - - private _onMouseMovement(toggleDataAttribute: boolean): void { - const parent: HTMLSbbExpansionPanelElement = this._element.closest('sbb-expansion-panel'); - // The `sbb.hover-mq` logic has been removed from scss, but it must be replicated to have the correct behavior on mobile. - if (!toggleDataAttribute || (parent && window.matchMedia('(any-hover: hover)').matches)) { - toggleDatasetEntry(parent, 'toggleHover', toggleDataAttribute); - } - } - - public render(): JSX.Element { - const { hostAttributes } = resolveButtonRenderVariables(this); - - return ( - this._emitExpandedEvent()} - onMouseenter={() => this._onMouseMovement(true)} - onMouseleave={() => this._onMouseMovement(false)} - > - - - {(this.iconName || this._namedSlots.icon) && ( - - {this.iconName && } - - )} - - - {!this.disabled && ( - - - - )} - - - ); - } -} diff --git a/src/components/sbb-expansion-panel/readme.md b/src/components/sbb-expansion-panel/readme.md deleted file mode 100644 index 604c840529..0000000000 --- a/src/components/sbb-expansion-panel/readme.md +++ /dev/null @@ -1,111 +0,0 @@ -The `sbb-expansion-panel` is a component which acts as an expandable summary-details widget. - -It can be used standalone or inside a [sbb-accordion](/docs/components-sbb-accordion-sbb-accordion--docs). - -## Slots - -In order to correctly display the component, it must be used together with -a [sbb-expansion-panel-header](/docs/components-sbb-accordion-sbb-expansion-panel-header--docs) -and a [sbb-expansion-panel-content](/docs/components-sbb-accordion-sbb-expansion-panel-content--docs); -the first will work as a state controller, the last will act as the expandable content. - -These two components automatically fill the two available slots, named `header` and `content`. - -```html - - This is the header. - This is the content. - -``` - -## States - -The visibility of the content is controlled by the value of the `expanded` property. - -```html - - ... - -``` - -The `disabled` state can be set using the self-named variable. In this state, the component can not be collapsed or expanded. - -```html - - ... - -``` - -## Style - -The component has two background options (`milk` and `white`, which is the default) that can be set using the `color` variable. - -```html - - ... - -``` - -It's also possible to display the `sbb-expansion-panel` without border by setting the `borderless` variable. - -```html - - ... - -``` - -Using the `titleLevel` variable, it's possible to wrap the `sbb-expansion-panel-header` in a heading tag; -if it's unset, a `
      ` is used as a wrapper. - -```html - - This is the header, and it will be wrapped in a h4 tag. - This is the content. - -``` - -## Accessibility - -When the `sbb-expansion-panel-header` and the `sbb-expansion-panel-content` are slotted into the component, -they both receive an `id`, if not set; then, the content's `id` is set as `aria-controls` attribute of the header, -and the header's `id` is set as `aria-labelledby` attribute on the content. - -The `expanded` attribute is used to correctly set the `aria-expanded` attribute on the header -and the `aria-hidden` attribute on the content. - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------ | ------------------- | ---------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `borderless` | `borderless` | Whether the panel has no border. | `boolean` | `false` | -| `color` | `color` | The background color of the panel. | `"milk" \| "white"` | `'white'` | -| `disableAnimation` | `disable-animation` | Whether the animations should be disabled. | `boolean` | `false` | -| `disabled` | `disabled` | Whether the panel is disabled, so its expanded state can't be changed. | `boolean` | `false` | -| `expanded` | `expanded` | Whether the panel is expanded. | `boolean` | `false` | -| `titleLevel` | `title-level` | Heading level; if unset, a `div` will be rendered. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `undefined` | - - -## Events - -| Event | Description | Type | -| ------------ | --------------------------------------------------------------------- | ------------------- | -| `did-close` | Emits whenever the sbb-expansion-panel is closed. | `CustomEvent` | -| `did-open` | Emits whenever the sbb-expansion-panel is opened. | `CustomEvent` | -| `will-close` | Emits whenever the sbb-expansion-panel begins the closing transition. | `CustomEvent` | -| `will-open` | Emits whenever the sbb-expansion-panel starts the opening transition. | `CustomEvent` | - - -## Slots - -| Slot | Description | -| ----------- | --------------------------------------------------- | -| `"content"` | Use this to render the sbb-expansion-panel-content. | -| `"header"` | Use this to render the sbb-expansion-panel-header. | - - ----------------------------------------------- - - diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.custom.d.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.custom.d.ts deleted file mode 100644 index 1bb7111d65..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.custom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InterfaceSbbExpansionPanelAttributes { - color: 'white' | 'milk'; -} diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts deleted file mode 100644 index b09b50d91c..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { E2EElement, E2EPage, EventSpy, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; -import sbbExpansionPanelHeaderEvents from '../sbb-expansion-panel-header/sbb-expansion-panel-header.events'; -import sbbExpansionPanelEvents from './sbb-expansion-panel.events'; - -describe('sbb-expansion-panel', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - Header - Content - - `); - - element = await page.find('sbb-expansion-panel'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('has slotted elements with the correct properties', async () => { - const header = await page.find('sbb-expansion-panel-header'); - expect(header).toEqualAttribute('id', 'sbb-expansion-panel-header-1'); - expect(header).toEqualAttribute('aria-controls', 'sbb-expansion-panel-content-1'); - expect(header).toEqualAttribute('data-icon', ''); - const content = await page.find('sbb-expansion-panel-content'); - expect(content).toEqualAttribute('id', 'sbb-expansion-panel-content-1'); - expect(content).toEqualAttribute('aria-labelledby', `sbb-expansion-panel-header-1`); - expect(content).toEqualAttribute('data-icon-space', ''); - }); - - it('has slotted elements with the correct properties when id are set', async () => { - page = await newE2EPage(); - await page.setContent(` - - Header - Content - - `); - - const header = await page.find('sbb-expansion-panel-header'); - expect(header).toEqualAttribute('aria-controls', 'content'); - const content = await page.find('sbb-expansion-panel-content'); - expect(content).toEqualAttribute('aria-labelledby', `header`); - }); - - it('click the header expands the panel, click again collapses it', async () => { - const header: E2EElement = await page.find('sbb-expansion-panel-header'); - const content: E2EElement = await page.find('sbb-expansion-panel-content'); - expect(await element.getProperty('expanded')).toEqual(false); - expect(header.getAttribute('aria-expanded')).toEqual('false'); - expect(content.getAttribute('aria-hidden')).toEqual('true'); - - const toggleExpandedEventSpy: EventSpy = await page.spyOnEvent( - sbbExpansionPanelHeaderEvents.toggleExpanded, - ); - const willOpenEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const willCloseEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willClose); - const didOpenEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.didOpen); - const didCloseEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.didClose); - - await header.click(); - await waitForCondition(() => toggleExpandedEventSpy.events.length === 1); - expect(toggleExpandedEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await element.getProperty('expanded')).toEqual(true); - expect(header.getAttribute('aria-expanded')).toEqual('true'); - expect(content.getAttribute('aria-hidden')).toEqual('false'); - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - - await header.click(); - await waitForCondition(() => toggleExpandedEventSpy.events.length === 2); - expect(toggleExpandedEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await element.getProperty('expanded')).toEqual(false); - expect(header.getAttribute('aria-expanded')).toEqual('false'); - expect(content.getAttribute('aria-hidden')).toEqual('true'); - await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - }); - - it('disabled property is proxied to header', async () => { - const header: E2EElement = await page.find('sbb-expansion-panel-header'); - expect(await header.getProperty('disabled')).toBeUndefined(); - expect(header).not.toHaveAttribute('aria-disabled'); - - element.setProperty('disabled', true); - await page.waitForChanges(); - expect(await header.getProperty('disabled')).toEqual(true); - expect(header).toEqualAttribute('aria-disabled', 'true'); - - element.setProperty('disabled', false); - await page.waitForChanges(); - expect(await header.getProperty('disabled')).toEqual(false); - expect(header).toEqualAttribute('aria-disabled', null); - }); -}); diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts deleted file mode 100644 index cf7d67d8ae..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didClose: 'did-close', - didOpen: 'did-open', - willClose: 'will-close', - willOpen: 'will-open', -}; diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.scss b/src/components/sbb-expansion-panel/sbb-expansion-panel.scss deleted file mode 100644 index f235f63bca..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.scss +++ /dev/null @@ -1,135 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-expansion-panel-animation-duration: var(--sbb-animation-duration-4x); - --sbb-expansion-panel-background-color: var(--sbb-color-white-default); - --sbb-expansion-panel-content-visibility: hidden; - --sbb-expansion-panel-grid-template-rows: 0fr; - --sbb-expansion-panel-content-opacity: 0; - --sbb-expansion-panel-content-transition: grid-template-rows - var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing), - padding var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing), - opacity var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing); - --sbb-expansion-panel-border-width: var(--sbb-border-width-1x); - --sbb-expansion-panel-border-color: var(--sbb-color-cloud-default); - --sbb-expansion-panel-border-block-start-width: var(--sbb-expansion-panel-border-width); - --sbb-expansion-panel-border-block-start-color: var(--sbb-color-cloud-default); - --sbb-expansion-panel-border-radius: var(--sbb-border-radius-4x); - --sbb-expansion-panel-start-end-radius: var(--sbb-expansion-panel-border-radius); - --sbb-expansion-panel-start-start-radius: var(--sbb-expansion-panel-border-radius); - --sbb-expansion-panel-end-end-radius: var(--sbb-expansion-panel-border-radius); - --sbb-expansion-panel-end-start-radius: var(--sbb-expansion-panel-border-radius); - - // Vars which will be used by child components - --sbb-expansion-panel-title-gap: var(--sbb-spacing-fixed-4x); - --sbb-expansion-panel-icon-size: var(--sbb-size-icon-ui-medium); -} - -:host([disabled]:not([disabled='false'])) { - @include sbb.if-forced-colors { - --sbb-expansion-panel-border-color: GrayText; - --sbb-expansion-panel-border-block-start-color: GrayText; - } -} - -:host([expanded]) { - --sbb-expansion-panel-content-visibility: visible; - --sbb-expansion-panel-grid-template-rows: 1fr; - --sbb-expansion-panel-content-opacity: 1; - --sbb-expansion-panel-content-transition: grid-template-rows - var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing), - padding var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing), - opacity var(--sbb-expansion-panel-animation-duration) - var(--sbb-expansion-panel-animation-duration) var(--sbb-animation-easing); -} - -:host([disable-animation]:not([disable-animation='false'])) { - --sbb-expansion-panel-animation-duration: 0s; -} - -:host([data-accordion][data-accordion-first]) { - --sbb-expansion-panel-start-end-radius: var(--sbb-expansion-panel-border-radius); - --sbb-expansion-panel-start-start-radius: var(--sbb-expansion-panel-border-radius); -} - -:host([data-accordion]:not([data-accordion-first])) { - --sbb-expansion-panel-border-block-start-width: 0; - --sbb-expansion-panel-border-block-start-color: transparent; - --sbb-expansion-panel-start-end-radius: 0; - --sbb-expansion-panel-start-start-radius: 0; -} - -:host([data-accordion][data-accordion-last]) { - --sbb-expansion-panel-end-end-radius: var(--sbb-expansion-panel-border-radius); - --sbb-expansion-panel-end-start-radius: var(--sbb-expansion-panel-border-radius); -} - -:host([data-accordion]:not([data-accordion-last])) { - --sbb-expansion-panel-end-end-radius: 0; - --sbb-expansion-panel-end-start-radius: 0; -} - -:host([color='milk']) { - --sbb-expansion-panel-background-color: var(--sbb-color-milk-default); -} - -:host([borderless]:not([borderless='false'])) { - --sbb-expansion-panel-border-width: 0; - --sbb-expansion-panel-border-color: transparent; - --sbb-expansion-panel-border-block-start-width: 0; - --sbb-expansion-panel-border-block-start-color: transparent; - @include sbb.if-forced-colors { - --sbb-expansion-panel-border-width: var(--sbb-border-width-1x); - --sbb-expansion-panel-border-block-start-width: var(--sbb-expansion-panel-border-width); - } -} - -:host([borderless]:not([borderless='false'])[data-accordion]:not([data-accordion-first])) { - --sbb-expansion-panel-border-block-start-width: var(--sbb-spacing-fixed-1x); - @include sbb.if-forced-colors { - --sbb-expansion-panel-border-block-start-width: 0; - } -} - -:host(:not([disabled]:not([disabled='false']))[data-toggle-hover]) { - --sbb-expansion-panel-background-color: var(--sbb-color-milk-default); - @include sbb.if-forced-colors { - --sbb-expansion-panel-border-color: Highlight; - --sbb-expansion-panel-border-block-start-color: Highlight; - } -} - -:host(:not([disabled]:not([disabled='false']))[color='milk'][data-toggle-hover]) { - --sbb-expansion-panel-background-color: var(--sbb-color-white-default); -} - -.sbb-expansion-panel { - background-color: var(--sbb-expansion-panel-background-color); - border: var(--sbb-expansion-panel-border-width) solid var(--sbb-expansion-panel-border-color); - border-block-start-color: var(--sbb-expansion-panel-border-block-start-color); - border-block-start-width: var(--sbb-expansion-panel-border-block-start-width); - border-radius: var(--sbb-expansion-panel-start-start-radius) - var(--sbb-expansion-panel-start-end-radius) var(--sbb-expansion-panel-end-end-radius) - var(--sbb-expansion-panel-end-start-radius); - background-clip: padding-box; -} - -.sbb-expansion-panel__header { - margin: 0; -} - -.sbb-expansion-panel__content-wrapper { - display: grid; - grid-template-rows: var(--sbb-expansion-panel-grid-template-rows); - visibility: var(--sbb-expansion-panel-content-visibility); - opacity: var(--sbb-expansion-panel-content-opacity); - transition: var(--sbb-expansion-panel-content-transition); -} - -.sbb-expansion-panel__content { - overflow: hidden; -} diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts deleted file mode 100644 index 2b468f8762..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { SbbExpansionPanel } from './sbb-expansion-panel'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-expansion-panel', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanel], - html: ` - - Header - Content - - `, - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      -
      - - - -
      -
      -
      - Header - Content -
      - `); - }); - - it('renders with level set', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanel], - html: ` - - Header - Content - - `, - }); - - expect(root).toEqualHtml(` - - -
      -

      - -

      -
      - - - -
      -
      -
      - Header - Content -
      - `); - }); -}); diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx b/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx deleted file mode 100644 index 42db6bd047..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** @jsx h */ -import events from './sbb-expansion-panel.events'; -import panelHeaderEvents from '../sbb-expansion-panel-header/sbb-expansion-panel-header.events'; -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import { InputType, StoryContext } from '@storybook/types'; - -const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt -quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et -lacus sollicitudin, quis malesuada lorem vehicula. Suspendisse at augue quis tellus vulputate tempor. Vivamus urna -velit, varius nec est ac, mollis efficitur lorem. Quisque non nisl eget massa interdum tempus. Praesent vel feugiat -metus.`; - -const headerText: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Header', - }, -}; - -const iconName: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Header', - }, -}; - -const contentText: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Content', - }, -}; - -const titleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6, null], -}; - -const color: InputType = { - control: { - type: 'inline-radio', - }, - options: ['white', 'milk'], -}; - -const expanded: InputType = { - control: { - type: 'boolean', - }, -}; - -const borderless: InputType = { - control: { - type: 'boolean', - }, -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, -}; - -const defaultArgTypes: ArgTypes = { - headerText, - iconName, - contentText, - expanded, - 'title-level': titleLevel, - color, - borderless, - disabled, - 'disable-animation': disableAnimation, -}; - -const defaultArgs: Args = { - headerText: 'Header', - iconName: undefined, - contentText: 'Content', - expanded: false, - 'title-level': titleLevel.options[2], - color: color.options[0], - borderless: false, - disabled: false, - 'disable-animation': false, -}; - -const Template = ({ headerText, iconName, contentText, ...args }): JSX.Element => ( - - {headerText} - {contentText} - -); - -const TemplateSlottedIcon = ({ headerText, iconName, contentText, ...args }): JSX.Element => ( - - - {headerText} - - - {contentText} - -); - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const Milk: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, color: color.options[1] }, -}; - -export const Borderless: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, borderless: true }, -}; - -export const Disabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, disabled: true }, -}; - -export const WithIcon: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, iconName: 'swisspass-medium' }, -}; - -export const WithSlottedIcon: StoryObj = { - render: TemplateSlottedIcon, - argTypes: defaultArgTypes, - args: { ...defaultArgs, iconName: 'swisspass-medium' }, -}; - -export const NoHeadingTag: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, 'title-level': titleLevel.options[6] }, -}; - -export const Expanded: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, expanded: true }, -}; - -export const ExpandedIcon: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, expanded: true, iconName: 'swisspass-medium' }, -}; - -export const LongText: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, headerText: longText, contentText: longText }, -}; - -export const NoAnimation: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, 'disable-animation': true }, -}; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': - context.args.color === 'white' && context.args.borderless - ? '#bdbdbd' - : 'var(--sbb-color-white-default)', -}); - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: [ - events.willOpen, - events.didOpen, - events.willClose, - events.didClose, - panelHeaderEvents.toggleExpanded, - ], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-accordion/sbb-expansion-panel', -}; - -export default meta; diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx b/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx deleted file mode 100644 index b66ac849c1..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - JSX, - Listen, - Prop, - Watch, -} from '@stencil/core'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; -import { toggleDatasetEntry } from '../../global/dom'; -import { InterfaceSbbExpansionPanelAttributes } from './sbb-expansion-panel.custom'; - -let nextId = 0; - -/** - * @slot header - Use this to render the sbb-expansion-panel-header. - * @slot content - Use this to render the sbb-expansion-panel-content. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel.scss', - tag: 'sbb-expansion-panel', -}) -export class SbbExpansionPanel implements ComponentInterface { - /** Heading level; if unset, a `div` will be rendered. */ - @Prop() public titleLevel?: InterfaceTitleAttributes['level']; - - /** The background color of the panel. */ - @Prop() public color: InterfaceSbbExpansionPanelAttributes['color'] = 'white'; - - /** Whether the panel is expanded. */ - @Prop({ mutable: true, reflect: true }) public expanded = false; - - /** Whether the panel is disabled, so its expanded state can't be changed. */ - @Prop({ reflect: true }) public disabled = false; - - /** Whether the panel has no border. */ - @Prop({ reflect: true }) public borderless = false; - - /** Whether the animations should be disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; - - /** Emits whenever the sbb-expansion-panel starts the opening transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; - - /** Emits whenever the sbb-expansion-panel is opened. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; - - /** Emits whenever the sbb-expansion-panel begins the closing transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter; - - /** Emits whenever the sbb-expansion-panel is closed. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter; - - @Element() private _element!: HTMLSbbExpansionPanelElement; - - @Listen('toggle-expanded') - public toggleExpanded(): void { - this.expanded = !this.expanded; - } - - @Watch('expanded') - public onExpandedChange(): void { - this._headerRef.setAttribute('aria-expanded', String(this.expanded)); - this._contentRef.setAttribute('aria-hidden', String(!this.expanded)); - - if (this.expanded) { - this.willOpen.emit(); - // As with 0s duration, transitionEnd will not be fired, we need to programmatically trigger didOpen event - if (this.disableAnimation) { - this._onOpened(); - } - } else { - this.willClose.emit(); - // As with 0s duration, transitionEnd will not be fired, we need to programmatically trigger didClose event - if (this.disableAnimation) { - this._onClosed(); - } - } - } - - @Watch('disabled') - public updateDisabledOnHeader(newDisabledValue: boolean): void { - this._headerRef.disabled = newDisabledValue; - } - - private _transitionEventController: AbortController; - private _progressiveId = `-${++nextId}`; - private _headerRef: HTMLSbbExpansionPanelHeaderElement; - private _contentRef: HTMLSbbExpansionPanelContentElement; - - public connectedCallback(): void { - const accordion = this._element.closest('sbb-accordion'); - toggleDatasetEntry(this._element, 'accordion', !!accordion); - } - - public disconnectedCallback(): void { - this._transitionEventController?.abort(); - toggleDatasetEntry(this._element, 'accordion', false); - } - - private _onOpened(): void { - this.didOpen.emit(); - } - - private _onClosed(): void { - this.didClose.emit(); - } - - private _onHeaderSlotChange(event): void { - const elements = (event.target as HTMLSlotElement).assignedElements(); - - // Changing titleLevel sometimes triggers a slot change with no assigned elements. - if (!elements.length) { - return; - } - - this._headerRef = elements.find( - (e): e is HTMLSbbExpansionPanelHeaderElement => e.tagName === 'SBB-EXPANSION-PANEL-HEADER', - ); - - if (!this._headerRef) { - return; - } - - this._headerRef.setAttribute('aria-expanded', String(this.expanded)); - if (this.disabled) { - this._headerRef.setAttribute('disabled', String(this.disabled)); - } - this._linkHeaderAndContent(); - } - - private _onContentSlotChange(event): void { - const elements = (event.target as HTMLSlotElement).assignedElements(); - - if (!elements.length) { - return; - } - - this._transitionEventController?.abort(); - - this._contentRef = (event.target as HTMLSlotElement) - .assignedElements() - .find( - (e): e is HTMLSbbExpansionPanelContentElement => - e.tagName === 'SBB-EXPANSION-PANEL-CONTENT', - ); - - if (!this._contentRef) { - return; - } - - this._transitionEventController = new AbortController(); - this._contentRef.setAttribute('aria-hidden', String(!this.expanded)); - this._contentRef.addEventListener('transitionend', (event) => this._onTransitionEnd(event), { - signal: this._transitionEventController.signal, - }); - this._linkHeaderAndContent(); - } - - private _linkHeaderAndContent(): void { - if (!this._headerRef || !this._contentRef) { - return; - } - - if (!this._headerRef.id) { - this._headerRef.setAttribute('id', `sbb-expansion-panel-header${this._progressiveId}`); - } - this._headerRef.setAttribute( - 'aria-controls', - this._contentRef.id || `sbb-expansion-panel-content${this._progressiveId}`, - ); - - if (!this._contentRef.id) { - this._contentRef.setAttribute('id', `sbb-expansion-panel-content${this._progressiveId}`); - } - this._contentRef.setAttribute( - 'aria-labelledby', - this._headerRef.id || `sbb-expansion-panel-header${this._progressiveId}`, - ); - toggleDatasetEntry(this._contentRef, 'iconSpace', this._headerRef.hasAttribute('data-icon')); - } - - private _onTransitionEnd(event): void { - // All transitions have the same timing and opacity is defined last, be sure that they have all been performed. - if (event.propertyName !== 'opacity') { - return; - } - - if (this.expanded) { - this._onOpened(); - } else { - this._onClosed(); - } - } - - public render(): JSX.Element { - const TAGNAME = this.titleLevel ? `h${this.titleLevel}` : 'div'; - - return ( -
      - - this._onHeaderSlotChange(event)}> - -
      - - this._onContentSlotChange(event)}> - -
      -
      - ); - } -} diff --git a/src/components/sbb-file-selector/readme.md b/src/components/sbb-file-selector/readme.md deleted file mode 100644 index 1b05812bcd..0000000000 --- a/src/components/sbb-file-selector/readme.md +++ /dev/null @@ -1,141 +0,0 @@ -The `sbb-file-selector` is a component which allows user to select one or more files from storage devices. -When files are selected, they appear as a list below the button/dropzone area. -For each file, the name and the size are displayed and an icon allows for deletion. - -### Variants - -It has two different display options based on the value of the `variant` property: -by default, a `sbb-button` is displayed, which mimics the native ``. - -```html - -``` - -Instead, if the `variant` property is set to `dropzone`, the `sbb-button` is shown within a "drag & drop" area. -In this case, it's possible to customize the area's title via the `titleContent` property. - -```html - -``` - -### Multiple and multipleMode - -In both variants, a single file can be selected by default; this can be changed setting the `multiple` property to `true`. - -```html - -``` - -The value of the `multipleMode` property determines whether added files should overwrite existing files (`default`) or be appended to them (`persistent`). - -```html - -``` - -### Accept - -The `accept` property can be used to force the user to select one or more specific file types; -in the next example, only images are allowed. - -```html - -``` - -### Disabled - -User interaction can be disabled using the `disabled` property. - -```html - -``` - -### Error slot - -The `error` named slot can be used to display an error message using the `sbb-form-error` component. - -```html - - An error occurred during file upload. - -``` - -### Events - -Whenever the selection changes, a `file-changed` event is fired, whose `event.detail` property contains the list -of currently selected files. The list can also be retrieved using the `getFiles()` method. - - -## Accessibility - -It's possible to improve the component accessibility using the `accessibilityLabel` property; this will be set -as `aria-label` of the inner native input and read together with the visible button text. -It's suggested to have a different value for each variant, e.g.: - -```html - - -``` - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------------------- | --------------------- | ------------------------------------------------------------------------ | --------------------------- | ----------- | -| `accept` | `accept` | A comma-separated list of allowed unique file type specifiers. | `string` | `undefined` | -| `accessibilityLabel` | `accessibility-label` | This will be forwarded as aria-label to the native input element. | `string` | `undefined` | -| `disabled` | `disabled` | Whether the component is disabled. | `boolean` | `undefined` | -| `multiple` | `multiple` | Whether more than one file can be selected. | `boolean` | `undefined` | -| `multipleMode` | `multiple-mode` | Whether the newly added files should override the previously added ones. | `"default" \| "persistent"` | `undefined` | -| `titleContent` | `title-content` | The title displayed in `dropzone` variant. | `string` | `undefined` | -| `variant` | `variant` | Whether the component has a dropzone area or not. | `"default" \| "dropzone"` | `'default'` | - - -## Events - -| Event | Description | Type | -| -------------- | ---------------------------------------------------------- | --------------------- | -| `file-changed` | An event which is emitted each time the file list changes. | `CustomEvent` | - - -## Methods - -### `getFiles() => Promise` - -Gets the currently selected files. - -#### Returns - -Type: `Promise` - - - - -## Slots - -| Slot | Description | -| --------- | ---------------------------------------------------------------- | -| `"error"` | Use this to provide a `sbb-form-error` to show an error message. | - - -## Dependencies - -### Depends on - -- [sbb-button](../sbb-button) -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-file-selector --> sbb-button - sbb-file-selector --> sbb-icon - sbb-button --> sbb-icon - style sbb-file-selector fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-file-selector/sbb-file-selector.custom.d.ts b/src/components/sbb-file-selector/sbb-file-selector.custom.d.ts deleted file mode 100644 index 82761f929b..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.custom.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface InterfaceSbbFileSelectorAttributes { - variant: 'default' | 'dropzone'; - multipleMode: 'default' | 'persistent'; -} diff --git a/src/components/sbb-file-selector/sbb-file-selector.e2e.ts b/src/components/sbb-file-selector/sbb-file-selector.e2e.ts deleted file mode 100644 index 4322de298b..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.e2e.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { E2EElement, E2EPage, EventSpy, newE2EPage } from '@stencil/core/testing'; -import events from './sbb-file-selector.events'; - -async function addFilesToComponentInput(page: E2EPage, numberOfFiles: number): Promise { - await page.evaluate((numberOfFiles: number): void => { - const dataTransfer: DataTransfer = new DataTransfer(); - for (let i: number = 0; i < numberOfFiles; i++) { - dataTransfer.items.add( - new File([`Hello world - ${i}`], `hello${i}.txt`, { - type: 'text/plain', - lastModified: new Date(i).getMilliseconds(), - }), - ); - } - const input: HTMLInputElement = document - .querySelector('sbb-file-selector') - .shadowRoot.querySelector('input'); - input.files = dataTransfer.files; - input.dispatchEvent(new Event('change')); - }, numberOfFiles); -} - -describe('sbb-file-selector', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(''); - element = await page.find('sbb-file-selector'); - }); - - it('renders', () => { - expect(element).toHaveClass('hydrated'); - }); - - it('loads a file, then deletes it', async () => { - const fileChangedSpy: EventSpy = await page.spyOnEvent(events.fileChangedEvent); - await addFilesToComponentInput(page, 1); - await page.waitForChanges(); - - expect(fileChangedSpy).toHaveReceivedEventTimes(1); - expect(await element.getProperty('files')).not.toBeNull(); - - const listItems: E2EElement = await page.find( - 'sbb-file-selector >>> .sbb-file-selector__file-list', - ); - expect(listItems).toEqualHtml(` -
      - - - hello0.txt - 15 B - - - - -
      - `); - - const button: E2EElement = await page.find( - 'sbb-file-selector >>> sbb-button[icon-name="trash-small"]', - ); - expect(button).not.toBeNull(); - await button.click(); - await page.waitForChanges(); - expect(fileChangedSpy).toHaveReceivedEventTimes(2); - expect((await page.findAll('sbb-file-selector >>> .sbb-file-selector__file')).length).toEqual( - 0, - ); - }); - - it('loads more than one file in multiple mode', async () => { - const fileChangedSpy: EventSpy = await page.spyOnEvent(events.fileChangedEvent); - await element.setProperty('multiple', true); - await page.waitForChanges(); - await addFilesToComponentInput(page, 2); - await page.waitForChanges(); - expect(fileChangedSpy).toHaveReceivedEvent(); - - const listItems: E2EElement[] = await page.findAll('sbb-file-selector >>> li'); - expect(listItems.length).toEqual(2); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-details')).length, - ).toEqual(2); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name'))[0], - ).toEqualText('hello0.txt'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name'))[1], - ).toEqualText('hello1.txt'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-size'))[0], - ).toEqualText('15 B'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-size'))[1], - ).toEqualText('15 B'); - }); - - it('loads files in multiple persistent mode', async () => { - const fileChangedSpy: EventSpy = await page.spyOnEvent(events.fileChangedEvent); - await element.setProperty('multiple', true); - await element.setProperty('multipleMode', 'persistent'); - await page.waitForChanges(); - await addFilesToComponentInput(page, 1); - await page.waitForChanges(); - expect(fileChangedSpy).toHaveReceivedEventTimes(1); - - expect(await element.getProperty('files')).not.toBeNull(); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name')).length, - ).toEqual(1); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-details')).length, - ).toEqual(1); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name'))[0], - ).toEqualText('hello0.txt'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-size'))[0], - ).toEqualText('15 B'); - - await page.evaluate((longContent: string) => { - const dataTransfer: DataTransfer = new DataTransfer(); - dataTransfer.items.add( - new File([`Hello world - 0`], `hello0.txt`, { - type: 'text/plain', - lastModified: new Date(0).getMilliseconds(), - }), - ); - dataTransfer.items.add(new File([longContent], 'third.txt', { type: 'text/plain' })); - const input: HTMLInputElement = document - .querySelector('sbb-file-selector') - .shadowRoot.querySelector('input'); - input.files = dataTransfer.files; - input.dispatchEvent(new Event('change')); - }, 'Lorem ipsum dolor sit amet. '.repeat(100)); - await page.waitForChanges(); - expect(fileChangedSpy).toHaveReceivedEventTimes(2); - expect((await page.findAll('sbb-file-selector >>> li')).length).toEqual(2); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name'))[0], - ).toEqualText('third.txt'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-size'))[0], - ).toEqualText('3 kB'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-name'))[1], - ).toEqualText('hello0.txt'); - expect( - (await page.findAll('sbb-file-selector >>> .sbb-file-selector__file-size'))[1], - ).toEqualText('15 B'); - }); -}); diff --git a/src/components/sbb-file-selector/sbb-file-selector.events.ts b/src/components/sbb-file-selector/sbb-file-selector.events.ts deleted file mode 100644 index 800aa618b0..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.events.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - fileChangedEvent: 'file-changed', -}; diff --git a/src/components/sbb-file-selector/sbb-file-selector.scss b/src/components/sbb-file-selector/sbb-file-selector.scss deleted file mode 100644 index 461b260acb..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-file-selector-color: var(--sbb-color-black-default); - --sbb-file-selector-subtitle-color: var(--sbb-color-granite-default); - --sbb-file-selector-background-color: var(--sbb-color-white-default); - --sbb-file-selector-border-color: var(--sbb-color-cloud-default); - --sbb-file-selector-transition-duration: var(--sbb-animation-duration-2x); - --sbb-file-selector-transition-easing-function: var(--sbb-animation-easing); - - width: #{sbb.px-to-rem-build(320)}; -} - -:host([disabled]:not([disabled='false'])) { - @include sbb.if-forced-colors { - --sbb-file-selector-color: GrayText; - --sbb-file-selector-subtitle-color: GrayText; - --sbb-file-selector-border-color: GrayText; - } -} - -:host([data-active]) { - --sbb-file-selector-background-color: var(--sbb-color-milk-default); - --sbb-file-selector-border-color: var(--sbb-color-storm-default); - @include sbb.if-forced-colors { - --sbb-file-selector-border-color: Highlight; - } -} - -.sbb-file-selector__input-container { - -webkit-tap-highlight-color: transparent; -} - -.sbb-file-selector__visually-hidden { - @include sbb.screen-reader-only; -} - -.sbb-file-selector__dropzone-area { - display: flex; - flex-direction: column; - align-items: center; - padding: var(--sbb-spacing-responsive-s); - background-color: var(--sbb-file-selector-background-color); - border: var(--sbb-border-width-1x) dashed var(--sbb-file-selector-border-color); - border-radius: var(--sbb-border-radius-4x); - transition-duration: var(--sbb-file-selector-transition-duration); - transition-timing-function: var(--sbb-file-selector-transition-easing-function); - transition-property: background-color, border-color; -} - -.sbb-file-selector__dropzone-area--icon { - color: var(--sbb-file-selector-color); - line-height: 0; -} - -.sbb-file-selector__dropzone-area--title { - @include sbb.text--bold; - @include sbb.title-6($exclude-spacing: true); - - color: var(--sbb-file-selector-color); -} - -.sbb-file-selector__dropzone-area--subtitle { - @include sbb.text-xs--regular; - - color: var(--sbb-file-selector-subtitle-color); - margin-block-end: var(--sbb-spacing-fixed-4x); -} - -.sbb-file-selector__file-list { - display: flex; - flex-direction: column; - row-gap: var(--sbb-spacing-fixed-3x); - margin-block: 0; - padding-inline: 0; - padding-block: var(--sbb-spacing-fixed-6x) var(--sbb-spacing-fixed-1x); -} - -.sbb-file-selector__file { - @include sbb.text-s--regular; - - display: flex; - gap: var(--sbb-spacing-fixed-4x); - align-items: center; - justify-content: space-between; -} - -.sbb-file-selector__file-details { - display: flex; - flex: 1; - justify-content: space-between; - gap: var(--sbb-spacing-fixed-4x); - overflow: auto; -} - -.sbb-file-selector__file-name { - @include sbb.ellipsis; -} - -.sbb-file-selector__file-size { - white-space: nowrap; - color: var(--sbb-color-metal-default); -} diff --git a/src/components/sbb-file-selector/sbb-file-selector.spec.ts b/src/components/sbb-file-selector/sbb-file-selector.spec.ts deleted file mode 100644 index eb3814ddde..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { SbbFileSelector } from './sbb-file-selector'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-file-selector', () => { - it('renders default', async () => { - const { root } = await newSpecPage({ - components: [SbbFileSelector], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      -

      -
      -
      -
      - `); - }); - - it('renders with dropzone area', async () => { - const { root } = await newSpecPage({ - components: [SbbFileSelector], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      -

      -
      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-file-selector/sbb-file-selector.stories.tsx b/src/components/sbb-file-selector/sbb-file-selector.stories.tsx deleted file mode 100644 index 802ef3a93d..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.stories.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** @jsx h */ -import events from './sbb-file-selector.events'; -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; -import { InputType } from '@storybook/types'; - -const variant: InputType = { - control: { - type: 'inline-radio', - }, - options: ['default', 'dropzone'], -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, -}; - -const titleContent: InputType = { - control: { - type: 'text', - }, -}; - -const multiple: InputType = { - control: { - type: 'boolean', - }, -}; - -const multipleMode: InputType = { - control: { - type: 'inline-radio', - }, - options: ['default', 'persistent'], -}; - -const accept: InputType = { - control: { - type: 'text', - }, -}; - -const accessibilityLabel: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - variant, - disabled, - 'title-content': titleContent, - multiple, - 'multiple-mode': multipleMode, - accept, - 'accessibility-label': accessibilityLabel, -}; - -const defaultArgs: Args = { - variant: variant.options[0], - disabled: false, - 'title-content': 'Title', - multiple: false, - 'multiple-mode': multipleMode.options[0], - accept: undefined, - 'accessibility-label': 'Select from hard disk', -}; - -const multipleDefaultArgs: Args = { - ...defaultArgs, - multiple: true, - 'accessibility-label': 'Select from hard disk - multiple files allowed', -}; - -const Template = (args): JSX.Element => ; - -const TemplateWithError = (args): JSX.Element => { - const sbbFormError = There has been an error.; - return ( - { - if (event.detail && event.detail.length > 0) { - document.getElementById('sbb-file-selector').append(sbbFormError); - } else { - sbbFormError.remove(); - } - }} - > - ); -}; - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const DefaultDisabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, disabled: true }, -}; - -export const DefaultMulti: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs }, -}; - -export const DefaultMultiPersistent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs, 'multiple-mode': multipleMode.options[1] }, -}; - -export const Dropzone: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options[1] }, -}; - -export const DropzoneDisabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options[1], disabled: true }, -}; - -export const DropzoneMulti: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs, variant: variant.options[1] }, -}; - -export const DropzoneMultiPersistent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...multipleDefaultArgs, - variant: variant.options[1], - 'multiple-mode': multipleMode.options[1], - }, -}; - -export const DefaultWithError: StoryObj = { - render: TemplateWithError, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const DropzoneWithError: StoryObj = { - render: TemplateWithError, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options[1] }, -}; - -export const DefaultOnlyPDF: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, accept: '.pdf' }, -}; - -const meta: Meta = { - decorators: [ - (Story) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - actions: { - handles: [events.fileChangedEvent], - }, - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-file-selector', -}; - -export default meta; diff --git a/src/components/sbb-file-selector/sbb-file-selector.tsx b/src/components/sbb-file-selector/sbb-file-selector.tsx deleted file mode 100644 index 04e6eae0f2..0000000000 --- a/src/components/sbb-file-selector/sbb-file-selector.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - JSX, - Method, - Prop, - State, -} from '@stencil/core'; -import { InterfaceSbbFileSelectorAttributes } from './sbb-file-selector.custom'; -import { - createNamedSlotState, - documentLanguage, - HandlerRepository, - languageChangeHandlerAspect, - namedSlotChangeHandlerAspect, -} from '../../global/eventing'; -import { - i18nFileSelectorButtonLabel, - i18nFileSelectorCurrentlySelected, - i18nFileSelectorDeleteFile, - i18nFileSelectorSubtitleLabel, -} from '../../global/i18n'; -import { toggleDatasetEntry } from '../../global/dom'; -import { sbbInputModalityDetector } from '../../global/a11y'; - -export type DOMEvent = globalThis.Event; - -/** - * @slot error - Use this to provide a `sbb-form-error` to show an error message. - */ -@Component({ - shadow: true, - styleUrl: 'sbb-file-selector.scss', - tag: 'sbb-file-selector', -}) -export class SbbFileSelector implements ComponentInterface { - /** Whether the component has a dropzone area or not. */ - @Prop() public variant: InterfaceSbbFileSelectorAttributes['variant'] = 'default'; - - /** Whether more than one file can be selected. */ - @Prop() public multiple: boolean; - - /** Whether the newly added files should override the previously added ones. */ - @Prop() public multipleMode: InterfaceSbbFileSelectorAttributes['multipleMode']; - - /** A comma-separated list of allowed unique file type specifiers. */ - @Prop() public accept: string; - - /** The title displayed in `dropzone` variant. */ - @Prop() public titleContent?: string; - - /** Whether the component is disabled. */ - @Prop({ reflect: true }) public disabled: boolean; - - /** This will be forwarded as aria-label to the native input element. */ - @Prop() public accessibilityLabel: string | undefined; - - /** The list of selected files. */ - @State() private _files: File[]; - - /** Current document language used for translations. */ - @State() private _currentLanguage = documentLanguage(); - - /** State of listed named slots, by indicating whether any element for a named slot is defined. */ - @State() private _namedSlots = createNamedSlotState('error'); - - /** An event which is emitted each time the file list changes. */ - @Event({ - eventName: 'file-changed', - }) - public fileChangedEvent: EventEmitter; - - @Element() private _element!: HTMLElement; - - // TODO: during Lit migration, convert this method in a getter - /** Gets the currently selected files. */ - @Method() - public async getFiles(): Promise { - return this._files || []; - } - - // Safari has a peculiar behavior when dragging files on the inner button in 'dropzone' variant; - // this will require a counter to correctly handle the dragEnter/dragLeave. - private _counter: number = 0; - - private _loadButton: HTMLElement; - private _dragTarget: HTMLElement; - private _hiddenInput: HTMLInputElement; - private _suffixes: string[] = ['B', 'kB', 'MB', 'GB', 'TB']; - private _liveRegion: HTMLElement; - - private _handlerRepository = new HandlerRepository( - this._element, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - - public connectedCallback(): void { - this._handlerRepository.connect(); - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - private _blockEvent(event: DragEvent): void { - event.stopPropagation(); - event.preventDefault(); - } - - private _checkFileEquality(file1: File, file2: File): boolean { - return ( - file1.name === file2.name && - file1.size === file2.size && - file1.lastModified === file2.lastModified - ); - } - - private _onDragEnter(event: DragEvent): void { - this._counter++; - if (!this.disabled) { - this._setDragState(event.target as HTMLElement, true); - this._blockEvent(event); - } - } - - private _onDragLeave(event: DragEvent): void { - this._counter--; - if (!this.disabled && event.target === this._dragTarget && this._counter === 0) { - this._setDragState(); - this._blockEvent(event); - } - } - - private _onFileDrop(event: DragEvent): void { - this._counter = 0; - if (!this.disabled) { - this._setDragState(); - this._blockEvent(event); - this._createFileList(event.dataTransfer.files); - } - } - - private _onFocus(): void { - if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { - toggleDatasetEntry(this._loadButton, 'focusVisible', true); - } - } - - private _onBlur(): void { - if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { - toggleDatasetEntry(this._loadButton, 'focusVisible', false); - } - } - - private _setDragState(dragTarget: HTMLElement = undefined, isDragEnter: boolean = false): void { - this._dragTarget = dragTarget; - toggleDatasetEntry(this._element, 'active', isDragEnter); - toggleDatasetEntry(this._loadButton, 'active', isDragEnter); - } - - private _readFiles(event: DOMEvent): void { - const fileInput = event.target as HTMLInputElement; - if (fileInput.files) { - this._createFileList(fileInput.files); - } - } - - private _createFileList(files: FileList): void { - if ( - !this.multiple || - this.multipleMode !== 'persistent' || - !this._files || - this._files.length === 0 - ) { - this._files = Array.from(files); - } else { - this._files = Array.from(files) - .filter( - (newFile: File): boolean => - this._files.findIndex((oldFile: File) => this._checkFileEquality(newFile, oldFile)) === - -1, - ) - .concat(this._files); - } - this._liveRegion.innerText = i18nFileSelectorCurrentlySelected(this._files.map((e) => e.name))[ - this._currentLanguage - ]; - this.fileChangedEvent.emit(this._files); - } - - private _removeFile(file: File): void { - this._files = this._files.filter((f: File) => !this._checkFileEquality(file, f)); - // The item must be removed from the hidden file input too; the FileList API is flawed, so the DataTransfer object is used. - const dt: DataTransfer = new DataTransfer(); - this._files.forEach((e: File) => dt.items.add(e)); - this._hiddenInput.files = dt.files; - this._liveRegion.innerText = i18nFileSelectorCurrentlySelected(this._files.map((e) => e.name))[ - this._currentLanguage - ]; - this.fileChangedEvent.emit(this._files); - } - - /** Calculates the correct unit for the file's size. */ - private _formatFileSize(size: number): string { - const i: number = Math.floor(Math.log(size) / Math.log(1024)); - return `${(size / Math.pow(1024, i)).toFixed(0)} ${this._suffixes[i]}`; - } - - private _renderDefaultMode(): JSX.Element { - return ( - { - this._loadButton = el; - }} - > - {i18nFileSelectorButtonLabel[this._currentLanguage]} - - ); - } - - private _renderDropzoneArea(): JSX.Element { - return ( - - - - - {this.titleContent} - - {i18nFileSelectorSubtitleLabel[this._currentLanguage]} - - - { - this._loadButton = el; - }} - > - {i18nFileSelectorButtonLabel[this._currentLanguage]} - - - - ); - } - - private _renderFileList(): JSX.Element { - const TAG_NAME: Record = - this._files.length > 1 - ? { WRAPPER: 'ul', ELEMENT: 'li' } - : { WRAPPER: 'div', ELEMENT: 'span' }; - return ( - - {this._files.map((file: File) => ( - - - {file.name} - {this._formatFileSize(file.size)} - - this._removeFile(file)} - aria-label={`${i18nFileSelectorDeleteFile[this._currentLanguage]} - ${file.name}`} - > - - ))} - - ); - } - - public render(): JSX.Element { - const ariaLabel = this.accessibilityLabel - ? `${i18nFileSelectorButtonLabel[this._currentLanguage]} - ${this.accessibilityLabel}` - : undefined; - return ( -
      -
      this._onDragEnter(e)} - onDragOver={(e: DragEvent) => this._blockEvent(e)} - onDragLeave={(e: DragEvent) => this._onDragLeave(e)} - onDrop={(e: DragEvent) => this._onFileDrop(e)} - > - -
      -

      (this._liveRegion = p)} - >

      - {this._files && this._files.length > 0 && this._renderFileList()} - {this._namedSlots.error && ( -
      - -
      - )} -
      - ); - } -} diff --git a/src/components/sbb-footer/readme.md b/src/components/sbb-footer/readme.md deleted file mode 100644 index aaf8c389e0..0000000000 --- a/src/components/sbb-footer/readme.md +++ /dev/null @@ -1,90 +0,0 @@ -The `sbb-footer` component is used to display page related information like copyright, contact or other -content related links; for these, the [sbb-link-list](/docs/components-sbb-link-list--docs) component can be used. - -## Variants - -There are two variants of the footer: the `variant='default'`, which displays the slotted content in regular -block element approach and the `variant='clock-columns'`, which uses a css-grid for displaying the content over different -breakpoints. - -**Note:** -Content, like `sbb-link-list` that could come along with a button, needs to be wrapped with a `
      ` element with a helper -class (`class="sbb-link-list-button-group"`) to be displayed correctly. - -```html - - - - Link 1 - Link 2 - Link 3 - Link 4 - Link 5 - - - - - - - - Jobs & careers - Rail traffic information - SBB News - SBB Community - Company - - ... - - - Refunds - Lost property office - Complaints - Praise - Report property damage - - -``` - -## Style - -It's possible to display the footer in `negative` variant; please also apply the negative attribute -to the content where needed (e.g. `sbb-link-list`, `sbb-link` and `sbb-divider`). - -```html - - - Refunds - Lost property office - Complaints - Praise - Report property damage - - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `accessibilityTitle` | `accessibility-title` | Footer title text, visually hidden, necessary for screen readers. | `string` | `undefined` | -| `accessibilityTitleLevel` | `accessibility-title-level` | Level of the accessibility title, will be rendered as heading tag (e.g. h1). Defaults to level 1. | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | `'1'` | -| `expanded` | `expanded` | Whether to allow the footer content to stretch to full width. By default, the content has the appropriate page size. | `boolean` | `false` | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | -| `variant` | `variant` | Variants to display the footer. The default, displays the content in regular block element approach. The clock-columns, used a css-grid for displaying the content over different breakpoints. | `"clock-columns" \| "default"` | `'default'` | - - ----------------------------------------------- - - diff --git a/src/components/sbb-footer/sbb-footer.custom.d.ts b/src/components/sbb-footer/sbb-footer.custom.d.ts deleted file mode 100644 index 957d09367d..0000000000 --- a/src/components/sbb-footer/sbb-footer.custom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InterfaceFooterAttributes { - variant: 'default' | 'clock-columns'; -} diff --git a/src/components/sbb-footer/sbb-footer.e2e.ts b/src/components/sbb-footer/sbb-footer.e2e.ts deleted file mode 100644 index 6ee43cc33f..0000000000 --- a/src/components/sbb-footer/sbb-footer.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-footer', () => { - let element: E2EElement, page: E2EPage; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-footer'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-footer/sbb-footer.scss b/src/components/sbb-footer/sbb-footer.scss deleted file mode 100644 index 397bc40778..0000000000 --- a/src/components/sbb-footer/sbb-footer.scss +++ /dev/null @@ -1,149 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-footer-gap-horizontal: var(--sbb-grid-base-gutter-responsive); - --sbb-footer-gap-vertical: var(--sbb-spacing-responsive-l); - --sbb-footer-background-color: var(--sbb-color-milk-default); - --sbb-footer-clock-width: #{sbb.px-to-rem-build(76)}; - --sbb-footer-color: var(--sbb-color-granite-default); - - @include sbb.mq($from: small) { - --sbb-footer-clock-width: #{sbb.px-to-rem-build(112)}; - } -} - -:host([negative]:not([negative='false'])) { - --sbb-footer-background-color: var(--sbb-color-charcoal-default); - --sbb-footer-color: var(--sbb-color-white-default); - --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); -} - -.sbb-footer { - // Include text style and color as fallback for paragraph texts inside the footer. - @include sbb.text-s--regular; - - color: var(--sbb-footer-color); - padding-block: var(--sbb-spacing-responsive-l); - background-color: var(--sbb-footer-background-color); - - @include sbb.if-forced-colors { - border-block-start: var(--sbb-border-width-1x) solid CanvasText; - } -} - -.sbb-footer-wrapper { - /* stylelint-disable-next-line plugin/stylelint-bem-namics */ - :host(:not([expanded]:not([expanded='false']))) & { - @include sbb.page-spacing; - } - - :host([expanded]:not([expanded='false'])) & { - @include sbb.page-spacing-expanded; - } -} - -.sbb-footer__title { - @include sbb.screen-reader-only; -} - -::slotted(.sbb-link-list-button-group) { - display: flex; - flex-direction: column; - gap: var(--sbb-spacing-fixed-6x); - align-items: flex-start; -} - -:host([variant='clock-columns']) { - // Content - slot { - display: grid; - grid-template-columns: auto; - grid-template-rows: auto; - column-gap: var(--sbb-footer-gap-horizontal); - - @include sbb.mq($from: small) { - grid-template-columns: calc(50% - (var(--sbb-footer-gap-horizontal) / 2)); - } - - @include sbb.mq($from: wide) { - max-width: var(--sbb-footer-content-max-width); - margin-inline: auto; - grid-template-columns: repeat(4, 1fr); - } - } - - ::slotted(*:not(:last-child, sbb-divider)) { - margin-block-end: var(--sbb-footer-gap-vertical); - } - - ::slotted(sbb-clock) { - width: var(--sbb-footer-clock-width); - grid-row: 1; - - @include sbb.mq($from: small) { - align-self: start; - grid-row: 2; - } - - @include sbb.mq($from: wide) { - grid-row: 1; - grid-column: 4 / 5; - justify-self: end; - } - } - - ::slotted(sbb-divider) { - margin-block: calc(var(--sbb-spacing-responsive-xl) - var(--sbb-footer-gap-vertical)) - var(--sbb-spacing-responsive-s); - - @include sbb.mq($from: small) { - grid-row: 3; - grid-column: 1 / 4; - width: 100%; - } - - @include sbb.mq($from: wide) { - grid-row: 2; - } - } - - @include sbb.mq($from: small) { - ::slotted(:nth-child(-n + 2)) { - grid-row: 1; - } - - ::slotted(:nth-child(3)), - ::slotted(:nth-child(4)) { - grid-row: 2; - } - - ::slotted(*:last-child) { - grid-row: 4; - } - } - - @include sbb.mq($from: large) { - ::slotted(*:last-child) { - grid-column: 1 / 4; - } - } - - @include sbb.mq($from: wide) { - ::slotted(:nth-child(-n + 4)) { - grid-row: 1; - } - - ::slotted(*:last-child) { - grid-row: 3; - } - - ::slotted(sbb-divider), - ::slotted(*:last-child) { - grid-column: 1 / 5; - } - } -} diff --git a/src/components/sbb-footer/sbb-footer.spec.ts b/src/components/sbb-footer/sbb-footer.spec.ts deleted file mode 100644 index 8559353b70..0000000000 --- a/src/components/sbb-footer/sbb-footer.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SbbFooter } from './sbb-footer'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-footer', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbFooter], - html: '', - }); - - expect(root).toEqualHtml(` - - -
      - -
      -
      -
      - `); - }); -}); diff --git a/src/components/sbb-footer/sbb-footer.stories.tsx b/src/components/sbb-footer/sbb-footer.stories.tsx deleted file mode 100644 index d71b12502e..0000000000 --- a/src/components/sbb-footer/sbb-footer.stories.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/** @jsx h */ -import readme from './readme.md'; -import { h, JSX } from 'jsx-dom'; -import isChromatic from 'chromatic'; -import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const variant: InputType = { - control: { - type: 'inline-radio', - }, - options: ['default', 'clock-columns'], -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const expanded: InputType = { - control: { - type: 'boolean', - }, -}; - -const accessibilityTitle: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - variant, - negative, - expanded, - 'accessibility-title': accessibilityTitle, -}; - -const defaultArgs: Args = { - variant: variant.options[1], - negative: false, - expanded: false, - 'accessibility-title': 'Footer', -}; - -const TemplateDefault = (args): JSX.Element => ( - - - - Refunds - - - Lost property office - - - Complaints - - - Praise - - - Report property damage - - - -); - -const TemplateClockColumns = ({ ...args }): JSX.Element => ( - - - - - Jobs & careers - - - Rail traffic information - - - SBB News - - - SBB Community - - - Company - - - - - - - - Refunds - - - Lost property office - - - Complaints - - - Praise - - - Report property damage - - - -); - -/* ************************************************* */ -/* The Stories */ -/* ************************************************* */ - -export const FooterClockColumns: StoryObj = { - render: TemplateClockColumns, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const FooterClockColumnsNegative: StoryObj = { - render: TemplateClockColumns, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - negative: true, - }, -}; - -export const FooterClockColumnsExpanded: StoryObj = { - render: TemplateClockColumns, - argTypes: defaultArgTypes, - args: { ...defaultArgs, expanded: true }, -}; - -export const FooterDefault: StoryObj = { - render: TemplateDefault, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - variant: variant.options[0], - }, -}; - -export const FooterDefaultNegative: StoryObj = { - render: TemplateDefault, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - variant: variant.options[0], - negative: true, - }, -}; - -export const FooterDefaultExpanded: StoryObj = { - render: TemplateDefault, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - variant: variant.options[0], - expanded: true, - }, -}; - -const meta: Meta = { - parameters: { - docs: { - extractComponentDescription: () => readme, - }, - layout: 'fullscreen', - }, - title: 'components/sbb-footer', -}; - -export default meta; diff --git a/src/components/sbb-footer/sbb-footer.tsx b/src/components/sbb-footer/sbb-footer.tsx deleted file mode 100644 index 6b5585addc..0000000000 --- a/src/components/sbb-footer/sbb-footer.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Component, h, JSX, Prop } from '@stencil/core'; -import { InterfaceFooterAttributes } from './sbb-footer.custom'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; - -@Component({ - shadow: true, - styleUrl: 'sbb-footer.scss', - tag: 'sbb-footer', -}) -export class SbbFooter { - /** - * Variants to display the footer. The default, displays the content in regular block element - * approach. The clock-columns, used a css-grid for displaying the content over different - * breakpoints. - */ - @Prop({ reflect: true }) public variant: InterfaceFooterAttributes['variant'] = 'default'; - - /** Negative coloring variant flag. */ - @Prop({ reflect: true }) public negative = false; - - /** - * Whether to allow the footer content to stretch to full width. - * By default, the content has the appropriate page size. - */ - @Prop({ reflect: true }) public expanded = false; - - /** Footer title text, visually hidden, necessary for screen readers. */ - @Prop() public accessibilityTitle?: string; - - /** Level of the accessibility title, will be rendered as heading tag (e.g. h1). Defaults to level 1. */ - @Prop() public accessibilityTitleLevel: InterfaceTitleAttributes['level'] = '1'; - - public render(): JSX.Element { - const TITLE_TAG_NAME = `h${this.accessibilityTitleLevel}`; - - return ( -
      - -
      - ); - } -} diff --git a/src/components/sbb-form-error/readme.md b/src/components/sbb-form-error/readme.md deleted file mode 100644 index ba0d823bcb..0000000000 --- a/src/components/sbb-form-error/readme.md +++ /dev/null @@ -1,34 +0,0 @@ -The `sbb-form-error` component can be used to provide an error message in inputs components like the -[sbb-checkbox-group](/docs/components-sbb-checkbox-sbb-checkbox-group--docs) and -[sbb-radio-button-group](/docs/components-sbb-radio-button-sbb-radio-button-group--docs), -or within the [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs). - -## Slots - -It is possible to provide the error message via an unnamed slot; -the component displays an icon by default, that can be changed using the `icon` slot. - -```html - - This is a required field. - - - - - This is a required field. - -``` - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | ---------- | ------------------------------- | --------- | ------- | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | - - ----------------------------------------------- - - diff --git a/src/components/sbb-form-error/sbb-form-error.e2e.ts b/src/components/sbb-form-error/sbb-form-error.e2e.ts deleted file mode 100644 index fbb3d2dda6..0000000000 --- a/src/components/sbb-form-error/sbb-form-error.e2e.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; - -describe('sbb-form-error', () => { - let element, page; - - it('renders', async () => { - page = await newE2EPage(); - await page.setContent(''); - - element = await page.find('sbb-form-error'); - expect(element).toHaveClass('hydrated'); - }); -}); diff --git a/src/components/sbb-form-error/sbb-form-error.scss b/src/components/sbb-form-error/sbb-form-error.scss deleted file mode 100644 index 10970a263b..0000000000 --- a/src/components/sbb-form-error/sbb-form-error.scss +++ /dev/null @@ -1,47 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-form-error-height: calc(var(--sbb-typo-line-height-body-text) * 1em); - --sbb-form-error-color: var(--sbb-color-red125-default); - - // Overrides sbb icon - --sbb-icon-svg-width: var(--sbb-size-icon-form-error); - --sbb-icon-svg-height: var(--sbb-size-icon-form-error); - - @include sbb.if-forced-colors { - --sbb-form-error-color: LinkText !important; - } - - @include sbb.text-xs--regular; - - display: flex; - align-items: flex-start; - color: var(--sbb-form-error-color); - min-height: var(--sbb-form-error-height); -} - -:host([negative]:not([negative='false'])) { - --sbb-form-error-color: var(--sbb-color-red-mode-dark); -} - -.form-error__icon { - display: flex; - align-items: center; - height: var(--sbb-form-error-height); - margin-inline-end: var(--sbb-spacing-fixed-1x); -} - -.form-error-content { - // Fix for line-height taking more than reserved space - line-height: var(--sbb-typo-line-height-body-text); - vertical-align: text-top; -} - -.form-error__icon-svg { - stroke: currentcolor; - height: var(--sbb-form-error-height); -} diff --git a/src/components/sbb-form-error/sbb-form-error.spec.ts b/src/components/sbb-form-error/sbb-form-error.spec.ts deleted file mode 100644 index 138bbf1b7a..0000000000 --- a/src/components/sbb-form-error/sbb-form-error.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SbbFormError } from './sbb-form-error'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-form-error', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbFormError], - html: 'Required', - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - Required - - `); - }); -}); diff --git a/src/components/sbb-form-error/sbb-form-error.stories.tsx b/src/components/sbb-form-error/sbb-form-error.stories.tsx deleted file mode 100644 index 66900782cf..0000000000 --- a/src/components/sbb-form-error/sbb-form-error.stories.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import type { Meta, StoryContext, StoryObj } from '@storybook/html'; -import type { InputType } from '@storybook/types'; -import { Args, ArgTypes } from '@storybook/html'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt -quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et -lacus sollicitudin, quis malesuada lorem vehicula. Suspendisse at augue quis tellus vulputate tempor. Vivamus urna -velit, varius nec est ac, mollis efficitur lorem. Quisque non nisl eget massa interdum tempus. Praesent vel feugiat -metus. Donec pharetra odio at turpis bibendum, vel commodo dui vulputate. Aenean congue nec nisl vel bibendum. -Praesent sit amet lorem augue. Suspendisse ornare a justo sagittis fermentum.`; - -const TemplateError = ({ errorText, ...args }): JSX.Element => ( - {errorText} -); - -const TemplateErrorWithIcon = ({ errorText, iconName, ...args }): JSX.Element => ( - - - {errorText} - -); - -const iconNameArg: InputType = { - control: { - type: 'text', - }, -}; -const errorTextArg: InputType = { - control: { - type: 'text', - }, -}; - -const negativeArg: InputType = { - control: { - type: 'boolean', - }, -}; - -const defaultArgTypes: ArgTypes = { - iconName: iconNameArg, - errorText: errorTextArg, - negative: negativeArg, -}; - -const defaultArgs: Args = { - iconName: undefined, - errorText: 'Required field.', - negative: false, -}; - -export const Error: StoryObj = { - render: TemplateError, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const ErrorNegative: StoryObj = { - render: TemplateError, - argTypes: defaultArgTypes, - args: { ...defaultArgs, negative: true }, -}; - -export const ErrorWithCustomIconAndLongMessage: StoryObj = { - render: TemplateErrorWithIcon, - argTypes: defaultArgTypes, - args: { - ...defaultArgs, - errorText: longText, - iconName: 'chevron-small-right-small', - }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-form-field/sbb-form-error', -}; - -export default meta; diff --git a/src/components/sbb-form-error/sbb-form-error.tsx b/src/components/sbb-form-error/sbb-form-error.tsx deleted file mode 100644 index 2227366acd..0000000000 --- a/src/components/sbb-form-error/sbb-form-error.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, h, JSX, Host, Prop, ComponentInterface, Element } from '@stencil/core'; -import { assignId } from '../../global/a11y'; -import { isValidAttribute } from '../../global/dom'; - -let nextId = 0; - -@Component({ - shadow: true, - styleUrl: './sbb-form-error.scss', - tag: 'sbb-form-error', -}) -export class SbbFormError implements ComponentInterface { - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - @Element() private _element!: HTMLElement; - - public connectedCallback(): void { - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - } - } - - public render(): JSX.Element { - return ( - `sbb-form-error-${++nextId}`)}> - - - - - - - - - - ); - } -} diff --git a/src/components/sbb-form-field-clear/readme.md b/src/components/sbb-form-field-clear/readme.md deleted file mode 100644 index 01bd925665..0000000000 --- a/src/components/sbb-form-field-clear/readme.md +++ /dev/null @@ -1,39 +0,0 @@ -The `sbb-form-field-clear` component can be used with the [sbb-form-field](/docs/components-sbb-form-field-sbb-form-field--docs) component -to provide the possibility to display a clear button which can clear the input value. - -```html - - - - -``` - -**Note:** it currently works with simple inputs and does not support, for example, `select` inputs. - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | ---------- | ------------------------------- | --------- | ------- | -| `negative` | `negative` | Negative coloring variant flag. | `boolean` | `false` | - - -## Dependencies - -### Depends on - -- [sbb-icon](../sbb-icon) - -### Graph -```mermaid -graph TD; - sbb-form-field-clear --> sbb-icon - style sbb-form-field-clear fill:#f9f,stroke:#333,stroke-width:4px -``` - ----------------------------------------------- - - diff --git a/src/components/sbb-form-field-clear/sbb-form-field-clear.e2e.ts b/src/components/sbb-form-field-clear/sbb-form-field-clear.e2e.ts deleted file mode 100644 index 71936294c5..0000000000 --- a/src/components/sbb-form-field-clear/sbb-form-field-clear.e2e.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; - -describe('sbb-form-field-clear', () => { - let element: E2EElement, page: E2EPage; - - beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - - `); - - element = await page.find('sbb-form-field-clear'); - }); - - it('renders', async () => { - expect(element).toHaveClass('hydrated'); - }); - - it('clears the value and sets the focus on the input', async () => { - await page.waitForChanges(); - expect(await page.evaluate(() => document.querySelector('input').value)).toBe('Input value'); - - await element.click(); - await page.waitForChanges(); - - expect(await page.evaluate(() => document.querySelector('input').value)).toBeFalsy(); - expect(await page.evaluate(() => document.activeElement.id)).toBe('input'); - expect( - await page.evaluate( - () => getComputedStyle(document.querySelector('sbb-form-field-clear')).display, - ), - ).toBe('none'); - }); - - it('is hidden if the form field is disabled', async () => { - page = await newE2EPage(); - await page.setContent(` - - - - `); - - element = await page.find('sbb-form-field-clear'); - await page.waitForChanges(); - - expect( - await page.evaluate( - () => getComputedStyle(document.querySelector('sbb-form-field-clear')).display, - ), - ).toBe('none'); - }); - - it('is hidden if the form field is readonly', async () => { - page = await newE2EPage(); - await page.setContent(` - - - - `); - - element = await page.find('sbb-form-field-clear'); - await page.waitForChanges(); - - expect( - await page.evaluate( - () => getComputedStyle(document.querySelector('sbb-form-field-clear')).display, - ), - ).toBe('none'); - }); -}); diff --git a/src/components/sbb-form-field-clear/sbb-form-field-clear.scss b/src/components/sbb-form-field-clear/sbb-form-field-clear.scss deleted file mode 100644 index 54dd6d6edd..0000000000 --- a/src/components/sbb-form-field-clear/sbb-form-field-clear.scss +++ /dev/null @@ -1,13 +0,0 @@ -@use '../../global/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; -} - -@include sbb.icon-button('.sbb-form-field-clear', 'sbb-icon'); diff --git a/src/components/sbb-form-field-clear/sbb-form-field-clear.spec.ts b/src/components/sbb-form-field-clear/sbb-form-field-clear.spec.ts deleted file mode 100644 index 5142610450..0000000000 --- a/src/components/sbb-form-field-clear/sbb-form-field-clear.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; -import { SbbFormFieldClear } from './sbb-form-field-clear'; -import { newSpecPage } from '@stencil/core/testing'; - -describe('sbb-form-field-clear', () => { - it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbFormField, SbbFormFieldClear], - html: ` - - - `, - }); - - expect(root).toEqualHtml(` - - -
      -
      - -
      - - - - - - -
      - -
      -
      - -
      -
      - -
      -
      -
      - - - - - - - - - -
      `); - }); -}); diff --git a/src/components/sbb-form-field-clear/sbb-form-field-clear.stories.tsx b/src/components/sbb-form-field-clear/sbb-form-field-clear.stories.tsx deleted file mode 100644 index 3bc4c5c35b..0000000000 --- a/src/components/sbb-form-field-clear/sbb-form-field-clear.stories.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** @jsx h */ -import { h, JSX } from 'jsx-dom'; -import readme from './readme.md'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, Decorator, ArgTypes, Args, StoryContext } from '@storybook/html'; -import type { InputType } from '@storybook/types'; - -const wrapperStyle = (context: StoryContext): Record => ({ - 'background-color': context.args.negative - ? 'var(--sbb-color-black-default)' - : 'var(--sbb-color-white-default)', -}); - -const negativeArg: InputType = { - control: { - type: 'boolean', - }, -}; - -const disabledArg: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Input attribute', - }, -}; - -const readonlyArg: InputType = { - control: { - type: 'boolean', - }, - table: { - category: 'Input attribute', - }, -}; - -const basicArgTypes: ArgTypes = { - negative: negativeArg, - disabled: disabledArg, - readonly: readonlyArg, -}; - -const basicArgs: Args = { - negative: false, - disabled: false, - readonly: false, -}; - -const DefaultTemplate = ({ negative, ...args }): JSX.Element => ( - - - - - -); - -export const Default: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, -}; - -export const Disabled: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, disabled: true }, -}; - -export const Readonly: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, readonly: true }, -}; - -export const DefaultNegative: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, negative: true }, -}; - -export const DisabledNegative: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, disabled: true, negative: true }, -}; - -export const ReadonlyNegative: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, readonly: true, negative: true }, -}; - -const meta: Meta = { - decorators: [ - (Story, context) => ( -
      - -
      - ), - withActions as Decorator, - ], - parameters: { - backgrounds: { - disable: true, - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'components/sbb-form-field/sbb-form-field-clear', -}; - -export default meta; diff --git a/src/components/sbb-form-field-clear/sbb-form-field-clear.tsx b/src/components/sbb-form-field-clear/sbb-form-field-clear.tsx deleted file mode 100644 index 80519bc082..0000000000 --- a/src/components/sbb-form-field-clear/sbb-form-field-clear.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - Component, - ComponentInterface, - Element, - Listen, - h, - Host, - JSX, - State, - Prop, -} from '@stencil/core'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { hostContext, isValidAttribute } from '../../global/dom'; -import { - HandlerRepository, - actionElementHandlerAspect, - documentLanguage, - languageChangeHandlerAspect, -} from '../../global/eventing'; -import { i18nClearInput } from '../../global/i18n'; - -@Component({ - shadow: true, - styleUrl: 'sbb-form-field-clear.scss', - tag: 'sbb-form-field-clear', -}) -export class SbbFormFieldClear implements ComponentInterface { - /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; - - @Element() private _element!: HTMLElement; - - @State() private _currentLanguage = documentLanguage(); - - private _handlerRepository = new HandlerRepository( - this._element, - actionElementHandlerAspect, - languageChangeHandlerAspect((l) => (this._currentLanguage = l)), - ); - private _formField: HTMLSbbFormFieldElement; - - @Listen('click') - public async handleClick(): Promise { - const input = await this._formField.getInputElement(); - if (!input || input.tagName !== 'INPUT') { - return; - } - await this._formField.clear(); - input.focus(); - input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - input.dispatchEvent(new window.Event('change', { bubbles: true })); - } - - public connectedCallback(): void { - this._handlerRepository.connect(); - this._formField = - (hostContext('sbb-form-field', this._element) as HTMLSbbFormFieldElement) ?? - (hostContext('[data-form-field]', this._element) as HTMLSbbFormFieldElement); - - if (this._formField) { - this.negative = isValidAttribute(this._formField, 'negative'); - } - } - - public disconnectedCallback(): void { - this._handlerRepository.disconnect(); - } - - public render(): JSX.Element { - const { hostAttributes } = resolveButtonRenderVariables(this as ButtonProperties); - - return ( - - - - - - ); - } -} diff --git a/src/components/sbb-form-field/readme.md b/src/components/sbb-form-field/readme.md deleted file mode 100644 index d6623bb032..0000000000 --- a/src/components/sbb-form-field/readme.md +++ /dev/null @@ -1,191 +0,0 @@ -The `sbb-form-field` component is intended to be used as a form input wrapper with label and errors. - -```html - - - - - - - - This field is required! - -``` - -In this document, "form field" refers to the wrapper component `sbb-form-field` and -"form field control" refers to the component that the `sbb-form-field` is wrapping -(e.g., the input, select, etc.) - -The following components are designed to work inside a `sbb-form-field`: - -- `` -- ` - This field is required! - -``` - -### Error messages - -Error messages can be shown under the form field by adding `sbb-form-error` elements inside the form field. -The component will automatically assign them to the `slot='error'`. - -```html - - - -``` - -In order to avoid the layout from "jumping" when an error is shown, the option of setting `error-space="reserve"` -on the `sbb-form-field` will reserve space for a single line of an error message. - -### Prefix & Suffix - -It is possible to add content as a prefix or suffix in a `sbb-form-field`. -This can be done via the `prefix` and `suffix` slots. - -Some components, like the [sbb-form-field-clear](/docs/components-sbb-form-field-sbb-form-field-clear--docs) or the -[sbb-slider](/docs/components-sbb-slider--docs), when used within the form field, will automatically occupy -one or both of these slots. -Please refer to their documentation for more details. - -```html - - - - - -``` - -## Style - -By default, the component has a defined width and min-width. However, this behavior can be overridden by setting -the `width` property to `collapse`: in this way the component adapts its width to the inner slotted input component. -This is useful, for example, for the [sbb-time-input](/docs/components-sbb-time-input--docs) component. -However, as the width-styles are exposed to the host, -it's possible to apply any desired width by setting just the `width` and `min-width` CSS properties. - -```html - - - - -``` - -## Accessibility - -By itself, the `sbb-form-field` does not apply any additional accessibility treatment to a form -element. However, several of the form field's optional features interact with the form element -contained within the form field. - -When you provide a label via `