Skip to content

Commit f6c1871

Browse files
asynclizcopybara-github
authored andcommitted
feat(labs): add switch utility class component
PiperOrigin-RevId: 900968035
1 parent fa02947 commit f6c1871

8 files changed

Lines changed: 680 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {createElementDirective} from './directives.js';
8+
9+
/**
10+
* Emulates the `:has-slotted` CSS pseudo class by adding a `.has-slotted` class
11+
* to `<slot>` elements when they have assigned nodes.
12+
*/
13+
export const hasSlotted = createElementDirective((element, opts) => {
14+
if (!element.matches('slot')) {
15+
throw new Error('hasSlotted() must be used on a <slot> element.');
16+
}
17+
18+
element.addEventListener(
19+
'slotchange',
20+
(event) => {
21+
element.classList.toggle(
22+
'has-slotted',
23+
(element as HTMLSlotElement).assignedNodes().length > 0,
24+
);
25+
},
26+
opts,
27+
);
28+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// import 'jasmine'; (google3-only)
8+
9+
import {Environment} from '@material/web/testing/environment.js';
10+
import {html, render} from 'lit';
11+
12+
import {hasSlotted} from './has-slotted.js';
13+
14+
describe('hasSlotted()', () => {
15+
const env = new Environment();
16+
let host: HTMLElement;
17+
let shadowRoot: ShadowRoot;
18+
let slot: HTMLSlotElement;
19+
20+
beforeEach(() => {
21+
host = document.createElement('div');
22+
shadowRoot = host.attachShadow({mode: 'open'});
23+
render(html`<slot ${hasSlotted()} name="test"></slot>`, shadowRoot);
24+
slot = shadowRoot.querySelector('slot')!;
25+
env.render(html`${host}`);
26+
});
27+
28+
it('throws an error if used on a non-<slot> element', () => {
29+
expect(() => {
30+
env.render(html`<div ${hasSlotted()}></div>`);
31+
}).toThrowError('hasSlotted() must be used on a <slot> element.');
32+
});
33+
34+
it('does not add .has-slotted class when slot has no assigned nodes', async () => {
35+
expect(slot.classList)
36+
.withContext('slot classList')
37+
.not.toContain('has-slotted');
38+
});
39+
40+
it('adds .has-slotted class when slot has assigned nodes', async () => {
41+
render(html`<span slot="test">Content</span>`, host);
42+
await env.waitForStability();
43+
44+
expect(slot.classList)
45+
.withContext('slot classList')
46+
.toContain('has-slotted');
47+
});
48+
49+
it('toggles .has-slotted class on slot content change', async () => {
50+
render(html`<span slot="test">Content</span>`, host);
51+
await env.waitForStability();
52+
53+
expect(slot.classList)
54+
.withContext('slot classList')
55+
.toContain('has-slotted');
56+
57+
host.firstElementChild?.remove();
58+
await env.waitForStability();
59+
60+
expect(slot.classList)
61+
.withContext('slot classList')
62+
.not.toContain('has-slotted');
63+
});
64+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//
2+
// Copyright 2026 Google LLC
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
6+
@mixin root {
7+
--handle-color: var(--md-sys-color-outline);
8+
--handle-size: 16px;
9+
--with-icon-handle-size: 24px;
10+
--icon-color: var(--md-sys-color-surface-container-highest);
11+
--icon-size: 18px;
12+
--state-layer-color: var(--md-sys-color-on-surface);
13+
--state-layer-size: 40px;
14+
--track-color: var(--md-sys-color-surface-container-highest);
15+
--track-height: 32px;
16+
--track-outline-color: var(--md-sys-color-outline);
17+
--track-outline-width: 2px;
18+
--track-width: 52px;
19+
}
20+
21+
@mixin hovered {
22+
--handle-color: var(--md-sys-color-on-surface-variant);
23+
}
24+
25+
@mixin selected {
26+
--track-color: var(--md-sys-color-primary);
27+
--track-outline-color: transparent;
28+
--handle-color: var(--md-sys-color-on-primary);
29+
--handle-size: 24px;
30+
--icon-color: var(--md-sys-color-on-primary-container);
31+
--state-layer-color: var(--md-sys-color-primary);
32+
}
33+
34+
@mixin selected-hovered {
35+
--handle-color: var(--md-sys-color-primary-container);
36+
}
37+
38+
@mixin pressed {
39+
--handle-size: 28px;
40+
}
41+
42+
@mixin disabled {
43+
--handle-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
44+
--track-color: hsl(
45+
from var(--md-sys-color-surface-container-highest) h s l / 12%
46+
);
47+
--track-outline-color: hsl(
48+
from var(--md-sys-color-on-surface) h s l / 12%
49+
);
50+
}
51+
52+
@mixin disabled-selected {
53+
--handle-color: var(--md-sys-color-surface);
54+
--icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
55+
--track-color: hsl(from var(--md-sys-color-on-surface) h s l / 12%);
56+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import './material-collection.js';
8+
import './index.js';
9+
10+
import {
11+
KnobTypesToKnobs,
12+
MaterialCollection,
13+
materialInitsToStoryInits,
14+
setUpDemo,
15+
} from './material-collection.js';
16+
import {boolInput, Knob, textInput} from './index.js';
17+
18+
import {stories, StoryKnobs} from './stories.js';
19+
20+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
21+
'Switch',
22+
[
23+
new Knob('selected', {ui: boolInput()}),
24+
new Knob('disabled', {ui: boolInput()}),
25+
new Knob('onIcon', {ui: textInput()}),
26+
new Knob('offIcon', {ui: textInput()}),
27+
],
28+
);
29+
30+
collection.addStories(...materialInitsToStoryInits(stories));
31+
32+
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import '@material/web/icon/icon.js';
8+
import '@material/web/labs/gb/components/switch/md-switch.js';
9+
10+
import {MaterialStoryInit} from './material-collection.js';
11+
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
12+
import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js';
13+
import {css, html, nothing} from 'lit';
14+
15+
/** Knob types for switch stories. */
16+
export interface StoryKnobs {
17+
selected: boolean;
18+
disabled: boolean;
19+
onIcon: string;
20+
offIcon: string;
21+
}
22+
23+
adoptStyles(document, [
24+
m3Styles,
25+
css`
26+
:root {
27+
--md-icon-font: 'Material Symbols Outlined';
28+
}
29+
`,
30+
]);
31+
32+
const playground: MaterialStoryInit<StoryKnobs> = {
33+
name: 'Playground',
34+
render(knobs) {
35+
return html`
36+
<md-switch .selected=${knobs.selected} ?disabled=${knobs.disabled}>
37+
${knobs.offIcon
38+
? html`<md-icon slot="off-icon">${knobs.offIcon}</md-icon>`
39+
: nothing}
40+
${knobs.onIcon
41+
? html`<md-icon slot="on-icon">${knobs.onIcon}</md-icon>`
42+
: nothing}
43+
</md-switch>
44+
`;
45+
},
46+
};
47+
48+
const withIcons: MaterialStoryInit<StoryKnobs> = {
49+
name: 'With icons',
50+
styles: css`
51+
md-switch.css-icons::part(switch) {
52+
--icon: 'close';
53+
}
54+
md-switch.css-icons:state(selected)::part(switch) {
55+
--icon: 'check';
56+
}
57+
`,
58+
render(knobs) {
59+
return html`
60+
<md-switch
61+
class="css-icons"
62+
.selected=${knobs.selected}
63+
?disabled=${knobs.disabled}>
64+
</md-switch>
65+
`;
66+
},
67+
};
68+
69+
/** Switch stories. */
70+
export const stories = [playground, withIcons];

0 commit comments

Comments
 (0)