diff --git a/src/elements/autocomplete/autocomplete-base-element.ts b/src/elements/autocomplete/autocomplete-base-element.ts index 56f2f2014f..398e162ef7 100644 --- a/src/elements/autocomplete/autocomplete-base-element.ts +++ b/src/elements/autocomplete/autocomplete-base-element.ts @@ -30,6 +30,11 @@ import style from './autocomplete-base-element.scss?lit&inline'; */ const ariaRoleOnHost = isSafari; +/** + * Custom event emitted on the input when an option is selected + */ +export const inputAutocompleteEvent = 'inputAutocomplete'; + export @hostAttributes({ popover: 'manual', @@ -205,6 +210,9 @@ abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( // Manually trigger the change events this.triggerElement.dispatchEvent(new Event('change', { bubbles: true })); this.triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + + // Custom input event emitted when input value changes after an option is selected + this.triggerElement.dispatchEvent(new Event(inputAutocompleteEvent)); this.triggerElement.focus(); } @@ -400,6 +408,7 @@ abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( 'keydown', (event: KeyboardEvent) => this.openedPanelKeyboardInteraction(event), { + capture: true, signal: this._openPanelEventsController.signal, }, ); diff --git a/src/elements/autocomplete/autocomplete.spec.ts b/src/elements/autocomplete/autocomplete.spec.ts index ad6e79b9cd..5b120a91be 100644 --- a/src/elements/autocomplete/autocomplete.spec.ts +++ b/src/elements/autocomplete/autocomplete.spec.ts @@ -8,6 +8,7 @@ import { describeIf, EventSpy, waitForLitRender } from '../core/testing.js'; import { SbbFormFieldElement } from '../form-field.js'; import { SbbOptionElement } from '../option.js'; +import { inputAutocompleteEvent } from './autocomplete-base-element.js'; import { SbbAutocompleteElement } from './autocomplete.js'; describe(`sbb-autocomplete`, () => { @@ -141,6 +142,7 @@ describe(`sbb-autocomplete`, () => { const optionSelectedEventSpy = new EventSpy(SbbOptionElement.events.optionSelected); const inputEventSpy = new EventSpy('input', input); const changeEventSpy = new EventSpy('change', input); + const inputAutocompleteEventSpy = new EventSpy(inputAutocompleteEvent, input); const optTwo = element.querySelector('#option-2')!; input.focus(); @@ -159,6 +161,7 @@ describe(`sbb-autocomplete`, () => { expect(inputEventSpy.count).to.be.equal(1); expect(changeEventSpy.count).to.be.equal(1); + expect(inputAutocompleteEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.firstEvent!.target).to.have.property('id', 'option-2'); expect(document.activeElement).to.be.equal(input); @@ -170,6 +173,7 @@ describe(`sbb-autocomplete`, () => { const optionSelectedEventSpy = new EventSpy(SbbOptionElement.events.optionSelected); const inputEventSpy = new EventSpy('input', input); const changeEventSpy = new EventSpy('change', input); + const inputAutocompleteEventSpy = new EventSpy(inputAutocompleteEvent, input); const optOne = element.querySelector('#option-1'); const optTwo = element.querySelector('#option-2'); const keydownSpy = new EventSpy('keydown', input); @@ -197,6 +201,7 @@ describe(`sbb-autocomplete`, () => { expect(optTwo).to.have.attribute('selected'); expect(inputEventSpy.count).to.be.equal(1); expect(changeEventSpy.count).to.be.equal(1); + expect(inputAutocompleteEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.count).to.be.equal(1); expect(input).to.have.attribute('aria-expanded', 'false'); expect(input).not.to.have.attribute('aria-activedescendant'); diff --git a/src/elements/chip-group.ts b/src/elements/chip-group.ts new file mode 100644 index 0000000000..bdeb44a0c7 --- /dev/null +++ b/src/elements/chip-group.ts @@ -0,0 +1,2 @@ +export * from './chip-group/chip.js'; +export * from './chip-group/chip-group.js'; diff --git a/src/elements/chip-group/chip-group.ts b/src/elements/chip-group/chip-group.ts new file mode 100644 index 0000000000..1820c8d8ff --- /dev/null +++ b/src/elements/chip-group/chip-group.ts @@ -0,0 +1 @@ +export * from './chip-group/chip-group.js'; diff --git a/src/elements/chip-group/chip-group/__snapshots__/chip-group.snapshot.spec.snap.js b/src/elements/chip-group/chip-group/__snapshots__/chip-group.snapshot.spec.snap.js new file mode 100644 index 0000000000..7e997c885b --- /dev/null +++ b/src/elements/chip-group/chip-group/__snapshots__/chip-group.snapshot.spec.snap.js @@ -0,0 +1,185 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-chip-group renders DOM"] = +` + + + +`; +/* end snapshot sbb-chip-group renders DOM */ + +snapshots["sbb-chip-group renders Shadow DOM"] = +`
+ + +
+`; +/* end snapshot sbb-chip-group renders Shadow DOM */ + +snapshots["sbb-chip-group renders with form-field DOM"] = +` + + + + + + + + + +`; +/* end snapshot sbb-chip-group renders with form-field DOM */ + +snapshots["sbb-chip-group renders with form-field Shadow DOM"] = +`
+
+ + +
+ + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+`; +/* end snapshot sbb-chip-group renders with form-field Shadow DOM */ + +snapshots["sbb-chip-group renders with form-field A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "statictext", + "name": "​" + }, + { + "role": "text leaf", + "name": "Field label" + }, + { + "role": "text container", + "name": "", + "children": [ + { + "role": "grid", + "name": "", + "children": [ + { + "role": "gridcell", + "name": "Value 1" + }, + { + "role": "button", + "name": "Remove Value 1" + }, + { + "role": "gridcell", + "name": "Value 2" + }, + { + "role": "button", + "name": "Remove Value 2" + } + ] + } + ] + }, + { + "role": "textbox", + "name": "Field label" + } + ] +} +

+`; +/* end snapshot sbb-chip-group renders with form-field A11y tree Firefox */ + +snapshots["sbb-chip-group renders with form-field A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "​" + }, + { + "role": "text", + "name": "Field label" + }, + { + "role": "generic", + "name": "", + "children": [ + { + "role": "gridcell", + "name": "Value 1" + }, + { + "role": "button", + "name": "Remove Value 1" + }, + { + "role": "gridcell", + "name": "Value 2" + }, + { + "role": "button", + "name": "Remove Value 2" + } + ] + }, + { + "role": "textbox", + "name": "Field label" + } + ] +} +

+`; +/* end snapshot sbb-chip-group renders with form-field A11y tree Chrome */ + diff --git a/src/elements/chip-group/chip-group/chip-group.scss b/src/elements/chip-group/chip-group/chip-group.scss new file mode 100644 index 0000000000..9536e6cee6 --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.scss @@ -0,0 +1,18 @@ +@use '../../core/styles/index' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-chip-group-input-min-width: #{sbb.px-to-rem-build(150)}; +} + +::slotted(input) { + flex: 1 1 var(--sbb-chip-group-input-min-width); +} + +.sbb-chip-group { + display: flex; + flex-wrap: wrap; + gap: var(--sbb-spacing-fixed-1x); +} diff --git a/src/elements/chip-group/chip-group/chip-group.snapshot.spec.ts b/src/elements/chip-group/chip-group/chip-group.snapshot.spec.ts new file mode 100644 index 0000000000..0384df427a --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.snapshot.spec.ts @@ -0,0 +1,58 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbChipGroupElement } from './chip-group.js'; +import './chip-group.js'; +import '../chip.js'; +import '../../form-field.js'; + +describe(`sbb-chip-group`, () => { + describe('renders', () => { + let element: SbbChipGroupElement; + + beforeEach(async () => { + element = await fixture( + html` + + `, + ); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders with form-field', () => { + let element: SbbChipGroupElement; + + beforeEach(async () => { + element = await fixture(html` + + + + + + + + + `); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/chip-group/chip-group/chip-group.spec.ts b/src/elements/chip-group/chip-group/chip-group.spec.ts new file mode 100644 index 0000000000..379320fcc9 --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.spec.ts @@ -0,0 +1,394 @@ +import { assert, expect } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteElement } from '../../autocomplete/autocomplete.js'; +import { inputAutocompleteEvent } from '../../autocomplete.js'; +import { fixture, tabKey } from '../../core/testing/private.js'; +import { EventSpy, waitForLitRender } from '../../core/testing.js'; +import type { SbbFormFieldElement } from '../../form-field.js'; +import type { SbbOptionElement } from '../../option.js'; +import type { SbbChipElement } from '../chip.js'; + +import { SbbChipGroupElement } from './chip-group.js'; +import '../chip.js'; +import '../../form-field.js'; +import '../../option.js'; + +describe('sbb-chip-group', () => { + let element: SbbChipGroupElement; + let chips: SbbChipElement[]; + let formField: SbbFormFieldElement; + let input: HTMLInputElement; + let focusStep: HTMLInputElement; + + describe('basic interactions', () => { + beforeEach(async () => { + await fixture(html` + Focus step + + + + + + + + + + `); + element = document.querySelector('sbb-chip-group')!; + chips = Array.from(document.querySelectorAll('sbb-chip')); + formField = document.querySelector('sbb-form-field')!; + input = document.querySelector('input')!; + focusStep = document.querySelector('#focusable')!; + + await waitForLitRender(formField); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbChipGroupElement); + }); + + it('should add chip on enter', async () => { + input.focus(); + await sendKeys({ type: 'chip 4' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(element); + + expect(element.value).to.include('chip 4'); + expect(element.querySelector('sbb-chip[value="chip 4"]')).to.exist; + expect(input.value).to.be.empty; // The input should be emptied + + // If the input is empty, it does not create a chip + await sendKeys({ press: 'Enter' }); + await waitForLitRender(element); + }); + + it('should delete chip delete button click', async () => { + const toDelete = chips[0]; + const toDeleteValue = toDelete.value; + const inputEventSpy = new EventSpy(SbbChipGroupElement.events.input, element); + const changeEventSpy = new EventSpy(SbbChipGroupElement.events.change, element); + + (toDelete.shadowRoot!.querySelector('.sbb-chip__delete') as HTMLElement).click(); + await waitForLitRender(element); + + // Expect the chip label to be focused + expect(element.value).not.to.include(toDeleteValue); + expect(element.querySelector(`sbb-chip[value="${toDeleteValue}"]`)).not.to.exist; + expect(inputEventSpy.count).to.be.equal(1); + expect(changeEventSpy.count).to.be.equal(1); + }); + + it('should react when input is disabled', async () => { + input.disabled = true; + await waitForLitRender(formField); + + expect(element.disabled).to.be.true; + expect(chips.every((c) => c.disabled)).to.be.true; + + input.disabled = false; + await waitForLitRender(formField); + + expect(element.disabled).to.be.false; + expect(chips.every((c) => c.disabled)).to.be.false; + }); + + it('should react when input is readonly', async () => { + input.toggleAttribute('readonly', true); + await waitForLitRender(formField); + + expect(chips.every((c) => c.readonly)).to.be.true; + + input.toggleAttribute('readonly', false); + await waitForLitRender(formField); + + expect(chips.every((c) => c.readonly)).to.be.false; + }); + + /** Verify whether the sync between slotted chip and the value works properly **/ + describe('slotted chips sync', () => { + it('should sync slotted chips when setting value', async () => { + // Add a chip ('chip 4') + let newValue = ['chip 1', 'chip 2', 'chip 3', 'chip 4']; + element.value = newValue; + await waitForLitRender(element); + + let slottedChipsValue = Array.from(element.querySelectorAll('sbb-chip')).map( + (c) => c.value, + ); + expect(slottedChipsValue).to.be.eql(newValue); + + // Remove a chip ('chip 3') + newValue = ['chip 1', 'chip 2', 'chip 4']; + element.value = newValue; + await waitForLitRender(element); + + slottedChipsValue = Array.from(element.querySelectorAll('sbb-chip')).map((c) => c.value); + expect(slottedChipsValue).to.be.eql(newValue); + + // Add and remove chips + newValue = ['chip 1', 'chip 2', 'chip 5']; + element.value = newValue; + await waitForLitRender(element); + + slottedChipsValue = Array.from(element.querySelectorAll('sbb-chip')).map((c) => c.value); + expect(slottedChipsValue).to.be.eql(newValue); + + // Empty value + element.value = null; + await waitForLitRender(element); + expect(element.querySelectorAll('sbb-chip').length).to.be.equal(0); + }); + + it('should sync value when slotting chips', async () => { + // Create and slot a new chip + const newChip = document.createElement('sbb-chip'); + newChip.setAttribute('value', 'chip 4'); + element.insertBefore(newChip, input); + + await waitForLitRender(element); + + expect(element.value).to.be.eql(['chip 1', 'chip 2', 'chip 3', 'chip 4']); + }); + }); + + describe('keyboard interactions', () => { + it('should handle tab-order', async () => { + focusStep.focus(); + + await sendKeys({ press: tabKey }); + + // Should focus the last enabled chip + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-1)!.value); + + await sendKeys({ press: tabKey }); + + expect(document.activeElement!.localName).to.be.equal('input'); + + await sendKeys({ down: 'Shift' }); + await sendKeys({ press: tabKey }); + await sendKeys({ up: 'Shift' }); + + // Should focus the last enabled chip + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-1)!.value); + + await sendKeys({ down: 'Shift' }); + await sendKeys({ press: tabKey }); + await sendKeys({ up: 'Shift' }); + + // Should not trap the focus and let the previous element to be focused + expect(document.activeElement!.localName).to.be.equal(focusStep.localName); + + // Should skip the last disabled chip and focus the second to last + chips.at(-1)!.disabled = true; + chips.at(-2)!.readonly = true; + await waitForLitRender(element); + + await sendKeys({ press: tabKey }); + + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-2)!.value); + }); + + it('should remove chip on delete key', async () => { + input.focus(); + + await sendKeys({ press: 'Backspace' }); + + // Should focus the last enabled chip + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-1)!.value); + const focusedChipValue = (document.activeElement as SbbChipElement).value; + + // Should remove the focused chip + await sendKeys({ press: 'Backspace' }); + await waitForLitRender(element); + + expect(element.value).not.to.contain(focusedChipValue); + + input.focus(); + await sendKeys({ type: 'a' }); + await sendKeys({ press: 'Backspace' }); + + // If the input is not empty, it should not move the focus to the chip + expect(document.activeElement!.localName).to.be.equal('input'); + }); + + it('should handle arrow navigation', async () => { + chips[1].disabled = true; + await waitForLitRender(element); + + chips[0].focus(); + + await sendKeys({ press: 'ArrowRight' }); + + // Should focus the delete button of the first chip + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips[0].value); + expect(document.activeElement!.shadowRoot!.activeElement!).to.have.class( + 'sbb-chip__delete', + ); + + await sendKeys({ press: 'ArrowDown' }); + + // Should skip the disabled chip and focus the last one + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-1)!.value); + expect(document.activeElement!.shadowRoot!.activeElement!).to.have.class( + 'sbb-chip__label-wrapper', + ); + + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'ArrowRight' }); + + // Should wrap and go back to the first chip + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips[0].value); + expect(document.activeElement!.shadowRoot!.activeElement!).to.have.class( + 'sbb-chip__label-wrapper', + ); + + await sendKeys({ press: 'ArrowLeft' }); + await sendKeys({ press: 'ArrowUp' }); + + expect((document.activeElement as SbbChipElement).value).to.be.equal(chips.at(-1)!.value); + expect(document.activeElement!.shadowRoot!.activeElement!).to.have.class( + 'sbb-chip__label-wrapper', + ); + }); + }); + }); + + describe('within form', () => { + let form: HTMLFormElement; + let fieldset: HTMLFieldSetElement; + + beforeEach(async () => { + await fixture(html` +
+
+ + + + + + + + + +
+
+ `); + element = document.querySelector('sbb-chip-group')!; + chips = Array.from(document.querySelectorAll('sbb-chip')); + formField = document.querySelector('sbb-form-field')!; + input = document.querySelector('input')!; + form = document.querySelector('form')!; + fieldset = document.querySelector('fieldset')!; + + await waitForLitRender(formField); + }); + + it('should update form value', async () => { + let formData = new FormData(form); + + expect(formData.getAll('chip-group-1')).to.be.eql(element.value); + input.focus(); + await sendKeys({ type: 'chip-4' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(formField); + + formData = new FormData(form); + expect(formData.getAll('chip-group-1')).to.be.eql(element.value); + + chips[0].remove(); + await waitForLitRender(formField); + + formData = new FormData(form); + expect(formData.getAll('chip-group-1')).to.be.eql(element.value); + }); + + it('should react when fieldset is disabled', async () => { + fieldset.disabled = true; + await waitForLitRender(formField); + + const formData = new FormData(form); + + expect(element).to.match(':disabled'); + expect(formData.getAll('chip-group-1')).to.be.eql([]); + }); + }); + + describe.only('with autocomplete', () => { + let autocomplete: SbbAutocompleteElement; + let options: SbbOptionElement[]; + + beforeEach(async () => { + await fixture(html` + + + + + + + + Option A + Option B + + + `); + element = document.querySelector('sbb-chip-group')!; + chips = Array.from(document.querySelectorAll('sbb-chip')); + formField = document.querySelector('sbb-form-field')!; + input = document.querySelector('input')!; + autocomplete = document.querySelector('sbb-autocomplete')!; + options = Array.from(document.querySelectorAll('sbb-option')); + + await waitForLitRender(formField); + }); + + it('should create chip when option is selected', async () => { + const inputAutocompleteEventSpy = new EventSpy(inputAutocompleteEvent, input); + + input.focus(); + await waitForLitRender(formField); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(formField); + + expect(inputAutocompleteEventSpy.count).to.be.equal(1); + expect(element.value).to.contain(options[0].value); + + autocomplete.open(); + options[1].click(); + await waitForLitRender(formField); + + expect(inputAutocompleteEventSpy.count).to.be.equal(2); + expect(element.value).to.contain(options[1].value); + }); + + /** + * This test cover the case where the input has a value and an option is selected. + * What should happen is that + * - autocomplete overwrites the input value with the clicked option + * - the chip-group creates the chip with the new input value + */ + it('should ignore the input value when an option is selected', async () => { + const inputAutocompleteEventSpy = new EventSpy('inputAutocomplete', input); + + input.focus(); + await sendKeys({ type: 'aa' }); + await waitForLitRender(formField); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(formField); + + expect(inputAutocompleteEventSpy.count).to.be.equal(1); + expect(element.value).to.contain(options[0].value); + expect(element.value).not.to.contain('aa'); + }); + }); +}); diff --git a/src/elements/chip-group/chip-group/chip-group.ssr.spec.ts b/src/elements/chip-group/chip-group/chip-group.ssr.spec.ts new file mode 100644 index 0000000000..debc74615b --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.ssr.spec.ts @@ -0,0 +1,30 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbChipGroupElement } from './chip-group.js'; +import '../chip.js'; +import '../../form-field.js'; + +describe(`sbb-chip-group ssr`, () => { + let root: SbbChipGroupElement; + + beforeEach(async () => { + root = await ssrHydratedFixture( + html` + + + + + `, + { + modules: ['./chip-group.js', '../chip.js', '../../form-field.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbChipGroupElement); + }); +}); diff --git a/src/elements/chip-group/chip-group/chip-group.stories.ts b/src/elements/chip-group/chip-group/chip-group.stories.ts new file mode 100644 index 0000000000..b33cbe1e0b --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.stories.ts @@ -0,0 +1,114 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { SbbChipGroupElement } from './chip-group.js'; +import readme from './readme.md?raw'; + +import '../chip.js'; +import '../../autocomplete/autocomplete.js'; +import '../../form-field/form-field.js'; +import '../../option/option.js'; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const readonly: InputType = { + control: { + type: 'boolean', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + disabled, + readonly, + negative, +}; + +const defaultArgs: Args = { + disabled: false, + readonly: false, + negative: false, +}; + +const Template = (args: Args): TemplateResult => html` +
+ + + + + + + + + +
+`; + +const WithAutocompleteTemplate = (args: Args): TemplateResult => html` + + + + + + + + + + Option A + Option B + Option C + + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const Readonly: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, readonly: true }, +}; + +// TODO +export const WithAutoComplete: StoryObj = { + render: WithAutocompleteTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: [SbbChipGroupElement.events.input, SbbChipGroupElement.events.change], + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-chip-group/sbb-chip-group', +}; + +export default meta; diff --git a/src/elements/chip-group/chip-group/chip-group.ts b/src/elements/chip-group/chip-group/chip-group.ts new file mode 100644 index 0000000000..7b0e31aff4 --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.ts @@ -0,0 +1,332 @@ +import { type CSSResultGroup, isServer, type PropertyValues, type TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { inputAutocompleteEvent } from '../../autocomplete.js'; +import { getNextElementIndex, isArrowKeyPressed } from '../../core/a11y.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { + type FormRestoreReason, + type FormRestoreState, + SbbDisabledMixin, + SbbFormAssociatedMixin, + SbbNegativeMixin, +} from '../../core/mixins.js'; +import { SbbChipElement } from '../chip.js'; + +import style from './chip-group.scss?lit&inline'; + +/** + * Describe the purpose of the component with a single short sentence. + * + * @event {CustomEvent} change - Notifies that the component's value has changed. + * @event {CustomEvent} input - Notifies that the component's value has changed. + * @slot - Use the unnamed slot to add `sbb-chip` elements. + * @overrideType value - string[] | null + */ +export +@customElement('sbb-chip-group') +@hostAttributes({ + tabindex: '0', +}) +class SbbChipGroupElement extends SbbDisabledMixin( + SbbNegativeMixin(SbbFormAssociatedMixin(LitElement)), +) { + public static override styles: CSSResultGroup = style; + public static readonly events: Record = { + input: 'input', + change: 'change', + } as const; + + /** Value of the form element. */ + @property() + public override set value(value: string[] | null) { + value = value ?? []; + super.value = value; + const oldValue = this.value; + + // Subtract from 'oldValue' the new 'value' + // The result are the chips to remove (handle duplicates) + const toRemove = [...oldValue]; + for (const c of value) { + if (toRemove.includes(c)) { + toRemove.splice(toRemove.indexOf(c), 1); + } + } + toRemove.forEach((value) => + this._chipElements() + .find((c) => c.value === value) + ?.remove(), + ); + + // Subtract from the new 'value' what was already present + // The result are the new chips to add (handle duplicates) + const toAdd = [...value]; + for (const c of oldValue) { + if (toAdd.includes(c)) { + toAdd.splice(toAdd.indexOf(c), 1); + } + } + toAdd.forEach((c) => this._createChipElement(c)); + } + public override get value(): string[] { + return this._chipElements().map((c) => c.value); + } + + /** Notifies that the component's value has changed. */ + private _change: EventEmitter = new EventEmitter(this, SbbChipGroupElement.events.change); + + /** Notifies that an option value has been selected. */ + private _input: EventEmitter = new EventEmitter(this, SbbChipGroupElement.events.input); + + /** + * Listens to the changes on `readonly` and `disabled` attributes of ``. + */ + private _inputAttributeObserver = !isServer + ? new MutationObserver(() => this._reactToInputChanges()) + : null; + + private _inputElement: HTMLInputElement | undefined; + private _inputAbortController: AbortController | undefined; + + public constructor() { + super(); + this.addEventListener(SbbChipElement.events.requestDelete, (ev) => + this._deleteChip(ev.target as SbbChipElement), + ); + + this.addEventListener('focus', () => this._focusLastChip()); + this.addEventListener('keydown', (ev) => this._onChipKeyDown(ev)); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._inputAttributeObserver?.disconnect(); + this._inputAbortController?.abort(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if ( + changedProperties.has('disabled') || + changedProperties.has('formDisabled') || + changedProperties.has('negative') + ) { + this._proxyStateToChips(); + } + } + + /** @internal */ + public formResetCallback(): void { + this.value = null; + } + + /** @internal */ + public formStateRestoreCallback( + state: FormRestoreState | null, + _reason: FormRestoreReason, + ): void { + if (!state) { + this.value = null; + return; + } + + // the state format is ['field-name', 'value'][] + this.value = (state as [string, string][]).map((entries) => entries[1]); + } + + protected updateFormValue(): void { + const data = new FormData(); + this.value.forEach((el) => data.append(this.name, el)); + this.internals.setFormValue(data); + } + + /** Return the list of chip elements **/ + private _chipElements(): SbbChipElement[] { + return Array.from(this.querySelectorAll('sbb-chip')); + } + + /** Return the list of enabled chip elements **/ + private _enabledChipElements(): SbbChipElement[] { + return Array.from(this.querySelectorAll('sbb-chip:not([disabled])')); + } + + private _onSlotChange(): void { + const input = this.querySelector('input'); + + // Connect to the input + if (input && input !== this._inputElement) { + this._inputAbortController?.abort(); + this._inputAttributeObserver?.disconnect(); + this._inputElement = input; + + this._inputAbortController = new AbortController(); + this._inputElement.addEventListener('keydown', (ev) => this._onInputKeyDown(ev), { + signal: this._inputAbortController.signal, + }); + this._inputElement.addEventListener( + inputAutocompleteEvent, + () => this._createChipFromInput(), + { + signal: this._inputAbortController.signal, + }, + ); + + this._inputAttributeObserver?.observe(this._inputElement, { + attributes: true, + attributeFilter: ['readonly', 'disabled'], + }); + } + + this._proxyStateToChips(); + this.updateFormValue(); + } + + /** + * Listen for keyboard events on the chip elements + **/ + private _onChipKeyDown(event: KeyboardEvent): void { + const eventTarget = event.target as SbbChipElement; + if (eventTarget.localName !== 'sbb-chip') { + return; + } + + // Arrow keys allow navigation between chips focus steps + if (isArrowKeyPressed(event)) { + // Collect an array of the enabled focus steps (2 for each chip) + const focusSteps = this._enabledChipElements().flatMap((c) => c.getFocusSteps()); + + // The true event target is shadowed by web-component boundary. + // We have to pierce the shadowDOM to get the focused step + const activeStep = eventTarget.shadowRoot!.activeElement as HTMLElement; + + const next = getNextElementIndex(event, focusSteps.indexOf(activeStep), focusSteps.length); + focusSteps[next].focus(); + return; + } + + switch (event.key) { + case 'Backspace': + if (!eventTarget.readonly && !eventTarget.disabled) { + event.preventDefault(); + this._deleteChip(eventTarget); + this._focusLastChip(); + } + break; + case 'Tab': + if (event.shiftKey) { + this._allowFocusEscape(); + } + break; + } + } + + /** + * Listen for keyboard events on the input + **/ + private _onInputKeyDown(event: KeyboardEvent): void { + switch (event.key) { + case 'Enter': + if (!event.defaultPrevented) { + event.preventDefault(); + this._createChipFromInput(); + } + break; + case 'Backspace': + if (!this._inputElement!.value) { + this._focusLastChip(); + } + break; + case 'Tab': + if (event.shiftKey && this._enabledChipElements().length === 0) { + this._allowFocusEscape(); + } + break; + } + } + + /** + * If the input is not empty, create a chip with its value + */ + private _createChipFromInput(): void { + if (this._inputElement!.value.trim()) { + this.value = [...this.value, this._inputElement!.value.trim()]; + this._inputElement!.value = ''; // Empty the input + this._emitInputEvents(); + } + } + + private _deleteChip(chip: SbbChipElement): void { + chip.remove(); + this._emitInputEvents(); + } + + /** + * Focus the last enabled chip. If none are present, focus the input + */ + private _focusLastChip(): void { + const enabledChips = this._enabledChipElements(); + if (enabledChips.length > 0) { + enabledChips[enabledChips.length - 1].focus(); + } else { + this._inputElement?.focus(); + } + } + + private _emitInputEvents(): void { + this._input.emit(); + this._change.emit(); + } + + private _createChipElement(value: string): void { + const newChip = document.createElement('sbb-chip'); + newChip.setAttribute('value', value); + this.insertBefore(newChip, this._inputElement!); + } + + /** + * Removes the `tabindex` from the chip-group and resets it back afterward, allowing the + * user to tab out of it. This prevents the set from capturing focus and redirecting + * it back to the last chip, creating a focus trap, if the user tries to tab away. + */ + private _allowFocusEscape(): void { + if (this.tabIndex !== -1) { + const previousTabIndex = this.tabIndex; + this.tabIndex = -1; + + // Note that this needs to be a `setTimeout`, because a `Promise.resolve` + // doesn't allow enough time for the focus to escape. + setTimeout(() => (this.tabIndex = previousTabIndex)); + } + } + + private _reactToInputChanges(): void { + this.disabled = this._inputElement!.disabled; + this._proxyStateToChips(); + } + + private _proxyStateToChips(): void { + this._chipElements().forEach((c) => { + c.disabled = this.disabled || this.formDisabled; + c.readonly = this._inputElement?.hasAttribute('readonly') ?? false; + c.negative = this.negative; + }); + } + + protected override render(): TemplateResult { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-chip-group': SbbChipGroupElement; + } +} diff --git a/src/elements/chip-group/chip-group/chip-group.visual.spec.ts b/src/elements/chip-group/chip-group/chip-group.visual.spec.ts new file mode 100644 index 0000000000..7be2f2451f --- /dev/null +++ b/src/elements/chip-group/chip-group/chip-group.visual.spec.ts @@ -0,0 +1,52 @@ +import { html } from 'lit'; + +import { + describeViewports, + describeEach, + visualDiffDefault, + visualDiffStandardStates, +} from '../../core/testing/private.js'; + +import './chip-group.js'; + +describe('sbb-chip-group', () => { + /** + * Add the `viewports` param to test only specific viewport; + * add the `viewportHeight` param to set a fixed height for the browser. + */ + describeViewports(() => { + // Create visual tests considering the implemented states (default, hover, active, focus) + for (const state of visualDiffStandardStates) { + it( + `${state.name}`, + state.with(async (setup) => { + await setup.withFixture(html``); + }), + ); + } + + /** + * Create visual tests combining the values of the provided object; + * useful when testing combinations of disabled, negative, visual variants, etc. + * eg. + * 1. one=true two={ name: 'A', value: 1 } + * 2. one=true two={ name: 'B', value: 2 } + * 3. one=false two={ name: 'A', value: 1 } + * 4. one=false two={ name: 'B', value: 2 } + */ + const example = { + two: [ + { name: 'A', value: 1 }, + { name: 'B', value: 2 }, + ], + }; + describeEach(example, ({ two }) => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(html` ${two.name} `); + }), + ); + }); + }); +}); diff --git a/src/elements/chip-group/chip-group/readme.md b/src/elements/chip-group/chip-group/readme.md new file mode 100644 index 0000000000..56e3072eaf --- /dev/null +++ b/src/elements/chip-group/chip-group/readme.md @@ -0,0 +1,128 @@ +The `sbb-chip-group` component is used as a container for one or multiple `sbb-chip`. +Generally, it is used in combination with a `sbb-form-field` to allow the input of multiple string values. + +The `value` property is synced with the slotted chips. Adding a `sbb-chip` to the slot will update the `value` property (and vice versa). + +```html + + + + + ... + + + +``` + +## Slots + +Use the unnamed slot to provide the `sbb-chip` and the `input` field + +## States + +The `sbb-chip-group` has a `disabled` and a `readonly` state that is automatically synced to the respective `input` property. + +```html + + + + ... + + + + + +``` + +The `sbb-chip-group` has a `negative` variant. If within a `sbb-form-field`, the properties automatically sync. + +```html + + + + ... + + + +``` + +## Usage + +### Use within forms + +The `sbb-chip-group` is a form associated element and can be part of a form. Its value is an array of strings. + +**Note:** The `name` must be set on the `sbb-chip-group`, not on the `input` + +```html +
+ + + + ... + + + +
+``` + +### Use with Autocomplete + +It is possible to combine the functionalities of `chip-group` and the [sbb-autocomplete](/docs/elements-sbb-autocomplete--docs). + +In this scenario, selecting an option will create a new chip with the option value. + +```html + + + + ... + + + + Option A + ... + + +``` + +## Keyboard interaction + +At any time, only a single chip (usually, the last one) is focusable and part of the tab order. Users can move between them using the arrow keys. + +| Keyboard | Action | +| --------------------------- | --------------------------------------------------------- | +| Enter | When the `input` is focused, add a new chip. | +| Backspace | When the `input` is empty & focused, focus the last chip. | +| Backspace | When the `sbb-chip` focused, delete it. | +| Left/Up Arrow | Move the next `sbb-chip`. | +| Right/Down Arrow | Move the previous `sbb-chip`. | + +## Accessibility + +The `sbb-chip-group` follows the `grid` pattern; + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | ------------------------- | ------- | -------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. | +| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `value` | `value` | public | `string[] \| null` | `null` | Value of the form element. | + +## Events + +| Name | Type | Description | Inherited From | +| -------- | ------------------- | ------------------------------------------------ | -------------- | +| `change` | `CustomEvent` | Notifies that the component's value has changed. | | +| `input` | `CustomEvent` | Notifies that the component's value has changed. | | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------ | +| | Use the unnamed slot to add `sbb-chip` elements. | diff --git a/src/elements/chip-group/chip.ts b/src/elements/chip-group/chip.ts new file mode 100644 index 0000000000..fa8c530782 --- /dev/null +++ b/src/elements/chip-group/chip.ts @@ -0,0 +1 @@ +export * from './chip/chip.js'; diff --git a/src/elements/chip-group/chip/__snapshots__/chip.snapshot.spec.snap.js b/src/elements/chip-group/chip/__snapshots__/chip.snapshot.spec.snap.js new file mode 100644 index 0000000000..96849af971 --- /dev/null +++ b/src/elements/chip-group/chip/__snapshots__/chip.snapshot.spec.snap.js @@ -0,0 +1,157 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-chip renders DOM"] = +` + +`; +/* end snapshot sbb-chip renders DOM */ + +snapshots["sbb-chip renders Shadow DOM"] = +`
+
+ + + Value + + +
+
+ + +
+
+`; +/* end snapshot sbb-chip renders Shadow DOM */ + +snapshots["sbb-chip renders disabled DOM"] = +` + +`; +/* end snapshot sbb-chip renders disabled DOM */ + +snapshots["sbb-chip renders disabled Shadow DOM"] = +`
+
+ + + Value + + +
+
+ + +
+
+`; +/* end snapshot sbb-chip renders disabled Shadow DOM */ + +snapshots["sbb-chip renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "gridcell", + "name": "Value" + }, + { + "role": "button", + "name": "Remove Value" + } + ] +} +

+`; +/* end snapshot sbb-chip renders A11y tree Chrome */ + +snapshots["sbb-chip renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "section", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Value" + } + ] + }, + { + "role": "button", + "name": "Remove Value" + } + ] +} +

