Skip to content

Adds support for custom checked icons in checkbox and option components #2425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
46 changes: 46 additions & 0 deletions docs/pages/components/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,49 @@ const App = () => {
```

{% endraw %}

### Custom checked icon

Add a custom checked icon using the `checked-icon` slot.

```html:preview
<sl-checkbox >
<sl-icon slot="checked-icon" name="patch-check"></sl-icon>
Check me
</sl-checkbox>
```

```jsx:react
import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox';
import SlIcon from '@shoelace-style/shoelace/dist/react/icon';

const App = () => (
<SlCheckbox>
<SlIcon slot="checked-icon" name="patch-check" />
Check me
</SlCheckbox>
);
```

### Custom indeterminate icon

Add a custom indeterminate icon using the `indeterminate-icon` slot.

```html:preview
<sl-checkbox indeterminate>
<sl-icon slot="indeterminate-icon" name="code-slash"></sl-icon>
Check me
</sl-checkbox>
```

```jsx:react
import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox';
import SlIcon from '@shoelace-style/shoelace/dist/react/icon';

const App = () => (
<SlCheckbox indeterminate>
<SlIcon slot="indeterminate-icon" name="code-slash" />
Check me
</SlCheckbox>
);
```
48 changes: 48 additions & 0 deletions docs/pages/components/option.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,51 @@ Add icons to the start and end of menu items using the `prefix` and `suffix` slo
</sl-option>
</sl-select>
```

### Custom checked icon

Add a custom checked icon using the `checked-icon` slot.

```html:preview
<sl-select label="Select one">
<sl-option value="option-1">
<sl-icon slot="checked-icon" name="patch-check"></sl-icon>
Option 1
</sl-option>

<sl-option value="option-2">
Option 2
<sl-icon slot="checked-icon" name="patch-check"></sl-icon>
</sl-option>

<sl-option value="option-3">
Option 3
<sl-icon slot="checked-icon" name="patch-check"></sl-icon>
</sl-option>
</sl-select>
```

```jsx:react
import SlIcon from '@shoelace-style/shoelace/dist/react/icon';
import SlOption from '@shoelace-style/shoelace/dist/react/option';
import SlSelect from '@shoelace-style/shoelace/dist/react/select';

const App = () => (
<SlSelect label="Select one">
<SlOption value="option-1">
<SlIcon slot="checked-icon" name="patch-check" />
Option 1
</SlOption>

<SlOption value="option-2">
Option 2
<SlIcon slot="checked-icon" name="patch-check" />
</SlOption>

<SlOption value="option-3">
Option 3
<SlIcon slot="checked-icon" name="patch-check" />
</SlOption>
</SlSelect>
);
```
46 changes: 28 additions & 18 deletions src/components/checkbox/checkbox.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
*
* @slot - The checkbox's label.
* @slot help-text - Text that describes how to use the checkbox. Alternatively, you can use the `help-text` attribute.
* @slot checked-icon - The icon to show when the checkbox is checked.
* @slot indeterminate-icon - The icon to show when the checkbox is indeterminate.
*
* @event sl-blur - Emitted when the checkbox loses focus.
* @event sl-change - Emitted when the checked state changes.
Expand All @@ -36,8 +38,10 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
* @csspart control - The square container that wraps the checkbox's checked state.
* @csspart control--checked - Matches the control part when the checkbox is checked.
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
* @csspart checked-icon - The checked icon.
* @csspart indeterminate-icon - The indeterminate icon.
* @csspart checked-icon-container - The container for the checked icon.
* @csspart indeterminate-icon-container - The container for the indeterminate icon.
* @csspart label - The container that wraps the checkbox's label.
* @csspart form-control-help-text - The help text's wrapper.
*/
Expand All @@ -50,7 +54,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
defaultValue: (control: SlCheckbox) => control.defaultChecked,
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
private readonly hasSlotController = new HasSlotController(this, 'help-text');
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'checked-icon', 'indeterminate-icon');

@query('input[type="checkbox"]') input: HTMLInputElement;

Expand Down Expand Up @@ -243,21 +247,27 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
: ''}"
class="checkbox__control"
>
${this.checked
? html`
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
`
: ''}
${!this.checked && this.indeterminate
? html`
<sl-icon
part="indeterminate-icon"
class="checkbox__indeterminate-icon"
library="system"
name="indeterminate"
></sl-icon>
`
: ''}
<div
part="checked-icon-container"
class="checkbox__icon-container"
?hidden=${!this.checked}
aria-hidden=${!this.checked ? 'true' : 'false'}
>
<slot name="checked-icon" class="checkbox__checked-icon">
<sl-icon library="system" name="check"></sl-icon>
</slot>
</div>

<div
part="indeterminate-icon-container"
class="checkbox__icon-container"
?hidden=${!this.indeterminate || this.checked}
aria-hidden=${!this.indeterminate || this.checked ? 'true' : 'false'}
>
<slot name="indeterminate-icon" class="checkbox__indeterminate-icon">
<sl-icon library="system" name="indeterminate"></sl-icon>
</slot>
</div>
</span>

