feat(tabs): adds tabs and tab element

PiperOrigin-RevId: 530452330
This commit is contained in:
Material Web Team 2023-05-08 17:29:12 -07:00 committed by Copybara-Service
parent 7dbf2a0259
commit cbb24dfbc3
16 changed files with 1366 additions and 103 deletions

View File

@ -53,7 +53,7 @@ Ripple | ✅ | ✅ | 🟡
Select | ✅ | ✅ | ❌
Slider | ✅ | ✅ | ❌
Switch | ✅ | ✅ | ❌
Tabs | 🟡 | ❌ | ❌
Tabs | ✅ | 🟡 | ❌
Text field | ✅ | ✅ | 🟡
### 1.1+ Components

6
tabs/_tab.scss Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
});
});
});

View File

@ -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-*;

View File

@ -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;
}

View File

@ -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
View 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;
}