Skip to content

Commit 0c8986c

Browse files
asynclizcopybara-github
authored andcommitted
feat(labs): add card utility class component
PiperOrigin-RevId: 897803929
1 parent 7bf4a7e commit 0c8986c

6 files changed

Lines changed: 495 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// Copyright 2026 Google LLC
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
6+
@mixin root {
7+
--container-color: transparent;
8+
--container-elevation: var(--md-sys-elevation-shadow-0);
9+
--container-shape: var(--md-sys-shape-corner-md);
10+
--outline-color: transparent;
11+
--outline-width: 0;
12+
--state-layer-color: var(--md-sys-color-on-surface);
13+
}
14+
15+
@mixin hover {
16+
--container-elevation: var(--md-sys-elevation-shadow-1);
17+
}
18+
19+
@mixin filled {
20+
--container-color: var(--md-sys-color-surface-container-highest);
21+
}
22+
23+
@mixin filled-disabled {
24+
--container-color: hsl(
25+
from var(--md-sys-color-surface-container-highest) h s l / 38%
26+
);
27+
}
28+
29+
@mixin outlined {
30+
--container-color: var(--md-sys-color-surface);
31+
--outline-color: var(--md-sys-color-outline-variant);
32+
--outline-width: 1px;
33+
}
34+
35+
@mixin outlined-focus {
36+
--outline-color: var(--md-sys-color-on-surface);
37+
}
38+
39+
@mixin outlined-disabled {
40+
--outline-color: hsl(from var(--md-sys-color-outline) h s l / 12%);
41+
}
42+
43+
@mixin elevated {
44+
--container-color: var(--md-sys-color-surface-container-low);
45+
--container-elevation: var(--md-sys-elevation-shadow-1);
46+
}
47+
48+
@mixin elevated-hover {
49+
--container-elevation: var(--md-sys-elevation-shadow-2);
50+
}
51+
52+
@mixin elevated-disabled {
53+
--container-color: hsl(from var(--md-sys-color-surface) h s l / 38%);
54+
}

labs/gb/components/card/card.scss

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 'card-tokens';
8+
// go/keep-sorted end
9+
10+
@layer md.sys, md.comp.ripple, md.comp.focus-ring;
11+
@layer md.comp.card {
12+
.card {
13+
& {
14+
@include card-tokens.root;
15+
}
16+
17+
&:is(:hover, .hover):where(:has(.card-btn:not(:disabled, .disabled))) {
18+
@include card-tokens.hover;
19+
}
20+
21+
&.card-filled {
22+
@include card-tokens.filled;
23+
24+
&:where(:disabled, .disabled) {
25+
@include card-tokens.filled-disabled;
26+
}
27+
}
28+
29+
&.card-outlined {
30+
@include card-tokens.outlined;
31+
32+
&:where(:focus-within, .focus) {
33+
@include card-tokens.outlined-focus;
34+
}
35+
36+
&:where(:disabled, .disabled) {
37+
@include card-tokens.outlined-disabled;
38+
}
39+
}
40+
41+
&.card-elevated {
42+
@include card-tokens.elevated;
43+
44+
&:is(:hover, .hover):where(:has(.card-btn:not(:disabled, .disabled))) {
45+
@include card-tokens.elevated-hover;
46+
}
47+
48+
&:where(:disabled, .disabled) {
49+
@include card-tokens.elevated-disabled;
50+
}
51+
}
52+
53+
& {
54+
display: flex;
55+
flex-direction: column;
56+
position: relative;
57+
overflow: hidden;
58+
background-color: var(--container-color);
59+
border-radius: var(--container-shape);
60+
box-shadow: var(--container-elevation);
61+
border: var(--outline-width) solid var(--outline-color);
62+
}
63+
64+
.card-btn {
65+
appearance: none;
66+
background-color: transparent;
67+
border: none;
68+
outline: none;
69+
position: absolute;
70+
inset: 0;
71+
color: var(--state-layer-color);
72+
}
73+
}
74+
}

