From fa2d00cc2a47cabbdb9d14ee781a86b5aca7839f Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 11 Aug 2025 13:59:49 +0200 Subject: [PATCH 1/4] refactor(checkbox): remove MDC dependency --- src/components/checkbox/checkbox.template.tsx | 3 -- src/components/checkbox/checkbox.tsx | 54 +++++++++---------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/components/checkbox/checkbox.template.tsx b/src/components/checkbox/checkbox.template.tsx index 3bd6a4d18a..40dde9bf6b 100644 --- a/src/components/checkbox/checkbox.template.tsx +++ b/src/components/checkbox/checkbox.template.tsx @@ -52,8 +52,6 @@ export const CheckboxTemplate: FunctionalComponent = ( return [
= ( > { - this.mdcCheckbox?.destroy(); - this.formField?.destroy(); - - const checkboxElement = this.getCheckboxElement(); - if (checkboxElement) { - checkboxElement.classList.remove( - cssClasses.ANIM_CHECKED_INDETERMINATE, - cssClasses.ANIM_CHECKED_UNCHECKED, - cssClasses.ANIM_INDETERMINATE_CHECKED, - cssClasses.ANIM_INDETERMINATE_UNCHECKED, - cssClasses.ANIM_UNCHECKED_CHECKED, - cssClasses.ANIM_UNCHECKED_INDETERMINATE - ); + const input = this.getCheckboxElement(); + if (input) { + delete input.dataset['indeterminate']; + input.indeterminate = false; } }; @@ -208,24 +200,26 @@ export class Checkbox { }; private initialize = () => { - const element = - this.limelCheckbox.shadowRoot.querySelector('.mdc-form-field'); - if (!element) { + const input = this.getCheckboxElement(); + if (!input) { return; } - this.formField = new MDCFormField(element); - this.mdcCheckbox = new MDCCheckbox(this.getCheckboxElement()); - this.formField.input = this.mdcCheckbox; + input.indeterminate = this.indeterminate; + input.checked = this.checked || this.indeterminate; }; private getCheckboxElement = () => { - return this.limelCheckbox.shadowRoot.querySelector('.mdc-checkbox'); + return this.limelCheckbox.shadowRoot.querySelector( + 'input[type="checkbox"]' + ) as HTMLInputElement; }; private onChange = (event: Event) => { event.stopPropagation(); - this.change.emit(this.mdcCheckbox.checked); + const input = event.currentTarget as HTMLInputElement; + const isChecked = input?.checked ?? this.checked; + this.change.emit(isChecked); this.modified = true; }; } From 1e53d9dd2e86955ee80c8fe8ba80a3c0eae1bcf8 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 12 Aug 2025 09:36:39 +0200 Subject: [PATCH 2/4] fix(checkbox): improve accessibility by better handling indeterminate state --- src/components/checkbox/checkbox.template.tsx | 6 ++++++ src/components/checkbox/checkbox.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/checkbox/checkbox.template.tsx b/src/components/checkbox/checkbox.template.tsx index 40dde9bf6b..8cfed4f8e0 100644 --- a/src/components/checkbox/checkbox.template.tsx +++ b/src/components/checkbox/checkbox.template.tsx @@ -47,6 +47,12 @@ export const CheckboxTemplate: FunctionalComponent = ( if (props.indeterminate) { inputProps['data-indeterminate'] = 'true'; + inputProps['aria-checked'] = 'mixed'; + } else { + inputProps['data-indeterminate'] = 'false'; + if (typeof props.checked === 'boolean') { + inputProps['aria-checked'] = String(props.checked); + } } return [ diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index 81dbb508a1..9ac1f5c6ff 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -123,7 +123,7 @@ export class Checkbox { return; } - input.checked = newValue; + input.checked = newValue || this.indeterminate; } @Watch('indeterminate') From 691d37d2860762cf408811cb219e2230a2cffe36 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 12 Aug 2025 09:55:06 +0200 Subject: [PATCH 3/4] fix(checkbox): prevent early lifecycle crashes `shadowRoot` is not available in `connectedCallback` (runs before first render). So we need to guard against it in `getCheckboxElement` being `null` to avoid runtime errors. --- src/components/checkbox/checkbox.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index 9ac1f5c6ff..16a5d5fb8f 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -209,10 +209,12 @@ export class Checkbox { input.checked = this.checked || this.indeterminate; }; - private getCheckboxElement = () => { - return this.limelCheckbox.shadowRoot.querySelector( - 'input[type="checkbox"]' - ) as HTMLInputElement; + private getCheckboxElement = (): HTMLInputElement | null => { + return ( + this.limelCheckbox?.shadowRoot?.querySelector( + 'input[type="checkbox"]' + ) || null + ); }; private onChange = (event: Event) => { From 7c684549e723c5c01cde164014745b23d2c3523a Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 12 Aug 2025 10:05:31 +0200 Subject: [PATCH 4/4] test(checkbox): add tests --- src/components/checkbox/checkbox.spec.ts | 126 +++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/components/checkbox/checkbox.spec.ts diff --git a/src/components/checkbox/checkbox.spec.ts b/src/components/checkbox/checkbox.spec.ts new file mode 100644 index 0000000000..80a290c5a2 --- /dev/null +++ b/src/components/checkbox/checkbox.spec.ts @@ -0,0 +1,126 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { Checkbox } from './checkbox'; + +describe('limel-checkbox (aria semantics)', () => { + async function setup(props: Partial = {}) { + const page = await newSpecPage({ + components: [Checkbox], + html: ``, + }); + const host = page.root as HTMLLimelCheckboxElement; + Object.assign(host, props); + await page.waitForChanges(); + const input: HTMLInputElement | null = host.shadowRoot?.querySelector( + 'input[type="checkbox"]' + ); + return { page, host, input }; + } + + it('sets aria-checked="false" when unchecked', async () => { + const { input } = await setup({ checked: false }); + expect(input?.getAttribute('aria-checked')).toBe('false'); + }); + + it('sets aria-checked="true" when checked', async () => { + const { host, page } = await setup({ checked: false }); + host.checked = true; + await page.waitForChanges(); + const input = host.shadowRoot?.querySelector('input[type="checkbox"]'); + expect(input?.getAttribute('aria-checked')).toBe('true'); + }); + + it('sets aria-checked="mixed" and checked property true when indeterminate', async () => { + const { host, page } = await setup({ checked: false }); + host.indeterminate = true; + await page.waitForChanges(); + const input = host.shadowRoot?.querySelector( + 'input[type="checkbox"]' + ) as HTMLInputElement; + expect(input.getAttribute('aria-checked')).toBe('mixed'); + // Visual hook: component forces input.checked when indeterminate for CSS + expect(input.checked).toBe(true); + expect(input.indeterminate).toBe(true); + }); + + it('returns to aria-checked="false" when indeterminate cleared and still unchecked', async () => { + const { host, page } = await setup({ + checked: false, + indeterminate: true, + }); + host.indeterminate = false; + await page.waitForChanges(); + const input = host.shadowRoot?.querySelector('input[type="checkbox"]'); + expect(input?.getAttribute('aria-checked')).toBe('false'); + }); + + it('emits change event with correct detail when toggled', async () => { + const { host, input, page } = await setup({ checked: false }); + const handler = jest.fn(); + host.addEventListener('change', (e: CustomEvent) => + handler(e.detail) + ); + (input as HTMLInputElement).checked = true; + input?.dispatchEvent( + new Event('change', { bubbles: true, composed: true }) + ); + await page.waitForChanges(); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(true); + }); + + it('renders dynamic-label instead of native input when readonly', async () => { + const { host } = await setup({ readonly: true, checked: true }); + const input = host.shadowRoot?.querySelector('input[type="checkbox"]'); + const dyn = host.shadowRoot?.querySelector('limel-dynamic-label'); + expect(input).toBeNull(); + expect(dyn).not.toBeNull(); + }); + + it('does not emit change when disabled', async () => { + const { host, input } = await setup({ disabled: true, checked: false }); + const handler = jest.fn(); + host.addEventListener('change', (e: CustomEvent) => + handler(e.detail) + ); + // Even if we simulate a change event, component logic should still emit + // because we currently don't guard in handler, but native input wouldn't fire in real UI. + // This test documents current behavior; adjust if handler changes. + (input as HTMLInputElement).checked = true; + input?.dispatchEvent(new Event('change')); + expect(handler).toHaveBeenCalledWith(true); + }); + + it('marks invalid when required and unchecked after interaction', async () => { + const { host, input, page } = await setup({ + required: true, + checked: false, + }); + // Simulate user interaction (toggle true then false) to set modified + (input as HTMLInputElement).checked = true; + input?.dispatchEvent(new Event('change', { bubbles: true })); + await page.waitForChanges(); + (input as HTMLInputElement).checked = false; + input?.dispatchEvent(new Event('change', { bubbles: true })); + await page.waitForChanges(); + // invalid state applied to wrapper div + const wrapper = host.shadowRoot?.querySelector('.checkbox'); + expect(wrapper?.classList.contains('invalid')).toBe(true); + }); + + it('clears indeterminate state properties when toggled from mixed to checked', async () => { + const { host, page } = await setup({ + indeterminate: true, + checked: false, + }); + // Simulate consumer changing to checked true and indeterminate false + host.checked = true; + host.indeterminate = false; + await page.waitForChanges(); + const input = host.shadowRoot?.querySelector( + 'input[type="checkbox"]' + ) as HTMLInputElement; + expect(input.indeterminate).toBe(false); + expect(input.checked).toBe(true); + expect(input.getAttribute('aria-checked')).toBe('true'); + }); +});