Skip to content

Commit 0fb72b5

Browse files
asynclizcopybara-github
authored andcommitted
chore(labs): add ripple and focus ring utility classes
PiperOrigin-RevId: 896249263
1 parent e5be451 commit 0fb72b5

File tree

9 files changed

+720
-0
lines changed

9 files changed

+720
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
17+
import {stories, StoryKnobs} from './stories.js';
18+
19+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
20+
'Focus',
21+
);
22+
23+
collection.addStories(...materialInitsToStoryInits(stories));
24+
25+
setUpDemo(collection);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {MaterialStoryInit} from './material-collection.js';
8+
import {styles as focusRingStyles} from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js';
9+
import {css, html} from 'lit';
10+
11+
/** Knob types for focus ring stories. */
12+
export interface StoryKnobs {}
13+
14+
const styles = [
15+
focusRingStyles,
16+
css`
17+
button {
18+
position: relative;
19+
height: 40px;
20+
border-radius: 20px;
21+
padding-inline: 16px;
22+
border: 1px solid currentColor;
23+
24+
&:has(.child-ring) {
25+
outline: none;
26+
}
27+
}
28+
29+
.child-ring {
30+
position: absolute;
31+
inset: -1px;
32+
border-radius: inherit;
33+
}
34+
35+
.parent-ring {
36+
display: inline-block;
37+
border-radius: 20px;
38+
isolation: isolate;
39+
button {
40+
outline: none;
41+
z-index: -1;
42+
}
43+
}
44+
`,
45+
];
46+
47+
const focusable: MaterialStoryInit<StoryKnobs> = {
48+
name: 'Focusable',
49+
styles,
50+
render() {
51+
return html`
52+
<button class="focus-ring-outer">Outer</button>
53+
<button class="focus-ring-inner">Inner</button>
54+
`;
55+
},
56+
};
57+
58+
const focusableParent: MaterialStoryInit<StoryKnobs> = {
59+
name: 'Focusable parent',
60+
styles,
61+
render() {
62+
return html`
63+
<button class="focus-ring-target">
64+
Outer
65+
<span class="focus-ring-outer child-ring"></span>
66+
</button>
67+
<button class="focus-ring-target">
68+
Inner
69+
<span class="focus-ring-inner child-ring"></span>
70+
</button>
71+
`;
72+
},
73+
};
74+
75+
const focusableChild: MaterialStoryInit<StoryKnobs> = {
76+
name: 'Focusable child',
77+
styles,
78+
render() {
79+
return html`
80+
<div class="focus-ring-outer parent-ring">
81+
<button class="focus-ring-target">Outer</button>
82+
</div>
83+
<div class="focus-ring-inner parent-ring">
84+
<button class="focus-ring-target">Inner</button>
85+
</div>
86+
`;
87+
},
88+
};
89+
90+
const forcedStates: MaterialStoryInit<StoryKnobs> = {
91+
name: 'Forced states',
92+
styles,
93+
render() {
94+
return html`
95+
<button class="focus-ring-outer focus-visible">Outer</button>
96+
<button class="focus-ring-inner focus-visible">Inner</button>
97+
`;
98+
},
99+
};
100+
101+
/** Focus ring stories. */
102+
export const stories = [
103+
focusable,
104+
focusableParent,
105+
focusableChild,
106+
forcedStates,
107+
];
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*!
2+
* Copyright 2026 Google LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
// go/keep-sorted start by_regex='(.+) prefix_order=sass:
7+
@use '../../../../tokens/versions/latest/sass/md-sys-state-focus-indicator';
8+
// go/keep-sorted end
9+
10+
@layer md.sys;
11+
@layer md.comp.focus-ring {
12+
.focus-ring-outer,
13+
.focus-ring-inner {
14+
--focus-ring-outline: none;
15+
--focus-ring-offset: 0;
16+
--focus-ring-animation: none;
17+
18+
outline: var(--focus-ring-outline);
19+
outline-offset: var(--focus-ring-offset);
20+
animation: var(--focus-ring-animation), var(--ripple-animation, none);
21+
}
22+
23+
// Focus rings support the following DOM use cases:
24+
// 1. Focus ring and interactive element are the same.
25+
// 2. Focus ring is a parent of a child interactive `.focus-ring-target`.
26+
// 3. Focus ring is a child of a parent interactive `.focus-ring-target`.
27+
// 4. Focus ring has `.focus-ring-host` and is a within an interactive shadow DOM host.
28+
//
29+
// Pseudo class emulation is only needed for the focus ring element, not for
30+
// the target or host elements.
31+
32+
.focus-ring-outer {
33+
&:is(:focus-visible, .focus-visible),
34+
&:has(.focus-ring-target:focus-visible),
35+
.focus-ring-target:focus-visible &,
36+
:host(:focus-visible) &.focus-ring-host {
37+
--focus-ring-outline: #{md-sys-state-focus-indicator.$thickness} solid var(--md-sys-color-secondary);
38+
--focus-ring-offset: #{md-sys-state-focus-indicator.$outer-offset};
39+
--focus-ring-animation: focus-ring-outer-grow 150ms
40+
cubic-bezier(0.2, 0, 0, 1),
41+
focus-ring-outer-shrink 450ms 150ms cubic-bezier(0.2, 0, 0, 1);
42+
}
43+
}
44+
45+
.focus-ring-inner {
46+
&:is(:focus-visible, .focus-visible),
47+
&:has(.focus-ring-target:focus-visible),
48+
.focus-ring-target:focus-visible &,
49+
:host(:focus-visible) &.focus-ring-host {
50+
--focus-ring-outline: #{md-sys-state-focus-indicator.$thickness} solid var(--md-sys-color-secondary);
51+
--focus-ring-offset: #{md-sys-state-focus-indicator.$inner-offset};
52+
--focus-ring-animation: focus-ring-inner-grow 150ms
53+
cubic-bezier(0.2, 0, 0, 1),
54+
focus-ring-inner-shrink 450ms 150ms cubic-bezier(0.2, 0, 0, 1);
55+
}
56+
}
57+
58+
@keyframes focus-ring-outer-grow {
59+
from {
60+
outline-width: 0;
61+
}
62+
63+
to {
64+
outline-width: 8px;
65+
}
66+
}
67+
68+
@keyframes focus-ring-outer-shrink {
69+
from {
70+
outline-width: 8px;
71+
}
72+
}
73+
74+
@keyframes focus-ring-inner-grow {
75+
from {
76+
outline-width: 0;
77+
outline-offset: 0;
78+
}
79+
to {
80+
outline-width: 8px;
81+
outline-offset: -8px;
82+
}
83+
}
84+
85+
@keyframes focus-ring-inner-shrink {
86+
from {
87+
outline-width: 8px;
88+
outline-offset: -8px;
89+
}
90+
}
91+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js';
8+
import {type ClassInfo} from 'lit/directives/class-map.js';
9+
10+
/** Focus ring type configuration types. */
11+
export type FocusRingType = 'outer' | 'inner';
12+
13+
/** Focus ring type configurations. */
14+
export const FOCUS_RING_TYPES = {
15+
outer: 'outer',
16+
inner: 'inner',
17+
} as const;
18+
19+
/** Focus ring classes. */
20+
export const FOCUS_RING_CLASSES = {
21+
focusRingOuter: 'focus-ring-outer',
22+
focusRingInner: 'focus-ring-inner',
23+
focusRingTarget: 'focus-ring-target',
24+
focusRingHost: 'focus-ring-host',
25+
focusVisible: PSEUDO_CLASSES.focusVisible,
26+
};
27+
28+
/** The state provided to the `focusRingClasses()` function. */
29+
export interface FocusRingClassesState {
30+
/** The type of focus ring. Defaults to outer. */
31+
type?: FocusRingType;
32+
/** Emulates `:focus-visible`. */
33+
focusVisible?: boolean;
34+
}
35+
36+
/**
37+
* Returns the focus ring classes to apply to an element based on the given
38+
* state.
39+
*
40+
* @param state The state of the focus ring.
41+
* @return An object of class names and truthy values if they apply.
42+
*/
43+
export function focusRingClasses({
44+
type = FOCUS_RING_TYPES.outer,
45+
focusVisible = false,
46+
}: FocusRingClassesState = {}): ClassInfo {
47+
return {
48+
[FOCUS_RING_CLASSES.focusRingOuter]:
49+
type === FOCUS_RING_TYPES.outer || !type,
50+
[FOCUS_RING_CLASSES.focusRingInner]: type === FOCUS_RING_TYPES.inner,
51+
[FOCUS_RING_CLASSES.focusVisible]: focusVisible,
52+
};
53+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
17+
import {stories, StoryKnobs} from './stories.js';
18+
19+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
20+
'Ripple',
21+
);
22+
23+
collection.addStories(...materialInitsToStoryInits(stories));
24+
25+
setUpDemo(collection);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {MaterialStoryInit} from './material-collection.js';
8+
import {ripple} from '@material/web/labs/gb/components/ripple/ripple.js';
9+
import {styles as rippleStyles} from '@material/web/labs/gb/components/ripple/ripple.cssresult.js';
10+
import {css, html} from 'lit';
11+
12+
/** Knob types for ripple stories. */
13+
export interface StoryKnobs {}
14+
15+
const styles = [
16+
rippleStyles,
17+
css`
18+
button {
19+
position: relative;
20+
height: 40px;
21+
border-radius: 20px;
22+
padding-inline: 16px;
23+
background-color: transparent;
24+
border: 1px solid currentColor;
25+
}
26+
27+
.child-ripple {
28+
position: absolute;
29+
inset: -1px;
30+
border-radius: inherit;
31+
}
32+
33+
.parent-ripple {
34+
display: inline-block;
35+
border-radius: 20px;
36+
isolation: isolate;
37+
button {
38+
z-index: -1;
39+
}
40+
}
41+
`,
42+
];
43+
44+
const element: MaterialStoryInit<StoryKnobs> = {
45+
name: 'Element',
46+
styles,
47+
render() {
48+
return html`
49+
<button class="ripple" ${ripple()}>Ripple</button>
50+
<button class="ripple" ${ripple()} disabled>Disabled</button>
51+
`;
52+
},
53+
};
54+
55+
const focusableParent: MaterialStoryInit<StoryKnobs> = {
56+
name: 'Focusable parent',
57+
styles,
58+
render() {
59+
return html`
60+
<button class="ripple-target" ${ripple()}>
61+
Ripple
62+
<span class="ripple child-ripple"></span>
63+
</button>
64+
`;
65+
},
66+
};
67+
68+
const focusableChild: MaterialStoryInit<StoryKnobs> = {
69+
name: 'Focusable child',
70+
styles,
71+
render() {
72+
return html`
73+
<div class="ripple parent-ripple" ${ripple()}>
74+
<button class="ripple-target">Ripple</button>
75+
</div>
76+
`;
77+
},
78+
};
79+
80+
const forcedStates: MaterialStoryInit<StoryKnobs> = {
81+
name: 'Forced states',
82+
styles,
83+
render() {
84+
return html`
85+
<button class="ripple hover">Hover</button>
86+
<button class="ripple active">Press</button>
87+
`;
88+
},
89+
};
90+
91+
/** Ripple stories. */
92+
export const stories = [element, focusableParent, focusableChild, forcedStates];

0 commit comments

Comments
 (0)