From 33d8df2507a906faa92a24ee5114ee9c9031bd8e Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Fri, 17 Apr 2026 07:14:15 -0700 Subject: [PATCH] feat(labs): add expressive list utility class component PiperOrigin-RevId: 901281224 --- labs/gb/components/list/_list-tokens.scss | 102 +++++++++ labs/gb/components/list/demo/demo.ts | 35 ++++ labs/gb/components/list/demo/stories.ts | 243 ++++++++++++++++++++++ labs/gb/components/list/list.scss | 212 +++++++++++++++++++ labs/gb/components/list/list.ts | 154 ++++++++++++++ labs/gb/components/list/md-list-item.ts | 133 ++++++++++++ labs/gb/components/list/md-list.ts | 61 ++++++ labs/gb/components/shared/directives.ts | 5 +- 8 files changed, 942 insertions(+), 3 deletions(-) create mode 100644 labs/gb/components/list/_list-tokens.scss create mode 100644 labs/gb/components/list/demo/demo.ts create mode 100644 labs/gb/components/list/demo/stories.ts create mode 100644 labs/gb/components/list/list.scss create mode 100644 labs/gb/components/list/list.ts create mode 100644 labs/gb/components/list/md-list-item.ts create mode 100644 labs/gb/components/list/md-list.ts diff --git a/labs/gb/components/list/_list-tokens.scss b/labs/gb/components/list/_list-tokens.scss new file mode 100644 index 0000000000..6d526b4b0e --- /dev/null +++ b/labs/gb/components/list/_list-tokens.scss @@ -0,0 +1,102 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin root { + --container-shape: var(--md-sys-shape-corner-lg); + --gap: 0; +} + +@mixin segmented { + --gap: 2px; +} + +@mixin item { + --container-height: 56px; + --container-color: transparent; + --container-shape: var(--md-sys-shape-corner-xs); + --label-text-color: var(--md-sys-color-on-surface); + --label-text: var(--md-sys-typescale-body-lg); + --label-text-tracking: var(--md-sys-typescale-body-lg-tracking); + --leading-space: 16px; + --trailing-space: 16px; + --between-space: 12px; + --top-space: 10px; + --bottom-space: 10px; + --avatar-size: 40px; + --avatar-shape: var(--md-sys-shape-corner-full); + --avatar-color: var(--md-sys-color-primary-container); + --avatar-label: var(--md-sys-typescale-title-md); + --avatar-label-tracking: var(--md-sys-typescale-title-md-tracking); + --avatar-label-color: var(--md-sys-color-on-primary-container); + --leading-icon-color: var(--md-sys-color-on-surface-variant); + --leading-icon-size: 20px; + --trailing-icon-color: var(--md-sys-color-on-surface-variant); + --trailing-icon-size: 20px; + --overline: var(--md-sys-typescale-label-sm); + --overline-tracking: var(--md-sys-typescale-label-sm-tracking); + --overline-color: var(--md-sys-color-on-surface-variant); + --supporting-text: var(--md-sys-typescale-body-md); + --supporting-text-tracking: var(--md-sys-typescale-body-md-tracking); + --supporting-text-color: var(--md-sys-color-on-surface-variant); + --trailing-supporting-text: var(--md-sys-typescale-label-sm); + --trailing-supporting-text-tracking: var( + --md-sys-typescale-label-sm-tracking + ); + --trailing-supporting-text-color: var(--md-sys-color-on-surface-variant); +} + +@mixin item-segmented { + --container-color: var(--md-sys-color-surface); +} + +@mixin item-hovered { + --container-shape: var(--md-sys-shape-corner-md); + --leading-icon-color: var(--md-sys-color-on-surface); + --trailing-icon-color: var(--md-sys-color-on-surface); +} + +@mixin item-focused { + --container-shape: var(--md-sys-shape-corner-lg); + --leading-icon-color: var(--md-sys-color-on-surface); + --trailing-icon-color: var(--md-sys-color-on-surface); +} + +@mixin item-pressed { + --container-shape: var(--md-sys-shape-corner-lg); + --leading-icon-color: var(--md-sys-color-on-surface); + --trailing-icon-color: var(--md-sys-color-on-surface); +} + +@mixin item-selected { + --container-shape: var(--md-sys-shape-corner-lg); + --container-color: var(--md-sys-color-secondary-container); + --label-text-color: var(--md-sys-color-on-secondary-container); + --leading-icon-color: var(--md-sys-color-on-secondary-container); + --trailing-icon-color: var(--md-sys-color-on-secondary-container); + --overline-color: var(--md-sys-color-on-secondary-container); + --supporting-text-color: var(--md-sys-color-on-secondary-container); + --trailing-supporting-text-color: var( + --md-sys-color-on-secondary-container + ); +} + +@mixin item-disabled { + --container-shape: var(--md-sys-shape-corner-xs); + --container-color: hsl(from var(--md-sys-color-on-surface) h s l / 10%); + --label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --leading-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ); + --trailing-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ); + --overline-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --supporting-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ); + --trailing-supporting-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ); +} diff --git a/labs/gb/components/list/demo/demo.ts b/labs/gb/components/list/demo/demo.ts new file mode 100644 index 0000000000..71db0a5ab5 --- /dev/null +++ b/labs/gb/components/list/demo/demo.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import {boolInput, Knob} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'List', + [ + new Knob('segmented', { + ui: boolInput(), + defaultValue: true, + }), + new Knob('nonInteractive', { + ui: boolInput(), + }), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/list/demo/stories.ts b/labs/gb/components/list/demo/stories.ts new file mode 100644 index 0000000000..46442d248a --- /dev/null +++ b/labs/gb/components/list/demo/stories.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/icon/icon.js'; +import '@material/web/labs/gb/components/list/md-list.js'; +import '@material/web/labs/gb/components/list/md-list-item.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import {styles as checkboxStyles} from '@material/web/labs/gb/components/checkbox/checkbox.cssresult.js'; +import {styles as focusRingStyles} from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js'; +import {list, listItem} from '@material/web/labs/gb/components/list/list.js'; +import {styles as listStyles} from '@material/web/labs/gb/components/list/list.cssresult.js'; +import {styles as radioStyles} from '@material/web/labs/gb/components/radio/radio.cssresult.js'; +import {styles as rippleStyles} from '@material/web/labs/gb/components/ripple/ripple.cssresult.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; +import {css, html} from 'lit'; + +/** Knob types for list stories. */ +export interface StoryKnobs { + segmented: boolean; + nonInteractive: boolean; +} + +adoptStyles(document, [ + m3Styles, + css` + :root { + --md-icon-font: 'Material Symbols Outlined'; + } + `, +]); + +const styles = [ + focusRingStyles, + rippleStyles, + listStyles, + checkboxStyles, + radioStyles, + css` + .container { + background-color: var(--md-sys-color-surface-container); + border-radius: var(--md-sys-shape-corner-lg); + padding: 2rem; + border: 1px solid var(--md-sys-color-outline-variant); + display: flex; + flex-direction: column; + gap: 1rem; + } + `, +]; + +const playground: MaterialStoryInit = { + name: 'Playground', + styles, + render(knobs) { + return html` +
+ + + Basic Item + + + star + With Leading Icon + + + A + With Avatar & Supporting Text + Supporting text goes here + + + image + Overline text + Complex Item + + With overline, support text, and two icons + + 100+ + chevron_right + + + check + Selected Item + + + block + Disabled Item + This item is disabled + + +
+ `; + }, +}; + +const staticList: MaterialStoryInit = { + name: 'Non-interactive', + styles, + render(knobs) { + return html` +
+ + + developer_board + The first computer (ENIAC) + Feb 14, 1946 + + + satellite_alt + Sputnik launched into space + Oct 4, 1957 + + + rocket_launch + The Apollo 11 moon landing + Jul 20, 1969 + + + moon_stars + Artemis 2 crewed lunar flyby + Apr 1, 2026 + + +
+ `; + }, +}; + +const singleAction: MaterialStoryInit = { + name: 'Single-action', + styles: [ + ...styles, + css` + .img { + width: 40px; + height: 40px; + border-radius: 8px; + background-color: var(--md-sys-color-tertiary-container); + } + `, + ], + render(knobs) { + return html` +
+ + +
+ Festivals + Food, music, arts, community... + May 8 +
+ +
+ Arts + + Literature, games, music, physical... + + May 8 +
+ +
+ Family & friends + + The relationships that bring and bind... + + May 8 +
+
+
+ `; + }, +}; + +// TODO: add multi-action list examples + +const singleSelect: MaterialStoryInit = { + name: 'Single-select', + styles, + render(knobs) { + return html` +
+ +
+ `; + }, +}; + +const multiSelect: MaterialStoryInit = { + name: 'Multi-select', + styles, + render(knobs) { + return html` +
+ +
+ `; + }, +}; + +/** List stories. */ +export const stories = [ + playground, + singleAction, + singleSelect, + multiSelect, + staticList, +]; diff --git a/labs/gb/components/list/list.scss b/labs/gb/components/list/list.scss new file mode 100644 index 0000000000..5c4842bcb6 --- /dev/null +++ b/labs/gb/components/list/list.scss @@ -0,0 +1,212 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use '../checkbox/checkbox-tokens'; +@use '../radio/radio-tokens'; +@use 'list-tokens'; +// go/keep-sorted end + +@layer md.sys, md.comp.ripple, md.comp.focus-ring, md.comp.checkbox, md.comp.radio; +@layer md.comp.list { + .list { + & { + @include list-tokens.root; + } + + &.list-segmented { + @include list-tokens.segmented; + --list-segmented: true; + } + + & { + display: flex; + flex-direction: column; + margin: unset; + padding: unset; + list-style: none; + gap: var(--gap); + border-radius: var(--container-shape); + } + + > :first-child:not(slot), + ::slotted(:first-child) { + border-start-start-radius: inherit; + border-start-end-radius: inherit; + --first-child: true; + } + + > :last-child:not(slot), + ::slotted(:last-child) { + border-end-start-radius: inherit; + border-end-end-radius: inherit; + --last-child: true; + } + + slot { + border-radius: inherit; + } + } + + .list-select { + &, + &::picker(select) { + // stylelint-disable-next-line scss/declaration-property-value-no-unknown -- + // Uses customizable select + appearance: base-select; + } + + border: none; + background: none; + height: fit-content; + } + + .list-item { + & { + @include list-tokens.item; + + @container style(--list-segmented: true) { + @include list-tokens.item-segmented; + } + } + + &:where(:not(.list-item-static)) { + &:is(:hover, .hover) { + @include list-tokens.item-hovered; + } + + &:is(:focus-within, .focus) { + @include list-tokens.item-focused; + } + + &:is(:active, .active) { + @include list-tokens.item-pressed; + } + } + + &:is(:checked, .checked) { + @include list-tokens.item-selected; + } + + &:is(:disabled, .disabled) { + @include list-tokens.item-disabled; + } + + & { + display: flex; + align-items: center; + gap: var(--between-space); + box-sizing: border-box; + min-height: var(--container-height); + border-radius: var(--container-shape); + background-color: var(--container-color); + padding-inline: var(--leading-space) var(--trailing-space); + padding-block: var(--top-space) var(--bottom-space); + color: var(--label-text-color); + font: var(--label-text); + letter-spacing: var(--label-text-tracking); + appearance: none; + border: none; + text-align: unset; + text-decoration: unset; + width: stretch; + } + + @container style(--first-child: true) { + border-start-start-radius: inherit; + border-start-end-radius: inherit; + } + + @container style(--last-child: true) { + border-end-start-radius: inherit; + border-end-end-radius: inherit; + } + + &:not(:disabled, .disabled, .list-item-static) { + cursor: pointer; + } + + .list-item-content { + display: flex; + flex-direction: column; + flex: 1; + } + + .list-item-leading, + .list-item-trailing { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + } + + .list-item-leading { + --md-icon-color: var(--leading-icon-color); + --md-icon-size: var(--leading-icon-size); + } + + .list-item-trailing { + --md-icon-color: var(--trailing-icon-color); + --md-icon-size: var(--trailing-icon-size); + } + + .list-item-overline { + font: var(--overline); + letter-spacing: var(--overline-tracking); + color: var(--overline-color); + } + + .list-item-supporting-text { + font: var(--supporting-text); + letter-spacing: var(--supporting-text-tracking); + color: var(--supporting-text-color); + } + + .list-item-trailing-text { + font: var(--trailing-supporting-text); + letter-spacing: var(--trailing-supporting-text-tracking); + color: var(--trailing-supporting-text-color); + } + + .list-item-avatar { + display: grid; + place-items: center; + background-color: var(--avatar-color); + border-radius: var(--avatar-shape); + aspect-ratio: 1; + width: var(--avatar-size); + font: var(--avatar-label); + letter-spacing: var(--avatar-label-tracking); + color: var(--avatar-label-color); + } + + &::checkmark { + font: var(--leading-icon-size) var(--md-icon-font); + color: var(--leading-icon-color); + content: 'check'; + } + + &:has(.list-item-radio, .list-item-checkbox)::checkmark { + display: none; + } + + .list-item-radio, + .list-item-checkbox { + width: min-content; + height: min-content; + --ripple: none; + --focus-ring-outline: none; + } + + &:is(:checked, .checked) .list-item-radio { + @include radio-tokens.selected; + --icon: 'radio_button_checked'; + } + + &:is(:checked, .checked) .list-item-checkbox { + @include checkbox-tokens.selected; + } + } +} diff --git a/labs/gb/components/list/list.ts b/labs/gb/components/list/list.ts new file mode 100644 index 0000000000..6d4555310c --- /dev/null +++ b/labs/gb/components/list/list.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FOCUS_RING_CLASSES} from '@material/web/labs/gb/components/focus/focus-ring.js'; +import { + RIPPLE_CLASSES, + setupRipple, +} from '@material/web/labs/gb/components/ripple/ripple.js'; +import {createClassMapDirective} from '@material/web/labs/gb/components/shared/directives.js'; +import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js'; +import {type ClassInfo} from 'lit/directives/class-map.js'; + +/** List classes. */ +export const LIST_CLASSES = { + list: 'list', + listSegmented: 'list-segmented', +} as const; + +/** The state provided to the `listClasses()` function. */ +export interface ListClassesState { + /** Whether to render the list with segmented items. */ + segmented?: boolean; +} + +/** + * Returns the list classes to apply to an element based on the given state. + * + * @param state The state of the list. + * @return An object of class names and truthy values if they apply. + */ +export function listClasses({ + segmented = false, +}: ListClassesState = {}): ClassInfo { + return { + [LIST_CLASSES.list]: true, + [LIST_CLASSES.listSegmented]: segmented, + }; +} + +/** + * A Lit directive that adds list styling and functionality to its element. + * + * @example + * ```ts + * html` + *
    + *
  • + *
  • + *
  • + *
+ * `; + * ``` + */ +export const list = createClassMapDirective({ + getClasses: listClasses, +}); + +/** List item classes. */ +export const LIST_ITEM_CLASSES = { + listItem: 'list-item', + listItemStatic: 'list-item-static', + listItemContent: 'list-item-content', + listItemLeading: 'list-item-leading', + listItemTrailing: 'list-item-trailing', + listItemOverline: 'list-item-overline', + listItemSupportingText: 'list-item-supporting-text', + listItemTrailingText: 'list-item-trailing-text', + listItemAvatar: 'list-item-avatar', + checked: PSEUDO_CLASSES.checked, + hover: PSEUDO_CLASSES.hover, + focus: PSEUDO_CLASSES.focus, + active: PSEUDO_CLASSES.active, + disabled: PSEUDO_CLASSES.disabled, +} as const; + +/** The state provided to the `listItemClasses()` function. */ +export interface ListItemClassesState { + /** Whether the list item is non-interactive. */ + static?: boolean; + /** Emulates `:checked`. */ + checked?: boolean; + /** Emulates `:hover`. */ + hover?: boolean; + /** Emulates `:focus`. */ + focus?: boolean; + /** Emulates `:active`. */ + active?: boolean; + /** Emulates `:disabled`. */ + disabled?: boolean; +} + +/** + * Returns the list item classes to apply to an element based on the given + * state. + * + * @param state The state of the list item. + * @return An object of class names and truthy values if they apply. + */ +export function listItemClasses({ + static: staticItem = false, + checked = false, + hover = false, + focus = false, + active = false, + disabled = false, +}: ListItemClassesState = {}): ClassInfo { + return { + [RIPPLE_CLASSES.ripple]: !staticItem, + [FOCUS_RING_CLASSES.focusRingInner]: !staticItem, + [LIST_ITEM_CLASSES.listItem]: true, + [LIST_ITEM_CLASSES.listItemStatic]: staticItem, + [LIST_ITEM_CLASSES.checked]: checked, + [LIST_ITEM_CLASSES.hover]: hover, + [LIST_ITEM_CLASSES.focus]: focus, + [LIST_ITEM_CLASSES.active]: active, + [LIST_ITEM_CLASSES.disabled]: disabled, + }; +} + +/** + * Sets up list item functionality for the given element. + * + * @param listItem The element on which to set up list item functionality. + * @param opts Setup options, supports a cleanup `signal`. + */ +export function setupListItem( + listItem: HTMLElement, + opts?: {signal?: AbortSignal}, +): void { + setupRipple(listItem, opts); +} + +/** + * A Lit directive that adds list item styling and functionality to its element. + * + * + * @example + * ```ts + * html` + *
    + *
  • + *
  • + *
  • + *
+ * `; + * ``` + */ +export const listItem = createClassMapDirective({ + getClasses: listItemClasses, + setupElement: setupListItem, +}); diff --git a/labs/gb/components/list/md-list-item.ts b/labs/gb/components/list/md-list-item.ts new file mode 100644 index 0000000000..38553824f8 --- /dev/null +++ b/labs/gb/components/list/md-list-item.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ARIAMixinStrict} from '@material/web/internal/aria/aria.js'; +import {mixinDelegatesAria} from '@material/web/internal/aria/delegate.js'; +import { + internals, + mixinElementInternals, +} from '@material/web/labs/behaviors/element-internals.js'; +import {hasSlotted} from '@material/web/labs/gb/components/shared/has-slotted.js'; +import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.css' with {type: 'css'}; // github-only +// import {styles as focusRingStyles} from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js'; // google3-only +import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.css' with {type: 'css'}; // github-only +// import {styles as rippleStyles} from '@material/web/labs/gb/components/ripple/ripple.cssresult.js'; // google3-only +import listStyles from './list.css' with {type: 'css'}; // github-only +// import {styles as listStyles} from './list.cssresult.js'; // google3-only + +import {listItem} from './list.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design list item component. */ + 'md-list-item': ListItem; + } +} + +// Separate variable needed for closure. +const baseClass = mixinDelegatesAria(mixinElementInternals(LitElement)); + +/** + * A Material Design list item component. + */ +@customElement('md-list-item') +export class ListItem extends baseClass { + /** @nocollapse */ + static override shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles: CSSResultOrNative[] = [ + focusRingStyles, + rippleStyles, + listStyles, + css` + :host { + display: flex; + align-items: center; + } + .list-item { + flex: 1; + align-items: inherit; + } + :is(.list-item-leading, .list-item-trailing):not(:has(.has-slotted)) { + display: none; + } + slot:not(.has-slotted) { + display: contents; + } + `, + ]; + + constructor() { + super(); + this[internals].role = 'listitem'; + } + + /** + * Whether the list item is selected. + */ + @property({type: Boolean}) checked = false; + + /** + * Whether the list item is disabled. + */ + @property({type: Boolean}) disabled = false; + + /** + * Whether the list item is non-interactive. + */ + @property({type: Boolean, reflect: true, attribute: 'static'}) + nonInteractive = false; + + protected override render() { + const state = { + checked: this.checked, + disabled: this.disabled, + static: this.nonInteractive, + }; + if (this.nonInteractive) { + return html`
${this.renderContent()}
`; + } + + // Needed for closure conformance + const {ariaLabel} = this as ARIAMixinStrict; + return html``; + } + + private renderContent() { + return html` + + + + + + + + + + + + + + `; + } +} diff --git a/labs/gb/components/list/md-list.ts b/labs/gb/components/list/md-list.ts new file mode 100644 index 0000000000..dd9d5fdedc --- /dev/null +++ b/labs/gb/components/list/md-list.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + internals, + mixinElementInternals, +} from '@material/web/labs/behaviors/element-internals.js'; +import {css, CSSResultOrNative, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import listStyles from './list.css' with {type: 'css'}; // github-only +// import {styles as listStyles} from './list.cssresult.js'; // google3-only + +import {list} from './list.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design list component. */ + 'md-list': List; + } +} + +// Separate variable needed for closure. +const baseClass = mixinElementInternals(LitElement); + +/** + * A Material Design list component. + */ +@customElement('md-list') +export class List extends baseClass { + static override styles: CSSResultOrNative[] = [ + listStyles, + css` + :host { + display: flex; + } + .list { + flex: 1; + } + `, + ]; + + constructor() { + super(); + this[internals].role = 'list'; + } + + /** + * Whether to render the list with segmented items. + */ + @property({type: Boolean}) segmented = false; + + protected override render() { + return html`
+ +
`; + } +} diff --git a/labs/gb/components/shared/directives.ts b/labs/gb/components/shared/directives.ts index 454db9d644..8cc1a92fbe 100644 --- a/labs/gb/components/shared/directives.ts +++ b/labs/gb/components/shared/directives.ts @@ -88,10 +88,9 @@ export function createClassMapDirective( return directive( class ComponentClassMapDirective extends SetupElementDirective { render(params?: State & AdditionalClasses) { - const {classes, ...state} = params || {}; return classMap({ - ...(classes || {}), - ...options.getClasses(state as State), + ...(params?.classes || {}), + ...options.getClasses(params), }); }