diff --git a/docs/pages/components/checkbox.md b/docs/pages/components/checkbox.md index f5197b9b1a..2fa541da22 100644 --- a/docs/pages/components/checkbox.md +++ b/docs/pages/components/checkbox.md @@ -176,3 +176,49 @@ const App = () => { ``` {% endraw %} + +### Custom checked icon + +Add a custom checked icon using the `checked-icon` slot. + +```html:preview + + + Check me + +``` + +```jsx:react +import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox'; +import SlIcon from '@shoelace-style/shoelace/dist/react/icon'; + +const App = () => ( + + + Check me + +); +``` + +### Custom indeterminate icon + +Add a custom indeterminate icon using the `indeterminate-icon` slot. + +```html:preview + + + Check me + +``` + +```jsx:react +import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox'; +import SlIcon from '@shoelace-style/shoelace/dist/react/icon'; + +const App = () => ( + + + Check me + +); +``` diff --git a/docs/pages/components/option.md b/docs/pages/components/option.md index 2558d67736..9dfa498bb3 100644 --- a/docs/pages/components/option.md +++ b/docs/pages/components/option.md @@ -80,3 +80,51 @@ Add icons to the start and end of menu items using the `prefix` and `suffix` slo ``` + +### Custom checked icon + +Add a custom checked icon using the `checked-icon` slot. + +```html:preview + + + + Option 1 + + + + Option 2 + + + + + Option 3 + + + +``` + +```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 = () => ( + + + + Option 1 + + + + Option 2 + + + + + Option 3 + + + +); +``` diff --git a/src/components/checkbox/checkbox.component.ts b/src/components/checkbox/checkbox.component.ts index 603dd4e655..7dd1ad5d8c 100644 --- a/src/components/checkbox/checkbox.component.ts +++ b/src/components/checkbox/checkbox.component.ts @@ -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. @@ -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 `` element. - * @csspart indeterminate-icon - The indeterminate icon, an `` 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. */ @@ -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; @@ -243,21 +247,27 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC : ''}" class="checkbox__control" > - ${this.checked - ? html` - - ` - : ''} - ${!this.checked && this.indeterminate - ? html` - - ` - : ''} +
+ + + +
+ +
+ + + +
diff --git a/src/components/checkbox/checkbox.styles.ts b/src/components/checkbox/checkbox.styles.ts index db09e7cfd1..8aaa724fcd 100644 --- a/src/components/checkbox/checkbox.styles.ts +++ b/src/components/checkbox/checkbox.styles.ts @@ -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 */ diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index 0a05a65bfc..d9b7a7b736 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -352,18 +352,45 @@ describe('', () => { describe('indeterminate', () => { it('should render indeterminate icon until checked', async () => { const el = await fixture(html``); - 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(html` + +
+
+ `); + 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(html` + +
-
+
+ `); + 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('-'); + }); + }); }); diff --git a/src/components/option/option.component.ts b/src/components/option/option.component.ts index f3950cfeda..34cc508c42 100644 --- a/src/components/option/option.component.ts +++ b/src/components/option/option.component.ts @@ -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 `` 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. @@ -138,7 +141,14 @@ export default class SlOption extends ShoelaceElement { @mouseenter=${this.handleMouseEnter} @mouseleave=${this.handleMouseLeave} > - +
+
+ + + +
+
+
diff --git a/src/components/option/option.styles.ts b/src/components/option/option.styles.ts index 146c7cbf1a..350f0810a7 100644 --- a/src/components/option/option.styles.ts +++ b/src/components/option/option.styles.ts @@ -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; @@ -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, diff --git a/src/components/option/option.test.ts b/src/components/option/option.test.ts index ac7f243a8a..1d3bbe8ff0 100644 --- a/src/components/option/option.test.ts +++ b/src/components/option/option.test.ts @@ -45,4 +45,25 @@ describe('', () => { const el = await fixture(html` Option `); expect(el.getTextLabel()).to.equal('Option'); }); + + it('should render a custom check icon when provided via slot', async () => { + const el = await fixture(html` + +
+ Option 1 +
+ `); + + 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('✓'); + }); });