labs/gb/components/card/card.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {FOCUS_RING_CLASSES} from '@material/web/labs/gb/components/focus/focus-ring.js';
8+
import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js';
9+
import {Directive, directive} from 'lit/directive.js';
10+
import {classMap, type ClassInfo} from 'lit/directives/class-map.js';
11+
12+
/** Card color configuration types. */
13+
export type CardColor = 'elevated' | 'filled' | 'outlined';
14+
15+
/** Card color configurations. */
16+
export const CARD_COLORS = {
17+
elevated: 'elevated',
18+
filled: 'filled',
19+
outlined: 'outlined',
20+
} as const;
21+
22+
/** Card classes. */
23+
export const CARD_CLASSES = {
24+
card: 'card',
25+
cardElevated: 'card-elevated',
26+
cardFilled: 'card-filled',
27+
cardOutlined: 'card-outlined',
28+
hover: PSEUDO_CLASSES.hover,
29+
focus: PSEUDO_CLASSES.focus,
30+
disabled: PSEUDO_CLASSES.disabled,
31+
} as const;
32+
33+
/** The state provided to the `cardClasses()` function. */
34+
export interface CardClassesState {
35+
/** The color of the card. */
36+
color?: CardColor;
37+
/** Whether the card is interactive. */
38+
interactive?: boolean;
39+
/** Emulates `:hover`. */
40+
hover?: boolean;
41+
/** Emulates `:focus`. */
42+
focus?: boolean;
43+
/** Emulates `:disabled`. */
44+
disabled?: boolean;
45+
}
46+
47+
/**
48+
* Returns the card classes to apply to an element based on the given state.
49+
*
50+
* @param state The state of the card.
51+
* @return An object of class names and truthy values if they apply.
52+
*/
53+
export function cardClasses({
54+
color,
55+
interactive = false,
56+
hover = false,
57+
focus = false,
58+
disabled = false,
59+
}: CardClassesState = {}): ClassInfo {
60+
return {
61+
[FOCUS_RING_CLASSES.focusRingOuter]: interactive,
62+
[CARD_CLASSES.card]: true,
63+
[CARD_CLASSES.cardElevated]: color === CARD_COLORS.elevated,
64+
[CARD_CLASSES.cardFilled]: color === CARD_COLORS.filled,
65+
[CARD_CLASSES.cardOutlined]: color === CARD_COLORS.outlined || !color,
66+
[CARD_CLASSES.hover]: hover,
67+
[CARD_CLASSES.focus]: focus,
68+
[CARD_CLASSES.disabled]: disabled,
69+
};
70+
}
71+
72+
/** The state provided to the `card()` directive. */
73+
export interface CardDirectiveState extends CardClassesState {
74+
/** Additional classes to apply to the element. */
75+
classes?: ClassInfo;
76+
}
77+
78+
class CardDirective extends Directive {
79+
render(state: CardDirectiveState = {}) {
80+
return classMap({
81+
...(state.classes || {}),
82+
...cardClasses(state),
83+
});
84+
}
85+
}
86+
87+
/**
88+
* A Lit directive that adds card styling and functionality to its element.
89+
*
90+
* @example
91+
* ```ts
92+
* html`
93+
* <div class="${card({color: 'filled'})} flex flex-row p-4">
94+
* Card content
95+
* </div>
96+
* `
97+
* ```
98+
*/
99+
export const card = directive(CardDirective);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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, selectDropdown} from './index.js';
17+
18+
import {stories, StoryKnobs} from './stories.js';
19+
20+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
21+
'Card',
22+
[
23+
new Knob('color', {
24+
ui: selectDropdown({
25+
options: [
26+
{value: 'filled', label: 'Filled'},
27+
{value: 'outlined', label: 'Outlined'},
28+
{value: 'elevated', label: 'Elevated'},
29+
],
30+
}),
31+
}),
32+
new Knob('disabled', {ui: boolInput()}),
33+
new Knob('interactive', {ui: boolInput()}),
34+
],
35+
);
36+
37+
collection.addStories(...materialInitsToStoryInits(stories));
38+
39+
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});

0 commit comments

Comments
 (0)