diff --git a/chips/chip-set.ts b/chips/chip-set.ts new file mode 100644 index 0000000000..26594e2756 --- /dev/null +++ b/chips/chip-set.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators.js'; + +import {ChipSet} from './lib/chip-set.js'; +import {styles} from './lib/chip-set-styles.css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-chip-set': MdChipSet; + } +} + +/** + * TODO(b/243982145): add docs + * + * @final + * @suppress {visibility} + */ +@customElement('md-chip-set') +export class MdChipSet extends ChipSet { + static override styles = [styles]; +} diff --git a/chips/demo/stories.ts b/chips/demo/stories.ts index 26d1b47c57..eaa86bce18 100644 --- a/chips/demo/stories.ts +++ b/chips/demo/stories.ts @@ -5,6 +5,7 @@ */ import '@material/web/icon/icon.js'; +import '@material/web/chips/chip-set.js'; import '@material/web/chips/assist-chip.js'; import '@material/web/chips/filter-chip.js'; import '@material/web/chips/input-chip.js'; @@ -34,18 +35,20 @@ const standard: MaterialStoryInit = { name: 'Assist chips', render({label, elevated, disabled}) { return html` - - - - + + + + + + `; } }; @@ -54,13 +57,15 @@ const links: MaterialStoryInit = { name: 'Assist link chips', render({label, elevated, disabled}) { return html` - ${GOOGLE_LOGO} + + ${GOOGLE_LOGO} + `; } }; @@ -69,68 +74,74 @@ const filters: MaterialStoryInit = { name: 'Filter chips', render({label, elevated, disabled}) { return html` - - - - - + + + + + + + `; } }; const inputs: MaterialStoryInit = { name: 'Input chips', - render({label, elevated, disabled}) { + render({label, disabled}) { return html` - - - local_laundry_service - - - - - + + + + local_laundry_service + + + + + + `; } }; const inputLinks: MaterialStoryInit = { name: 'Input link chips', - render({label, elevated, disabled}) { + render({label, disabled}) { return html` - ${GOOGLE_LOGO} + + ${GOOGLE_LOGO} + `; } }; @@ -139,18 +150,20 @@ const suggestions: MaterialStoryInit = { name: 'Suggestion chips', render({label, elevated, disabled}) { return html` - - - - + + + + + + `; } }; @@ -159,13 +172,15 @@ const suggestionLinks: MaterialStoryInit = { name: 'Suggestion link chips', render({label, elevated, disabled}) { return html` - ${GOOGLE_LOGO} + + ${GOOGLE_LOGO} + `; } }; diff --git a/chips/lib/_chip-set.scss b/chips/lib/_chip-set.scss new file mode 100644 index 0000000000..d52a4c19a7 --- /dev/null +++ b/chips/lib/_chip-set.scss @@ -0,0 +1,12 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin styles() { + :host { + display: flex; + flex-wrap: wrap; + gap: 8px; + } +} diff --git a/chips/lib/chip-set-styles.scss b/chips/lib/chip-set-styles.scss new file mode 100644 index 0000000000..321d833dcf --- /dev/null +++ b/chips/lib/chip-set-styles.scss @@ -0,0 +1,10 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use './chip-set'; +// go/keep-sorted end + +@include chip-set.styles; diff --git a/chips/lib/chip-set.ts b/chips/lib/chip-set.ts new file mode 100644 index 0000000000..b0560e66b9 --- /dev/null +++ b/chips/lib/chip-set.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html, isServer, LitElement} from 'lit'; +import {queryAssignedElements} from 'lit/decorators.js'; + +import {Chip} from './chip.js'; + +/** + * A chip set component. + */ +export class ChipSet extends LitElement { + get chips() { + return this.childElements.filter( + (child => child instanceof Chip) as (child: HTMLElement) => + child is Chip); + } + + @queryAssignedElements({flatten: true}) + private readonly childElements!: HTMLElement[]; + + constructor() { + super(); + if (!isServer) { + this.addEventListener('focusin', this.updateTabIndices.bind(this)); + this.addEventListener('keydown', this.handleKeyDown.bind(this)); + } + } + + protected override render() { + return html``; + } + + private handleKeyDown(event: KeyboardEvent) { + const isDown = event.key === 'ArrowDown'; + const isUp = event.key === 'ArrowUp'; + const isLeft = event.key === 'ArrowLeft'; + const isRight = event.key === 'ArrowRight'; + const isHome = event.key === 'Home'; + const isEnd = event.key === 'End'; + // Ignore non-navigation keys + if (!isLeft && !isRight && !isDown && !isUp && !isHome && !isEnd) { + return; + } + + // Prevent default interactions, such as scrolling. + event.preventDefault(); + + const {chips} = this; + // Don't try to select another chip if there aren't any. + if (chips.length < 2) { + return; + } + + if (isHome || isEnd) { + const index = isHome ? 0 : chips.length - 1; + chips[index].focus(); + this.updateTabIndices(); + return; + } + + // Check if moving forwards or backwards + const isRtl = getComputedStyle(this).direction === 'rtl'; + const forwards = isRtl ? isLeft || isDown : isRight || isDown; + const focusedChip = chips.find(chip => chip.matches(':focus-within')); + if (!focusedChip) { + // If there is not already a chip focused, select the first or last chip + // based on the direction we're traveling. + const nextChip = forwards ? chips[0] : chips[chips.length - 1]; + nextChip.focus(); + this.updateTabIndices(); + return; + } + + const currentIndex = chips.indexOf(focusedChip); + let nextIndex = forwards ? currentIndex + 1 : currentIndex - 1; + // Search for the next sibling that is not disabled to select. + // If we return to the host index, there is nothing to select. + while (nextIndex !== currentIndex) { + if (nextIndex >= chips.length) { + // Return to start if moving past the last item. + nextIndex = 0; + } else if (nextIndex < 0) { + // Go to end if moving before the first item. + nextIndex = chips.length - 1; + } + + // Check if the next sibling is disabled. If so, + // move the index and continue searching. + const nextChip = chips[nextIndex]; + if (nextChip.disabled) { + if (forwards) { + nextIndex++; + } else { + nextIndex--; + } + + continue; + } + + nextChip.focus(); + this.updateTabIndices(); + break; + } + } + + private updateTabIndices() { + const {chips} = this; + let hasFocusedChip = false; + for (const chip of chips) { + if (chip.matches(':focus-within')) { + chip.removeAttribute('tabindex'); + hasFocusedChip = true; + } else { + chip.tabIndex = -1; + } + } + + if (!hasFocusedChip) { + chips[0]?.removeAttribute('tabindex'); + } + } +} diff --git a/chips/lib/chip.ts b/chips/lib/chip.ts index b074d2ef9a..d3a391b30d 100644 --- a/chips/lib/chip.ts +++ b/chips/lib/chip.ts @@ -21,6 +21,11 @@ export abstract class Chip extends LitElement { requestUpdateOnAriaChange(this); } + static override shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true + }; + @property({type: Boolean}) disabled = false; @property() label = '';