From f884bc89437e251286e9c78ff6083ef213b5634e Mon Sep 17 00:00:00 2001 From: Raouf Date: Tue, 23 Jun 2026 17:43:04 +0200 Subject: [PATCH 01/11] feat(pie-switch): DSW-4019 remove unnessary prop and bind checked correctly --- .changeset/small-mugs-mix.md | 6 ++ .../stories/pie-switch.stories.ts | 4 +- .../testing/pie-switch.test.stories.ts | 29 ++++++- packages/components/pie-switch/README.md | 3 +- packages/components/pie-switch/src/defs.ts | 8 +- packages/components/pie-switch/src/index.ts | 44 +++++----- .../components/pie-switch/src/switch.scss | 6 -- .../test/component/pie-switch.spec.ts | 84 ++++++++++--------- .../test/helpers/page-objects/selectors.ts | 4 - 9 files changed, 111 insertions(+), 77 deletions(-) create mode 100644 .changeset/small-mugs-mix.md diff --git a/.changeset/small-mugs-mix.md b/.changeset/small-mugs-mix.md new file mode 100644 index 0000000000..7b1124f79d --- /dev/null +++ b/.changeset/small-mugs-mix.md @@ -0,0 +1,6 @@ +--- +"@justeattakeaway/pie-switch": major +--- + +[Removed] - Removed `aria.describedBy` support from `pie-switch`; it no longer renders an internal description element or maps it to `aria-describedby`. +[Added] - Added `defaultChecked` support so `pie-switch` resets correctly with forms via `formResetCallback`. diff --git a/apps/pie-storybook/stories/pie-switch.stories.ts b/apps/pie-storybook/stories/pie-switch.stories.ts index 642538e973..af7cf3605d 100644 --- a/apps/pie-storybook/stories/pie-switch.stories.ts +++ b/apps/pie-storybook/stories/pie-switch.stories.ts @@ -16,7 +16,6 @@ const defaultArgs: SwitchProps = { label: 'Label', aria: { label: 'switch label', - describedBy: 'switch description', }, name: 'switch', value: 'switchValue', @@ -107,6 +106,7 @@ const Template : TemplateFunction = (props) => { const { aria, checked, + defaultChecked, disabled, label, labelPlacement, @@ -125,6 +125,7 @@ const Template : TemplateFunction = (props) => { .aria="${aria}" ?required="${required}" ?checked="${checked}" + ?defaultChecked="${defaultChecked}" ?disabled="${disabled}" @change="${changeAction}"> `; @@ -222,7 +223,6 @@ const MultiSwitchFormTemplate: TemplateFunction = (props: SwitchPro name: 'onion', aria: { label: 'a custom label for the onion switch', - describedBy: 'a custom description for the onion switch', }, value: 'onion_yes', checked: false, diff --git a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts index 5c0256dd3d..e44cc9de24 100644 --- a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts +++ b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts @@ -2,6 +2,7 @@ import { html, nothing } from 'lit'; import { type Meta } from '@storybook/web-components'; import '@justeattakeaway/pie-webc/components/switch'; +import '@justeattakeaway/pie-webc/components/text-input'; import { type SwitchProps, labelPlacements, defaultProps } from '@justeattakeaway/pie-webc/components/switch'; import '@justeattakeaway/pie-icons-webc/dist/IconCheck.js'; @@ -16,7 +17,6 @@ const defaultArgs: SwitchProps = { label: 'Label', aria: { label: 'switch label', - describedBy: 'switch description', }, name: 'switch', value: 'switchValue', @@ -126,6 +126,7 @@ const Template : TemplateFunction = (props) => { const { aria, checked, + defaultChecked, disabled, label, labelPlacement, @@ -144,6 +145,7 @@ const Template : TemplateFunction = (props) => { .aria="${aria}" ?required="${required}" ?checked="${checked}" + ?defaultChecked="${defaultChecked}" ?disabled="${disabled}" @change="${changeAction}"> `; @@ -183,6 +185,7 @@ const ExternalLabelsTemplate: TemplateFunction = (props: SwitchProp name="${props.name || nothing}" value="${props.value || nothing}" ?checked="${props.checked}" + ?defaultChecked="${props.defaultChecked}" @change="${changeAction}"> @@ -195,6 +198,7 @@ const ExternalLabelsTemplate: TemplateFunction = (props: SwitchProp data-test-id="external-switch-wrapping" name="${props.name || nothing}" value="${props.value || nothing}" + ?defaultChecked="${props.defaultChecked}" @change="${changeAction}"> @@ -208,8 +212,31 @@ const ExternalLabelsTemplate: TemplateFunction = (props: SwitchProp data-test-id="external-switch-multi" name="${props.name || nothing}" value="${props.value || nothing}" + ?defaultChecked="${props.defaultChecked}" @change="${changeAction}"> + +
+ + + + + +
+ + `; const createSwitchStory = createStory(Template, defaultArgs); diff --git a/packages/components/pie-switch/README.md b/packages/components/pie-switch/README.md index bdec80dfe0..44a5771605 100644 --- a/packages/components/pie-switch/README.md +++ b/packages/components/pie-switch/README.md @@ -36,11 +36,12 @@ Ideally, you should install the component using the **`@justeattakeaway/pie-webc | Prop | Options | Description | Default | |------------------|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|-------------| | `checked` | `true`, `false` | Same as the HTML `checked` attribute; indicates whether the switch is on or off. | `false` | +| `defaultChecked` | `true`, `false` | Same as the HTML `checked` attribute default value; used when resetting the parent form. | `false` | | `disabled` | `true`, `false` | Same as the HTML `disabled` attribute; indicates whether the switch is disabled or not. | `false` | | `required` | `true`, `false` | Same as the HTML `required` attribute; indicates whether the switch must be turned on when submitted as part of a form. | `false` | | `label` | — | The label text for the switch. | — | | `labelPlacement` | `"leading"`, `"trailing"` | Set the placement of the label. Leading will have the label before the switch, taking writing direction into account. | `"leading"` | -| `aria` | `{ label?: string, describedBy?: string }` | The ARIA labels used for the switch. | `undefined` | +| `aria` | `{ label?: string }` | The ARIA labels used for the switch. | `undefined` | | `name` | — | Indicates the name of the switch (for use with forms). | — | | `value` | — | Indicates the value of the switch (for use with forms). | `"on"` | diff --git a/packages/components/pie-switch/src/defs.ts b/packages/components/pie-switch/src/defs.ts index 5173bfb40d..36abfda6fe 100644 --- a/packages/components/pie-switch/src/defs.ts +++ b/packages/components/pie-switch/src/defs.ts @@ -3,8 +3,7 @@ import { type ComponentDefaultProps } from '@justeattakeaway/pie-webc-core'; export const labelPlacements = ['leading', 'trailing'] as const; type AriaProps = { - label?: string, - describedBy?: string + label?: string }; export interface SwitchProps { @@ -16,6 +15,10 @@ export interface SwitchProps { * Same as the HTML checked attribute - indicates whether the switch is on or off */ checked?: boolean; + /** + * Same as the HTML checked attribute default value - used when the parent form is reset + */ + defaultChecked?: boolean; /** * Same as the HTML required attribute - indicates whether the switch must be turned or not */ @@ -53,6 +56,7 @@ export type DefaultProps = ComponentDefaultProps`; } - private renderAriaDescription () { - if (!this.aria?.describedBy) { - return nothing; - } - - // we apply aria-hidden to the element containing the description because it prevent some screen readers such as Apple VoiceOver from announcing the description once - // on the input and again separately. The description is still announced once, when the input is focused/selected. - return html` - `; - } - render () { const { label, @@ -254,17 +258,15 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) type="checkbox" class="c-switch-input" .required=${required} - .checked="${checked}" + .checked="${live(checked)}" .disabled="${disabled}" @change="${this.handleChange}" - aria-label="${ifDefined(aria?.label || label)}" - aria-describedby="${aria?.describedBy ? 'switch-description' : nothing}"> + aria-label="${ifDefined(aria?.label || label)}">
${checked ? html`` : nothing}
${this.renderSwitchLabel('trailing')} - ${this.renderAriaDescription()} `; } } diff --git a/packages/components/pie-switch/src/switch.scss b/packages/components/pie-switch/src/switch.scss index 7f6e4ec865..fca899fa2e 100644 --- a/packages/components/pie-switch/src/switch.scss +++ b/packages/components/pie-switch/src/switch.scss @@ -116,12 +116,6 @@ color: var(--dt-color-content-default-solid); } -// The description is only required for screen readers so we need to visually hide the description -.c-switch-description { - @include p.visually-hidden; -} - - .c-switch-wrapper--allow-animation { /* stylelint-disable-next-line no-descending-specificity */ .c-switch { diff --git a/packages/components/pie-switch/test/component/pie-switch.spec.ts b/packages/components/pie-switch/test/component/pie-switch.spec.ts index 367223f3c9..87fe6385b7 100644 --- a/packages/components/pie-switch/test/component/pie-switch.spec.ts +++ b/packages/components/pie-switch/test/component/pie-switch.spec.ts @@ -249,46 +249,6 @@ test.describe('Component: `Pie switch`', () => { await expect(switchInput).toHaveAttribute('aria-label', 'Aria label'); }); }); - - test.describe('when describedBy exist', () => { - const ariaDescriptionText = 'Aria description'; - - test('should render the component with the correct description id', async ({ page }) => { - // Arrange - const switchPage = new BasePage(page, 'switch'); - const props: Partial = { - aria: { - describedBy: ariaDescriptionText, - }, - }; - - await switchPage.load({ ...props }); - - // Act - const switchInput = page.getByTestId(pieSwitch.selectors.input.dataTestId); - - // Assert - await expect(switchInput).toHaveAttribute('aria-describedBy', 'switch-description'); - }); - - test('should render a description element with the correct text', async ({ page }) => { - // Arrange - const switchPage = new BasePage(page, 'switch'); - const props: Partial = { - aria: { - describedBy: ariaDescriptionText, - }, - }; - - await switchPage.load({ ...props }); - - // Act - const ariaDescriptionElement = page.getByTestId(pieSwitch.selectors.ariaDescription.dataTestId); - - // Assert - await expect(ariaDescriptionElement).toContainText(ariaDescriptionText); - }); - }); }); test.describe('Props: `LabelProps`', () => { @@ -391,5 +351,49 @@ test.describe('Component: `Pie switch`', () => { expect(formDataObj.switch).toBeUndefined(); }); + + test('should reset checked state to defaultChecked true when form is reset', async ({ page }) => { + // Arrange + const switchPage = new BasePage(page, 'switch--test-form-integration'); + const props: Partial = { + checked: false, + defaultChecked: true, + }; + await switchPage.load({ ...props }); + + const switchEl = page.locator('#pie-switch'); + await expect(switchEl).not.toHaveAttribute('checked'); + + // Act + await page.$eval('#testForm', (form) => { + (form as HTMLFormElement).reset(); + }); + + // Assert + await expect(switchEl).toHaveAttribute('checked', ''); + }); + + test('should reset checked state to defaultChecked false when form is reset', async ({ page }) => { + // Arrange + const switchPage = new BasePage(page, 'switch--test-form-integration'); + const props: Partial = { + checked: false, + defaultChecked: false, + }; + await switchPage.load({ ...props }); + + const switchEl = page.locator('#pie-switch'); + + // Act + await page.getByTestId(pieSwitch.selectors.container.dataTestId).click(); + await expect(switchEl).toHaveAttribute('checked', ''); + + await page.$eval('#testForm', (form) => { + (form as HTMLFormElement).reset(); + }); + + // Assert + await expect(switchEl).not.toHaveAttribute('checked'); + }); }); }); diff --git a/packages/components/pie-switch/test/helpers/page-objects/selectors.ts b/packages/components/pie-switch/test/helpers/page-objects/selectors.ts index 64479d3e77..bd6713e9bb 100644 --- a/packages/components/pie-switch/test/helpers/page-objects/selectors.ts +++ b/packages/components/pie-switch/test/helpers/page-objects/selectors.ts @@ -18,10 +18,6 @@ const pieSwitch = { dataTestId: 'switch-label-trailing', }, }, - ariaDescription: { - description: 'The selector for the switch aria description', - dataTestId: 'switch-description', - }, }, }; From 6d7628e112cc414b1b4da8fc0d3157679a88ff28 Mon Sep 17 00:00:00 2001 From: Raouf Date: Wed, 24 Jun 2026 12:01:47 +0200 Subject: [PATCH 02/11] fix(pie-switch): DSW-4019 revert some changes --- .changeset/small-mugs-mix.md | 2 +- .../testing/pie-switch.test.stories.ts | 22 ------------------- packages/components/pie-switch/src/index.ts | 14 +++++------- .../components/pie-switch/src/switch.scss | 8 +++++++ 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/.changeset/small-mugs-mix.md b/.changeset/small-mugs-mix.md index 7b1124f79d..a00f9fab7f 100644 --- a/.changeset/small-mugs-mix.md +++ b/.changeset/small-mugs-mix.md @@ -1,5 +1,5 @@ --- -"@justeattakeaway/pie-switch": major +"@justeattakeaway/pie-switch": minor --- [Removed] - Removed `aria.describedBy` support from `pie-switch`; it no longer renders an internal description element or maps it to `aria-describedby`. diff --git a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts index e44cc9de24..b9e85afa84 100644 --- a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts +++ b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts @@ -2,7 +2,6 @@ import { html, nothing } from 'lit'; import { type Meta } from '@storybook/web-components'; import '@justeattakeaway/pie-webc/components/switch'; -import '@justeattakeaway/pie-webc/components/text-input'; import { type SwitchProps, labelPlacements, defaultProps } from '@justeattakeaway/pie-webc/components/switch'; import '@justeattakeaway/pie-icons-webc/dist/IconCheck.js'; @@ -216,27 +215,6 @@ const ExternalLabelsTemplate: TemplateFunction = (props: SwitchProp @change="${changeAction}"> -
- - - - - -
- - `; const createSwitchStory = createStory(Template, defaultArgs); diff --git a/packages/components/pie-switch/src/index.ts b/packages/components/pie-switch/src/index.ts index 5eac22785a..b84420dff8 100644 --- a/packages/components/pie-switch/src/index.ts +++ b/packages/components/pie-switch/src/index.ts @@ -205,15 +205,12 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) } /** - * If a label is provided, renders it if `labelPlacement` matches the given position. - * If no label is provided, or `labelPlacement` does not match the given position, nothing is rendered. - * - * @private + * Renders the visible switch label, or nothing when no label is set. */ - private renderSwitchLabel (placement : SwitchProps['labelPlacement']) { + private renderSwitchLabel () { const { label, labelPlacement } = this; - if (!label || labelPlacement !== placement) { + if (!label) { return nothing; } @@ -231,6 +228,7 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) render () { const { label, + labelPlacement, aria, checked, disabled, @@ -241,13 +239,13 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) const classes = { 'c-switch-wrapper': true, 'c-switch-wrapper--allow-animation': _isAnimationAllowed, + [`c-switch-wrapper--label-${labelPlacement}`]: true, }; return html` `; } } diff --git a/packages/components/pie-switch/src/switch.scss b/packages/components/pie-switch/src/switch.scss index fca899fa2e..626053fa0b 100644 --- a/packages/components/pie-switch/src/switch.scss +++ b/packages/components/pie-switch/src/switch.scss @@ -116,6 +116,14 @@ color: var(--dt-color-content-default-solid); } +.c-switch-wrapper--label-leading { + flex-direction: row-reverse; +} + +.c-switch-wrapper--label-trailing { + flex-direction: row; +} + .c-switch-wrapper--allow-animation { /* stylelint-disable-next-line no-descending-specificity */ .c-switch { From f36b91e99b05ddd89c657e2809890ac09fce87ef Mon Sep 17 00:00:00 2001 From: Raouf Date: Wed, 24 Jun 2026 12:06:05 +0200 Subject: [PATCH 03/11] fix(pie-switch): DSW-4019 update docs --- apps/pie-storybook/stories/pie-switch.stories.ts | 7 +++++++ .../stories/testing/pie-switch.test.stories.ts | 6 ++++++ packages/components/pie-switch/README.md | 2 +- packages/components/pie-switch/src/defs.ts | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/pie-storybook/stories/pie-switch.stories.ts b/apps/pie-storybook/stories/pie-switch.stories.ts index af7cf3605d..650a2f89d8 100644 --- a/apps/pie-storybook/stories/pie-switch.stories.ts +++ b/apps/pie-storybook/stories/pie-switch.stories.ts @@ -32,6 +32,13 @@ const switchStoryMeta: SwitchStoryMeta = { summary: defaultProps.checked, }, }, + defaultChecked: { + description: 'Same as the HTML checked attribute default value - indicates whether the switch is checked by default and is used when resetting the parent form', + control: 'boolean', + defaultValue: { + summary: defaultProps.defaultChecked, + }, + }, disabled: { description: 'Same as the HTML disabled attribute - indicates whether the switch is disabled or not', control: 'boolean', diff --git a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts index b9e85afa84..38cad5e98a 100644 --- a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts +++ b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts @@ -31,6 +31,12 @@ const switchStoryMeta: SwitchStoryMeta = { summary: defaultProps.checked, }, }, + defaultChecked: { + control: 'boolean', + defaultValue: { + summary: defaultProps.defaultChecked, + }, + }, disabled: { control: 'boolean', defaultValue: { diff --git a/packages/components/pie-switch/README.md b/packages/components/pie-switch/README.md index 44a5771605..f444001790 100644 --- a/packages/components/pie-switch/README.md +++ b/packages/components/pie-switch/README.md @@ -36,7 +36,7 @@ Ideally, you should install the component using the **`@justeattakeaway/pie-webc | Prop | Options | Description | Default | |------------------|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|-------------| | `checked` | `true`, `false` | Same as the HTML `checked` attribute; indicates whether the switch is on or off. | `false` | -| `defaultChecked` | `true`, `false` | Same as the HTML `checked` attribute default value; used when resetting the parent form. | `false` | +| `defaultChecked` | `true`, `false` | Same as the HTML `checked` attribute default value; indicates whether the switch is checked by default and is used when resetting the parent form. | `false` | | `disabled` | `true`, `false` | Same as the HTML `disabled` attribute; indicates whether the switch is disabled or not. | `false` | | `required` | `true`, `false` | Same as the HTML `required` attribute; indicates whether the switch must be turned on when submitted as part of a form. | `false` | | `label` | — | The label text for the switch. | — | diff --git a/packages/components/pie-switch/src/defs.ts b/packages/components/pie-switch/src/defs.ts index 36abfda6fe..3707897cf9 100644 --- a/packages/components/pie-switch/src/defs.ts +++ b/packages/components/pie-switch/src/defs.ts @@ -16,7 +16,7 @@ export interface SwitchProps { */ checked?: boolean; /** - * Same as the HTML checked attribute default value - used when the parent form is reset + * Same as the HTML checked attribute default value - indicates whether the switch is checked by default and is used when resetting the parent form */ defaultChecked?: boolean; /** From ce2e7ed2ca18de6b3611f1ca9194196a3c58bca0 Mon Sep 17 00:00:00 2001 From: Raouf Date: Wed, 24 Jun 2026 16:59:04 +0200 Subject: [PATCH 04/11] fix(pie-switch): DSW-4019 improve screen readers output --- .../testing/pie-switch.test.stories.ts | 3 --- packages/components/pie-switch/src/index.ts | 23 +++++++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts index 38cad5e98a..23da91a69d 100644 --- a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts +++ b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts @@ -14,9 +14,6 @@ type SwitchStoryMeta = Meta; const defaultArgs: SwitchProps = { ...defaultProps, label: 'Label', - aria: { - label: 'switch label', - }, name: 'switch', value: 'switchValue', }; diff --git a/packages/components/pie-switch/src/index.ts b/packages/components/pie-switch/src/index.ts index b84420dff8..1d3bc5fa55 100644 --- a/packages/components/pie-switch/src/index.ts +++ b/packages/components/pie-switch/src/index.ts @@ -1,6 +1,7 @@ import { html, unsafeCSS, nothing, } from 'lit'; +import { html as staticHtml, unsafeStatic } from 'lit/static-html.js'; import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -63,7 +64,7 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) @query('input[type="checkbox"]') private input!: HTMLInputElement; - @query('label') + @query('label, input[type="checkbox"]') public focusTarget!: HTMLElement; private _abortController!: AbortController; @@ -82,6 +83,7 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) connectedCallback (): void { super.connectedCallback(); + this.setAttribute('role', 'presentation'); this._abortController = new AbortController(); const { signal } = this._abortController; @@ -207,6 +209,15 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) /** * Renders the visible switch label, or nothing when no label is set. */ + private getExternalLabelText (): string | undefined { + const text = Array.from(this._internals?.labels ?? []) + .map((el) => el.textContent?.trim()) + .filter(Boolean) + .join(' '); + + return text || undefined; + } + private renderSwitchLabel () { const { label, labelPlacement } = this; @@ -242,8 +253,10 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) [`c-switch-wrapper--label-${labelPlacement}`]: true, }; - return html` - `; + `; } } From a50c7b8d4991036ef01a86d6e7b818e40e771d46 Mon Sep 17 00:00:00 2001 From: Raouf Date: Wed, 24 Jun 2026 18:01:20 +0200 Subject: [PATCH 05/11] fix(pie-switch): DSW-4019 update stories --- .../testing/pie-switch.test.stories.ts | 76 +++++++++---------- .../test/component/pie-switch.spec.ts | 28 +++---- 2 files changed, 51 insertions(+), 53 deletions(-) diff --git a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts index 23da91a69d..905e02cf4d 100644 --- a/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts +++ b/apps/pie-storybook/stories/testing/pie-switch.test.stories.ts @@ -91,37 +91,50 @@ const changeAction = () => console.info(EXPECTED_EVENT_MESSAGE); const submitAction = (event: Event) => { event.preventDefault(); // Prevent the actual submission - const formLog = document.querySelector('#formLog') as HTMLElement; + const form = event.currentTarget as HTMLFormElement; + const formLog = form.parentElement?.querySelector('#formLog') as HTMLElement | null; + const output = form.parentElement?.querySelector('#formDataOutput') as HTMLDivElement | null; // Display a success message to the user when they submit the form - formLog.innerHTML = 'Form submitted!'; - formLog.style.display = 'block'; + if (formLog) { + formLog.innerHTML = 'Form submitted!'; + formLog.style.display = 'block'; + } + + // Serialize form data into an object so tests can assert submitted payload. + const formData = new FormData(form); + const formDataObj: Record = {}; + + formData.forEach((value, key) => { + formDataObj[key] = value; + }); + + if (output) { + output.innerText = JSON.stringify(formDataObj); + } // Reset the success message after roughly 8 seconds setTimeout(() => { - formLog.innerHTML = ''; - formLog.style.display = 'none'; + if (formLog) { + formLog.innerHTML = ''; + formLog.style.display = 'none'; + } }, 8000); }; -const submitActionTestForm = (event: Event) => { - event.preventDefault(); // Prevent the actual submission - - const form = document.querySelector('#testForm') as HTMLFormElement; - - // Serialize form data - const formData = new FormData(form); - const formDataObj: Record = {}; +const resetAction = (event: Event) => { + const form = event.currentTarget as HTMLFormElement; + const formLog = form.parentElement?.querySelector('#formLog') as HTMLElement | null; + const output = form.parentElement?.querySelector('#formDataOutput') as HTMLDivElement | null; - // Serialize form data into an object - formData.forEach((value, key) => { formDataObj[key] = value; }); + if (formLog) { + formLog.innerHTML = ''; + formLog.style.display = 'none'; + } - // Append serialized form data as JSON to a hidden element - const dataHolder = document.createElement('div'); - dataHolder.id = 'formDataJson'; - dataHolder.textContent = JSON.stringify(formDataObj); - dataHolder.style.display = 'none'; - document.body.appendChild(dataHolder); + if (output) { + output.innerText = ''; + } }; const Template : TemplateFunction = (props) => { @@ -155,28 +168,17 @@ const Template : TemplateFunction = (props) => { const FormTemplate: TemplateFunction = (props: SwitchProps) => html` -
- -
- ${Template({ - ...props, -})} -
- -
-`; - -const TestFormTemplate: TemplateFunction = (props: SwitchProps) => html` - -
+
${Template({ ...props, })}
+
+
`; const ExternalLabelsTemplate: TemplateFunction = (props: SwitchProps) => html` @@ -224,8 +226,6 @@ const createSwitchStory = createStory(Template, defaultArgs); const createSwitchStoryWithForm = createStory(FormTemplate, defaultArgs); -const createSwitchTestStoryWithForm = createStory(TestFormTemplate, defaultArgs); - const createSwitchStoryWithExternalLabels = createStory(ExternalLabelsTemplate, defaultArgs); const formIntegrationOnly = { @@ -244,8 +244,6 @@ export const Default = createSwitchStory({}, { export const FormIntegration = createSwitchStoryWithForm(); -export const TestFormIntegration = createSwitchTestStoryWithForm(); - export const ExternalLabels = createSwitchStoryWithExternalLabels(); const baseSharedPropsMatrix: Partial> = { diff --git a/packages/components/pie-switch/test/component/pie-switch.spec.ts b/packages/components/pie-switch/test/component/pie-switch.spec.ts index 87fe6385b7..8c9208581a 100644 --- a/packages/components/pie-switch/test/component/pie-switch.spec.ts +++ b/packages/components/pie-switch/test/component/pie-switch.spec.ts @@ -277,7 +277,7 @@ test.describe('Component: `Pie switch`', () => { const switchName = 'switch'; const switchValue = 'switchValue'; - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { required: true, }; @@ -290,8 +290,8 @@ test.describe('Component: `Pie switch`', () => { await page.click('#submitButton'); // Assert - const formDataJson = await page.$eval('#formDataJson', (el) => el.textContent); - const formDataObj = JSON.parse(formDataJson || '{}'); + const formDataOutput = await page.$eval('#formDataOutput', (el) => el.textContent); + const formDataObj = JSON.parse(formDataOutput || '{}'); // Check if the switch value is in the form data expect(switchName in formDataObj).toBe(true); @@ -300,7 +300,7 @@ test.describe('Component: `Pie switch`', () => { test('form should be invalid and not submit if the switch is required but not set', async ({ page }) => { // // Arrange - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { required: true, }; @@ -311,13 +311,13 @@ test.describe('Component: `Pie switch`', () => { await page.click('#submitButton'); // Assert - const formDataJsonElement = await page.$('#formDataJson'); - expect(formDataJsonElement).toBeNull(); + const formDataOutput = await page.$eval('#formDataOutput', (el) => el.textContent?.trim() ?? ''); + expect(formDataOutput).toBe(''); }); test('should not be included in the submitted form data if disabled and checked', async ({ page }) => { // Arrange - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { checked: true, disabled: true, @@ -328,15 +328,15 @@ test.describe('Component: `Pie switch`', () => { await page.click('#submitButton'); // Assert - const formDataJson = await page.$eval('#formDataJson', (el) => el.textContent); - const formDataObj = JSON.parse(formDataJson || '{}'); + const formDataOutput = await page.$eval('#formDataOutput', (el) => el.textContent); + const formDataObj = JSON.parse(formDataOutput || '{}'); expect(formDataObj.switch).toBeUndefined(); }); test('should not be included in the submitted form data if disabled and not checked', async ({ page }) => { // Arrange - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { disabled: true, }; @@ -346,15 +346,15 @@ test.describe('Component: `Pie switch`', () => { await page.click('#submitButton'); // Assert - const formDataJson = await page.$eval('#formDataJson', (el) => el.textContent); - const formDataObj = JSON.parse(formDataJson || '{}'); + const formDataOutput = await page.$eval('#formDataOutput', (el) => el.textContent); + const formDataObj = JSON.parse(formDataOutput || '{}'); expect(formDataObj.switch).toBeUndefined(); }); test('should reset checked state to defaultChecked true when form is reset', async ({ page }) => { // Arrange - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { checked: false, defaultChecked: true, @@ -375,7 +375,7 @@ test.describe('Component: `Pie switch`', () => { test('should reset checked state to defaultChecked false when form is reset', async ({ page }) => { // Arrange - const switchPage = new BasePage(page, 'switch--test-form-integration'); + const switchPage = new BasePage(page, 'switch--form-integration'); const props: Partial = { checked: false, defaultChecked: false, From 78774eb3c9624984c3c8851e92f841c631b02a47 Mon Sep 17 00:00:00 2001 From: Raouf Date: Wed, 24 Jun 2026 18:20:45 +0200 Subject: [PATCH 06/11] fix(pie-switch): DSW-4019 revert some changes --- .changeset/small-mugs-mix.md | 7 +++++-- packages/components/pie-switch/src/index.ts | 15 +-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/.changeset/small-mugs-mix.md b/.changeset/small-mugs-mix.md index a00f9fab7f..a075747c26 100644 --- a/.changeset/small-mugs-mix.md +++ b/.changeset/small-mugs-mix.md @@ -2,5 +2,8 @@ "@justeattakeaway/pie-switch": minor --- -[Removed] - Removed `aria.describedBy` support from `pie-switch`; it no longer renders an internal description element or maps it to `aria-describedby`. -[Added] - Added `defaultChecked` support so `pie-switch` resets correctly with forms via `formResetCallback`. +- [Removed] - (Breaking Change) `aria.describedby` support from pie-switch. It was redundant and misused as `aria-describedby` expects element IDs to reference existing descriptive elements, which isn’t feasible across Shadow DOM boundaries. It also conflicted with aria-label, internal labels, and external label handling. +- [Added] - Wrapped the checked value with the [lit live directive ](https://lit.dev/docs/api/directives/#live) to ensure the DOM and component state stay in sync. +- [Added] - Missing `defaultChecked` prop and form rest logic +- [Changed] - Refactored the toggle between leading and trailing labels to be handled via CSS. +- [Changed] - Updated the component to use a `
` as the parent wrapper when the label prop isn’t provided. This avoids nested `
${this.renderSwitchLabel()} + ${this.renderAriaDescription()} `; } } diff --git a/packages/components/pie-switch/src/switch.scss b/packages/components/pie-switch/src/switch.scss index 626053fa0b..365f9981ff 100644 --- a/packages/components/pie-switch/src/switch.scss +++ b/packages/components/pie-switch/src/switch.scss @@ -116,6 +116,11 @@ color: var(--dt-color-content-default-solid); } +// The description is only required for screen readers so we need to visually hide the description +.c-switch-description { + @include p.visually-hidden; +} + .c-switch-wrapper--label-leading { flex-direction: row-reverse; } diff --git a/packages/components/pie-switch/test/component/pie-switch.spec.ts b/packages/components/pie-switch/test/component/pie-switch.spec.ts index 8c9208581a..71aa51c7ef 100644 --- a/packages/components/pie-switch/test/component/pie-switch.spec.ts +++ b/packages/components/pie-switch/test/component/pie-switch.spec.ts @@ -249,6 +249,46 @@ test.describe('Component: `Pie switch`', () => { await expect(switchInput).toHaveAttribute('aria-label', 'Aria label'); }); }); + + test.describe('when describedBy exist', () => { + const ariaDescriptionText = 'Aria description'; + + test('should render the component with the correct description id', async ({ page }) => { + // Arrange + const switchPage = new BasePage(page, 'switch'); + const props: Partial = { + aria: { + describedBy: ariaDescriptionText, + }, + }; + + await switchPage.load({ ...props }); + + // Act + const switchInput = page.getByTestId(pieSwitch.selectors.input.dataTestId); + + // Assert + await expect(switchInput).toHaveAttribute('aria-describedby', 'switch-description'); + }); + + test('should render a description element with the correct text', async ({ page }) => { + // Arrange + const switchPage = new BasePage(page, 'switch'); + const props: Partial = { + aria: { + describedBy: ariaDescriptionText, + }, + }; + + await switchPage.load({ ...props }); + + // Act + const ariaDescriptionElement = page.getByTestId(pieSwitch.selectors.ariaDescription.dataTestId); + + // Assert + await expect(ariaDescriptionElement).toContainText(ariaDescriptionText); + }); + }); }); test.describe('Props: `LabelProps`', () => { diff --git a/packages/components/pie-switch/test/helpers/page-objects/selectors.ts b/packages/components/pie-switch/test/helpers/page-objects/selectors.ts index bd6713e9bb..64479d3e77 100644 --- a/packages/components/pie-switch/test/helpers/page-objects/selectors.ts +++ b/packages/components/pie-switch/test/helpers/page-objects/selectors.ts @@ -18,6 +18,10 @@ const pieSwitch = { dataTestId: 'switch-label-trailing', }, }, + ariaDescription: { + description: 'The selector for the switch aria description', + dataTestId: 'switch-description', + }, }, }; From 8b49a038b0de8fda821471b43982c40dced00559 Mon Sep 17 00:00:00 2001 From: Raouf Date: Fri, 26 Jun 2026 19:25:59 +0200 Subject: [PATCH 11/11] fix(pie-switch): DSW-4019 fix click --- packages/components/pie-switch/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/components/pie-switch/src/index.ts b/packages/components/pie-switch/src/index.ts index ff5e4fd35a..fa78b9384f 100644 --- a/packages/components/pie-switch/src/index.ts +++ b/packages/components/pie-switch/src/index.ts @@ -64,6 +64,9 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) @query('input[type="checkbox"]') private input!: HTMLInputElement; + @query('.c-switch') + private switchBody!: HTMLElement; + @query('label, input[type="checkbox"]') public focusTarget!: HTMLElement; @@ -98,7 +101,8 @@ export class PieSwitch extends FormControlMixin(DelegatesFocusMixin(PieElement)) // of the click was the host element itself (e.g., via an external label). // This ignores clicks bubbling up from the internal shadow DOM and prevents loops. // Also forward clicks from the visual switch body when no internal label exists. - if (source === this || (!this.label && source instanceof HTMLElement && source.closest('.c-switch'))) { + const isInsideSwitchBody = !this.label && event.composedPath().includes(this.switchBody); + if (source === this || isInsideSwitchBody) { this.input.click(); } }, { signal });