From cbb24dfbc3e30ff05040e77dd0564d0c9b4b56aa Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Mon, 8 May 2023 17:29:12 -0700 Subject: [PATCH] feat(tabs): adds tabs and tab element PiperOrigin-RevId: 530452330 --- README.md | 2 +- tabs/_tab.scss | 6 + tabs/harness.ts | 41 +++ tabs/lib/_tab.scss | 289 ++++++++++++++++ tabs/lib/_tabs.scss | 44 +++ tabs/lib/tab-styles.scss | 10 + tabs/lib/tab.ts | 202 +++++++++++ tabs/lib/tabs-styles.scss | 10 + tabs/lib/tabs.ts | 327 ++++++++++++++++++ tabs/tab.ts | 28 ++ tabs/tabs.ts | 30 ++ tabs/tabs_test.ts | 85 +++++ tokens/_index.scss | 4 +- tokens/_md-comp-primary-navigation-tab.scss | 50 --- tokens/_md-comp-secondary-navigation-tab.scss | 49 --- tokens/_md-comp-tab.scss | 292 ++++++++++++++++ 16 files changed, 1366 insertions(+), 103 deletions(-) create mode 100644 tabs/_tab.scss create mode 100644 tabs/harness.ts create mode 100644 tabs/lib/_tab.scss create mode 100644 tabs/lib/_tabs.scss create mode 100644 tabs/lib/tab-styles.scss create mode 100644 tabs/lib/tab.ts create mode 100644 tabs/lib/tabs-styles.scss create mode 100644 tabs/lib/tabs.ts create mode 100644 tabs/tab.ts create mode 100644 tabs/tabs.ts create mode 100644 tabs/tabs_test.ts delete mode 100644 tokens/_md-comp-primary-navigation-tab.scss delete mode 100644 tokens/_md-comp-secondary-navigation-tab.scss create mode 100644 tokens/_md-comp-tab.scss diff --git a/README.md b/README.md index ed56cb84ef..7249252761 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Ripple | ✅ | ✅ | 🟡 Select | ✅ | ✅ | ❌ Slider | ✅ | ✅ | ❌ Switch | ✅ | ✅ | ❌ -Tabs | 🟡 | ❌ | ❌ +Tabs | ✅ | 🟡 | ❌ Text field | ✅ | ✅ | 🟡 ### 1.1+ Components diff --git a/tabs/_tab.scss b/tabs/_tab.scss new file mode 100644 index 0000000000..8709ac7412 --- /dev/null +++ b/tabs/_tab.scss @@ -0,0 +1,6 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@forward './lib/tab' show theme; diff --git a/tabs/harness.ts b/tabs/harness.ts new file mode 100644 index 0000000000..2516dd7246 --- /dev/null +++ b/tabs/harness.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ElementWithHarness, Harness} from '../testing/harness.js'; + +import {Tab} from './lib/tab.js'; +import {Tabs} from './lib/tabs.js'; + +/** + * Test harness for Tab. + */ +export class TabHarness extends Harness { + override async getInteractiveElement() { + await this.element.updateComplete; + return this.element.querySelector( + '.button')!; + } + + async isIndicatorShowing() { + await this.element.updateComplete; + const opacity = getComputedStyle(this.element.indicator)['opacity']; + return opacity === '1'; + } +} + +/** + * Test harness for Tabs. + */ +export class TabsHarness extends Harness { + get harnessedItems() { + // Test access to protected property + // tslint:disable-next-line:no-dict-access-on-struct-type + return (this.element['items'] as Array>) + .map(item => { + return (item.harness ?? new TabHarness(item)) as TabHarness; + }); + } +} diff --git a/tabs/lib/_tab.scss b/tabs/lib/_tab.scss new file mode 100644 index 0000000000..886c09f1f5 --- /dev/null +++ b/tabs/lib/_tab.scss @@ -0,0 +1,289 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use 'sass:map'; +// go/keep-sorted end +// go/keep-sorted start +@use '../../elevation/elevation'; +@use '../../focus/focus-ring'; +@use '../../ripple/ripple'; +@use '../../sass/string-ext'; +@use '../../sass/theme'; +@use '../../tokens'; +// go/keep-sorted end + +@mixin theme($tokens) { + $reference: tokens.md-comp-tab-values(); + $tokens: theme.validate-theme($reference, $tokens); + $tokens: theme.create-theme-vars($tokens, ''); + @include theme.emit-theme-vars($tokens); +} + +@mixin styles() { + // contains tokens for all variants and applied where needed + $tokens: theme.create-theme-vars(tokens.md-comp-tab-values(), ''); + + :host { + // apply primary-tokens by default + $primary-prefix: 'primary-tab-'; + @each $token, $value in $tokens { + @if string-ext.has-prefix($token, $primary-prefix) { + $token: string-ext.trim-prefix(#{$token}, $primary-prefix); + --_#{$token}: #{$value}; + } + } + + display: inline-flex; + outline: none; + -webkit-tap-highlight-color: transparent; + vertical-align: middle; + + @include ripple.theme( + ( + focus-color: var(--_focus-state-layer-color), + focus-opacity: var(--_focus-state-layer-opacity), + hover-color: var(--_hover-state-layer-color), + hover-opacity: var(--_hover-state-layer-opacity), + pressed-color: var(--_pressed-state-layer-color), + pressed-opacity: var(--_pressed-state-layer-opacity), + ) + ); + + @include focus-ring.theme( + ( + shape: 8px, + offset: -7px, + ) + ); + } + + .button { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: none; + outline: none; + user-select: none; + -webkit-appearance: none; + vertical-align: middle; + background: transparent; + text-decoration: none; + width: 100%; + position: relative; + padding: 0; + margin: 0; + z-index: 0; // Ensure this is a stacking context so the indicator displays + font: var(--_label-text-type); + background-color: var(--_container-color); + border-bottom: var(--_divider-thickness) solid var(--_divider-color); + color: var(--_label-text-color); + + &::-moz-focus-inner { + padding: 0; + border: 0; + } + } + + .button, + md-ripple { + border-radius: var(--_container-shape); + } + + .touch { + position: absolute; + top: 50%; + height: 48px; + left: 0; + right: 0; + transform: translateY(-50%); + } + + .content { + position: relative; + box-sizing: border-box; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + + // TODO (b/261201556) replace with spacing token + $_content-padding: 8px; + // tabs are naturally sized up to their max height. + max-height: calc(var(--_container-height) + 2 * $_content-padding); + padding: $_content-padding; + gap: 4px; + } + + .content.inline-icon { + flex-direction: row; + } + + .indicator { + position: absolute; + box-sizing: border-box; + z-index: -1; + transform-origin: bottom left; + background: var(--_active-indicator-color); + border-radius: var(--_active-indicator-shape); + height: var(--_active-indicator-height); + inset: auto 0 0 0; + // hidden unless the tab is selected + opacity: 0; + } + + // unselected states + .button ::slotted([slot='icon']) { + display: inline-flex; + position: relative; + writing-mode: horizontal-tb; + fill: currentColor; + color: var(--_icon-color); + font-size: var(--_icon-size); + width: var(--_icon-size); + height: var(--_icon-size); + } + + .button:hover { + color: var(--_hover-label-text-color); + cursor: pointer; + } + + .button:hover ::slotted([slot='icon']) { + color: var(--_hover-icon-color); + } + + .button:focus { + color: var(--_focus-label-text-color); + } + + .button:focus ::slotted([slot='icon']) { + color: var(--_focus-icon-color); + } + + .button:active { + color: var(--_pressed-label-text-color); + outline: none; + } + + .button:active ::slotted([slot='icon']) { + color: var(--_pressed-icon-color); + } + + // selected styling + :host([selected]) .indicator { + opacity: 1; + } + :host([selected]) .button { + color: var(--_active-label-text-color); + @include elevation.theme( + ( + level: var(--_container-elevation), + ) + ); + + @include ripple.theme( + ( + focus-color: var(--_active-focus-state-layer-color), + focus-opacity: var(--_active-focus-state-layer-opacity), + hover-color: var(--_active-hover-state-layer-color), + hover-opacity: var(--_active-hover-state-layer-opacity), + pressed-color: var(--_active-pressed-state-layer-color), + pressed-opacity: var(--_active-pressed-state-layer-opacity), + ) + ); + } + + :host([selected]) .button ::slotted([slot='icon']) { + color: var(--_active-icon-color); + } + + // selected states + :host([selected]) .button:hover { + color: var(--_active-hover-label-text-color); + } + + :host([selected]) .button:hover ::slotted([slot='icon']) { + color: var(--_active-hover-icon-color); + } + + :host([selected]) .button:focus { + color: var(--_active-focus-label-text-color); + } + + :host([selected]) .button:focus ::slotted([slot='icon']) { + color: var(--_active-focus-icon-color); + } + + :host([selected]) .button:active { + color: var(--_active-pressed-label-text-color); + } + + :host([selected]) .button:active ::slotted([slot='icon']) { + color: var(--_active-pressed-icon-color); + } + + // TODO (b/261201556) implement disabled and high contrast mode + // styling in beta version. + // disabled state + :host([disabled]) { + cursor: default; + pointer-events: none; + // TODO (b/261201556) implement disabled styling in beta version. + opacity: 0.38; + } + + // secondary + :host([variant~='secondary']) { + // apply secondary-tab tokens + $secondary-prefix: 'secondary-tab-'; + @each $token, $value in $tokens { + @if string-ext.has-prefix($token, $secondary-prefix) { + $token: string-ext.trim-prefix(#{$token}, $secondary-prefix); + --_#{$token}: #{$value}; + } + } + } + + :host([variant~='secondary']) .content { + width: 100%; + } + + :host([variant~='secondary']) .indicator { + min-width: 100%; + } + + // vertical (no tokens for vertical as yet) + :host([variant~='vertical']) { + flex: 0; + } + + :host([variant~='vertical']) .button { + width: 100%; + flex-direction: row; + border-bottom: none; + border-right: var(--_divider-thickness) solid var(--_divider-color); + } + + :host([variant~='vertical']) .content { + width: 100%; + } + + :host([variant~='vertical']) .indicator { + height: 100%; + min-width: var(--_active-indicator-height); + inset: 0 0 0 auto; + } + + :host([variant~='vertical'][variant~='primary']) { + --_active-indicator-shape: 9999px 0 0 9999px; + } + + :host, + ::slotted(*) { + white-space: nowrap; + } +} diff --git a/tabs/lib/_tabs.scss b/tabs/lib/_tabs.scss new file mode 100644 index 0000000000..137987f14b --- /dev/null +++ b/tabs/lib/_tabs.scss @@ -0,0 +1,44 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Note, there are currently no tokens for tabs. Instead, tabs are entirely +// themed via primary/secondary tab. +@mixin styles() { + :host { + box-sizing: border-box; + display: flex; + justify-content: space-between; + align-items: center; + overflow: auto; + scroll-behavior: smooth; + scrollbar-width: none; + position: relative; + } + + :host([hidden]) { + display: none; + } + + :host([variant~='vertical']:not([hidden])) { + display: inline-flex; + flex-direction: column; + align-items: stretch; + gap: 0px; + } + + :host::-webkit-scrollbar { + display: none; + } + + ::slotted(*) { + flex: 1; + } + + // draw selected on top so its indicator can be transitioned from the + // previously selected tab, on top of it + ::slotted([selected]) { + z-index: 1; + } +} diff --git a/tabs/lib/tab-styles.scss b/tabs/lib/tab-styles.scss new file mode 100644 index 0000000000..82f9e08dbf --- /dev/null +++ b/tabs/lib/tab-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './tab'; +// go/keep-sorted end + +@include tab.styles; diff --git a/tabs/lib/tab.ts b/tabs/lib/tab.ts new file mode 100644 index 0000000000..d081c209a2 --- /dev/null +++ b/tabs/lib/tab.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../../elevation/elevation.js'; +import '../../focus/focus-ring.js'; +import '../../ripple/ripple.js'; + +import {html, isServer, LitElement, nothing, PropertyValues} from 'lit'; +import {property, query, queryAsync, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {when} from 'lit/directives/when.js'; + +import {requestUpdateOnAriaChange} from '../../aria/delegate.js'; +import {dispatchActivationClick, isActivationClick} from '../../controller/events.js'; +import {ripple} from '../../ripple/directive.js'; +import {MdRipple} from '../../ripple/ripple.js'; + +/** + * An element that can select items. + */ +export interface SelectionGroupElement extends HTMLElement { + selected?: number; + selectedItem?: Tab; + previousSelectedItem?: Tab; +} + +type Style = ''|'primary'|'secondary'; +type Orientation = ''|'vertical'; + +/** + * Tab variant can be `primary` or `secondary and can include a space + * separated `vertical`. + */ +export type Variant = Style|`${Style} ${Orientation}`|`${Orientation} ${Style}`; + +/** + * Tab component. + */ +export class Tab extends LitElement { + static { + requestUpdateOnAriaChange(this); + } + + static override shadowRootOptions: + ShadowRootInit = {mode: 'open', delegatesFocus: true}; + + /** + * Styling variant to display, 'primary' or 'secondary' and can also + * include `vertical`. + * Defaults to `primary`. + */ + @property({reflect: true}) variant: Variant = 'primary'; + + /** + * Whether or not the item is `disabled`. + */ + @property({type: Boolean, reflect: true}) disabled = false; + + /** + * Whether or not the item is `selected`. + **/ + @property({type: Boolean, reflect: true}) selected = false; + + /** + * Whether or not the icon renders inline with label or stacked vertically. + */ + @property({type: Boolean}) inlineIcon = false; + + @query('.button') private readonly button!: HTMLElement|null; + + @queryAsync('md-ripple') private readonly ripple!: Promise; + + // note, this is public so it can participate in selection animation. + /** + * Selection indicator element. + */ + @query('.indicator') readonly indicator!: HTMLElement; + + @state() private showRipple = false; + + // whether or not selection state can be animated; used to avoid initial + // animation and becomes true one task after first update. + private canAnimate = false; + + constructor() { + super(); + if (!isServer) { + this.addEventListener('click', this.handleActivationClick); + } + } + + override focus() { + this.button?.focus(); + } + + override blur() { + this.button?.blur(); + } + + protected override render() { + const contentClasses = { + 'inline-icon': this.inlineIcon, + }; + return html` + `; + } + + protected override async firstUpdated() { + await new Promise(requestAnimationFrame); + this.canAnimate = true; + } + + protected override updated(changed: PropertyValues) { + if (changed.has('selected') && this.shouldAnimate()) { + this.animateSelected(); + } + } + + private readonly handleActivationClick = (event: MouseEvent) => { + if (!isActivationClick((event)) || !this.button) { + return; + } + this.focus(); + dispatchActivationClick(this.button); + }; + + private shouldAnimate() { + return this.canAnimate && !this.disabled && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + private readonly getRipple = () => { + this.showRipple = true; + return this.ripple; + }; + + private readonly renderRipple = () => { + return html``; + }; + + private get selectionGroup() { + return this.parentElement as SelectionGroupElement; + } + + private animateSelected() { + this.indicator.getAnimations().forEach(a => { + a.cancel(); + }); + const frames = this.getKeyframes(); + if (frames !== null) { + this.indicator.animate(frames, {duration: 400, easing: 'ease-out'}); + } + } + + private getKeyframes() { + if (!this.selected) { + return null; + } + const from: Keyframe = {}; + const isVertical = this.variant.includes('vertical'); + const fromRect = + (this.selectionGroup?.previousSelectedItem?.indicator + .getBoundingClientRect() ?? + ({} as DOMRect)); + const fromPos = isVertical ? fromRect.top : fromRect.left; + const fromExtent = isVertical ? fromRect.height : fromRect.width; + const toRect = this.indicator.getBoundingClientRect(); + const toPos = isVertical ? toRect.top : toRect.left; + const toExtent = isVertical ? toRect.height : toRect.width; + const axis = isVertical ? 'Y' : 'X'; + const scale = fromExtent / toExtent; + if (fromPos !== undefined && toPos !== undefined && !isNaN(scale)) { + from['transform'] = `translate${axis}(${ + (fromPos - toPos).toFixed(4)}px) scale${axis}(${scale.toFixed(4)})`; + } else { + from['opacity'] = 0; + } + // note, including `transform: none` avoids quirky Safari behavior + // that can hide the animation. + return [from, {'transform': 'none'}]; + } +} diff --git a/tabs/lib/tabs-styles.scss b/tabs/lib/tabs-styles.scss new file mode 100644 index 0000000000..10cf418614 --- /dev/null +++ b/tabs/lib/tabs-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './tabs'; +// go/keep-sorted end + +@include tabs.styles; diff --git a/tabs/lib/tabs.ts b/tabs/lib/tabs.ts new file mode 100644 index 0000000000..66db74dccc --- /dev/null +++ b/tabs/lib/tabs.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html, isServer, LitElement, PropertyValues} from 'lit'; +import {property, state} from 'lit/decorators.js'; + +import {Variant} from './tab.js'; + +/** + * Type for list items. + */ +export interface Tab extends HTMLElement { + disabled?: boolean; + selected?: boolean; + variant?: string; +} + +const NAVIGATION_KEYS = new Map([ + ['default', new Set(['Home', 'End', 'Space'])], + ['horizontal', new Set(['ArrowLeft', 'ArrowRight'])], + ['vertical', new Set(['ArrowUp', 'ArrowDown'])] +]); + +/** + * @fires change Fired when the selected tab changes. The target's selected or + * selectedItem and previousSelected or previousSelectedItem provide information + * about the selection change. The change event is fired when a user interaction + * like a space/enter key or click cause a selection change. The tab selection + * based on these actions can be cancelled by calling preventDefault on the + * triggering `keydown` or `click` event. + * + * @example + * // perform an action if a tab is clicked + * tabs.addEventListener('change', (event: Event) => { + * if (event.target.selected === 2) + * takeAction(); + * } + * }); + * + * // prevent a click from triggering tab selection under some condition + * tabs.addEventListener('click', (event: Event) => { + * if (notReady) + * event.preventDefault(); + * } + * }); + * + */ +export class Tabs extends LitElement { + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true + }; + + /** + * Styling variant to display, 'primary' or 'secondary' and can also + * include `vertical`. + * Defaults to `primary`. + */ + @property({reflect: true}) variant: Variant = 'primary'; + + /** + * Whether or not the item is `disabled`. + */ + @property({type: Boolean}) disabled = false; + + /** + * Index of the selected item. + */ + @property({type: Number}) selected = 0; + + /** + * Whether or not to select an item when focused. + */ + @property({type: Boolean}) selectOnFocus = false; + + private previousSelected = -1; + private orientation = 'horizontal'; + private readonly scrollMargin = 48; + // note, populated via slotchange. + @state() private items: Tab[] = []; + + private readonly selectedAttribute = `selected`; + + /** + * The item currently selected. + */ + get selectedItem() { + return this.items[this.selected]; + } + + /** + * The item previously selected. + */ + get previousSelectedItem() { + return this.items[this.previousSelected]; + } + + /** + * The item currently focused. + */ + protected get focusedItem() { + return this.items.find((e: HTMLElement) => e.matches(':focus-within')); + } + + constructor() { + super(); + if (!isServer) { + this.addEventListener('keydown', this.handleKeydown); + this.addEventListener('keyup', this.handleKeyup); + this.addEventListener('focusout', this.handleFocusout); + } + } + + // focus item on keydown and optionally select it + private readonly handleKeydown = async (event: KeyboardEvent) => { + const {key} = event; + const shouldHandleKey = NAVIGATION_KEYS.get('default')!.has(key) || + NAVIGATION_KEYS.get(this.orientation)!.has(key); + // await to after user may cancel event. + if (!shouldHandleKey || (await this.wasEventPrevented(event, true)) || + this.disabled) { + return; + } + let indexToFocus = -1; + const focused = this.focusedItem ?? this.selectedItem; + const itemCount = this.items.length; + const isPrevKey = key === 'ArrowLeft' || key === 'ArrowUp'; + const isNextKey = key === 'ArrowRight' || key === 'ArrowDown'; + if (key === 'Home') { + indexToFocus = 0; + } else if (key === 'End') { + indexToFocus = itemCount - 1; + } else if (key === 'Space') { + indexToFocus = this.items.indexOf(focused); + } else if (isPrevKey || isNextKey) { + const d = (this.items.indexOf(focused) || 0) + + (isPrevKey ? -1 : + isNextKey ? 1 : + 0); + indexToFocus = d < 0 ? itemCount - 1 : d % itemCount; + } + const itemToFocus = + this.findFocusableItem(indexToFocus, key === 'End' || isPrevKey); + indexToFocus = this.items.indexOf(itemToFocus!); + if (itemToFocus !== null && itemToFocus !== focused) { + const shouldSelect = this.selectOnFocus || key === 'Space'; + if (shouldSelect) { + this.selected = indexToFocus; + } + this.updateFocusableItem(itemToFocus); + itemToFocus.focus(); + if (shouldSelect) { + await this.dispatchInteraction(); + } + } + }; + + // scroll to item on keyup. + private readonly handleKeyup = () => { + this.scrollItemIntoView(this.focusedItem ?? this.selectedItem); + }; + + // restore focus to selected item when blurring. + private readonly handleFocusout = async () => { + await this.updateComplete; + const nowFocused = + (this.getRootNode() as unknown as DocumentOrShadowRoot).activeElement as + Tab; + if (this.items.indexOf(nowFocused) === -1) { + this.updateFocusableItem(this.selectedItem); + } + }; + + private findFocusableItem(i = -1, prev = false, tries = 0): Tab|null { + const itemCount = this.items.length - 1; + while (this.items[i]?.disabled && tries <= itemCount) { + tries++; + i = (i + (prev ? -1 : 1)); + if (i > itemCount) { + return this.findFocusableItem(0, false, tries); + } else if (i < 0) { + return this.findFocusableItem(itemCount, true, tries); + } + } + return this.items[i] ?? null; + } + + // Note, this is async to allow the event to bubble to user code, which + // may call `preventDefault`. If it does, avoid performing the tabs action + // which is selecting a new tab. Sometimes, the native event must be + // prevented to avoid, for example, scrolling. In this case, the event is + // patched to be able to detect if the user calls prevent default. + // Alternatively, the event could be stopped and re-dispatched synchroously, + // but this would be complicated since the event should be re-dispatched from + // the initial element to potentially trigger a native action (e.g. a history + // navigation via a tab label), and this could result in some listener hearing + // 2x events. + private async wasEventPrevented(event: Event, preventNativeDefault = false) { + if (preventNativeDefault) { + // prevent native default to stop, e.g. scrolling. + event.preventDefault(); + // reset prevention to see if user is cancelling this action. + Object.defineProperties(event, { + 'defaultPrevented': {value: false, writable: true, configurable: true}, + 'preventDefault': { + value() { + this.defaultPrevented = true; + }, + writable: true, + configurable: true + } + }); + } + // allow event to propagate to user code. + await new Promise(requestAnimationFrame); + return event.defaultPrevented; + } + + private async dispatchInteraction() { + // wait for items to render. + await new Promise(requestAnimationFrame); + const event = new Event('change', {bubbles: true}); + this.dispatchEvent(event); + } + + protected override willUpdate(changed: PropertyValues) { + if (changed.has('selected')) { + this.previousSelected = changed.get('selected') ?? -1; + } + if (changed.has('variant')) { + this.orientation = + this.variant.includes('vertical') ? 'vertical' : 'horizontal'; + } + } + + protected override async updated(changed: PropertyValues) { + // if there's no items, they may not be ready, so wait before syncronizing + if (this.items.length === 0) { + await new Promise(requestAnimationFrame); + } + const itemsOrVariantChanged = + changed.has('items') || changed.has('variant'); + // sync variant with items. + if (itemsOrVariantChanged || changed.has('disabled')) { + this.items.forEach(i => { + i.variant = this.variant; + i.disabled = this.disabled; + }); + } + if (itemsOrVariantChanged || changed.has('selected')) { + if (this.previousSelectedItem !== this.selectedItem) { + this.previousSelectedItem?.removeAttribute(this.selectedAttribute); + this.selectedItem?.setAttribute(this.selectedAttribute, ''); + } + if (this.selectedItem !== this.focusedItem) { + this.updateFocusableItem(this.selectedItem); + } + await this.scrollItemIntoView(); + } + } + + private updateFocusableItem(item: HTMLElement|null) { + const tabIndex = 'tabindex'; + this.items.forEach(e => { + if (e === item) { + e.removeAttribute(tabIndex); + } else { + e.setAttribute(tabIndex, '-1'); + } + }); + } + + protected override render() { + return html` + + `; + } + + private async handleItemClick(event: Event) { + const {target} = event; + if (await this.wasEventPrevented(event)) { + return; + } + const item = (target as Element).closest(`${this.localName} > *`) as Tab; + const i = this.items.indexOf(item); + if (i > -1 && this.selected !== i) { + this.selected = i; + this.updateFocusableItem(this.selectedItem); + // note, Safari will not focus the button here, but if focus is manually + // triggered, this can match focus-visible and show the focus-ring, + // so avoid the temptation to cal focus! + await this.dispatchInteraction(); + } + } + + private handleSlotChange(e: Event) { + this.items = + (e.target as HTMLSlotElement).assignedElements({flatten: true}) as + Tab[]; + } + + // ensures the given item is visible in view; defaults to the selected item + private async scrollItemIntoView(item = this.selectedItem) { + if (!item) { + return; + } + // wait for items to render. + await new Promise(requestAnimationFrame); + const isVertical = this.orientation === 'vertical'; + const offset = isVertical ? item.offsetTop : item.offsetLeft; + const extent = isVertical ? item.offsetHeight : item.offsetWidth; + const scroll = isVertical ? this.scrollTop : this.scrollLeft; + const hostExtent = isVertical ? this.offsetHeight : this.offsetWidth; + const min = offset - this.scrollMargin; + const max = offset + extent - hostExtent + this.scrollMargin; + const to = Math.min(min, Math.max(max, scroll)); + this.scrollTo({ + behavior: 'smooth', + [isVertical ? 'left' : 'top']: 0, + [isVertical ? 'top' : 'left']: to + }); + } +} diff --git a/tabs/tab.ts b/tabs/tab.ts new file mode 100644 index 0000000000..31a625af61 --- /dev/null +++ b/tabs/tab.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators.js'; + +import {Tab} from './lib/tab.js'; +import {styles} from './lib/tab-styles.css.js'; + +export {Variant} from './lib/tab.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-tab': MdTab; + } +} + +// TODO(b/267336507): add docs +/** + * @summary Tab allow users to display a tab within a Tabs. + * + */ +@customElement('md-tab') +export class MdTab extends Tab { + static override styles = [styles]; +} diff --git a/tabs/tabs.ts b/tabs/tabs.ts new file mode 100644 index 0000000000..7f90e6a310 --- /dev/null +++ b/tabs/tabs.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './tab.js'; + +import {customElement} from 'lit/decorators.js'; + +import {Tabs} from './lib/tabs.js'; +import {styles} from './lib/tabs-styles.css.js'; + +export {Variant, MdTab} from './tab.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-tabs': MdTabs; + } +} + +// TODO(b/267336507): add docs +/** + * @summary Tabs displays a list of selectable tabs. + * + */ +@customElement('md-tabs') +export class MdTabs extends Tabs { + static override styles = [styles]; +} diff --git a/tabs/tabs_test.ts b/tabs/tabs_test.ts new file mode 100644 index 0000000000..efee712026 --- /dev/null +++ b/tabs/tabs_test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html} from 'lit'; + +import {Environment} from '../testing/environment.js'; +import {createTokenTests} from '../testing/tokens.js'; + +import {TabsHarness} from './harness.js'; +import {MdTab} from './tab.js'; +import {MdTabs} from './tabs.js'; + +interface TabsTestProps { + selected?: number; +} + +function getTabsTemplate(props?: TabsTestProps) { + return html` + + A + B + C + `; +} + +describe('', () => { + const env = new Environment(); + + async function setupTest( + props?: TabsTestProps, template = getTabsTemplate) { + const root = env.render(template(props)); + await env.waitForStability(); + const tab = root.querySelector('md-tabs')!; + const harness = new TabsHarness(tab); + return {harness, root}; + } + + describe('.styles', () => { + createTokenTests(MdTabs.styles); + createTokenTests(MdTab.styles); + }); + + describe('properties', () => { + it('renders selected with indicator', async () => { + const {harness} = await setupTest({selected: 1}); + expect(harness.element.selected).toBe(1); + expect(harness.element.selectedItem) + .toBe(harness.harnessedItems[1].element); + harness.harnessedItems.forEach(async (tabHarness, i) => { + const shouldBeSelected = i === harness.element.selected; + await tabHarness.element.updateComplete; + expect(tabHarness.element.selected).toBe(shouldBeSelected); + expect(await tabHarness.isIndicatorShowing()).toBe(shouldBeSelected); + }); + await env.waitForStability(); + harness.element.selected = 0; + await harness.element.updateComplete; + expect(harness.element.selected).toBe(0); + harness.harnessedItems.forEach(async (tabHarness, i) => { + const shouldBeSelected = i === harness.element.selected; + await tabHarness.element.updateComplete; + expect(tabHarness.element.selected).toBe(shouldBeSelected); + expect(await tabHarness.isIndicatorShowing()).toBe(shouldBeSelected); + }); + }); + + it('updates selectedItem/previousSelectedItem', async () => { + const {harness} = await setupTest({selected: 1}); + expect(harness.element.selectedItem) + .toBe(harness.harnessedItems[1].element); + expect(harness.element.previousSelectedItem).toBeUndefined(); + harness.element.selected = 0; + await harness.element.updateComplete; + expect(harness.element.selectedItem) + .toBe(harness.harnessedItems[0].element); + expect(harness.element.previousSelectedItem) + .toBe(harness.harnessedItems[1].element); + }); + }); +}); \ No newline at end of file diff --git a/tokens/_index.scss b/tokens/_index.scss index 36e532c87c..2758d5a282 100644 --- a/tokens/_index.scss +++ b/tokens/_index.scss @@ -59,14 +59,11 @@ @forward './md-comp-outlined-select' as md-comp-outlined-select-*; @forward './md-comp-outlined-text-field' as md-comp-outlined-text-field-*; @forward './md-comp-plain-tooltip' as md-comp-plain-tooltip-*; -@forward './md-comp-primary-navigation-tab' as md-comp-primary-navigation-tab-*; @forward './md-comp-radio-button' as md-comp-radio-button-*; @forward './md-comp-rich-tooltip' as md-comp-rich-tooltip-*; @forward './md-comp-scrim' as md-comp-scrim-*; @forward './md-comp-search-bar' as md-comp-search-bar-*; @forward './md-comp-search-view' as md-comp-search-view-*; -@forward './md-comp-secondary-navigation-tab' as - md-comp-secondary-navigation-tab-*; @forward './md-comp-sheet-bottom' as md-comp-sheet-bottom-*; @forward './md-comp-sheet-floating' as md-comp-sheet-floating-*; @forward './md-comp-sheet-side' as md-comp-sheet-side-*; @@ -75,6 +72,7 @@ @forward './md-comp-standard-menu-button' as md-comp-standard-menu-button-*; @forward './md-comp-suggestion-chip' as md-comp-suggestion-chip-*; @forward './md-comp-switch' as md-comp-switch-*; +@forward './md-comp-tab' as md-comp-tab-*; @forward './md-comp-test-table' as md-comp-test-table-*; @forward './md-comp-text-button' as md-comp-text-button-*; @forward './md-comp-time-input' as md-comp-time-input-*; diff --git a/tokens/_md-comp-primary-navigation-tab.scss b/tokens/_md-comp-primary-navigation-tab.scss deleted file mode 100644 index 83358b7a55..0000000000 --- a/tokens/_md-comp-primary-navigation-tab.scss +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright 2023 Google LLC -// SPDX-License-Identifier: Apache-2.0 -// - -// go/keep-sorted start -@use 'sass:map'; -// go/keep-sorted end -// go/keep-sorted start -@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-primary-navigation-tab'; -// go/keep-sorted end - -$_default: ( - 'md-sys-color': md-sys-color.values-light(), - 'md-sys-elevation': md-sys-elevation.values(), - 'md-sys-shape': md-sys-shape.values(), - 'md-sys-state': md-sys-state.values(), - 'md-sys-typescale': md-sys-typescale.values(), -); - -$_unsupported-tokens: ( - 'with-label-text-label-text-font', - 'with-label-text-label-text-line-height', - 'with-label-text-label-text-size', - 'with-label-text-label-text-tracking', - 'with-label-text-label-text-weight' -); - -@function values($deps: $_default, $exclude-hardcoded-values: false) { - $tokens: md-comp-primary-navigation-tab.values( - $deps, - $exclude-hardcoded-values - ); - $tokens: map.remove($tokens, $_unsupported-tokens...); - - // TODO(b/271876162): remove when tokens compiler emits typescale tokens - $tokens: map.merge( - $tokens, - ( - 'with-label-text-label-text-type': - map.get($deps, 'md-sys-typescale', 'title-small'), - ) - ); - @return $tokens; -} diff --git a/tokens/_md-comp-secondary-navigation-tab.scss b/tokens/_md-comp-secondary-navigation-tab.scss deleted file mode 100644 index 1e8a3be606..0000000000 --- a/tokens/_md-comp-secondary-navigation-tab.scss +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright 2023 Google LLC -// SPDX-License-Identifier: Apache-2.0 -// - -// go/keep-sorted start -@use 'sass:map'; -// go/keep-sorted end -// go/keep-sorted start -@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-secondary-navigation-tab'; -// go/keep-sorted end - -$_default: ( - 'md-sys-color': md-sys-color.values-light(), - 'md-sys-elevation': md-sys-elevation.values(), - 'md-sys-shape': md-sys-shape.values(), - 'md-sys-state': md-sys-state.values(), - 'md-sys-typescale': md-sys-typescale.values(), -); - -$_unsupported-tokens: ( - 'label-text-font', - 'label-text-line-height', - 'label-text-size', - 'label-text-tracking', - 'label-text-weight' -); - -@function values($deps: $_default, $exclude-hardcoded-values: false) { - $tokens: md-comp-secondary-navigation-tab.values( - $deps, - $exclude-hardcoded-values - ); - $tokens: map.remove($tokens, $_unsupported-tokens...); - // TODO(b/271876162): remove when tokens compiler emits typescale tokens - $tokens: map.merge( - $tokens, - ( - 'with-label-text-label-text-type': - map.get($deps, 'md-sys-typescale', 'title-small'), - ) - ); - @return $tokens; -} diff --git a/tokens/_md-comp-tab.scss b/tokens/_md-comp-tab.scss new file mode 100644 index 0000000000..f99df4a66b --- /dev/null +++ b/tokens/_md-comp-tab.scss @@ -0,0 +1,292 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use 'sass:map'; +// go/keep-sorted end +// go/keep-sorted start +@use './md-comp-divider'; +@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-primary-navigation-tab'; +@use './v0_172/md-comp-secondary-navigation-tab'; +@use './values'; +// go/keep-sorted end + +$_default: ( + 'md-sys-color': md-sys-color.values-light(), + 'md-sys-elevation': md-sys-elevation.values(), + 'md-sys-shape': md-sys-shape.values(), + 'md-sys-state': md-sys-state.values(), + 'md-sys-typescale': md-sys-typescale.values(), + 'md-comp-divider': md-comp-divider.values(), +); + +$supported-tokens: ( + // go/keep-sorted start + 'primary-tab-active-focus-icon-color', + 'primary-tab-active-focus-label-text-color', + 'primary-tab-active-focus-state-layer-color', + 'primary-tab-active-focus-state-layer-opacity', + 'primary-tab-active-hover-icon-color', + 'primary-tab-active-hover-label-text-color', + 'primary-tab-active-hover-state-layer-color', + 'primary-tab-active-hover-state-layer-opacity', + 'primary-tab-active-icon-color', + 'primary-tab-active-indicator-color', + 'primary-tab-active-indicator-height', + 'primary-tab-active-indicator-shape', + 'primary-tab-active-label-text-color', + 'primary-tab-active-pressed-icon-color', + 'primary-tab-active-pressed-label-text-color', + 'primary-tab-active-pressed-state-layer-color', + 'primary-tab-active-pressed-state-layer-opacity', + 'primary-tab-container-color', + 'primary-tab-container-elevation', + 'primary-tab-container-height', + 'primary-tab-container-shape', + 'primary-tab-divider-color', + 'primary-tab-divider-thickness', + 'primary-tab-focus-icon-color', + 'primary-tab-focus-label-text-color', + 'primary-tab-focus-state-layer-color', + 'primary-tab-focus-state-layer-opacity', + 'primary-tab-hover-icon-color', + 'primary-tab-hover-label-text-color', + 'primary-tab-hover-state-layer-color', + 'primary-tab-hover-state-layer-opacity', + 'primary-tab-icon-color', + 'primary-tab-icon-size', + 'primary-tab-label-text-color', + 'primary-tab-label-text-type', + 'primary-tab-pressed-icon-color', + 'primary-tab-pressed-label-text-color', + 'primary-tab-pressed-state-layer-color', + 'primary-tab-pressed-state-layer-opacity', + 'secondary-tab-active-focus-icon-color', + 'secondary-tab-active-focus-label-text-color', + 'secondary-tab-active-focus-state-layer-color', + 'secondary-tab-active-focus-state-layer-opacity', + 'secondary-tab-active-hover-icon-color', + 'secondary-tab-active-hover-label-text-color', + 'secondary-tab-active-hover-state-layer-color', + 'secondary-tab-active-hover-state-layer-opacity', + 'secondary-tab-active-icon-color', + 'secondary-tab-active-indicator-color', + 'secondary-tab-active-indicator-height', + 'secondary-tab-active-indicator-shape', + 'secondary-tab-active-label-text-color', + 'secondary-tab-active-pressed-icon-color', + 'secondary-tab-active-pressed-label-text-color', + 'secondary-tab-active-pressed-state-layer-color', + 'secondary-tab-active-pressed-state-layer-opacity', + 'secondary-tab-container-color', + 'secondary-tab-container-elevation', + 'secondary-tab-container-height', + 'secondary-tab-container-shape', + 'secondary-tab-divider-color', + 'secondary-tab-divider-thickness', + 'secondary-tab-focus-icon-color', + 'secondary-tab-focus-label-text-color', + 'secondary-tab-focus-state-layer-color', + 'secondary-tab-focus-state-layer-opacity', + 'secondary-tab-hover-icon-color', + 'secondary-tab-hover-label-text-color', + 'secondary-tab-hover-state-layer-color', + 'secondary-tab-hover-state-layer-opacity', + 'secondary-tab-icon-color', + 'secondary-tab-icon-size', + 'secondary-tab-label-text-color', + 'secondary-tab-label-text-type', + 'secondary-tab-pressed-icon-color', + 'secondary-tab-pressed-label-text-color', + 'secondary-tab-pressed-state-layer-color', + 'secondary-tab-pressed-state-layer-opacity', + // go/keep-sorted end +); + +$unsupported-tokens: ( + // include an icon and the size will adjust; + // height is 48 and it's 64 with icon + 'primary-tab-with-icon-and-label-text-container-height', + 'primary-tab-with-label-text-label-text-font', + 'primary-tab-with-label-text-label-text-line-height', + 'primary-tab-with-label-text-label-text-size', + 'primary-tab-with-label-text-label-text-tracking', + 'primary-tab-with-label-text-label-text-weight', + 'secondary-tab-container-shadow-color', + 'secondary-tab-label-text-font', + 'secondary-tab-label-text-line-height', + 'secondary-tab-label-text-size', + 'secondary-tab-label-text-tracking', + 'secondary-tab-label-text-weight' +); + +// Note, this combines the raw primary and secondary tab variant tokens +// into a single set prefixed with `primary-tab` or `secondary-tab`. +// Tokens are normalized between the variants, added or removed and renamed +// as needed. +@function values($deps: $_default, $exclude-hardcoded-values: false) { + // prepare token values by normalizing and combinding primary/secondary + // generated tokens *before* fixing up names and limiting to supported tokens. + // 1. for primary + // a. add divider/text tokens + // b. prefix with `primary-tab` + // 2. for secondary + // a. add divider/text tokens + // b. add missing secondary tokens to match primary + // c. prefix with `secondary-tab` + + $primary-tokens: md-comp-primary-navigation-tab.values( + $deps, + $exclude-hardcoded-values + ); + $primary-tokens: _add-missing-tokens($primary-tokens, $deps); + $primary-tokens: _prefix-tokens($primary-tokens, 'primary-tab'); + $secondary-tokens: md-comp-secondary-navigation-tab.values( + $deps, + $exclude-hardcoded-values + ); + $secondary-tokens: _add-missing-tokens($secondary-tokens, $deps); + $secondary-tokens: _add-missing-secondary-tokens($secondary-tokens); + $secondary-tokens: _prefix-tokens($secondary-tokens, 'secondary-tab'); + + $base-tokens: map.merge($primary-tokens, $secondary-tokens); + + // now refine the normalized generated tokens to only renamed/supported tokens. + $tokens: values.validate( + $base-tokens, + $supported-tokens: $supported-tokens, + $unsupported-tokens: $unsupported-tokens, + $renamed-tokens: ( + // rename primary inactive- + 'primary-tab-inactive-focus-state-layer-color': + 'primary-tab-focus-state-layer-color', + 'primary-tab-inactive-focus-state-layer-opacity': + 'primary-tab-focus-state-layer-opacity', + 'primary-tab-inactive-hover-state-layer-color': + 'primary-tab-hover-state-layer-color', + 'primary-tab-inactive-hover-state-layer-opacity': + 'primary-tab-hover-state-layer-opacity', + 'primary-tab-inactive-pressed-state-layer-color': + 'primary-tab-pressed-state-layer-color', + 'primary-tab-inactive-pressed-state-layer-opacity': + 'primary-tab-pressed-state-layer-opacity', + // rename primary with-icon- and inactive- + 'primary-tab-with-icon-active-focus-icon-color': + 'primary-tab-active-focus-icon-color', + 'primary-tab-with-icon-active-hover-icon-color': + 'primary-tab-active-hover-icon-color', + 'primary-tab-with-icon-active-icon-color': 'primary-tab-active-icon-color', + 'primary-tab-with-icon-active-pressed-icon-color': + 'primary-tab-active-pressed-icon-color', + 'primary-tab-with-icon-icon-size': 'primary-tab-icon-size', + 'primary-tab-with-icon-inactive-focus-icon-color': + 'primary-tab-focus-icon-color', + 'primary-tab-with-icon-inactive-hover-icon-color': + 'primary-tab-hover-icon-color', + 'primary-tab-with-icon-inactive-icon-color': 'primary-tab-icon-color', + 'primary-tab-with-icon-inactive-pressed-icon-color': + 'primary-tab-pressed-icon-color', + // rename primary with-label-text- and inactive- + 'primary-tab-with-label-text-active-focus-label-text-color': + 'primary-tab-active-focus-label-text-color', + 'primary-tab-with-label-text-active-hover-label-text-color': + 'primary-tab-active-hover-label-text-color', + 'primary-tab-with-label-text-active-label-text-color': + 'primary-tab-active-label-text-color', + 'primary-tab-with-label-text-active-pressed-label-text-color': + 'primary-tab-active-pressed-label-text-color', + 'primary-tab-with-label-text-inactive-focus-label-text-color': + 'primary-tab-focus-label-text-color', + 'primary-tab-with-label-text-inactive-hover-label-text-color': + 'primary-tab-hover-label-text-color', + 'primary-tab-with-label-text-inactive-label-text-color': + 'primary-tab-label-text-color', + 'primary-tab-with-label-text-inactive-pressed-label-text-color': + 'primary-tab-pressed-label-text-color', + 'primary-tab-with-label-text-label-text-type': + 'primary-tab-label-text-type', + // rename secondary with-icon- and inactive- + 'secondary-tab-inactive-label-text-color': + 'secondary-tab-label-text-color', + 'secondary-tab-with-icon-active-icon-color': + 'secondary-tab-active-icon-color', + 'secondary-tab-with-icon-focus-icon-color': + 'secondary-tab-focus-icon-color', + 'secondary-tab-with-icon-hover-icon-color': + 'secondary-tab-hover-icon-color', + 'secondary-tab-with-icon-icon-size': 'secondary-tab-icon-size', + 'secondary-tab-with-icon-inactive-icon-color': 'secondary-tab-icon-color', + 'secondary-tab-with-icon-pressed-icon-color': + 'secondary-tab-pressed-icon-color' + ) + ); + + @return $tokens; +} + +@function _prefix-tokens($tokens, $prefix: '') { + @each $key, $value in $tokens { + $tokens: map.remove($tokens, $key); + $key: '#{$prefix}-#{$key}'; + $tokens: map.set($tokens, $key, $value); + } + @return $tokens; +} + +// add tokens for divider / label-text +@function _add-missing-tokens($tokens, $deps) { + $divider-tokens: map.get($deps, 'md-comp-divider'); + @each $key, $value in $divider-tokens { + $key: 'divider-#{$key}'; + $tokens: map.set($tokens, $key, $value); + } + + // TODO(b/271876162): remove when tokens compiler emits typescale tokens + $tokens: map.merge( + $tokens, + ( + 'label-text-type': map.get($deps, 'md-sys-typescale', 'title-small'), + ) + ); + @return $tokens; +} + +// add missing secondary tokens to match primary variant. +@function _add-missing-secondary-tokens($tokens) { + $tokens: map.merge( + $tokens, + ( + 'active-focus-icon-color': map.get($tokens, 'icon-color'), + 'active-focus-label-text-color': + map.get($tokens, 'active-label-text-color'), + 'active-focus-state-layer-color': + map.get($tokens, 'focus-state-layer-color'), + 'active-focus-state-layer-opacity': + map.get($tokens, 'focus-state-layer-opacity'), + 'active-hover-icon-color': map.get($tokens, 'icon-color'), + 'active-hover-label-text-color': + map.get($tokens, 'active-label-text-color'), + 'active-hover-state-layer-color': + map.get($tokens, 'hover-state-layer-color'), + 'active-hover-state-layer-opacity': + map.get($tokens, 'hover-state-layer-opacity'), + 'active-icon-color': map.get($tokens, 'icon-color'), + 'active-indicator-shape': 0, + 'active-pressed-icon-color': map.get($tokens, 'icon-color'), + 'active-pressed-label-text-color': + map.get($tokens, 'active-label-text-color'), + 'active-pressed-state-layer-color': + map.get($tokens, 'pressed-state-layer-color'), + 'active-pressed-state-layer-opacity': + map.get($tokens, 'pressed-state-layer-opacity'), + ) + ); + @return $tokens; +}