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'); + }); +}); diff --git a/src/components/checkbox/checkbox.template.tsx b/src/components/checkbox/checkbox.template.tsx index 3bd6a4d18a..8cfed4f8e0 100644 --- a/src/components/checkbox/checkbox.template.tsx +++ b/src/components/checkbox/checkbox.template.tsx @@ -47,13 +47,17 @@ 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 [
= ( > { - 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,28 @@ 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'); + private getCheckboxElement = (): HTMLInputElement | null => { + return ( + this.limelCheckbox?.shadowRoot?.querySelector( + 'input[type="checkbox"]' + ) || null + ); }; 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; }; }