mirror of
https://github.com/material-components/material-web.git
synced 2024-09-11 21:57:41 +03:00
feat(tabs): adds tabs and tab element
PiperOrigin-RevId: 530452330
This commit is contained in:
parent
7dbf2a0259
commit
cbb24dfbc3
@ -53,7 +53,7 @@ Ripple | ✅ | ✅ | 🟡
|
||||
Select | ✅ | ✅ | ❌
|
||||
Slider | ✅ | ✅ | ❌
|
||||
Switch | ✅ | ✅ | ❌
|
||||
Tabs | 🟡 | ❌ | ❌
|
||||
Tabs | ✅ | 🟡 | ❌
|
||||
Text field | ✅ | ✅ | 🟡
|
||||
|
||||
### 1.1+ Components
|
||||
|
6
tabs/_tab.scss
Normal file
6
tabs/_tab.scss
Normal file
@ -0,0 +1,6 @@
|
||||
//
|
||||
// Copyright 2023 Google LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
|
||||
@forward './lib/tab' show theme;
|
41
tabs/harness.ts
Normal file
41
tabs/harness.ts
Normal file
@ -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<Tab> {
|
||||
override async getInteractiveElement() {
|
||||
await this.element.updateComplete;
|
||||
return this.element.querySelector<HTMLButtonElement|HTMLLinkElement>(
|
||||
'.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<Tabs> {
|
||||
get harnessedItems() {
|
||||
// Test access to protected property
|
||||
// tslint:disable-next-line:no-dict-access-on-struct-type
|
||||
return (this.element['items'] as Array<ElementWithHarness<Tab>>)
|
||||
.map(item => {
|
||||
return (item.harness ?? new TabHarness(item)) as TabHarness;
|
||||
});
|
||||
}
|
||||
}
|
289
tabs/lib/_tab.scss
Normal file
289
tabs/lib/_tab.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
44
tabs/lib/_tabs.scss
Normal file
44
tabs/lib/_tabs.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
10
tabs/lib/tab-styles.scss
Normal file
10
tabs/lib/tab-styles.scss
Normal file
@ -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;
|
202
tabs/lib/tab.ts
Normal file
202
tabs/lib/tab.ts
Normal file
@ -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<MdRipple|null>;
|
||||
|
||||
// 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`
|
||||
<button
|
||||
class="button md3-button"
|
||||
?disabled=${this.disabled}
|
||||
aria-label=${this.ariaLabel || nothing}
|
||||
${ripple(this.getRipple)}
|
||||
>
|
||||
<md-focus-ring></md-focus-ring>
|
||||
<md-elevation></md-elevation>
|
||||
${when(this.showRipple, this.renderRipple)}
|
||||
<span class="touch"></span>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<span class="label">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<div class="indicator"></div>
|
||||
</div>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
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`<md-ripple ?disabled="${this.disabled}"></md-ripple>`;
|
||||
};
|
||||
|
||||
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'}];
|
||||
}
|
||||
}
|
10
tabs/lib/tabs-styles.scss
Normal file
10
tabs/lib/tabs-styles.scss
Normal file
@ -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;
|
327
tabs/lib/tabs.ts
Normal file
327
tabs/lib/tabs.ts
Normal file
@ -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`
|
||||
<slot @slotchange=${this.handleSlotChange} @click=${
|
||||
this.handleItemClick}></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
28
tabs/tab.ts
Normal file
28
tabs/tab.ts
Normal file
@ -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];
|
||||
}
|
30
tabs/tabs.ts
Normal file
30
tabs/tabs.ts
Normal file
@ -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];
|
||||
}
|
85
tabs/tabs_test.ts
Normal file
85
tabs/tabs_test.ts
Normal file
@ -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`
|
||||
<md-tabs
|
||||
.selected=${props?.selected ?? 0}
|
||||
>
|
||||
<md-tab>A</md-tab>
|
||||
<md-tab>B</md-tab>
|
||||
<md-tab>C</md-tab>
|
||||
</md-tabs>`;
|
||||
}
|
||||
|
||||
describe('<md-tabs>', () => {
|
||||
const env = new Environment();
|
||||
|
||||
async function setupTest(
|
||||
props?: TabsTestProps, template = getTabsTemplate) {
|
||||
const root = env.render(template(props));
|
||||
await env.waitForStability();
|
||||
const tab = root.querySelector<MdTabs>('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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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-*;
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
292
tokens/_md-comp-tab.scss
Normal file
292
tokens/_md-comp-tab.scss
Normal file
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user