<div part="label" class="checkbox__label">
Expand Down
12 changes: 11 additions & 1 deletion src/components/checkbox/checkbox.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,19 @@ export default css`

.checkbox__checked-icon,
.checkbox__indeterminate-icon {
display: inline-flex;
display: flex;
width: var(--toggle-size);
height: var(--toggle-size);
align-items: center;
justify-content: center;
}

.checkbox__icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

/* Hover */
Expand Down
37 changes: 32 additions & 5 deletions src/components/checkbox/checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,18 +352,45 @@ describe('<sl-checkbox>', () => {
describe('indeterminate', () => {
it('should render indeterminate icon until checked', async () => {
const el = await fixture<SlCheckbox>(html`<sl-checkbox indeterminate></sl-checkbox>`);
let indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;
const container = el.shadowRoot!.querySelector('[part="indeterminate-icon-container"]');

expect(indeterminateIcon).not.to.be.null;
expect(container).not.to.be.null;
expect(container!.hasAttribute('hidden')).to.be.false;

el.click();
await el.updateComplete;

indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;

expect(indeterminateIcon).to.be.null;
expect(container!.hasAttribute('hidden')).to.be.true;
});

runFormControlBaseTests('sl-checkbox');
});

describe('custom icons', () => {
it('should allow custom checked icon via slot', async () => {
const el = await fixture<SlCheckbox>(html`
<sl-checkbox checked>
<div slot="checked-icon" class="custom-icon">✓</div>
</sl-checkbox>
`);
const slot = el.shadowRoot!.querySelector('slot[name="checked-icon"]')!;
const assignedElements = (slot as HTMLSlotElement).assignedElements() as Element[];

expect(assignedElements.length).to.equal(1);
expect(assignedElements[0].textContent).to.equal('✓');
});

it('should allow custom indeterminate icon via slot', async () => {
const el = await fixture<SlCheckbox>(html`
<sl-checkbox indeterminate>
<div slot="indeterminate-icon" class="custom-icon">-</div>
</sl-checkbox>
`);
const slot = el.shadowRoot!.querySelector('slot[name="indeterminate-icon"]')!;
const assignedElements = (slot as HTMLSlotElement).assignedElements() as Element[];

expect(assignedElements.length).to.equal(1);
expect(assignedElements[0].textContent).to.equal('-');
});
});
});
14 changes: 12 additions & 2 deletions src/components/option/option.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import type { CSSResultGroup } from 'lit';
* @slot - The option's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
* @slot checked-icon - The icon to show when the option is selected.
*
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart base - The component's base wrapper.
* @csspart checked-icon - The checked icon.
* @csspart checked-icon-container - The container for the checked icon.
* @csspart empty-icon - The placeholder icon space when not selected.
* @csspart label - The option's label.
* @csspart prefix - The container that wraps the prefix.
* @csspart suffix - The container that wraps the suffix.
Expand Down Expand Up @@ -138,7 +141,14 @@ export default class SlOption extends ShoelaceElement {
@mouseenter=${this.handleMouseEnter}
@mouseleave=${this.handleMouseLeave}
>
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
<div class="option__icon-container" part="checked-icon-container">
<div ?hidden=${!this.selected}>
<slot name="checked-icon" class="option__check">
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
</slot>
</div>
<div class="option__empty-icon" part="empty-icon" ?hidden=${this.selected}></div>
</div>
<slot part="prefix" name="prefix" class="option__prefix"></slot>
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot part="suffix" name="suffix" class="option__suffix"></slot>
Expand Down
22 changes: 16 additions & 6 deletions src/components/option/option.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ export default css`
cursor: not-allowed;
}

.option__icon-container {
display: flex;
width: 20px;
margin-inline-end: var(--sl-spacing-2x-small);
flex-shrink: 0;
align-items: center;
justify-content: center;
}

.option__empty-icon {
width: 20px;
height: 20px;
}

.option__label {
flex: 1 1 auto;
display: inline-block;
Expand All @@ -55,12 +69,8 @@ export default css`
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
padding-inline-end: var(--sl-spacing-2x-small);
}

.option--selected .option__check {
visibility: visible;
width: 100%;
height: 100%;
}

.option__prefix,
Expand Down
21 changes: 21 additions & 0 deletions src/components/option/option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,25 @@ describe('<sl-option>', () => {
const el = await fixture<SlOption>(html` <sl-option><strong>Option</strong></sl-option> `);
expect(el.getTextLabel()).to.equal('Option');
});

it('should render a custom check icon when provided via slot', async () => {
const el = await fixture<SlOption>(html`
<sl-option selected>
<div slot="checked-icon" class="custom-icon">✓</div>
Option 1
</sl-option>
`);

await el.updateComplete;

const iconContainer = el.shadowRoot!.querySelector('.option__icon-container')!;
expect(iconContainer).to.be.visible;

const slotElement = iconContainer.querySelector('slot[name="checked-icon"]');
expect(slotElement).to.be.visible;

const customIcon = el.querySelector('div[slot="checked-icon"]');
expect(customIcon).to.be.visible;
expect(customIcon!.textContent).to.equal('✓');
});
});