+`; +/* end snapshot sbb-chip renders A11y tree Firefox */ + +snapshots["sbb-chip renders disabled A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Value" + } + ] +} +

+`; +/* end snapshot sbb-chip renders disabled A11y tree Chrome */ + +snapshots["sbb-chip renders disabled A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Value" + } + ] +} +

+`; +/* end snapshot sbb-chip renders disabled A11y tree Firefox */ + diff --git a/src/elements/chip-group/chip/chip.scss b/src/elements/chip-group/chip/chip.scss new file mode 100644 index 0000000000..d509a74cc2 --- /dev/null +++ b/src/elements/chip-group/chip/chip.scss @@ -0,0 +1,104 @@ +@use '../../core/styles/index' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-chip-background-color: var(--sbb-color-milk); + --sbb-chip-border: var(--sbb-border-width-1x) solid var(--sbb-chip-border-color); + --sbb-chip-border-color: var(--sbb-color-cloud); + --sbb-chip-border-radius: var(--sbb-border-radius-2x); + --sbb-chip-color: var(--sbb-color-granite); + --sbb-chip-color-hover: var(--sbb-color-charcoal); + --sbb-chip-background-color-active: var(--sbb-color-white); + --sbb-chip-background-color-hover: var(--sbb-color-white); + --sbb-chip-transition-duration: var( + --sbb-disable-animation-duration, + var(--sbb-animation-duration-2x) + ); + + display: inline-block; +} + +:host([negative]) { + --sbb-chip-border-color: var(--sbb-color-iron); + --sbb-chip-background-color: var(--sbb-color-charcoal); + --sbb-chip-color: var(--sbb-color-cloud); + --sbb-chip-color-hover: var(--sbb-color-white); + --sbb-chip-background-color-active: var(--sbb-color-midnight); + --sbb-chip-background-color-hover: var(--sbb-color-midnight); +} + +:host(:is([disabled], [readonly])) { + .sbb-chip__label-wrapper { + border: var(--sbb-chip-border); + border-radius: var(--sbb-chip-border-radius); + } + + .sbb-chip__delete { + display: none; + } +} + +@include sbb.hover-mq($hover: true) { + :host(:not([disabled])) { + .sbb-chip__label-wrapper:hover { + color: var(--sbb-chip-color-hover); + cursor: pointer; + background-color: var(--sbb-chip-background-color-hover); + + .sbb-chip__label { + transition: transform var(--sbb-chip-transition-duration) var(--sbb-animation-easing); + transform: translateY(#{sbb.px-to-rem-build(-1)}); + } + } + + .sbb-chip__delete:hover { + --sbb-button-color-text: var(--sbb-chip-color-hover); + } + } +} + +.sbb-chip { + background-color: var(--sbb-chip-background-color); + border-radius: var(--sbb-chip-border-radius); + color: var(--sbb-chip-color); + display: flex; + align-items: stretch; + height: #{sbb.px-to-rem-build(24)}; + + &:has(.sbb-chip__label-wrapper:focus) { + @include sbb.focus-outline; + + --sbb-focus-outline-offset: #{sbb.px-to-rem-build(-1)}; // Outline overlaps the border + } +} + +.sbb-chip__label-wrapper { + border: var(--sbb-chip-border); + border-right: none; + border-radius: var(--sbb-chip-border-radius) 0 0 var(--sbb-chip-border-radius); + display: flex; + align-items: center; + padding-inline: var(--sbb-spacing-fixed-2x); + outline: none; +} + +.sbb-chip__label { + @include sbb.text-xxs--regular; + + display: inline-block; +} + +.sbb-chip__delete { + --sbb-button-border-radius: 0 var(--sbb-chip-border-radius) var(--sbb-chip-border-radius) 0; + --sbb-button-color-text: var(--sbb-chip-color); + --sbb-button-color-active-background: var(--sbb-chip-background-color-active); + --sbb-button-color-hover-background: var(--sbb-chip-background-color-hover); + --sbb-icon-svg-width: calc(var(--sbb-size-icon-ui-small) - var(--sbb-border-width-1x) * 2); + --sbb-icon-svg-height: calc(var(--sbb-size-icon-ui-small) - var(--sbb-border-width-1x) * 2); + --sbb-focus-outline-offset: 0; + + border: var(--sbb-chip-border); + border-radius: 0 var(--sbb-chip-border-radius) var(--sbb-chip-border-radius) 0; +} diff --git a/src/elements/chip-group/chip/chip.snapshot.spec.ts b/src/elements/chip-group/chip/chip.snapshot.spec.ts new file mode 100644 index 0000000000..e4f4825e07 --- /dev/null +++ b/src/elements/chip-group/chip/chip.snapshot.spec.ts @@ -0,0 +1,45 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbChipElement } from './chip.js'; +import './chip.js'; + +describe(`sbb-chip`, () => { + describe('renders', () => { + let element: SbbChipElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); + + describe('renders disabled', () => { + let element: SbbChipElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/chip-group/chip/chip.spec.ts b/src/elements/chip-group/chip/chip.spec.ts new file mode 100644 index 0000000000..630bea1476 --- /dev/null +++ b/src/elements/chip-group/chip/chip.spec.ts @@ -0,0 +1,59 @@ +import { assert, expect } from '@open-wc/testing'; +import { sendKeys, sendMouse } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { fixture, tabKey } from '../../core/testing/private.js'; +import { waitForLitRender } from '../../core/testing.js'; + +import { SbbChipElement } from './chip.js'; + +describe('sbb-chip', () => { + let element: SbbChipElement; + + beforeEach(async () => { + element = await fixture(html``); + await waitForLitRender(element); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbChipElement); + }); + + it('should focus the chip label', async () => { + element.focus(); + + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect(element.shadowRoot!.activeElement).to.have.class('sbb-chip__label-wrapper'); + }); + + it('should focus the chip label on click', async () => { + element.click(); + + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect(element.shadowRoot!.activeElement).to.have.class('sbb-chip__label-wrapper'); + + await sendKeys({ press: tabKey }); // reset the focus + await sendMouse({ type: 'click', position: [element.offsetTop + 10, element.offsetLeft + 10] }); + await waitForLitRender(element); + + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect(element.shadowRoot!.activeElement).to.have.class('sbb-chip__label-wrapper'); + }); + + it('should ignore click when disabled', async () => { + element.disabled = true; + await waitForLitRender(element); + element.click(); + + expect(document.activeElement!.localName).not.to.be.equal('sbb-chip'); + }); + + it('should focus on click when readonly', async () => { + element.readonly = true; + await waitForLitRender(element); + element.click(); + + expect(document.activeElement!.localName).to.be.equal('sbb-chip'); + expect(element.shadowRoot!.activeElement).to.have.class('sbb-chip__label-wrapper'); + }); +}); diff --git a/src/elements/chip-group/chip/chip.ssr.spec.ts b/src/elements/chip-group/chip/chip.ssr.spec.ts new file mode 100644 index 0000000000..bc435b2813 --- /dev/null +++ b/src/elements/chip-group/chip/chip.ssr.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbChipElement } from './chip.js'; + +describe(`sbb-chip ssr`, () => { + let root: SbbChipElement; + + beforeEach(async () => { + root = await ssrHydratedFixture(html``, { + modules: ['./chip.js'], + }); + }); + + it('renders', () => { + assert.instanceOf(root, SbbChipElement); + }); +}); diff --git a/src/elements/chip-group/chip/chip.stories.ts b/src/elements/chip-group/chip/chip.stories.ts new file mode 100644 index 0000000000..7e382c5c06 --- /dev/null +++ b/src/elements/chip-group/chip/chip.stories.ts @@ -0,0 +1,86 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import readme from './readme.md?raw'; +import './chip.js'; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const readonly: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + value, + disabled, + negative, + readonly, +}; + +const defaultArgs: Args = { + value: 'Value', + disabled: false, + negative: false, + readonly: false, +}; + +const Template = (args: Args): TemplateResult => html``; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const Readonly: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, readonly: true }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-chip-group/sbb-chip', +}; + +export default meta; diff --git a/src/elements/chip-group/chip/chip.ts b/src/elements/chip-group/chip/chip.ts new file mode 100644 index 0000000000..34507fef2d --- /dev/null +++ b/src/elements/chip-group/chip/chip.ts @@ -0,0 +1,111 @@ +import { type CSSResultGroup, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { SbbLanguageController } from '../../core/controllers.js'; +import { forceType } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { SbbDisabledMixin, SbbNegativeMixin } from '../../core/mixins.js'; + +import '../../button/mini-button.js'; + +import style from './chip.scss?lit&inline'; + +import { i18nChipDelete } from '@sbb-esta/lyne-elements/core/i18n/i18n'; + +/** + * Describe the purpose of the component with a single short sentence. + * + * @slot - Use the unnamed slot to add the display value. If not provided, the 'value' will be used. + */ +export +@customElement('sbb-chip') +class SbbChipElement extends SbbNegativeMixin(SbbDisabledMixin(LitElement)) { + public static override styles: CSSResultGroup = style; + public static readonly events = { + requestDelete: 'requestDelete', + } as const; + + /** The value of chip. Will be used as label. */ + @forceType() @property() public accessor value: string = ''; + + /** Whether the component is readonly */ + @forceType() + @property({ type: Boolean, reflect: true }) + public accessor readonly: boolean = false; + + /** @internal */ + private _requestDelete = new EventEmitter(this, SbbChipElement.events.requestDelete); + private _language = new SbbLanguageController(this); + + public override click(): void { + if (this.disabled) { + return; + } + this._chipLabel().click(); + } + + public override focus(): void { + if (this.disabled) { + return; + } + this._chipLabel().focus(); + } + + /** + * Return the two focusable elements of the chip. + * @internal + */ + public getFocusSteps(): HTMLElement[] { + return [this._chipLabel(), this._deleteButton()]; + } + + protected override firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + + // Remove the delete button from the tab order. + // SetTimeout is needed to override the button tabindex initialization + setTimeout(() => (this._deleteButton().tabIndex = -1)); + } + + private _chipLabel(): HTMLElement { + return this.shadowRoot!.querySelector('.sbb-chip__label-wrapper')!; + } + + private _deleteButton(): HTMLElement { + return this.shadowRoot!.querySelector('sbb-mini-button')!; + } + + protected override render(): TemplateResult { + return html` +
+
this._chipLabel().focus()} + > + + ${this.value} + +
+
+ this._requestDelete.emit()} + > +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-chip': SbbChipElement; + } +} diff --git a/src/elements/chip-group/chip/chip.visual.spec.ts b/src/elements/chip-group/chip/chip.visual.spec.ts new file mode 100644 index 0000000000..28f4dcea8e --- /dev/null +++ b/src/elements/chip-group/chip/chip.visual.spec.ts @@ -0,0 +1,88 @@ +import { html } from 'lit'; + +import { + describeEach, + describeViewports, + visualDiffActive, + visualDiffDefault, + visualDiffFocus, + visualDiffHover, +} from '../../core/testing/private.js'; + +import './chip.js'; + +const cases = { + negative: [true, false], +}; + +describe('sbb-chip', () => { + describeViewports({ viewports: ['zero', 'medium'] }, () => { + describeEach(cases, ({ negative }) => { + it( + `${visualDiffDefault.name}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(html``); + }), + ); + + it( + 'slotted label', + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html`Value`, + ); + }), + ); + + it( + 'disabled', + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html``, + ); + }), + ); + + it( + 'readonly', + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html``, + ); + }), + ); + + for (const state of [visualDiffActive, visualDiffFocus, visualDiffHover]) { + // Focus on chip + it( + `${state.name}`, + state.with(async (setup) => { + await setup.withFixture( + html``, + ); + + const chipStateElement = setup.snapshotElement + .querySelector('sbb-chip')! + .getFocusSteps()[0]; + setup.withStateElement(chipStateElement); + }), + ); + + // Focus on delete button + it( + `delete_${state.name}`, + state.with(async (setup) => { + await setup.withFixture( + html``, + ); + + const deleteStateElement = setup.snapshotElement + .querySelector('sbb-chip')! + .getFocusSteps()[1]; + setup.withStateElement(deleteStateElement); + }), + ); + } + }); + }); +}); diff --git a/src/elements/chip-group/chip/readme.md b/src/elements/chip-group/chip/readme.md new file mode 100644 index 0000000000..8f1ad52212 --- /dev/null +++ b/src/elements/chip-group/chip/readme.md @@ -0,0 +1,36 @@ +The `sbb-chip` is a component meant to be used in combination with the [sbb-chip-group](/docs/elements-sbb-chip-group-sbb-chip-group--docs) + +```html + + + ... + +``` + +## Slots + +It is possible to provide a label via the unnamed slot. If not present, the `value` will be used. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | --------- | ------- | ----------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `readonly` | `readonly` | public | `boolean` | `false` | Whether the component is readonly | +| `value` | `value` | public | `string` | `''` | The value of chip. Will be used as label. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ----------- | ---------- | ------ | -------------- | +| `click` | public | | | `void` | | +| `focus` | public | | | `void` | | + +## Slots + +| Name | Description | +| ---- | ----------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add the display value. If not provided, the 'value' will be used. | diff --git a/src/elements/core/i18n/i18n.ts b/src/elements/core/i18n/i18n.ts index 9772e244cb..d106732188 100644 --- a/src/elements/core/i18n/i18n.ts +++ b/src/elements/core/i18n/i18n.ts @@ -719,3 +719,11 @@ export const i18nSelectedPage = (pageNumber: number): Record => fr: `Page ${pageNumber} sélectionnée.`, it: `Pagina ${pageNumber} selezionata.`, }); + +// Usage example is "Remove ${chip label}“ +export const i18nChipDelete: Record = { + de: 'TODO', + en: 'Remove', + fr: 'TODO', + it: 'Rimuovi', +}; diff --git a/src/elements/core/styles/core.scss b/src/elements/core/styles/core.scss index 6f26425cd5..b4e6e40fae 100644 --- a/src/elements/core/styles/core.scss +++ b/src/elements/core/styles/core.scss @@ -2,11 +2,12 @@ sbb-css-tokens; @use './core/mediaqueries'; @use './core/functions'; -@use './mixins/font-face'; @use './mixins/a11y'; +@use './mixins/font-face'; +@use './mixins/helpers'; +@use './mixins/inputs'; @use './mixins/scrollbar'; @use './mixins/typo'; -@use './mixins/helpers'; @include helpers.box-sizing; @@ -68,28 +69,79 @@ html { @include typo.placeholder; } -// TODO: Remove if webkit bug is resolved: https://bugs.webkit.org/show_bug.cgi?id=223814 sbb-form-field { - :where(input, textarea):disabled::placeholder { - color: var(--sbb-color-granite); - -webkit-text-fill-color: var(--sbb-color-granite); + :where(input, select, textarea, sbb-select) { + @include typo.text-m--regular; + @include helpers.ellipsis; + @include inputs.input-reset; + + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize CSS of several frameworks. + outline: none !important; + overflow: var(--sbb-form-field-overflow); + width: 100%; + box-sizing: border-box; + color: var(--sbb-form-field-text-color); + + // Fill color needed for Safari + -webkit-text-fill-color: var(--sbb-form-field-text-color); + opacity: 1; + background-color: transparent; + + // To be more specific than the styles in normalize.scss we need to use !important + // TODO: Find a better solution + font-size: var(--sbb-form-field-input-text-size) !important; + font-family: var(--sbb-typo-font-family) !important; + line-height: var(--sbb-typo-line-height-body-text) !important; + + &::placeholder { + @include typo.placeholder; + } } - &[floating-label] :where(input, textarea)::placeholder { - color: transparent; - -webkit-text-fill-color: transparent; + &[floating-label] :where(input, select, textarea, sbb-select)::placeholder { + color: transparent !important; + -webkit-text-fill-color: transparent !important; @include a11y.if-forced-colors { - color: Canvas; - -webkit-text-fill-color: Canvas; + color: Canvas !important; + -webkit-text-fill-color: Canvas !important; } } - textarea { + &:not([floating-label]) :where(input, select, textarea, sbb-select):disabled::placeholder { + color: var(--sbb-color-granite); + -webkit-text-fill-color: var(--sbb-color-granite); + } + + // Fix positioning issue for select which occurs in Safari + :where(select) { + vertical-align: middle; + } + + :where(select, sbb-select) { + padding-inline-end: var(--sbb-form-field-select-inline-padding-end); + } + + :where(textarea) { @include scrollbar.scrollbar; + + & { + position: relative; + resize: none; + + // White-space break needed for Firefox + white-space: break-spaces; + overflow-y: auto; + min-height: calc((var(--sbb-typo-line-height-body-text) * 1em)); + } + } + + &[size='l'] :where(textarea) { + padding-block-end: #{functions.px-to-rem-build(5.5)}; } - &[negative] textarea { + &[negative] :where(textarea) { @include scrollbar.scrollbar($negative: true); } } diff --git a/src/elements/form-field/form-field/form-field.scss b/src/elements/form-field/form-field/form-field.scss index b19220bfc8..645a29b1bc 100644 --- a/src/elements/form-field/form-field/form-field.scss +++ b/src/elements/form-field/form-field/form-field.scss @@ -403,83 +403,6 @@ } } -// Input - -.sbb-form-field__input ::slotted(:where(input, select, textarea, sbb-select)) { - @include sbb.text-m--regular; - @include sbb.ellipsis; - @include sbb.input-reset; - - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize CSS of several frameworks. - outline: none !important; - overflow: var(--sbb-form-field-overflow); - width: 100%; - box-sizing: border-box; - color: var(--sbb-form-field-text-color); - - // Fill color needed for Safari - -webkit-text-fill-color: var(--sbb-form-field-text-color); - opacity: 1; - background-color: transparent; - - // To be more specific than the styles in normalize.scss we need to use !important - // TODO: Find a better solution - font-size: var(--sbb-form-field-input-text-size) !important; - font-family: var(--sbb-typo-font-family) !important; - line-height: var(--sbb-typo-line-height-body-text) !important; - - &::placeholder { - @include sbb.placeholder; - - :host([floating-label]) & { - color: transparent !important; - -webkit-text-fill-color: transparent !important; - - @include sbb.if-forced-colors { - color: Canvas !important; - -webkit-text-fill-color: Canvas !important; - } - } - - :host([data-disabled]:not([floating-label])) & { - color: var(--sbb-color-granite); - -webkit-text-fill-color: var(--sbb-color-granite); - } - } - - // Fix positioning issue for select which occurs in Safari - :host([data-input-type='select']) & { - vertical-align: middle; - } -} - -.sbb-form-field__input ::slotted(:where(select, sbb-select)) { - padding-inline-end: var(--sbb-form-field-select-inline-padding-end); -} - -.sbb-form-field__input ::slotted(textarea) { - @include sbb.scrollbar; - - & { - position: relative; - resize: none; - - // White-space break needed for Firefox - white-space: break-spaces; - overflow-y: auto; - min-height: calc((var(--sbb-typo-line-height-body-text) * 1em)); - } - - :host([size='l']) & { - padding-block-end: #{sbb.px-to-rem-build(5.5)}; - } - - :host([negative]) & { - @include sbb.scrollbar($negative: true); - } -} - .sbb-form-field__error { display: flex; min-height: var(--sbb-form-field-error-min-height); diff --git a/src/elements/form-field/form-field/form-field.ts b/src/elements/form-field/form-field/form-field.ts index c1d3762236..07c95ebd17 100644 --- a/src/elements/form-field/form-field/form-field.ts +++ b/src/elements/form-field/form-field/form-field.ts @@ -203,10 +203,12 @@ class SbbFormFieldElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement) /** * It is used internally to assign the attributes of `` to `_id` and `_input` and to observe the native readonly and disabled attributes. */ - private _onSlotInputChange(event: Event): void { - const newInput = (event.target as HTMLSlotElement) - .assignedElements() - .find((e): e is HTMLElement => this._supportedInputElements.includes(e.localName)); + private _onSlotInputChange(): void { + // Find the slotted 'supportedInputElement', even if it's nested + const newInput = this.querySelector( + `:not(slot):where(${this._supportedInputElements.join(',')})`, + ) as HTMLElement | undefined; + this._assignSlots(); if (this._input && this._input.localName === 'input' && newInput !== this._input) { @@ -494,7 +496,7 @@ class SbbFormFieldElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement) private _syncNegative(): void { this.querySelectorAll?.( - 'sbb-form-error,sbb-mini-button,sbb-popover-trigger,sbb-form-field-clear,sbb-datepicker-next-day,sbb-datepicker-previous-day,sbb-datepicker-toggle,sbb-select,sbb-autocomplete,sbb-autocomplete-grid', + 'sbb-form-error,sbb-mini-button,sbb-popover-trigger,sbb-form-field-clear,sbb-datepicker-next-day,sbb-datepicker-previous-day,sbb-datepicker-toggle,sbb-select,sbb-autocomplete,sbb-autocomplete-grid,sbb-chip-group', ).forEach((element) => element.toggleAttribute('negative', this.negative)); }