diff --git a/select/_filled-select.scss b/select/_filled-select.scss new file mode 100644 index 0000000000..42dd6d7797 --- /dev/null +++ b/select/_filled-select.scss @@ -0,0 +1,6 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@forward './lib/filled-select' show theme; diff --git a/select/_outlined-select.scss b/select/_outlined-select.scss new file mode 100644 index 0000000000..a05d39250e --- /dev/null +++ b/select/_outlined-select.scss @@ -0,0 +1,6 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@forward './lib/outlined-select' show theme; diff --git a/select/filled-select.ts b/select/filled-select.ts new file mode 100644 index 0000000000..157021a6d4 --- /dev/null +++ b/select/filled-select.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators.js'; + +import {styles as filledForcedColorsStyles} from './lib/filled-forced-colors-styles.css.js'; +import {FilledSelect} from './lib/filled-select.js'; +import {styles} from './lib/filled-select-styles.css.js'; +import {styles as sharedStyles} from './lib/shared-styles.css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-filled-select': MdFilledSelect; + } +} + +/** + * @summary + * Select menus display a list of choices on temporary surfaces and display the + * currently selected menu item above the menu. + * + * @description + * The select component allows users to choose a value from a fixed list of + * available options. Composed of an interactive anchor button and a menu, it is + * analogous to the native HTML ` { + protected getField() { + return this.element.renderRoot.querySelector('.field') as Field; + } + /** + * Shows the menu and returns the first list item element. + */ + protected override async getInteractiveElement() { + await this.element.updateComplete; + return this.getField(); + } + + override async startHover() { + const field = await this.getField(); + const element = await (new SelectFieldHardness(field)).getInteractiveElement(); + this.simulateStartHover(element); + } + + /** @return ListItem harnesses for the menu's items. */ + getItems() { + return this.element.options.map((item) => new SelectOptionHarness(item)); + } + + async click(quick = true) { + this.element.quick = quick; + await this.element.updateComplete; + const field = await this.getField(); + field.click(); + } + + async clickOption(index: number) { + const menu = this.element.renderRoot.querySelector('md-menu')!; + if (!menu.open) { + console.warn( + 'Internal menu is not open. Try calling SelectHarness.prototype.click()'); + } + this.getItems()[index].element.click(); + } +} + +// Private class (not exported) +class SelectFieldHardness extends FieldHarness { + /* Expose so that we can call it from our internal code in SelectHarness. */ + override getInteractiveElement() { + return super.getInteractiveElement(); + } +} diff --git a/select/lib/_filled-select.scss b/select/lib/_filled-select.scss new file mode 100644 index 0000000000..5a319f429c --- /dev/null +++ b/select/lib/_filled-select.scss @@ -0,0 +1,163 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:string'; +// go/keep-sorted end +// go/keep-sorted start +@use '../../field/filled-field'; +@use '../../sass/shape'; +@use '../../sass/theme'; +@use '../../tokens'; +// go/keep-sorted end + +@mixin theme($tokens) { + $tokens: theme.validate-theme(tokens.md-comp-filled-select-values(), $tokens); + + @each $token, $value in $tokens { + --md-filled-select-#{$token}: #{$value}; + } +} + +@mixin styles() { + $tokens: tokens.md-comp-filled-select-values(); + + :host { + @each $token, $value in $tokens { + --_#{$token}: #{$value}; + } + + @include filled-field.theme( + ( + // go/keep-sorted start + 'active-indicator-color': var(--_text-field-active-indicator-color), + 'active-indicator-height': var(--_text-field-active-indicator-height), + 'container-color': var(--_text-field-container-color), + 'container-shape': + shape.corners-to-shape-token('--_text-field-container-shape'), + 'content-color': var(--_text-field-input-text-color), + 'content-type': var(--_text-field-input-text-type), + 'disabled-active-indicator-color': + var(--_text-field-disabled-active-indicator-color), + 'disabled-active-indicator-height': + var(--_text-field-disabled-active-indicator-height), + 'disabled-active-indicator-opacity': + var(--_text-field-disabled-active-indicator-opacity), + 'disabled-container-color': var(--_text-field-disabled-container-color), + 'disabled-container-opacity': + var(--_text-field-disabled-container-opacity), + 'disabled-content-color': var(--_text-field-disabled-input-text-color), + 'disabled-content-opacity': + var(--_text-field-disabled-input-text-opacity), + 'disabled-label-text-color': + var(--_text-field-disabled-label-text-color), + 'disabled-label-text-opacity': + var(--_text-field-disabled-label-text-opacity), + 'disabled-leading-content-color': + var(--_text-field-disabled-leading-icon-color), + 'disabled-leading-content-opacity': + var(--_text-field-disabled-leading-icon-opacity), + 'disabled-supporting-text-color': + var(--_text-field-disabled-supporting-text-color), + 'disabled-supporting-text-opacity': + var(--_text-field-disabled-supporting-text-opacity), + 'disabled-trailing-content-color': + var(--_text-field-disabled-trailing-icon-color), + 'disabled-trailing-content-opacity': + var(--_text-field-disabled-trailing-icon-opacity), + 'error-active-indicator-color': + var(--_text-field-error-active-indicator-color), + 'error-content-color': var(--_text-field-error-input-text-color), + 'error-focus-active-indicator-color': + var(--_text-field-error-focus-active-indicator-color), + 'error-focus-content-color': + var(--_text-field-error-focus-input-text-color), + 'error-focus-label-text-color': + var(--_text-field-error-focus-label-text-color), + 'error-focus-leading-content-color': + var(--_text-field-error-focus-leading-icon-color), + 'error-focus-supporting-text-color': + var(--_text-field-error-focus-supporting-text-color), + 'error-focus-trailing-content-color': + var(--_text-field-error-focus-trailing-icon-color), + 'error-hover-active-indicator-color': + var(--_text-field-error-hover-active-indicator-color), + 'error-hover-content-color': + var(--_text-field-error-hover-input-text-color), + 'error-hover-label-text-color': + var(--_text-field-error-hover-label-text-color), + 'error-hover-leading-content-color': + var(--_text-field-error-hover-leading-icon-color), + 'error-hover-state-layer-color': + var(--_text-field-error-hover-state-layer-color), + 'error-hover-state-layer-opacity': + var(--_text-field-error-hover-state-layer-opacity), + 'error-hover-supporting-text-color': + var(--_text-field-error-hover-supporting-text-color), + 'error-hover-trailing-content-color': + var(--_text-field-error-hover-trailing-icon-color), + 'error-label-text-color': var(--_text-field-error-label-text-color), + 'error-leading-content-color': + var(--_text-field-error-leading-icon-color), + 'error-supporting-text-color': + var(--_text-field-error-supporting-text-color), + 'error-trailing-content-color': + var(--_text-field-error-trailing-icon-color), + 'focus-active-indicator-color': + var(--_text-field-focus-active-indicator-color), + 'focus-active-indicator-height': + var(--_text-field-focus-active-indicator-height), + 'focus-content-color': var(--_text-field-focus-input-text-color), + 'focus-label-text-color': var(--_text-field-focus-label-text-color), + 'focus-leading-content-color': + var(--_text-field-focus-leading-icon-color), + 'focus-supporting-text-color': + var(--_text-field-focus-supporting-text-color), + 'focus-trailing-content-color': + var(--_text-field-focus-trailing-icon-color), + 'hover-active-indicator-color': + var(--_text-field-hover-active-indicator-color), + 'hover-active-indicator-height': + var(--_text-field-hover-active-indicator-height), + 'hover-content-color': var(--_text-field-hover-input-text-color), + 'hover-label-text-color': var(--_text-field-hover-label-text-color), + 'hover-leading-content-color': + var(--_text-field-hover-leading-icon-color), + 'hover-state-layer-color': var(--_text-field-hover-state-layer-color), + 'hover-state-layer-opacity': + var(--_text-field-hover-state-layer-opacity), + 'hover-supporting-text-color': + var(--_text-field-hover-supporting-text-color), + 'hover-trailing-content-color': + var(--_text-field-hover-trailing-icon-color), + 'label-text-color': var(--_text-field-label-text-color), + 'label-text-populated-line-height': + var(--_text-field-label-text-populated-line-height), + 'label-text-populated-size': + var(--_text-field-label-text-populated-size), + 'label-text-type': var(--_text-field-label-text-type), + 'leading-content-color': var(--_text-field-leading-icon-color), + 'supporting-text-color': var(--_text-field-supporting-text-color), + 'supporting-text-type': var(--_text-field-supporting-text-type), + 'trailing-content-color': var(--_text-field-trailing-icon-color), + // go/keep-sorted end + ) + ); + } + + [hasstart] .icon.leading { + font-size: var(--_text-field-leading-icon-size); + height: var(--_text-field-leading-icon-size); + width: var(--_text-field-leading-icon-size); + } + + [hasend] .icon.trailing { + font-size: var(--_text-field-trailing-icon-size); + height: var(--_text-field-trailing-icon-size); + width: var(--_text-field-trailing-icon-size); + } +} diff --git a/select/lib/_outlined-select.scss b/select/lib/_outlined-select.scss new file mode 100644 index 0000000000..dc4bd34c18 --- /dev/null +++ b/select/lib/_outlined-select.scss @@ -0,0 +1,146 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:string'; +// go/keep-sorted end +// go/keep-sorted start +@use '../../field/outlined-field'; +@use '../../sass/shape'; +@use '../../sass/theme'; +@use '../../tokens'; +// go/keep-sorted end + +@mixin theme($tokens) { + $tokens: theme.validate-theme( + tokens.md-comp-outlined-select-values(), + $tokens + ); + + @each $token, $value in $tokens { + --md-outlined-select-#{$token}: #{$value}; + } +} + +@mixin styles() { + $tokens: tokens.md-comp-outlined-select-values(); + + :host { + @each $token, $value in $tokens { + --_#{$token}: #{$value}; + } + + @include outlined-field.theme( + ( + // go/keep-sorted start + 'container-shape': var(--_text-field-container-shape), + 'content-color': var(--_text-field-input-text-color), + 'content-type': var(--_text-field-input-text-type), + 'disabled-content-color': var(--_text-field-disabled-input-text-color), + 'disabled-content-opacity': + var(--_text-field-disabled-input-text-opacity), + 'disabled-label-text-color': + var(--_text-field-disabled-label-text-color), + 'disabled-label-text-opacity': + var(--_text-field-disabled-label-text-opacity), + 'disabled-leading-content-color': + var(--_text-field-disabled-leading-icon-color), + 'disabled-leading-content-opacity': + var(--_text-field-disabled-leading-icon-opacity), + 'disabled-outline-color': var(--_text-field-disabled-outline-color), + 'disabled-outline-opacity': var(--_text-field-disabled-outline-opacity), + 'disabled-outline-width': var(--_text-field-disabled-outline-width), + 'disabled-supporting-text-color': + var(--_text-field-disabled-supporting-text-color), + 'disabled-supporting-text-opacity': + var(--_text-field-disabled-supporting-text-opacity), + 'disabled-trailing-content-color': + var(--_text-field-disabled-trailing-icon-color), + 'disabled-trailing-content-opacity': + var(--_text-field-disabled-trailing-icon-opacity), + 'error-content-color': var(--_text-field-error-input-text-color), + 'error-focus-content-color': + var(--_text-field-error-focus-input-text-color), + 'error-focus-label-text-color': + var(--_text-field-error-focus-label-text-color), + 'error-focus-leading-content-color': + var(--_text-field-error-focus-leading-icon-color), + 'error-focus-outline-color': + var(--_text-field-error-focus-outline-color), + 'error-focus-supporting-text-color': + var(--_text-field-error-focus-supporting-text-color), + 'error-focus-trailing-content-color': + var(--_text-field-error-focus-trailing-icon-color), + 'error-hover-content-color': + var(--_text-field-error-hover-input-text-color), + 'error-hover-label-text-color': + var(--_text-field-error-hover-label-text-color), + 'error-hover-leading-content-color': + var(--_text-field-error-hover-leading-icon-color), + 'error-hover-outline-color': + var(--_text-field-error-hover-outline-color), + 'error-hover-supporting-text-color': + var(--_text-field-error-hover-supporting-text-color), + 'error-hover-trailing-content-color': + var(--_text-field-error-hover-trailing-icon-color), + 'error-label-text-color': var(--_text-field-error-label-text-color), + 'error-leading-content-color': + var(--_text-field-error-leading-icon-color), + 'error-outline-color': var(--_text-field-error-outline-color), + 'error-supporting-text-color': + var(--_text-field-error-supporting-text-color), + 'error-trailing-content-color': + var(--_text-field-error-trailing-icon-color), + 'focus-content-color': var(--_text-field-focus-input-text-color), + 'focus-label-text-color': var(--_text-field-focus-label-text-color), + 'focus-leading-content-color': + var(--_text-field-focus-leading-icon-color), + 'focus-outline-color': var(--_text-field-focus-outline-color), + 'focus-outline-width': var(--_text-field-focus-outline-width), + 'focus-supporting-text-color': + var(--_text-field-focus-supporting-text-color), + 'focus-trailing-content-color': + var(--_text-field-focus-trailing-icon-color), + 'hover-content-color': var(--_text-field-hover-input-text-color), + 'hover-label-text-color': var(--_text-field-hover-label-text-color), + 'hover-leading-content-color': + var(--_text-field-hover-leading-icon-color), + 'hover-outline-color': var(--_text-field-hover-outline-color), + 'hover-outline-width': var(--_text-field-hover-outline-width), + 'hover-supporting-text-color': + var(--_text-field-hover-supporting-text-color), + 'hover-trailing-content-color': + var(--_text-field-hover-trailing-icon-color), + 'label-text-color': var(--_text-field-label-text-color), + 'label-text-populated-line-height': + var(--_text-field-label-text-populated-line-height), + 'label-text-populated-size': + var(--_text-field-label-text-populated-size), + 'label-text-type': var(--_text-field-label-text-type), + 'leading-content-color': var(--_text-field-leading-icon-color), + 'outline-color': var(--_text-field-outline-color), + 'outline-width': var(--_text-field-outline-width), + 'supporting-text-color': var(--_text-field-supporting-text-color), + 'supporting-text-type': var(--_text-field-supporting-text-type), + 'trailing-content-color': var(--_text-field-trailing-icon-color), + // go/keep-sorted end + ) + ); + } + + [hasstart] .icon.leading { + font-size: var(--_text-field-leading-icon-size); + height: var(--_text-field-leading-icon-size); + width: var(--_text-field-leading-icon-size); + } + + [hasend] .icon.trailing { + font-size: var(--_text-field-trailing-icon-size); + height: var(--_text-field-trailing-icon-size); + width: var(--_text-field-trailing-icon-size); + } +} diff --git a/select/lib/_shared.scss b/select/lib/_shared.scss new file mode 100644 index 0000000000..0372ed6105 --- /dev/null +++ b/select/lib/_shared.scss @@ -0,0 +1,48 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:string'; +// go/keep-sorted end +// go/keep-sorted start +@use '../../elevation/lib/elevation'; +@use '../../sass/theme'; +@use '../../tokens'; +// go/keep-sorted end + +@mixin styles() { + :host { + color: unset; + min-width: 210px; + } + + .field { + cursor: default; + outline: none; + } + + .select { + position: relative; + } + + .field, + .select { + min-width: inherit; + } + + :host { + display: inline-flex; + } + + .label { + width: 100%; + } + + :host([disabled]) { + pointer-events: none; + } +} diff --git a/select/lib/filled-forced-colors-styles.scss b/select/lib/filled-forced-colors-styles.scss new file mode 100644 index 0000000000..2eee55f109 --- /dev/null +++ b/select/lib/filled-forced-colors-styles.scss @@ -0,0 +1,29 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './filled-select'; +// go/keep-sorted end + +@media (forced-colors: active) { + :host { + @include filled-select.theme( + ( + text-field-disabled-active-indicator-color: GrayText, + text-field-disabled-active-indicator-opacity: 1, + text-field-disabled-input-text-color: GrayText, + text-field-disabled-input-text-opacity: 1, + text-field-disabled-label-text-color: GrayText, + text-field-disabled-label-text-opacity: 1, + text-field-disabled-leading-icon-color: GrayText, + text-field-disabled-leading-icon-opacity: 1, + text-field-disabled-supporting-text-color: GrayText, + text-field-disabled-supporting-text-opacity: 1, + text-field-disabled-trailing-icon-color: GrayText, + text-field-disabled-trailing-icon-opacity: 1, + ) + ); + } +} diff --git a/select/lib/filled-select-styles.scss b/select/lib/filled-select-styles.scss new file mode 100644 index 0000000000..7a58e80057 --- /dev/null +++ b/select/lib/filled-select-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './filled-select'; +// go/keep-sorted end + +@include filled-select.styles(); diff --git a/select/lib/filled-select.ts b/select/lib/filled-select.ts new file mode 100644 index 0000000000..e2ebd4083d --- /dev/null +++ b/select/lib/filled-select.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../../field/filled-field.js'; + +import {literal} from 'lit/static-html.js'; + +import {Select} from './select.js'; + +// tslint:disable-next-line:enforce-comments-on-exported-symbols +export abstract class FilledSelect extends Select { + protected readonly fieldTag = literal`md-filled-field`; +} diff --git a/select/lib/outlined-forced-colors-styles.scss b/select/lib/outlined-forced-colors-styles.scss new file mode 100644 index 0000000000..6ab23fd6f3 --- /dev/null +++ b/select/lib/outlined-forced-colors-styles.scss @@ -0,0 +1,29 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './outlined-select'; +// go/keep-sorted end + +@media (forced-colors: active) { + :host { + @include outlined-select.theme( + ( + text-field-disabled-input-text-color: GrayText, + text-field-disabled-input-text-opacity: 1, + text-field-disabled-label-text-color: GrayText, + text-field-disabled-label-text-opacity: 1, + text-field-disabled-leading-icon-color: GrayText, + text-field-disabled-leading-icon-opacity: 1, + text-field-disabled-outline-color: GrayText, + text-field-disabled-outline-opacity: 1, + text-field-disabled-supporting-text-color: GrayText, + text-field-disabled-supporting-text-opacity: 1, + text-field-disabled-trailing-icon-color: GrayText, + text-field-disabled-trailing-icon-opacity: 1, + ) + ); + } +} diff --git a/select/lib/outlined-select-styles.scss b/select/lib/outlined-select-styles.scss new file mode 100644 index 0000000000..db8994538d --- /dev/null +++ b/select/lib/outlined-select-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './outlined-select'; +// go/keep-sorted end + +@include outlined-select.styles(); diff --git a/select/lib/outlined-select.ts b/select/lib/outlined-select.ts new file mode 100644 index 0000000000..182bf338fd --- /dev/null +++ b/select/lib/outlined-select.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../../field/outlined-field.js'; + +import {literal} from 'lit/static-html.js'; + +import {Select} from './select.js'; + +// tslint:disable-next-line:enforce-comments-on-exported-symbols +export abstract class OutlinedSelect extends Select { + protected readonly fieldTag = literal`md-outlined-field`; +} diff --git a/select/lib/select.ts b/select/lib/select.ts new file mode 100644 index 0000000000..20083bf3b8 --- /dev/null +++ b/select/lib/select.ts @@ -0,0 +1,607 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../../menu/menu.js'; + +import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit'; +import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; +import {ClassInfo, classMap} from 'lit/directives/class-map.js'; +import {html as staticHtml, StaticValue} from 'lit/static-html.js'; + +import {Field} from '../../field/lib/field.js'; +import {List} from '../../list/lib/list.js'; +import {DEFAULT_TYPEAHEAD_BUFFER_TIME, Menu} from '../../menu/lib/menu.js'; +import {DefaultCloseMenuEvent, isElementInSubtree, isSelectableKey} from '../../menu/lib/shared.js'; +import {TYPEAHEAD_RECORD} from '../../menu/lib/typeaheadController.js'; + +import {getSelectedItems, RequestDeselectionEvent, RequestSelectionEvent, SelectOption, SelectOptionRecord} from './shared.js'; + +/** + * @fires input Fired when a selection is made by the user via mouse or keyboard + * interaction. + * @fires change Fired when a selection is made by the user via mouse or + * keyboard interaction. + */ +export abstract class Select extends LitElement { + /** + * Opens the menu synchronously with no animation. + */ + @property({type: Boolean}) quick = false; + /** + * Whether or not the select is required. + */ + @property({type: Boolean}) required = false; + /** + * Disables the select. + */ + @property({type: Boolean, reflect: true}) disabled = false; + /** + * The error message that replaces supporting text when `error` is true. If + * `errorText` is an empty string, then the supporting text will continue to + * show. + * + * Calling `reportValidity()` will automatically update `errorText` to the + * native `validationMessage`. + */ + @property({type: String}) errorText = ''; + /** + * The floating label for the field. + */ + @property() label = ''; + /** + * Conveys additional information below the text field, such as how it should + * be used. + */ + @property({type: String}) supportingText = ''; + /** + * Gets or sets whether or not the text field is in a visually invalid state. + * + * Calling `reportValidity()` will automatically update `error`. + */ + @property({type: Boolean, reflect: true}) error = false; + /** + * Whether or not the underlying md-menu should be position: fixed to display + * in a top-level manner. + */ + @property({type: Boolean}) menuFixed = false; + /** + * The max time between the keystrokes of the typeahead select / menu behavior + * before it clears the typeahead buffer. + */ + @property({type: Number}) typeaheadBufferTime = DEFAULT_TYPEAHEAD_BUFFER_TIME; + /** + * Whether or not the text field has a leading icon. Used for SSR. + */ + @property({type: Boolean}) hasLeadingIcon = false; + /** + * Whether or not the text field has a trailing icon. Used for SSR. + */ + @property({type: Boolean}) hasTrailingIcon = false; + /** + * Text to display in the field. Only set for SSR. + */ + @property() displayText = ''; + /** + * When set to true, the error text's `role="alert"` will be removed, then + * re-added after an animation frame. This will re-announce an error message + * to screen readers. + */ + @state() protected refreshErrorAlert = false; + @state() protected focused = false; + @state() protected open = false; + @query('.field') protected field!: Field; + @query('md-menu') protected menu!: Menu; + @queryAssignedElements({slot: 'leadingicon', flatten: true}) + protected readonly leadingIcons!: Element[]; + @queryAssignedElements({slot: 'trailingicon', flatten: true}) + protected readonly trailingIcons!: Element[]; + + /** + * The value of the currently selected option. + * + * Note: For SSR, set `[selected]` on the requested option and `displayText` + * rather than setting `value` setting `value` will incur a DOM query. + */ + @property() + get value(): string { + return this._value; + } + + set value(value: string) { + this.lastUserSetValue = value; + this.select(value); + } + + get options() { + // NOTE: this does a DOM query. + return (this.menu?.items ?? []) as SelectOption[]; + } + + /** + * The index of the currently selected option. + * + * Note: For SSR, set `[selected]` on the requested option and `displayText` + * rather than setting `selectedIndex` setting `selectedIndex` will incur a + * DOM query. + */ + @property({type: Number}) + get selectedIndex(): number { + // tslint:disable-next-line:enforce-name-casing + const [_option, index] = (this.getSelectedOptions() ?? [])[0] ?? []; + return index ?? -1; + } + + set selectedIndex(index: number) { + this.lastUserSetSelectedIndex = index; + this.selectIndex(index); + } + + /** + * Returns an array of selected options. + * + * NOTE: md-select only suppoprts single selection. + */ + get selectedOptions() { + return (this.getSelectedOptions() ?? []).map(([option]) => option); + } + + protected abstract readonly fieldTag: StaticValue; + // tslint:disable-next-line:enforce-name-casing + protected _value = ''; + + /** + * Used for initializing select when the user sets the `value` directly. + */ + protected lastUserSetValue: string|null = null; + + /** + * Used for initializing select when the user sets the `selectedIndex` + * directly. + */ + protected lastUserSetSelectedIndex: number|null = null; + + /** + * Used for `input` and `change` event change detection. + */ + protected lastSelectedOption: SelectOption|null = null; + + // tslint:disable-next-line:enforce-name-casing + protected _lastSelectedOptionRecords: SelectOptionRecord[] = []; + + override render(): TemplateResult { + return html` + + ${this.renderField()} + ${this.renderMenu()} + + `; + } + + protected getRenderClasses(): ClassInfo { + return { + 'disabled': this.disabled, + 'error': this.error, + }; + } + + protected renderField() { + return staticHtml` + <${this.fieldTag} + aria-haspopup="listbox" + role="combobox" + tabindex=${this.disabled ? '-1' : '0'} + aria-expanded=${this.open ? 'true' : 'false'} + class="field" + label=${this.label} + .focused=${this.focused || this.open} + .populated=${!!this.displayText} + .disabled=${this.disabled} + .required=${this.required} + .error=${this.error} + .hasStart=${this.hasLeadingIcon} + .hasEnd=${this.hasTrailingIcon} + @keydown =${this.handleKeydown} + @click=${this.handleClick} + @focus=${this.handleFocus} + @blur=${this.handleBlur}> + ${this.renderFieldContent()} + `; + } + + protected renderFieldContent() { + return [ + this.renderLeadingIcon(), + this.renderLabel(), + this.renderTrailingIcon(), + this.renderSupportingText(), + ]; + } + + protected renderLeadingIcon() { + return html` + + + + `; + } + + protected renderTrailingIcon() { + return html` + + + + `; + } + + protected renderLabel() { + // need to render   so that line-height can apply and give it a + // non-zero height + return html`
${this.displayText || html` `}
`; + } + + protected renderSupportingText() { + const text = this.getSupportingText(); + if (!text) { + return nothing; + } + + return html`${text}`; + } + + protected getSupportingText() { + return this.error && this.errorText ? this.errorText : this.supportingText; + } + + protected shouldErrorAnnounce() { + // Announce if there is an error and error text visible. + // If refreshErrorAlert is true, do not announce. This will remove the + // role="alert" attribute. Another render cycle will happen after an + // animation frame to re-add the role. + return this.error && !!this.errorText && !this.refreshErrorAlert; + } + + protected renderMenu(): TemplateResult { + return html` + + ${this.renderMenuContent()} + `; + } + + protected renderMenuContent(): TemplateResult { + return html``; + } + + /** + * Handles opening the select on keydown and typahead selection when the menu + * is closed. + */ + protected handleKeydown(e: KeyboardEvent) { + if (this.open || this.disabled) { + return; + } + + const typeaheadController = this.menu?.typeaheadController; + const isOpenKey = + e.code === 'Space' || e.code === 'ArrowDown' || e.code === 'Enter'; + + // Do not open if currently typing ahead because the user may be typing the + // spacebar to match a word with a space + if (!typeaheadController.isTypingAhead && isOpenKey) { + e.preventDefault(); + this.open = true; + return; + } + + const isPrintableKey = e.key.length === 1; + + // Handles typing ahead when the menu is closed by delegating the event to + // the underlying menu's typeaheadController + if (isPrintableKey) { + typeaheadController.onKeydown(e); + e.preventDefault(); + + const {lastActiveRecord} = typeaheadController; + + if (!lastActiveRecord) { + return; + } + + const hasChanged = this.selectItem( + lastActiveRecord[TYPEAHEAD_RECORD.ITEM] as SelectOption); + + if (hasChanged) { + this.dispatchInteractionEvents(); + } + } + } + + protected handleClick() { + this.open = true; + } + + protected handleFocus() { + this.focused = true; + } + + protected handleBlur() { + this.focused = false; + } + + /** + * Handles closing the menu when the focus leaves the select's subtree. + */ + protected handleFocusout(e: FocusEvent) { + // Don't close the menu if we are switching focus between menu, + // select-option, and field + if (e.relatedTarget && isElementInSubtree(e.relatedTarget, this)) { + return; + } + + this.open = false; + } + + /** + * Gets a list of all selected select options as a list item record array. + * + * @return An array of selected list option records. + */ + protected getSelectedOptions() { + if (!this.menu) { + this._lastSelectedOptionRecords = []; + return null; + } + + const items = this.menu.items as SelectOption[]; + this._lastSelectedOptionRecords = getSelectedItems(items); + return this._lastSelectedOptionRecords; + } + + override async getUpdateComplete() { + await this.menu?.updateComplete; + return super.getUpdateComplete(); + } + + /** + * Gets the selected options from the DOM, and updates the value and display + * text to the first selected option's value and headline respectively. + * + * @return Whether or not the selected option has changed since last update. + */ + protected updateValueAndDisplayText() { + const selectedOptions = this.getSelectedOptions() ?? []; + // Used to determine whether or not we need to fire an input / change event + // which fire whenever the option element changes (value or selectedIndex) + // on user interaction. + let hasSelectedOptionChanged = false; + + if (selectedOptions.length) { + const [firstSelectedOption] = selectedOptions[0]; + hasSelectedOptionChanged = + this.lastSelectedOption !== firstSelectedOption; + this.lastSelectedOption = firstSelectedOption; + this._value = firstSelectedOption.value; + this.displayText = firstSelectedOption.headline; + + } else { + hasSelectedOptionChanged = this.lastSelectedOption !== null; + this.lastSelectedOption = null; + this._value = ''; + this.displayText = ''; + } + + return hasSelectedOptionChanged; + } + + override update(changed: PropertyValues) { + // In SSR the options will be ready to query, so try to figure out what + // the value and display text should be. + if (!this.hasUpdated) { + this.initUserSelection(); + } + + super.update(changed); + } + + override async firstUpdated(changed: PropertyValues) { + await this.menu.updateComplete; + // If this has been handled on update already due to SSR, try again. + if (!this._lastSelectedOptionRecords.length) { + this.initUserSelection(); + } + + super.firstUpdated(changed); + } + + protected override updated(changedProperties: PropertyValues) { + // Keep changedProperties arg so that subclasses may call it + + if (this.refreshErrorAlert) { + // The past render cycle removed the role="alert" from the error message. + // Re-add it after an animation frame to re-announce the error. + requestAnimationFrame(() => { + this.refreshErrorAlert = false; + }); + } + } + + /** + * Focuses and activates the last selected item upon opening, and resets other + * active items. + */ + protected async handleOpening() { + const items = this.menu.items; + const activeItem = List.getActiveItem(items)?.item; + const [selectedItem] = this._lastSelectedOptionRecords[0] ?? [null]; + + // This is true if the user keys through the list but clicks out of the menu + // thus no close-menu event is fired by an item and we can't clean up in + // handleCloseMenu. + if (activeItem && activeItem !== selectedItem) { + activeItem.active = false; + } + + if (selectedItem) { + selectedItem.active = true; + selectedItem.focus(); + } + } + + protected handleClosing() { + this.open = false; + } + + /** + * Determines the reason for closing, and updates the UI accordingly. + */ + protected handleCloseMenu(e: InstanceType) { + const reason = e.reason; + const item = e.itemPath[0] as SelectOption; + this.open = false; + let hasChanged = false; + + if (reason.kind === 'CLICK_SELECTION') { + hasChanged = this.selectItem(item); + } else if (reason.kind === 'KEYDOWN' && isSelectableKey(reason.key)) { + hasChanged = this.selectItem(item); + } else { + // This can happen on ESC being pressed + item.active = false; + item.blur(); + } + + // Dispatch interaction events since selection has been made via keyboard + // or mouse. + if (hasChanged) { + this.dispatchInteractionEvents(); + } + } + + /** + * Selects a given option, deselects other options, and updates the UI. + * + * @return Whether the last selected option has changed. + */ + protected selectItem(item: SelectOption) { + this._lastSelectedOptionRecords.forEach(([option]) => { + if (item !== option) { + option.selected = false; + } + }); + item.selected = true; + + return this.updateValueAndDisplayText(); + } + + /** + * Handles updating selection when an option element requests selection via + * property / attribute change. + */ + protected handleRequestSelection(e: RequestSelectionEvent) { + const requestingOptionEl = e.target as SelectOption & HTMLElement; + + // No-op if this item is already selected. + if (this._lastSelectedOptionRecords.some( + ([option]) => option === requestingOptionEl)) { + return; + } + + this.selectItem(requestingOptionEl); + } + + /** + * Handles updating selection when an option element requests deselection via + * property / attribute change. + */ + protected handleRequestDeselection(e: RequestDeselectionEvent) { + const requestingOptionEl = e.target as SelectOption & HTMLElement; + + // No-op if this item is not even in the list of tracked selected items. + if (!this._lastSelectedOptionRecords.some( + ([option]) => option === requestingOptionEl)) { + return; + } + + this.updateValueAndDisplayText(); + } + + /** + * Selects an option given the value of the option, and updates MdSelect's + * value. + */ + select(value: string) { + const optionToSelect = this.options.find(option => option.value === value); + if (optionToSelect) { + this.selectItem(optionToSelect); + } + } + + /** + * Selects an option given the index of the option, and updates MdSelect's + * value. + */ + selectIndex(index: number) { + const optionToSelect = this.options[index]; + if (optionToSelect) { + this.selectItem(optionToSelect); + } + } + + /** + * Attempts to initialize the selected option from user-settable values like + * SSR, setting `value`, or `selectedIndex` at startup. + */ + protected initUserSelection() { + // User has set `.value` directly, but internals have not yet booted up. + if (this.lastUserSetValue && !this._lastSelectedOptionRecords.length) { + this.select(this.lastUserSetValue); + + // User has set `.selectedIndex` directly, but internals have not yet + // booted up. + } else if ( + this.lastUserSetSelectedIndex !== null && + !this._lastSelectedOptionRecords.length) { + this.selectIndex(this.lastUserSetSelectedIndex); + + // Regular boot up! + } else { + this.updateValueAndDisplayText(); + } + } + + protected handleIconChange() { + this.hasLeadingIcon = this.leadingIcons.length > 0; + this.hasTrailingIcon = this.trailingIcons.length > 0; + } + + /** + * Dispatches the `input` and `change` events. + */ + protected dispatchInteractionEvents() { + this.dispatchEvent(new Event('input', {bubbles: true, composed: true})); + this.dispatchEvent(new Event('change', {bubbles: true})); + } +} diff --git a/select/lib/selectoption/harness.ts b/select/lib/selectoption/harness.ts new file mode 100644 index 0000000000..2feb32b9f7 --- /dev/null +++ b/select/lib/selectoption/harness.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ListItemHarness} from '../../../list/lib/listitem/harness.js'; + +/** + * Test harness for menu item. + */ +export class SelectOptionHarness extends ListItemHarness {} diff --git a/select/lib/selectoption/select-option.ts b/select/lib/selectoption/select-option.ts new file mode 100644 index 0000000000..73e4cc3da5 --- /dev/null +++ b/select/lib/selectoption/select-option.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {MenuItemEl} from '../../../menu/lib/menuitem/menu-item.js'; +import {ARIARole} from '../../../types/aria.js'; +import {RequestDeselectionEvent, RequestSelectionEvent, SelectOption} from '../shared.js'; + +/** + * @fires close-menu {CloseMenuEvent} Closes the encapsulating menu on + * @fires request-selection {RequestSelectionEvent} Requests the parent + * md-select to select this element (and deselect others if single-selection) + * when `selected` changed to `true`. + * @fires request-deselection {RequestDeselectionEvent} Requests the parent + * md-select to deselect this element when `selected` changed to `false`. + */ +export class SelectOptionEl extends MenuItemEl implements SelectOption { + override role: ARIARole = 'option'; + + /** + * Form value of the option. + */ + @property() value = ''; + + /** + * Whether or not this option is selected. + */ + @property({type: Boolean, reflect: true}) selected = false; + + override willUpdate(changed: PropertyValues) { + if (changed.has('selected')) { + // Synchronize selected -> active but not the other way around because + // active is used for keyboard navigation and doesn't mean the option + // should be selected if active. + this.active = this.selected; + this.ariaSelected = this.selected ? 'true' : 'false'; + // By default active = true focuses the element. We want to prevent that + // in this case because we set active = this.selected and that may mess + // around with menu's restore focus function once the menu closes. + this.focusOnSelection = false; + } + + super.willUpdate(changed); + } + + override updated(changed: PropertyValues) { + super.updated(changed); + // Restore the active = true focusing behavior which happens in + // super.updated() if it was turned off. + this.focusOnSelection = true; + + // Do not dispatch event on first update / boot-up. + if (changed.has('selected') && changed.get('selected') !== undefined) { + // This section is really useful for when the user sets selected on the + // option programmatically. Most other cases (click and keyboard) are + // handled by md-select because it needs to coordinate the + // single-selection behavior. + if (this.selected) { + this.dispatchEvent(new RequestSelectionEvent()); + } else { + this.dispatchEvent(new RequestDeselectionEvent()); + } + } + } +} diff --git a/select/lib/shared-styles.scss b/select/lib/shared-styles.scss new file mode 100644 index 0000000000..c851549014 --- /dev/null +++ b/select/lib/shared-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './shared'; +// go/keep-sorted end + +@include shared.styles(); diff --git a/select/lib/shared.ts b/select/lib/shared.ts new file mode 100644 index 0000000000..0b356343df --- /dev/null +++ b/select/lib/shared.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {MenuItem} from '../../menu/lib/shared.js'; + +/** + * The interface specific to a Select Option + */ +interface SelectOptionSelf { + /** + * The form value associated with the Select Option. (Note: the visual portion + * of the SelectOption is the headline defined in ListItem) + */ + value: string; + /** + * Whether or not the SelectOption is selected. + */ + selected: boolean; +} + +/** + * The interface to implement for a select option. Additionally, the element + * must have `md-list-item` and `md-menu-item` attributes on the host. + */ +export type SelectOption = SelectOptionSelf&MenuItem; + +/** + * A type that describes a SelectOption and its index. + */ +export type SelectOptionRecord = [SelectOption, number]; + +/** + * Given a list of select options, this function will return an array of + * SelectOptionRecords that are selected. + * + * @return An array of SelectOptionRecords describing the options that are + * selected. + */ +export function getSelectedItems(items: SelectOption[]) { + const selectedItemRecords: SelectOptionRecord[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.selected) { + selectedItemRecords.push([item, i]); + } + } + + return selectedItemRecords; +} + +/** + * An event fired by a SelectOption to request selection from md-select. + * Typically fired after `selected` changes from `false` to `true`. + */ +export class RequestSelectionEvent extends Event { + constructor() { + super('request-selection', {bubbles: true, composed: true}); + } +} + +/** + * An event fired by a SelectOption to request deselection from md-select. + * Typically fired after `selected` changes from `true` to `false`. + */ +export class RequestDeselectionEvent extends Event { + constructor() { + super('request-deselection', {bubbles: true, composed: true}); + } +} diff --git a/select/outlined-select.ts b/select/outlined-select.ts new file mode 100644 index 0000000000..2ddae2b431 --- /dev/null +++ b/select/outlined-select.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators.js'; + +import {styles as outlinedForcedColorsStyles} from './lib/outlined-forced-colors-styles.css.js'; +import {OutlinedSelect} from './lib/outlined-select.js'; +import {styles} from './lib/outlined-select-styles.css.js'; +import {styles as sharedStyles} from './lib/shared-styles.css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-outlined-select': MdOutlinedSelect; + } +} + +/** + * @summary + * Select menus display a list of choices on temporary surfaces and display the + * currently selected menu item above the menu. + * + * @description + * The select component allows users to choose a value from a fixed list of + * available options. Composed of an interactive anchor button and a menu, it is + * analogous to the native HTML `` element. This is the option that + * can be placed inside of an md-select. + * + * This component is a subclass of `md-menu-item` and can accept the same slots, + * properties, and events as `md-menu-item`. + * + * @example + * ```html + * + * + * + * + * + * + * + * + * + * ``` + * + * @final + * @suppress {visibility} + */ +@customElement('md-select-option') +export class MdSelectOption extends SelectOptionEl { + static override styles = + [listItemStyles, styles, listItemForcedColorsStyles, forcedColorsStyles]; +} diff --git a/tokens/_md-comp-filled-select.scss b/tokens/_md-comp-filled-select.scss index cbf8439653..43a27a0022 100644 --- a/tokens/_md-comp-filled-select.scss +++ b/tokens/_md-comp-filled-select.scss @@ -4,14 +4,128 @@ // // go/keep-sorted start +@use 'sass:map'; +// go/keep-sorted end +// go/keep-sorted start +@use '../sass/shape'; @use './md-sys-color'; @use './md-sys-elevation'; @use './md-sys-shape'; @use './md-sys-state'; @use './md-sys-typescale'; @use './v0_172/md-comp-filled-select'; +@use './values'; // go/keep-sorted end +$supported-tokens: ( + // go/keep-sorted start + 'text-field-active-indicator-color', + 'text-field-active-indicator-height', + 'text-field-container-color', + 'text-field-container-shape', + 'text-field-disabled-active-indicator-color', + 'text-field-disabled-active-indicator-height', + 'text-field-disabled-active-indicator-opacity', + 'text-field-disabled-container-color', + 'text-field-disabled-container-opacity', + 'text-field-disabled-input-text-color', + 'text-field-disabled-input-text-opacity', + 'text-field-disabled-label-text-color', + 'text-field-disabled-label-text-opacity', + 'text-field-disabled-leading-icon-color', + 'text-field-disabled-leading-icon-opacity', + 'text-field-disabled-supporting-text-color', + 'text-field-disabled-supporting-text-opacity', + 'text-field-disabled-trailing-icon-color', + 'text-field-disabled-trailing-icon-opacity', + 'text-field-error-active-indicator-color', + 'text-field-error-focus-active-indicator-color', + 'text-field-error-focus-input-text-color', + 'text-field-error-focus-label-text-color', + 'text-field-error-focus-leading-icon-color', + 'text-field-error-focus-supporting-text-color', + 'text-field-error-focus-trailing-icon-color', + 'text-field-error-hover-active-indicator-color', + 'text-field-error-hover-input-text-color', + 'text-field-error-hover-label-text-color', + 'text-field-error-hover-leading-icon-color', + 'text-field-error-hover-state-layer-color', + 'text-field-error-hover-state-layer-opacity', + 'text-field-error-hover-supporting-text-color', + 'text-field-error-hover-trailing-icon-color', + 'text-field-error-input-text-color', + 'text-field-error-label-text-color', + 'text-field-error-leading-icon-color', + 'text-field-error-supporting-text-color', + 'text-field-error-trailing-icon-color', + 'text-field-focus-active-indicator-color', + 'text-field-focus-active-indicator-height', + 'text-field-focus-input-text-color', + 'text-field-focus-label-text-color', + 'text-field-focus-leading-icon-color', + 'text-field-focus-supporting-text-color', + 'text-field-focus-trailing-icon-color', + 'text-field-hover-active-indicator-color', + 'text-field-hover-active-indicator-height', + 'text-field-hover-input-text-color', + 'text-field-hover-label-text-color', + 'text-field-hover-leading-icon-color', + 'text-field-hover-state-layer-color', + 'text-field-hover-state-layer-opacity', + 'text-field-hover-supporting-text-color', + 'text-field-hover-trailing-icon-color', + 'text-field-input-text-color', + 'text-field-label-text-color', + 'text-field-label-text-populated-size', + 'text-field-leading-icon-color', + 'text-field-leading-icon-size', + 'text-field-supporting-text-color', + 'text-field-trailing-icon-color', + 'text-field-trailing-icon-size', + // go/keep-sorted end +); + +$unsupported-tokens: ( + // go/keep-sorted start + 'menu-cascading-menu-indicator-icon-color', + 'menu-cascading-menu-indicator-icon-size', + 'menu-container-color', + 'menu-container-elevation', + 'menu-container-shadow-color', + 'menu-container-shape', + 'menu-divider-color', + 'menu-divider-height', + 'menu-list-item-container-height', + 'menu-list-item-label-text-color', + 'menu-list-item-label-text-font', + 'menu-list-item-label-text-line-height', + 'menu-list-item-label-text-size', + 'menu-list-item-label-text-tracking', + 'menu-list-item-label-text-type', + 'menu-list-item-label-text-weight', + 'menu-list-item-selected-container-color', + 'menu-list-item-with-leading-icon-leading-icon-color', + 'menu-list-item-with-leading-icon-leading-icon-size', + 'menu-list-item-with-trailing-icon-trailing-icon-color', + 'menu-list-item-with-trailing-icon-trailing-icon-size', + 'text-field-input-text-font', + 'text-field-input-text-line-height', + 'text-field-input-text-size', + 'text-field-input-text-tracking', + 'text-field-input-text-weight', + 'text-field-label-text-font', + 'text-field-label-text-line-height', + 'text-field-label-text-size', + 'text-field-label-text-tracking', + 'text-field-label-text-weight', + 'text-field-supporting-text-font', + 'text-field-supporting-text-line-height', + 'text-field-supporting-text-size', + 'text-field-supporting-text-tracking', + 'text-field-supporting-text-weight', + // go/keep-sorted end +); + $_default: ( 'md-sys-color': md-sys-color.values-light(), 'md-sys-elevation': md-sys-elevation.values(), @@ -21,5 +135,40 @@ $_default: ( ); @function values($deps: $_default, $exclude-hardcoded-values: false) { - @return md-comp-filled-select.values($deps, $exclude-hardcoded-values); + $tokens: values.validate( + md-comp-filled-select.values($deps, $exclude-hardcoded-values), + $supported-tokens: $supported-tokens, + $unsupported-tokens: $unsupported-tokens + ); + + @each $token, $value in $tokens { + $tokens: map.set( + $tokens, + $token, + var(--md-filled-select-#{$token}, #{$value}) + ); + } + + $tokens: shape.resolve-tokens($tokens, 'text-field-container-shape'); + @return $tokens; +} + +// TODO(b/276957188): Remove this when we resolve issues with values fn. +@function theme-values($deps: $_default, $exclude-hardcoded-values: false) { + $tokens: values.validate( + md-comp-filled-select.values($deps, $exclude-hardcoded-values), + $supported-tokens: $supported-tokens, + $unsupported-tokens: $unsupported-tokens + ); + + @each $token, $value in $tokens { + @if $value { + $tokens: map.set($tokens, $token, #{$value}); + } @else { + $tokens: map.remove($tokens, $token); + } + } + + $tokens: shape.resolve-tokens($tokens, 'text-field-container-shape'); + @return $tokens; } diff --git a/tokens/_md-comp-outlined-select.scss b/tokens/_md-comp-outlined-select.scss index fc9488badf..9ae57fa86d 100644 --- a/tokens/_md-comp-outlined-select.scss +++ b/tokens/_md-comp-outlined-select.scss @@ -4,14 +4,130 @@ // // go/keep-sorted start +@use 'sass:map'; +@use 'sass:string'; +// go/keep-sorted end +// go/keep-sorted start +@use '../sass/shape'; @use './md-sys-color'; @use './md-sys-elevation'; @use './md-sys-shape'; @use './md-sys-state'; @use './md-sys-typescale'; @use './v0_172/md-comp-outlined-select'; +@use './values'; // go/keep-sorted end +$supported-tokens: ( + // go/keep-sorted start + 'text-field-container-shape', + 'text-field-disabled-input-text-color', + 'text-field-disabled-input-text-opacity', + 'text-field-disabled-label-text-color', + 'text-field-disabled-label-text-opacity', + 'text-field-disabled-leading-icon-color', + 'text-field-disabled-leading-icon-opacity', + 'text-field-disabled-outline-color', + 'text-field-disabled-outline-opacity', + 'text-field-disabled-outline-width', + 'text-field-disabled-supporting-text-color', + 'text-field-disabled-supporting-text-opacity', + 'text-field-disabled-trailing-icon-color', + 'text-field-disabled-trailing-icon-opacity', + 'text-field-error-focus-input-text-color', + 'text-field-error-focus-label-text-color', + 'text-field-error-focus-leading-icon-color', + 'text-field-error-focus-outline-color', + 'text-field-error-focus-supporting-text-color', + 'text-field-error-focus-trailing-icon-color', + 'text-field-error-hover-input-text-color', + 'text-field-error-hover-label-text-color', + 'text-field-error-hover-leading-icon-color', + 'text-field-error-hover-outline-color', + 'text-field-error-hover-supporting-text-color', + 'text-field-error-hover-trailing-icon-color', + 'text-field-error-input-text-color', + 'text-field-error-label-text-color', + 'text-field-error-leading-icon-color', + 'text-field-error-outline-color', + 'text-field-error-supporting-text-color', + 'text-field-error-trailing-icon-color', + 'text-field-focus-input-text-color', + 'text-field-focus-label-text-color', + 'text-field-focus-leading-icon-color', + 'text-field-focus-outline-color', + 'text-field-focus-outline-width', + 'text-field-focus-supporting-text-color', + 'text-field-focus-trailing-icon-color', + 'text-field-hover-input-text-color', + 'text-field-hover-label-text-color', + 'text-field-hover-leading-icon-color', + 'text-field-hover-outline-color', + 'text-field-hover-outline-width', + 'text-field-hover-supporting-text-color', + 'text-field-hover-trailing-icon-color', + 'text-field-input-text-color', + 'text-field-input-text-type', + 'text-field-label-text-color', + 'text-field-label-text-populated-size', + 'text-field-label-text-type', + 'text-field-leading-icon-color', + 'text-field-leading-icon-size', + 'text-field-outline-color', + 'text-field-outline-width', + 'text-field-supporting-text-color', + 'text-field-supporting-text-type', + 'text-field-trailing-icon-color', + 'text-field-trailing-icon-size', + // go/keep-sorted end +); + +$unsupported-tokens: ( + // go/keep-sorted start + 'menu-cascading-menu-indicator-icon-color', + 'menu-cascading-menu-indicator-icon-size', + 'menu-container-color', + 'menu-container-elevation', + 'menu-container-shadow-color', + 'menu-container-shape', + 'menu-divider-color', + 'menu-divider-height', + 'menu-list-item-container-height', + 'menu-list-item-label-text-color', + 'menu-list-item-label-text-font', + 'menu-list-item-label-text-line-height', + 'menu-list-item-label-text-size', + 'menu-list-item-label-text-tracking', + 'menu-list-item-label-text-type', + 'menu-list-item-label-text-weight', + 'menu-list-item-selected-container-color', + 'menu-list-item-with-leading-icon-leading-icon-color', + 'menu-list-item-with-leading-icon-leading-icon-size', + 'menu-list-item-with-trailing-icon-trailing-icon-color', + 'menu-list-item-with-trailing-icon-trailing-icon-size', + 'text-field-container-color', + 'text-field-error-hover-state-layer-color', + 'text-field-error-hover-state-layer-opacity', + 'text-field-hover-state-layer-color', + 'text-field-hover-state-layer-opacity', + 'text-field-input-text-font', + 'text-field-input-text-line-height', + 'text-field-input-text-size', + 'text-field-input-text-tracking', + 'text-field-input-text-weight', + 'text-field-label-text-font', + 'text-field-label-text-line-height', + 'text-field-label-text-size', + 'text-field-label-text-tracking', + 'text-field-label-text-weight', + 'text-field-supporting-text-font', + 'text-field-supporting-text-line-height', + 'text-field-supporting-text-size', + 'text-field-supporting-text-tracking', + 'text-field-supporting-text-weight', + // go/keep-sorted end +); + $_default: ( 'md-sys-color': md-sys-color.values-light(), 'md-sys-elevation': md-sys-elevation.values(), @@ -21,5 +137,38 @@ $_default: ( ); @function values($deps: $_default, $exclude-hardcoded-values: false) { - @return md-comp-outlined-select.values($deps, $exclude-hardcoded-values); + $tokens: values.validate( + md-comp-outlined-select.values($deps, $exclude-hardcoded-values), + $supported-tokens: $supported-tokens, + $unsupported-tokens: $unsupported-tokens + ); + + @each $token, $value in $tokens { + $tokens: map.set( + $tokens, + $token, + var(--md-outlined-select-#{$token}, #{$value}) + ); + } + + @return $tokens; +} + +// TODO(b/276957188): Remove this when we resolve issues with values fn. +@function theme-values($deps: $_default, $exclude-hardcoded-values: false) { + $tokens: values.validate( + md-comp-outlined-select.values($deps, $exclude-hardcoded-values), + $supported-tokens: $supported-tokens, + $unsupported-tokens: $unsupported-tokens + ); + + @each $token, $value in $tokens { + @if $value { + $tokens: map.set($tokens, $token, #{$value}); + } @else { + $tokens: map.remove($tokens, $token); + } + } + + @return $tokens; }