Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/empty-cougars-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sl-design-system/checkbox': patch
---

Fix checkbox group not working with tooltips (only `sl-checkbox` elements are tracked by `@queryAssignedElements`).
Forward `aria-describedby` to the inner input element for screen reader support.
26 changes: 26 additions & 0 deletions packages/components/checkbox/src/checkbox-group.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type SlFormControlEvent } from '@sl-design-system/form';
import '@sl-design-system/form/register.js';
import '@sl-design-system/tooltip/register.js';
import { fixture } from '@sl-design-system/vitest-browser-lit';
import { LitElement, type TemplateResult, html } from 'lit';
import { spy } from 'sinon';
Expand Down Expand Up @@ -437,4 +438,29 @@ describe('sl-checkbox-group', () => {
expect(fitc.shadowRoot!.activeElement).to.equal(input);
});
});

describe('with tooltips', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-checkbox-group required>
<sl-checkbox value="a">A</sl-checkbox>
<sl-checkbox value="b" aria-describedby="tip1">B</sl-checkbox>
<sl-tooltip id="tip1">Tooltip</sl-tooltip>
<sl-checkbox value="c">C</sl-checkbox>
</sl-checkbox-group>
`);
});

it('should only contain checkbox elements in the boxes property', () => {
expect(el.boxes).to.have.lengthOf(3);
expect(el.boxes?.every(box => box.tagName.toLowerCase() === 'sl-checkbox')).to.be.true;
});

it('should correctly reflect only checkbox values', async () => {
await userEvent.click(el.querySelector('sl-checkbox[value="b"]')!);
await new Promise(resolve => setTimeout(resolve, 50));

expect(el.value).to.deep.equal([null, 'b', null]);
});
});
});
36 changes: 36 additions & 0 deletions packages/components/checkbox/src/checkbox-group.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import '@sl-design-system/button/register.js';
import '@sl-design-system/button-bar/register.js';
import '@sl-design-system/form/register.js';
import { tooltip } from '@sl-design-system/tooltip';
import '@sl-design-system/tooltip/register.js';
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
import { type TemplateResult, html } from 'lit';
import '../register.js';
Expand Down Expand Up @@ -191,3 +193,37 @@ export const CustomAsyncValidity: Story = {
}
}
};

export const WithTooltips: Story = {
render: () => {
const onClick = (event: Event & { target: HTMLElement }): void => {
event.target.closest('sl-form')?.reportValidity();
};

return html`
<p>
This story demonstrates how to use tooltips with checkboxes inside a checkbox group using
both the <code>tooltip()</code> directive and the manual
<code>aria-describedby</code> approach with a separate <code>sl-tooltip</code> element.
</p>

<sl-form>
<sl-form-field label="Subscriptions">
<sl-checkbox-group name="subscriptions" required>
<sl-checkbox ${tooltip('Newsletter tooltip')} value="newsletter"
>Newsletter</sl-checkbox
>
<sl-checkbox value="promotions" aria-describedby="tooltip1">Promotions</sl-checkbox>
<sl-tooltip id="tooltip1">Promotions tooltip</sl-tooltip>
<sl-checkbox ${tooltip('Product updates tooltip')} value="updates"
>Product updates</sl-checkbox
>
Comment on lines +213 to +220
</sl-checkbox-group>
</sl-form-field>
<sl-button-bar>
<sl-button @click=${onClick}>Report validity</sl-button>
</sl-button-bar>
</sl-form>
`;
}
};
2 changes: 1 addition & 1 deletion packages/components/checkbox/src/checkbox-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class CheckboxGroup<T = any> extends FormControlMixin(LitElement) {
readonly internals = this.attachInternals();

/** @internal The slotted checkboxes. */
@queryAssignedElements() boxes?: Array<Checkbox<T>>;
@queryAssignedElements({ selector: 'sl-checkbox' }) boxes?: Array<Checkbox<T>>;

/** @internal Emits when the component loses focus. */
@event({ name: 'sl-blur' }) blurEvent!: EventEmitter<SlBlurEvent>;
Expand Down
16 changes: 16 additions & 0 deletions packages/components/checkbox/src/checkbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ describe('sl-checkbox', () => {
expect(input.type).to.equal('checkbox');
});

it('should have aria-describedby in observedAttributes', () => {
expect((el.constructor as typeof Checkbox).observedAttributes).to.include('aria-describedby');
});

it('should not be checked', () => {
expect(el.checked).not.to.be.true;
expect(input.checked).not.to.be.true;
Expand Down Expand Up @@ -128,6 +132,18 @@ describe('sl-checkbox', () => {
expect(el.input).to.have.attribute('aria-labelledby', 'id');
});

it('should proxy the aria-describedby attribute to the input element', async () => {
el.setAttribute('aria-describedby', 'tooltip-id');
await new Promise(resolve => setTimeout(resolve, 50));

expect(el).to.not.have.attribute('aria-describedby');
expect(el.input).to.have.attribute('aria-describedby', 'tooltip-id');
});

it('should return the input element from getProxyTarget', () => {
expect(el.getProxyTarget()).to.equal(el.input);
});

it('should be pristine', () => {
expect(el.dirty).not.to.be.true;
});
Expand Down
43 changes: 43 additions & 0 deletions packages/components/checkbox/src/checkbox.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import '@sl-design-system/button/register.js';
import '@sl-design-system/button-bar/register.js';
import '@sl-design-system/form/register.js';
import { tooltip } from '@sl-design-system/tooltip';
import '@sl-design-system/tooltip/register.js';
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
import { type TemplateResult, html, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
Expand Down Expand Up @@ -314,3 +316,44 @@ export const CustomAsyncValidity: Story = {
}
}
};

export const WithTooltip: Story = {
render: () => {
const onClick = (event: Event & { target: HTMLElement }): void => {
event.target.closest('sl-form')?.reportValidity();
};

return html`
<p>
This story demonstrates how to use tooltips with checkboxes using both the
<code>tooltip()</code> directive and the manual <code>aria-describedby</code> approach with
a separate <code>sl-tooltip</code> element.
</p>

<h3>Using the tooltip directive</h3>
<sl-form>
<sl-form-field label="Subscriptions">
<sl-checkbox ${tooltip('Newsletter tooltip')} value="newsletter" required
>Newsletter</sl-checkbox
>
</sl-form-field>
<sl-button-bar>
<sl-button @click=${onClick}>Report validity</sl-button>
</sl-button-bar>
</sl-form>

<h3>Using aria-describedby</h3>
<sl-form>
<sl-form-field label="Subscriptions">
<sl-checkbox value="promotions" aria-describedby="tooltip1" required
>Promotions</sl-checkbox
>
<sl-tooltip id="tooltip1">Promotions tooltip</sl-tooltip>
</sl-form-field>
<sl-button-bar>
<sl-button @click=${onClick}>Report validity</sl-button>
</sl-button-bar>
</sl-form>
`;
}
};
14 changes: 12 additions & 2 deletions packages/components/checkbox/src/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ let nextUniqueId = 0;
@localized()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Checkbox<T = any> extends ObserveAttributesMixin(FormControlMixin(LitElement), [
'aria-describedby',
'aria-disabled',
'aria-label',
'aria-labelledby'
Expand Down Expand Up @@ -166,6 +167,7 @@ export class Checkbox<T = any> extends ObserveAttributesMixin(FormControlMixin(L
}

this.setFormControlElement(this.input);
this.setAttributesTarget(this.input);

this.#onLabelSlotChange();
}
Expand Down Expand Up @@ -221,6 +223,15 @@ export class Checkbox<T = any> extends ObserveAttributesMixin(FormControlMixin(L
this.input.blur();
}

/**
* Returns the proxy target for the tooltip to anchor to.
*
* @internal
*/
getProxyTarget(): HTMLInputElement {
return this.input;
}

override getLocalizedValidationMessage(): string {
if (!this.validity.customError && this.validity.valueMissing) {
return msg('Please check this box.', { id: 'sl.checkbox.validation.valueMissing' });
Expand Down Expand Up @@ -283,6 +294,7 @@ export class Checkbox<T = any> extends ObserveAttributesMixin(FormControlMixin(L
this.#syncInput(this.input);

this.setFormControlElement(this.input);
this.setAttributesTarget(this.input);
}
}

Expand Down Expand Up @@ -336,7 +348,5 @@ export class Checkbox<T = any> extends ObserveAttributesMixin(FormControlMixin(L

input.checked = !!this.checked;
input.indeterminate = !!this.indeterminate;

this.setAttributesTarget(input);
}
}
Loading