From 960ecd1da28ddd0ee9c17858793400a33e86bd2c Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Sun, 3 Oct 2021 17:06:39 -0500 Subject: [PATCH 01/23] feat(combobox): begin working branch for combobox additions --- package.json | 4 +- packages/combobox/README.md | 28 + packages/combobox/exports.json | 5 + packages/combobox/package.json | 86 ++ packages/combobox/sp-combobox-item.ts | 21 + packages/combobox/sp-combobox.ts | 21 + packages/combobox/src/Combobox.ts | 580 +++++++++ packages/combobox/src/ComboboxItem.ts | 70 + packages/combobox/src/combobox-item.css | 19 + packages/combobox/src/combobox.css | 55 + packages/combobox/src/index.ts | 14 + packages/combobox/src/spectrum-combobox.css | 1138 +++++++++++++++++ packages/combobox/src/spectrum-config.js | 123 ++ packages/combobox/src/spectrum-textfield.css | 130 ++ packages/combobox/stories/combobox.stories.ts | 161 +++ .../combobox/test/benchmark/basic-test.ts | 19 + packages/combobox/test/combobox.data.test.ts | 320 +++++ .../combobox/test/combobox.placement.test.ts | 239 ++++ packages/combobox/test/combobox.test.ts | 1059 +++++++++++++++ packages/combobox/test/index.html | 23 + packages/combobox/tsconfig.json | 10 + packages/number-field/src/NumberField.ts | 4 +- packages/picker-button/exports.json | 4 + packages/picker-button/package.json | 5 + .../stories/picker-button-sizes.stories.ts | 2 +- .../stories/picker-button.stories.ts | 2 +- packages/textfield/src/Textfield.ts | 2 +- tsconfig-all.json | 1 + web-test-runner.config.js | 2 + yarn.lock | 5 + 30 files changed, 4146 insertions(+), 6 deletions(-) create mode 100644 packages/combobox/README.md create mode 100644 packages/combobox/exports.json create mode 100644 packages/combobox/package.json create mode 100644 packages/combobox/sp-combobox-item.ts create mode 100644 packages/combobox/sp-combobox.ts create mode 100644 packages/combobox/src/Combobox.ts create mode 100644 packages/combobox/src/ComboboxItem.ts create mode 100644 packages/combobox/src/combobox-item.css create mode 100644 packages/combobox/src/combobox.css create mode 100644 packages/combobox/src/index.ts create mode 100644 packages/combobox/src/spectrum-combobox.css create mode 100644 packages/combobox/src/spectrum-config.js create mode 100644 packages/combobox/src/spectrum-textfield.css create mode 100644 packages/combobox/stories/combobox.stories.ts create mode 100644 packages/combobox/test/benchmark/basic-test.ts create mode 100644 packages/combobox/test/combobox.data.test.ts create mode 100644 packages/combobox/test/combobox.placement.test.ts create mode 100644 packages/combobox/test/combobox.test.ts create mode 100644 packages/combobox/test/index.html create mode 100644 packages/combobox/tsconfig.json create mode 100644 packages/picker-button/exports.json diff --git a/package.json b/package.json index 847356b270..d75fe0b793 100644 --- a/package.json +++ b/package.json @@ -249,7 +249,9 @@ "tools/**/*.ts", "!tools/**/*.d.ts", "tasks/esbuild-packages.js", - "tasks/ts-tools.js" + "tasks/ts-tools.js", + "packages/**/exports.json", + "tools/**/exports.json" ], "output": [ "packages/**/*.js", diff --git a/packages/combobox/README.md b/packages/combobox/README.md new file mode 100644 index 0000000000..1c8c7096ce --- /dev/null +++ b/packages/combobox/README.md @@ -0,0 +1,28 @@ +## Description + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/combobox?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/combobox) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/combobox?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/combobox) + +``` +yarn add @spectrum-web-components/combobox +``` + +Import the side effectful registration of `` via: + +``` +import '@spectrum-web-components/combobox/sp-combobox.js'; +``` + +When looking to leverage the `Combobox` base class as a type and/or for extension purposes, do so via: + +``` +import { Combobox } from '@spectrum-web-components/combobox'; +``` + +## Example + +```html + +``` diff --git a/packages/combobox/exports.json b/packages/combobox/exports.json new file mode 100644 index 0000000000..b31c9a78bb --- /dev/null +++ b/packages/combobox/exports.json @@ -0,0 +1,5 @@ +{ + "./src/*": "./src/*", + "./sp-combobox-item.js": "./sp-combobox-item.js", + "./sp-combobox.js": "./sp-combobox.js" +} diff --git a/packages/combobox/package.json b/packages/combobox/package.json new file mode 100644 index 0000000000..e43ccf7e65 --- /dev/null +++ b/packages/combobox/package.json @@ -0,0 +1,86 @@ +{ + "name": "@spectrum-web-components/combobox", + "version": "0.0.1", + "publishConfig": { + "access": "public" + }, + "description": "", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/adobe/spectrum-web-components.git", + "directory": "packages/combobox" + }, + "author": "", + "homepage": "https://adobe.github.io/spectrum-web-components/components/combobox", + "bugs": { + "url": "https://github.com/adobe/spectrum-web-components/issues" + }, + "main": "src/index.js", + "module": "src/index.js", + "type": "module", + "exports": { + ".": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./package.json": "./package.json", + "./src/Combobox.js": { + "development": "./src/Combobox.dev.js", + "default": "./src/Combobox.js" + }, + "./src/ComboboxItem.js": { + "development": "./src/ComboboxItem.dev.js", + "default": "./src/ComboboxItem.js" + }, + "./src/combobox-item.css.js": "./src/combobox-item.css.js", + "./src/combobox.css.js": "./src/combobox.css.js", + "./src/index.js": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./sp-combobox-item.js": { + "development": "./sp-combobox-item.dev.js", + "default": "./sp-combobox-item.js" + }, + "./sp-combobox.js": { + "development": "./sp-combobox.dev.js", + "default": "./sp-combobox.js" + } + }, + "scripts": { + "test": "echo \"Error: run tests from mono-repo root.\" && exit 1" + }, + "files": [ + "*.d.ts", + "*.js", + "*.js.map", + "/src/", + "custom-elements.json" + ], + "keywords": [ + "spectrum css", + "web components", + "lit-element", + "lit-html" + ], + "dependencies": { + "@spectrum-web-components/action-button": "^0.40.1", + "@spectrum-web-components/base": "^0.40.1", + "@spectrum-web-components/icon": "^0.40.1", + "@spectrum-web-components/icons-ui": "^0.40.1", + "@spectrum-web-components/menu": "^0.40.1", + "@spectrum-web-components/overlay": "^0.40.1", + "@spectrum-web-components/picker-button": "^0.40.1", + "@spectrum-web-components/popover": "^0.40.1", + "@spectrum-web-components/textfield": "^0.40.1" + }, + "devDependencies": { + "@spectrum-css/combobox": "^2.0.46" + }, + "types": "./src/index.d.ts", + "sideEffects": [ + "./sp-*.js", + "./sp-*.ts" + ] +} diff --git a/packages/combobox/sp-combobox-item.ts b/packages/combobox/sp-combobox-item.ts new file mode 100644 index 0000000000..d3d50d57bc --- /dev/null +++ b/packages/combobox/sp-combobox-item.ts @@ -0,0 +1,21 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { ComboboxItem } from './src/ComboboxItem.js'; + +customElements.define('sp-combobox-item', ComboboxItem); + +declare global { + interface HTMLElementTagNameMap { + 'sp-combobox-item': ComboboxItem; + } +} diff --git a/packages/combobox/sp-combobox.ts b/packages/combobox/sp-combobox.ts new file mode 100644 index 0000000000..3df84ad815 --- /dev/null +++ b/packages/combobox/sp-combobox.ts @@ -0,0 +1,21 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { Combobox } from './src/Combobox.js'; + +customElements.define('sp-combobox', Combobox); + +declare global { + interface HTMLElementTagNameMap { + 'sp-combobox': Combobox; + } +} diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts new file mode 100644 index 0000000000..c41d47f754 --- /dev/null +++ b/packages/combobox/src/Combobox.ts @@ -0,0 +1,580 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + CSSResultArray, + html, + PropertyValues, + TemplateResult, +} from '@spectrum-web-components/base'; +import { + property, + query, +} from '@spectrum-web-components/base/src/decorators.js'; +import { + ifDefined, + live, + repeat, +} from '@spectrum-web-components/base/src/directives.js'; +import '../sp-combobox-item.js'; +import { ComboboxItem } from './ComboboxItem.js'; +import { Overlay } from '@spectrum-web-components/overlay'; +import '@spectrum-web-components/overlay/sp-overlay.js'; +import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js'; +import '@spectrum-web-components/popover/sp-popover.js'; +import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/menu/sp-menu-item.js'; +import '@spectrum-web-components/picker-button/sp-picker-button.js'; +import { Textfield } from '@spectrum-web-components/textfield'; + +import styles from './combobox.css.js'; +import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js'; +import { Menu, MenuItem } from '@spectrum-web-components/menu'; + +export type ComboboxOption = { + id: string; + value: string; +}; + +/** + * @element sp-combobox + */ +export class Combobox extends Textfield { + public static override get styles(): CSSResultArray { + return [...super.styles, styles, chevronStyles]; + } + + /** + * The currently active ComboboxItem descendent, when available. + */ + @property({ attribute: false }) + public activeDescendent?: ComboboxOption | MenuItem; + + @property({ attribute: false }) + public availableOptions: (ComboboxOption | MenuItem)[] = []; + + @property() + public ariaAutocomplete: 'list' | 'none' = 'none'; + + @property({ attribute: 'label-position' }) + public labelPosition: 'inline-start' | undefined; + + /** + * Whether the listbox is visible. + **/ + @property({ type: Boolean, reflect: true }) + public open = false; + + @query('slot:not([name])') + public optionSlot!: HTMLSlotElement; + + @query('#listbox') + public listbox!: HTMLDivElement; + + @query('#input') + public input!: HTMLInputElement; + + public overlay!: HTMLDivElement; + + @property({ type: Array }) + public options?: ComboboxOption[]; + + /** + * The array of the children of the combobox, ie ComboboxItems. + **/ + @property({ type: Array }) + public optionEls: MenuItem[] = []; + + // { value: "String thing", id: "string1" } + public override focus(): void { + this.focusElement.focus(); + } + + public override click(): void { + this.focus(); + this.focusElement.click(); + } + + public onComboboxKeydown(event: KeyboardEvent): void { + if (event.altKey && event.code === 'ArrowDown') { + this.open = true; + } else if (event.code === 'ArrowDown') { + event.preventDefault(); + this.open = true; + this.activateNextDescendent(); + const activeEl = this.querySelector( + `#${(this.activeDescendent as ComboboxOption).id}` + ) as HTMLElement; + if (activeEl) { + activeEl.scrollIntoView({ block: 'nearest' }); + } + } else if (event.code === 'ArrowUp') { + event.preventDefault(); + this.open = true; + this.activatePreviousDescendent(); + const activeEl = this.querySelector( + `#${(this.activeDescendent as ComboboxOption).id}` + ) as HTMLElement; + if (activeEl) { + activeEl.scrollIntoView({ block: 'nearest' }); + } + } else if (event.code === 'Escape') { + if (!this.open) { + this.value = ''; + } + this.open = false; + } else if (event.code === 'Enter') { + this.selectDescendent(); + this.open = false; + } else if (event.code === 'Home') { + this.focusElement.setSelectionRange(0, 0); + this.activeDescendent = undefined; + } else if (event.code === 'End') { + const { length } = this.value; + this.focusElement.setSelectionRange(length, length); + this.activeDescendent = undefined; + } else if (event.code === 'ArrowLeft') { + this.activeDescendent = undefined; + } else if (event.code === 'ArrowRight') { + this.activeDescendent = undefined; + } + } + + /** + * Convert the flattened array of assigned elements of `slot[name='option']` to + * an array of `ComboboxOptions` for use in rendering options in the shadow DOM.s + **/ + public onSlotchange(): void { + this.setOptionsFromSlottedItems(); + this.itemObserver.disconnect(); + // const comboboxItems = this.optionSlot.assignedElements({ + // flatten: true, + // }) as ComboboxItem[]; + this.optionEls.map((item) => { + this.itemObserver.observe(item, { + attributes: true, + attributeFilter: ['id'], + childList: true, + }); + }); + } + + public setOptionsFromSlottedItems(): void { + const elements = this.optionSlot.assignedElements({ + flatten: true, + }) as MenuItem[]; + // Element data + this.optionEls = elements; + } + + public activateNextDescendent(): void { + const activeIndex = !this.activeDescendent + ? -1 + : this.availableOptions.indexOf(this.activeDescendent); + const nextActiveIndex = + (this.availableOptions.length + activeIndex + 1) % + this.availableOptions.length; + this.activeDescendent = this.availableOptions[nextActiveIndex]; + } + + public activatePreviousDescendent(): void { + const activeIndex = !this.activeDescendent + ? 0 + : this.availableOptions.indexOf(this.activeDescendent); + const previousActiveIndex = + (this.availableOptions.length + activeIndex - 1) % + this.availableOptions.length; + this.activeDescendent = this.availableOptions[previousActiveIndex]; + } + + public selectDescendent(): void { + if (!this.activeDescendent) { + return; + } + this.value = this.activeDescendent.value; + } + + public filterAvailableOptions(): void { + if (this.autocomplete === 'none') { + return; + } + const valueLowerCase = this.value.toLowerCase(); + this.availableOptions = (this.options || this.optionEls).filter( + (descendent) => { + const descendentValueLowerCase = descendent.value.toLowerCase(); + return descendentValueLowerCase.startsWith(valueLowerCase); + } + ); + Overlay.update(); + } + + public onComboboxInput({ + target, + }: Event & { target: HTMLInputElement }): void { + // Element data. + this.value = target.value; + this.activeDescendent = undefined; + this.open = true; + } + + public handleListPointerenter(event: PointerEvent): void { + const descendent = event + .composedPath() + .find((el) => typeof (el as MenuItem).value !== 'undefined'); + if (descendent) this.activeDescendent = descendent as MenuItem; + } + + public handleListPointerleave(): void { + this.activeDescendent = undefined; + } + + public handleMenuChange(event: PointerEvent & { target: Menu }): void { + const { target } = event; + this.value = target.selected[0]; + event.preventDefault(); + this.open = false; + this._returnItems(); + this.focus(); + } + + // public onOverlayScroll = (): void => { + // const overlayMenu = this.overlay.children[0] as HTMLElement; + // const menu = this.listbox.children[0] as HTMLElement; + // menu.scroll(overlayMenu.scrollLeft, overlayMenu.scrollTop); + // }; + + public onOpened(): void { + // this.overlayObserver.observe( + // this.overlay.parentElement as HTMLElement, + // { + // attributes: true, + // // attributeFilter: [ "style" ], + // } + // ); + // const menu = this.overlay.children[0] as HTMLElement; + // menu.addEventListener('scroll', this.onOverlayScroll); + // this.overlay.addEventListener( + // 'transitionend', + // () => { + // this.positionListbox(); + // }, + // { once: true } + // ); + } + + public toggleOpen(): void { + this.open = !this.open; + } + + protected override shouldUpdate(changed: PropertyValues): boolean { + if (changed.has('open') && !this.open) { + this.activeDescendent = undefined; + } + if (changed.has('value')) { + this.filterAvailableOptions(); + } + return super.shouldUpdate(changed); + } + + // private positionListboxFromEntries(_entries: MutationRecord[]): void { + // this.positionListbox(); + // this.overlay.addEventListener( + // 'transitionend', + // () => { + // if (!this.open) return; + // this.positionListbox(); + // }, + // { once: true } + // ); + // } + + private positionListbox(): void { + const targetRect = this.overlay.getBoundingClientRect(); + const rootRect = this.getBoundingClientRect(); + this.listbox.style.transform = `translate(${ + targetRect.x - rootRect.x + }px, ${targetRect.y - rootRect.y}px)`; + this.listbox.style.height = `${targetRect.height}px`; + this.listbox.style.maxHeight = `${targetRect.height}px`; + } + + protected override onBlur(event: FocusEvent): void { + if ( + event.relatedTarget && + this.contains(event.relatedTarget as HTMLElement) + ) { + return; + } + super.onBlur(event); + } + + protected override renderField(): TemplateResult { + return html` + ${this.renderStateIcons()} + { + this.open = false; + }} + @sp-opened=${this.onOpened} + type=${this.type} + aria-describedby=${this.helpTextId} + aria-label=${ifDefined(this.label || this.placeholder)} + aria-invalid=${ifDefined(this.invalid || undefined)} + maxlength=${ifDefined( + this.maxlength > -1 ? this.maxlength : undefined + )} + minlength=${ifDefined( + this.minlength > -1 ? this.minlength : undefined + )} + pattern=${ifDefined(this.pattern)} + placeholder=${ifDefined(this.placeholder)} + @change=${this.handleChange} + @input=${this.handleInput} + @focus=${this.onFocus} + @blur=${this.onBlur} + ?disabled=${this.disabled} + ?required=${this.required} + ?readonly=${this.readonly} + /> + `; + } + + protected override render(): TemplateResult { + const width = (this.input || this).offsetWidth; + return html` + ${super.render()} + + + + + + + + ${repeat( + this.availableOptions, + (option) => option.id, + (option) => { + return html` + + ${option.value} + + `; + } + )} + + + + + `; + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + this.overlay = this.shadowRoot.querySelector( + '#overlay' + ) as HTMLDivElement; + this.addEventListener('focusout', (event: FocusEvent) => { + const isMenuItem = + event.relatedTarget && + this.contains(event.relatedTarget as Node); + if (event.target === this && !isMenuItem) { + this.focused = false; + } + }); + this.updateComplete.then(() => { + this.availableOptions = this.options || this.optionEls; + }); + } + + private _returnItems = (): void => { + return; + }; + + protected async manageListOverlay(): Promise { + if (this.open) { + this.focused = true; + // this._returnItems = await openOverlay( + // this.shadowRoot.querySelector('#input') as HTMLElement, + // 'click', + // this.overlay, + // { + // offset: 0, + // placement: 'bottom-start', + // receivesFocus: 'false', + // } + // ); + this.focus(); + } else { + // this._returnItems(); + // this._returnItems = () => { + // return; + // }; + // this.overlayObserver.disconnect(); + // this.overlay.removeEventListener('scroll', this.onOverlayScroll); + } + } + + protected override updated(changed: PropertyValues): void { + if (changed.has('open')) { + this.manageListOverlay(); + } + if (!this.focused) { + this.open = false; + } + if (changed.has('value')) { + if (this.overlay && this.open) this.positionListbox(); + } + if (changed.has('activeDescendent')) { + if (changed.get('activeDescendent')) { + (changed.get('activeDescendent') as MenuItem).focused = false; + } + if ( + this.activeDescendent && + typeof (this.activeDescendent as MenuItem).focused !== + 'undefined' + ) { + (this.activeDescendent as MenuItem).focused = true; + } + } + } + + protected override async getUpdateComplete(): Promise { + const complete = await super.getUpdateComplete(); + const list = this.shadowRoot.querySelector( + '#listbox' + ) as HTMLUListElement; + if (list) { + const descendents = [...list.children] as ComboboxItem[]; + await Promise.all( + descendents.map((descendent) => descendent.updateComplete) + ); + } + return complete; + } + + public override connectedCallback(): void { + super.connectedCallback(); + // if (!this.overlayObserver) { + // this.overlayObserver = new MutationObserver( + // this.positionListboxFromEntries.bind(this) + // ); + // } + if (!this.itemObserver) { + this.itemObserver = new MutationObserver( + this.setOptionsFromSlottedItems.bind(this) + ); + } + } + + public override disconnectedCallback(): void { + // this.overlayObserver.disconnect(); + this.itemObserver.disconnect(); + this.open = false; + super.disconnectedCallback(); + } + + // private overlayObserver!: MutationObserver; + private itemObserver!: MutationObserver; +} + +/** + * + + #shadow-root + this.shadowRoot.querySelector('#listbox').children; + this.shadowRoot.querySelectorAll('li'); +
+ +
+ + + * + */ + +/** + * + * Public API + * popover requirement + * + * Aria-Spectrum consumption + * + * visual delivery - Spectrum CSS + * + * + * does test:watch build the plugins correctly? + */ diff --git a/packages/combobox/src/ComboboxItem.ts b/packages/combobox/src/ComboboxItem.ts new file mode 100644 index 0000000000..e431fd4a94 --- /dev/null +++ b/packages/combobox/src/ComboboxItem.ts @@ -0,0 +1,70 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + CSSResultGroup, + html, + PropertyValues, + SpectrumElement, + TemplateResult, +} from '@spectrum-web-components/base'; + +import styles from './combobox-item.css.js'; + +/** + * @element sp-combobox-item + */ +export class ComboboxItem extends SpectrumElement { + public static override get styles(): CSSResultGroup { + return [styles]; + } + + public get value(): string { + return (this.textContent as string).trim(); + } + + protected override render(): TemplateResult { + return html` + + `; + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + if (!this.hasAttribute('role')) { + this.setAttribute('role', 'option'); + } + // If I am an light DOM child of combobox + // if ((this.parentElement as HTMLElement)?.localName === 'sp-combobox') { + // this.slot = 'option'; + + // /* + // + // + // + // */ + // } + // If I am in the shadow DOM of a combobox + if ( + (this.getRootNode() as ShadowRoot).host?.localName === 'sp-combobox' + ) { + return; + + /* + + + + */ + } + this.slot = 'option'; + } +} diff --git a/packages/combobox/src/combobox-item.css b/packages/combobox/src/combobox-item.css new file mode 100644 index 0000000000..13de8c391a --- /dev/null +++ b/packages/combobox/src/combobox-item.css @@ -0,0 +1,19 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +:host { + display: block; +} + +:host([aria-selected]) { + outline: 1px solid blue; +} diff --git a/packages/combobox/src/combobox.css b/packages/combobox/src/combobox.css new file mode 100644 index 0000000000..e880a6d95c --- /dev/null +++ b/packages/combobox/src/combobox.css @@ -0,0 +1,55 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +@import url('./spectrum-combobox.css'); + +:host { + display: inline-flex; + flex-wrap: wrap; +} + +:host([label-position='inline-start']) { + flex-wrap: nowrap; +} + +sp-field-label { + display: block; + width: 100%; +} + +:host([label-position='inline-start']) sp-field-label { + width: auto; +} + +sp-popover { + max-block-size: var(--sp-combobox-popover-max-block-size); +} + +sp-popover:not(sp-overlay sp-popover) { + display: none; +} + +.icon { + margin: 0; +} + +::slotted([slot='option']) { + display: none; +} + +.button { + bottom: 0; +} + +[hidden] { + display: none !important; +} diff --git a/packages/combobox/src/index.ts b/packages/combobox/src/index.ts new file mode 100644 index 0000000000..8710c7fe3a --- /dev/null +++ b/packages/combobox/src/index.ts @@ -0,0 +1,14 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export * from './Combobox.js'; +export * from './ComboboxItem.js'; diff --git a/packages/combobox/src/spectrum-combobox.css b/packages/combobox/src/spectrum-combobox.css new file mode 100644 index 0000000000..1a875d8578 --- /dev/null +++ b/packages/combobox/src/spectrum-combobox.css @@ -0,0 +1,1138 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/* THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ +:host { + --spectrum-combobox-inline-size: var(--spectrum-field-width); + --spectrum-combobox-min-inline-size: calc( + var(--spectrum-combo-box-minimum-width-multiplier) * + var(--spectrum-combobox-block-size) + ); + --spectrum-combobox-button-width: var(--spectrum-combobox-block-size); + --spectrum-combobox-focus-indicator-thickness: var( + --spectrum-focus-indicator-thickness + ); + --spectrum-combobox-focus-indicator-gap: var( + --spectrum-focus-indicator-gap + ); + --spectrum-combobox-focus-indicator-color: var( + --spectrum-focus-indicator-color + ); + --spectrum-combobox-border-radius: var(--spectrum-corner-radius-100); + --spectrum-combobox-border-width: var(--spectrum-border-width-100); + --spectrum-combobox-spacing-label-to-combobox: var( + --spectrum-field-label-to-component + ); + --spectrum-combobox-font-family: var(--spectrum-sans-font-family-stack); + --spectrum-combobox-font-weight: var(--spectrum-regular-font-weight); + --spectrum-combobox-font-style: var(--spectrum-default-font-style); + --spectrum-combobox-line-height: var(--spectrum-line-height-100); + --spectrum-combobox-font-color-default: var( + --spectrum-neutral-content-color-default + ); + --spectrum-combobox-font-color-hover: var( + --spectrum-neutral-content-color-hover + ); + --spectrum-combobox-font-color-focus: var( + --spectrum-neutral-content-color-focus + ); + --spectrum-combobox-font-color-focus-hover: var( + --spectrum-neutral-content-color-focus-hover + ); + --spectrum-combobox-font-color-key-focus: var( + --spectrum-neutral-content-color-key-focus + ); + --spectrum-combobox-font-color-placeholder: var(--spectrum-gray-600); + --spectrum-combobox-background-color-default: var(--spectrum-gray-50); + --spectrum-combobox-background-color-hover: var(--spectrum-gray-50); + --spectrum-combobox-background-color-focus: var(--spectrum-gray-50); + --spectrum-combobox-background-color-focus-hover: var(--spectrum-gray-50); + --spectrum-combobox-background-color-key-focus: var(--spectrum-gray-50); + --spectrum-combobox-background-color-disabled: var( + --spectrum-disabled-background-color + ); + --spectrum-combobox-font-color-disabled: var( + --spectrum-disabled-content-color + ); + --spectrum-combobox-border-color-invalid-default: var( + --spectrum-negative-border-color-default + ); + --spectrum-combobox-border-color-invalid-hover: var( + --spectrum-negative-border-color-hover + ); + --spectrum-combobox-border-color-invalid-focus: var( + --spectrum-negative-border-color-focus + ); + --spectrum-combobox-border-color-invalid-focus-hover: var( + --spectrum-negative-border-color-focus-hover + ); + --spectrum-combobox-border-color-invalid-key-focus: var( + --spectrum-negative-border-color-key-focus + ); + --spectrum-combobox-alert-icon-color: var(--spectrum-negative-visual-color); + --mod-textfield-focus-indicator-gap: var( + --mod-combobox-focus-indicator-gap, + var(--spectrum-combobox-focus-indicator-gap) + ); + --mod-textfield-focus-indicator-width: var( + --mod-combobox-focus-indicator-thickness, + var(--spectrum-combobox-focus-indicator-thickness) + ); + --mod-textfield-focus-indicator-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-focus-indicator-color, + var(--spectrum-combobox-focus-indicator-color) + ) + ); +} +:host([size='s']) { + --spectrum-combobox-block-size: var(--spectrum-component-height-75); + --spectrum-combobox-icon-size: var(--spectrum-workflow-icon-size-75); + --spectrum-combobox-font-size: var(--spectrum-font-size-75); + --spectrum-combobox-spacing-inline-icon-to-button: var( + --spectrum-combo-box-visual-to-field-button-small + ); + --spectrum-combobox-block-spacing-edge-to-progress-circle: var( + --spectrum-field-top-to-progress-circle-small + ); + --spectrum-combobox-block-spacing-edge-to-alert: var( + --spectrum-field-top-to-alert-icon-small + ); + --spectrum-combobox-spacing-edge-to-menu: var( + --spectrum-component-to-menu-small + ); + --spectrum-combobox-spacing-block-start-edge-to-text: var( + --spectrum-component-top-to-text-75 + ); + --spectrum-combobox-spacing-block-end-edge-to-text: var( + --spectrum-component-bottom-to-text-75 + ); + --spectrum-combobox-spacing-inline-start-edge-to-text: var( + --spectrum-component-edge-to-text-75 + ); + --spectrum-combobox-spacing-inline-end-edge-to-text: var( + --spectrum-component-edge-to-text-75 + ); +} +:host { + --spectrum-combobox-block-size: var(--spectrum-component-height-100); + --spectrum-combobox-icon-size: var(--spectrum-workflow-icon-size-100); + --spectrum-combobox-font-size: var(--spectrum-font-size-100); + --spectrum-combobox-spacing-inline-icon-to-button: var( + --spectrum-combo-box-visual-to-field-button-medium + ); + --spectrum-combobox-block-spacing-edge-to-progress-circle: var( + --spectrum-field-top-to-progress-circle-medium + ); + --spectrum-combobox-block-spacing-edge-to-alert: var( + --spectrum-field-top-to-alert-icon-medium + ); + --spectrum-combobox-spacing-edge-to-menu: var( + --spectrum-component-to-menu-medium + ); + --spectrum-combobox-spacing-block-start-edge-to-text: var( + --spectrum-component-top-to-text-100 + ); + --spectrum-combobox-spacing-block-end-edge-to-text: var( + --spectrum-component-bottom-to-text-100 + ); + --spectrum-combobox-spacing-inline-start-edge-to-text: var( + --spectrum-component-edge-to-text-100 + ); + --spectrum-combobox-spacing-inline-end-edge-to-text: var( + --spectrum-component-edge-to-text-100 + ); +} +:host([size='l']) { + --spectrum-combobox-block-size: var(--spectrum-component-height-200); + --spectrum-combobox-icon-size: var(--spectrum-workflow-icon-size-200); + --spectrum-combobox-font-size: var(--spectrum-font-size-200); + --spectrum-combobox-spacing-inline-icon-to-button: var( + --spectrum-combo-box-visual-to-field-button-large + ); + --spectrum-combobox-block-spacing-edge-to-progress-circle: var( + --spectrum-field-top-to-progress-circle-large + ); + --spectrum-combobox-block-spacing-edge-to-alert: var( + --spectrum-field-top-to-alert-icon-large + ); + --spectrum-combobox-spacing-edge-to-menu: var( + --spectrum-component-to-menu-large + ); + --spectrum-combobox-spacing-block-start-edge-to-text: var( + --spectrum-component-top-to-text-200 + ); + --spectrum-combobox-spacing-block-end-edge-to-text: var( + --spectrum-component-bottom-to-text-200 + ); + --spectrum-combobox-spacing-inline-start-edge-to-text: var( + --spectrum-component-edge-to-text-200 + ); + --spectrum-combobox-spacing-inline-end-edge-to-text: var( + --spectrum-component-edge-to-text-200 + ); +} +:host([size='xl']) { + --spectrum-combobox-block-size: var(--spectrum-component-height-300); + --spectrum-combobox-icon-size: var(--spectrum-workflow-icon-size-300); + --spectrum-combobox-font-size: var(--spectrum-font-size-300); + --spectrum-combobox-spacing-inline-icon-to-button: var( + --spectrum-combo-box-visual-to-field-button-extra-large + ); + --spectrum-combobox-block-spacing-edge-to-progress-circle: var( + --spectrum-field-top-to-progress-circle-extra-large + ); + --spectrum-combobox-block-spacing-edge-to-alert: var( + --spectrum-field-top-to-alert-icon-extra-large + ); + --spectrum-combobox-spacing-edge-to-menu: var( + --spectrum-component-to-menu-extra-large + ); + --spectrum-combobox-spacing-block-start-edge-to-text: var( + --spectrum-component-top-to-text-300 + ); + --spectrum-combobox-spacing-block-end-edge-to-text: var( + --spectrum-component-bottom-to-text-300 + ); + --spectrum-combobox-spacing-inline-start-edge-to-text: var( + --spectrum-component-edge-to-text-300 + ); + --spectrum-combobox-spacing-inline-end-edge-to-text: var( + --spectrum-component-edge-to-text-300 + ); +} +:host([quiet]) { + --spectrum-combobox-min-inline-size: calc( + var(--spectrum-combo-box-quiet-minimum-width-multiplier) * + var(--spectrum-combobox-block-size) + ); + --spectrum-combobox-spacing-inline-icon-to-button: var( + --spectrum-combo-box-visual-to-field-button-quiet + ); + --spectrum-combobox-spacing-inline-start-edge-to-text: var( + --spectrum-field-edge-to-text-quiet + ); + --spectrum-combobox-button-inline-offset: calc( + var(--mod-combobox-block-size, var(--spectrum-combobox-block-size)) / 2 - + var(--mod-combobox-icon-size, var(--spectrum-combobox-icon-size)) / + 2 + ); + --spectrum-combobox-border-color-disabled: var( + --spectrum-disabled-border-color + ); + --spectrum-combobox-background-color-default: var( + --spectrum-transparent-white-100 + ); + --spectrum-combobox-background-color-hover: var( + --spectrum-transparent-white-100 + ); + --spectrum-combobox-background-color-focus: var( + --spectrum-transparent-white-100 + ); + --spectrum-combobox-background-color-focus-hover: var( + --spectrum-transparent-white-100 + ); + --spectrum-combobox-background-color-key-focus: var( + --spectrum-transparent-white-100 + ); + --spectrum-combobox-background-color-disabled: var( + --spectrum-transparent-white-100 + ); +} +:host([quiet][size='s']) { + --spectrum-combobox-spacing-label-to-combobox: var( + --spectrum-field-label-to-component-quiet-small + ); +} +:host([quiet]) { + --spectrum-combobox-spacing-label-to-combobox: var( + --spectrum-field-label-to-component-quiet-medium + ); +} +:host([quiet][size='l']) { + --spectrum-combobox-spacing-label-to-combobox: var( + --spectrum-field-label-to-component-quiet-large + ); +} +:host([quiet][size='xl']) { + --spectrum-combobox-spacing-label-to-combobox: var( + --spectrum-field-label-to-component-quiet-extra-large + ); +} +@media (forced-colors: active) { + :host { + --highcontrast-combobox-focus-indicator-color: Highlight; + --highcontrast-combobox-border-color: ButtonText; + --highcontrast-combobox-font-color: CanvasText; + --highcontrast-combobox-font-color-disabled: GrayText; + --highcontrast-combobox-icon-color: ButtonText; + --highcontrast-combobox-background-color: Background; + --highcontrast-combobox-border-color-disabled: GrayText; + --highcontrast-combobox-font-color-placeholder: CanvasText; + } +} +:host { + block-size: var( + --mod-combobox-block-size, + var(--spectrum-combobox-block-size) + ); + border-radius: var( + --mod-combobox-border-radius, + var(--spectrum-combobox-border-radius) + ); + display: inline-flex; + flex-flow: row; + inline-size: var( + --mod-combobox-inline-size, + var(--spectrum-combobox-inline-size) + ); + margin-block-start: var( + --mod-combobox-spacing-label-to-combobox, + var(--spectrum-combobox-spacing-label-to-combobox) + ); + min-inline-size: var( + --mod-combobox-min-inline-size, + var(--spectrum-combobox-min-inline-size) + ); + position: relative; +} +.spectrum-Popover.is-open { + transform: translateY( + var( + --mod-combobox-spacing-edge-to-menu, + var(--spectrum-combobox-spacing-edge-to-menu) + ) + ); +} +.spectrum-Combobox-progress-circle { + inset-block-end: var( + --mod-combobox-block-spacing-edge-to-alert, + var(--spectrum-combobox-block-spacing-edge-to-alert) + ); + inset-block-start: var( + --mod-combobox-block-spacing-edge-to-progress-circle, + var(--spectrum-combobox-block-spacing-edge-to-progress-circle) + ); + inset-inline-end: calc( + var( + --mod-combobox-spacing-inline-icon-to-button, + var(--spectrum-combobox-spacing-inline-icon-to-button) + ) + + var( + --mod-combobox-button-width, + var(--spectrum-combobox-button-width) + ) + ); + position: absolute; +} +:host([dir='rtl']) .spectrum-Combobox-progress-circle { + inset-inline-end: inherit; + inset-inline-start: calc( + var( + --mod-combobox-spacing-inline-icon-to-button, + var(--spectrum-combobox-spacing-inline-icon-to-button) + ) + + var( + --mod-combobox-button-width, + var(--spectrum-combobox-button-width) + ) + ); +} +.button { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-default, + var(--spectrum-combobox-font-color-default) + ) + ); + inset-inline-end: calc( + var( + --mod-combobox-button-inline-offset, + var(--spectrum-combobox-button-inline-offset, 0px) + ) * -1 + ); + position: absolute; +} +.button .spectrum-PickerButton-fill { + border-width: var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ); +} +:host(:not([invalid])) .button:not(:disabled) .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-default, + var(--spectrum-combobox-border-color-default) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled):focus, +:host(:not([invalid])[focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus, + var(--spectrum-combobox-font-color-focus) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled):focus .spectrum-PickerButton-fill, +:host(:not([invalid])[focused]) + .button:not(:disabled) + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-focus, + var(--spectrum-combobox-border-color-focus) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled).focus-visible, +:host(:not([invalid])[keyboard-focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled):focus-visible, +:host(:not([invalid])[keyboard-focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +:host(:not([invalid])) + .button:not(:disabled).focus-visible + .spectrum-PickerButton-fill, +:host(:not([invalid])[keyboard-focused]) + .button:not(:disabled) + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-key-focus, + var(--spectrum-combobox-border-color-key-focus) + ) + ); +} +:host(:not([invalid])) + .button:not(:disabled):focus-visible + .spectrum-PickerButton-fill, +:host(:not([invalid])[keyboard-focused]) + .button:not(:disabled) + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-key-focus, + var(--spectrum-combobox-border-color-key-focus) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled):active, +:host(:not([invalid])) .button:not(:disabled):hover { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-hover, + var(--spectrum-combobox-font-color-hover) + ) + ); +} +:host(:not([invalid])) + .button:not(:disabled):active + .spectrum-PickerButton-fill, +:host(:not([invalid])) + .button:not(:disabled):hover + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-hover, + var(--spectrum-combobox-border-color-hover) + ) + ); +} +:host(:not([invalid])) .button:not(:disabled):focus:hover, +:host(:not([invalid])[focused]) .button:not(:disabled):hover { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus-hover, + var(--spectrum-combobox-font-color-focus-hover) + ) + ); +} +:host(:not([invalid])) + .button:not(:disabled):focus:hover + .spectrum-PickerButton-fill, +:host(:not([invalid])[focused]) + .button:not(:disabled):hover + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-focus-hover, + var(--spectrum-combobox-border-color-focus-hover) + ) + ); +} +:host([invalid]) .button:not(:disabled) .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-default, + var(--spectrum-combobox-border-color-invalid-default) + ) + ); +} +:host([invalid]) .button:not(:disabled):focus, +:host([invalid][focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus, + var(--spectrum-combobox-font-color-focus) + ) + ); +} +:host([invalid]) .button:not(:disabled):focus .spectrum-PickerButton-fill, +:host([invalid][focused]) .button:not(:disabled) .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-focus, + var(--spectrum-combobox-border-color-invalid-focus) + ) + ); +} +:host([invalid]) .button:not(:disabled).focus-visible, +:host([invalid][keyboard-focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +:host([invalid]) .button:not(:disabled):focus-visible, +:host([invalid][keyboard-focused]) .button:not(:disabled) { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +:host([invalid]) + .button:not(:disabled).focus-visible + .spectrum-PickerButton-fill, +:host([invalid][keyboard-focused]) + .button:not(:disabled) + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-key-focus, + var(--spectrum-combobox-border-color-invalid-key-focus) + ) + ); +} +:host([invalid]) + .button:not(:disabled):focus-visible + .spectrum-PickerButton-fill, +:host([invalid][keyboard-focused]) + .button:not(:disabled) + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-key-focus, + var(--spectrum-combobox-border-color-invalid-key-focus) + ) + ); +} +:host([invalid]) .button:not(:disabled):active, +:host([invalid]) .button:not(:disabled):hover { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-hover, + var(--spectrum-combobox-font-color-hover) + ) + ); +} +:host([invalid]) .button:not(:disabled):active .spectrum-PickerButton-fill, +:host([invalid]) .button:not(:disabled):hover .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-hover, + var(--spectrum-combobox-border-color-invalid-hover) + ) + ); +} +:host([invalid]) .button:not(:disabled):focus:hover, +:host([invalid][focused]) .button:not(:disabled):hover { + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus-hover, + var(--spectrum-combobox-font-color-focus-hover) + ) + ); +} +:host([invalid]) .button:not(:disabled):focus:hover .spectrum-PickerButton-fill, +:host([invalid][focused]) + .button:not(:disabled):hover + .spectrum-PickerButton-fill { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-focus-hover, + var(--spectrum-combobox-border-color-invalid-focus-hover) + ) + ); +} +.button:disabled, +:host([disabled]) .button { + color: var( + --highcontrast-combobox-font-color-disabled, + var( + --mod-combobox-font-color-disabled, + var(--spectrum-combobox-font-color-disabled) + ) + ); +} +.button:disabled .spectrum-PickerButton-fill, +:host([disabled]) .button .spectrum-PickerButton-fill { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-disabled, + var(--spectrum-combobox-background-color-disabled) + ) + ); +} +.spectrum-Combobox-textfield { + inline-size: 100%; +} +#input { + backface-visibility: hidden; + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-default, + var(--spectrum-combobox-background-color-default) + ) + ); + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-default, + var(--spectrum-combobox-border-color-default) + ) + ); + border-width: var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-default, + var(--spectrum-combobox-font-color-default) + ) + ); + font-family: var( + --mod-combobox-font-family, + var(--spectrum-combobox-font-family) + ); + font-size: var( + --mod-combobox-font-size, + var(--spectrum-combobox-font-size) + ); + font-style: var( + --mod-combobox-font-style, + var(--spectrum-combobox-font-style) + ); + font-weight: var( + --mod-combobox-font-weight, + var(--spectrum-combobox-font-weight) + ); + line-height: var( + --mod-combobox-line-height, + var(--spectrum-combobox-line-height) + ); + padding-block-end: calc( + var( + --mod-combobox-spacing-block-end-edge-to-text, + var(--spectrum-combobox-spacing-block-end-edge-to-text) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) + ); + padding-block-start: calc( + var( + --mod-combobox-spacing-block-start-edge-to-text, + var(--spectrum-combobox-spacing-block-start-edge-to-text) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) + ); + padding-inline-end: calc( + var(--mod-combobox-button-width, var(--spectrum-combobox-button-width)) + + var( + --mod-combobox-spacing-inline-end-edge-to-text, + var(--spectrum-combobox-spacing-inline-end-edge-to-text) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) * 2 + ); + padding-inline-start: calc( + var( + --mod-combobox-spacing-inline-start-edge-to-text, + var(--spectrum-combobox-spacing-inline-start-edge-to-text) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) + ); +} +#input::placeholder { + color: var( + --highcontrast-combobox-font-color-placeholder, + var( + --mod-combobox-font-color-placeholder, + var(--spectrum-combobox-font-color-placeholder) + ) + ); +} +#input:active, +#input:hover, +.spectrum-Combobox-textfield #input { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-hover, + var(--spectrum-combobox-background-color-hover) + ) + ); + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-hover, + var(--spectrum-combobox-border-color-hover) + ) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-hover, + var(--spectrum-combobox-font-color-hover) + ) + ); +} +#input:active::placeholder, +#input:hover::placeholder, +.spectrum-Combobox-textfield #input::placeholder { + color: var( + --highcontrast-combobox-font-color-placeholder, + var( + --mod-combobox-font-color-hover, + var(--spectrum-combobox-font-color-hover) + ) + ); +} +#input:focus, +.spectrum-Combobox-textfield #input { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-focus, + var(--spectrum-combobox-background-color-focus) + ) + ); + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-focus, + var(--spectrum-combobox-border-color-focus) + ) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus, + var(--spectrum-combobox-font-color-focus) + ) + ); +} +#input:focus::placeholder, +.spectrum-Combobox-textfield #input::placeholder { + color: var( + --highcontrast-combobox-font-color-placeholder, + var( + --mod-combobox-font-color-focus, + var(--spectrum-combobox-font-color-focus) + ) + ); +} +#input:focus:hover, +.spectrum-Combobox-textfield #input:hover { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-focus-hover, + var(--spectrum-combobox-background-color-focus-hover) + ) + ); + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-focus-hover, + var(--spectrum-combobox-border-color-focus-hover) + ) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-focus-hover, + var(--spectrum-combobox-font-color-focus-hover) + ) + ); +} +#input.focus-visible, +.spectrum-Combobox-textfield #input { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-key-focus, + var(--spectrum-combobox-background-color-key-focus) + ) + ); + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-key-focus, + var(--spectrum-combobox-border-color-key-focus) + ) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +#input:focus-visible, +.spectrum-Combobox-textfield #input { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-key-focus, + var(--spectrum-combobox-background-color-key-focus) + ) + ); + border-color: var( + --highcontrast-combobox-border-color, + var( + --mod-combobox-border-color-key-focus, + var(--spectrum-combobox-border-color-key-focus) + ) + ); + color: var( + --highcontrast-combobox-font-color, + var( + --mod-combobox-font-color-key-focus, + var(--spectrum-combobox-font-color-key-focus) + ) + ); +} +.spectrum-Combobox-textfield #input { + padding-inline-end: calc( + var(--mod-combobox-button-width, var(--spectrum-combobox-button-width)) + + var( + --mod-combobox-spacing-inline-icon-to-button, + var(--spectrum-combobox-spacing-inline-icon-to-button) + ) + + var(--mod-combobox-icon-size, var(--spectrum-combobox-icon-size)) + + var( + --mod-combobox-spacing-inline-end-edge-to-text, + var(--spectrum-combobox-spacing-inline-end-edge-to-text) + ) - + var( + --mod-combobox-button-inline-offset, + var(--spectrum-combobox-button-inline-offset, 0px) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) * 2 + ); +} +#input .spectrum-Combobox-textfield { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-default, + var(--spectrum-combobox-border-color-invalid-default) + ) + ); +} +.spectrum-Combobox-textfield #input:active, +.spectrum-Combobox-textfield #input:hover, +.spectrum-Combobox-textfield:hover #input { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-hover, + var(--spectrum-combobox-border-color-invalid-hover) + ) + ); +} +.spectrum-Combobox-textfield #input:focus, +.spectrum-Combobox-textfield:focus-within #input, +:host([focused]) .spectrum-Combobox-textfield #input { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-focus, + var(--spectrum-combobox-border-color-invalid-focus) + ) + ); +} +.spectrum-Combobox-textfield #input:focus:hover, +.spectrum-Combobox-textfield:focus-within #input:hover, +:host([focused]) .spectrum-Combobox-textfield #input:hover { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-focus-hover, + var(--spectrum-combobox-border-color-invalid-focus-hover) + ) + ); +} +.spectrum-Combobox-textfield #input.focus-visible, +:host([keyboard-focused]) .spectrum-Combobox-textfield #input { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-key-focus, + var(--spectrum-combobox-border-color-invalid-key-focus) + ) + ); +} +.spectrum-Combobox-textfield #input:focus-visible, +:host([keyboard-focused]) .spectrum-Combobox-textfield #input { + border-color: var( + --highcontrast-combobox-focus-indicator-color, + var( + --mod-combobox-border-color-invalid-key-focus, + var(--spectrum-combobox-border-color-invalid-key-focus) + ) + ); +} +#input:disabled, +.spectrum-Combobox-textfield #input, +:host([disabled]) .spectrum-Combobox-textfield#input { + background-color: var( + --highcontrast-combobox-background-color, + var( + --mod-combobox-background-color-disabled, + var(--spectrum-combobox-background-color-disabled) + ) + ); + border-color: var( + --highcontrast-combobox-border-color-disabled, + var( + --mod-combobox-background-color-disabled, + var(--spectrum-combobox-background-color-disabled) + ) + ); + color: var( + --highcontrast-combobox-font-color-disabled, + var( + --mod-combobox-font-color-disabled, + var(--spectrum-combobox-font-color-disabled) + ) + ); +} +#input:disabled::placeholder, +.spectrum-Combobox-textfield #input::placeholder, +:host([disabled]) .spectrum-Combobox-textfield#input::placeholder { + color: var( + --highcontrast-combobox-font-color-disabled, + var( + --mod-combobox-font-color-disabled, + var(--spectrum-combobox-font-color-disabled) + ) + ); +} +.spectrum-Combobox-textfield .spectrum-Textfield-validationIcon { + block-size: var( + --mod-combobox-icon-size, + var(--spectrum-combobox-icon-size) + ); + color: var( + --highcontrast-combobox-icon-color, + var( + --mod-combobox-alert-icon-color, + var(--spectrum-combobox-alert-icon-color) + ) + ); + inline-size: var( + --mod-combobox-icon-size, + var(--spectrum-combobox-icon-size) + ); + inset-block-end: var( + --mod-combobox-block-spacing-edge-to-alert, + var(--spectrum-combobox-block-spacing-edge-to-alert) + ); + inset-block-start: var( + --mod-combobox-block-spacing-edge-to-alert, + var(--spectrum-combobox-block-spacing-edge-to-alert) + ); + inset-inline-end: calc( + var( + --mod-combobox-spacing-inline-icon-to-button, + var(--spectrum-combobox-spacing-inline-icon-to-button) + ) + + var( + --mod-combobox-button-width, + var(--spectrum-combobox-button-width) + ) + ); +} +.spectrum-Textfield.is-loading .spectrum-Textfield-validationIcon, +.spectrum-Textfield.is-readOnly .spectrum-Textfield-validationIcon, +:host([disabled]) .spectrum-Textfield .spectrum-Textfield-validationIcon { + color: #0000; +} +:host([quiet]) { + border-radius: 0; +} +:host([quiet]:not([invalid])) + .button:not(:disabled) + .spectrum-PickerButton-fill, +:host([quiet][invalid]) .button:not(:disabled) .spectrum-PickerButton-fill { + background: none; + border-color: #0000; +} +:host([quiet]) .spectrum-Combobox-textfield .spectrum-Textfield-validationIcon { + inset-inline-end: var( + --mod-combobox-button-width, + var(--spectrum-combobox-button-width) + ); +} +:host([quiet]) #input { + border-block-end-width: var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ); + padding-block-end: calc( + var( + --mod-combobox-spacing-block-end-edge-to-text, + var(--spectrum-combobox-spacing-block-end-edge-to-text) + ) - + var( + --mod-combobox-border-width, + var(--spectrum-combobox-border-width) + ) + ); + padding-block-start: var( + --mod-combobox-spacing-block-start-edge-to-text, + var(--spectrum-combobox-spacing-block-start-edge-to-text) + ); + padding-inline-end: calc( + var(--mod-combobox-button-width, var(--spectrum-combobox-button-width)) + + var( + --mod-combobox-spacing-inline-end-edge-to-text, + var(--spectrum-combobox-spacing-inline-end-edge-to-text) + ) - + var( + --mod-combobox-button-inline-offset, + var(--spectrum-combobox-button-inline-offset, 0px) + ) + ); + padding-inline-start: var( + --mod-combobox-spacing-inline-start-edge-to-text, + var(--spectrum-combobox-spacing-inline-start-edge-to-text) + ); +} +:host([quiet]) .spectrum-Combobox-textfield #input { + padding-inline-end: calc( + var(--mod-combobox-button-width, var(--spectrum-combobox-button-width)) + + var( + --mod-combobox-spacing-inline-icon-to-button, + var(--spectrum-combobox-spacing-inline-icon-to-button) + ) + + var(--mod-combobox-icon-size, var(--spectrum-combobox-icon-size)) + + var( + --mod-combobox-spacing-inline-end-edge-to-text, + var(--spectrum-combobox-spacing-inline-end-edge-to-text) + ) - + var( + --mod-combobox-button-inline-offset, + var(--spectrum-combobox-button-inline-offset, 0px) + ) + ); +} +:host([quiet]) #input:disabled, +:host([quiet]) .spectrum-Combobox-textfield #input, +:host([quiet][disabled]) .spectrum-Combobox-textfield#input { + border-block-end-color: var( + --highcontrast-combobox-border-color-disabled, + var( + --mod-combobox-border-color-disabled, + var(--spectrum-combobox-border-color-disabled) + ) + ); +} +:host { + --spectrum-combobox-border-color-default: var( + --system-spectrum-combobox-border-color-default + ); + --spectrum-combobox-border-color-hover: var( + --system-spectrum-combobox-border-color-hover + ); + --spectrum-combobox-border-color-focus: var( + --system-spectrum-combobox-border-color-focus + ); + --spectrum-combobox-border-color-focus-hover: var( + --system-spectrum-combobox-border-color-focus-hover + ); + --spectrum-combobox-border-color-key-focus: var( + --system-spectrum-combobox-border-color-key-focus + ); +} diff --git a/packages/combobox/src/spectrum-config.js b/packages/combobox/src/spectrum-config.js new file mode 100644 index 0000000000..58426d69ee --- /dev/null +++ b/packages/combobox/src/spectrum-config.js @@ -0,0 +1,123 @@ +// @ts-check +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the 'License'); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + builder, + converterFor, +} from '../../../tasks/process-spectrum-utils.js'; + +const converter = converterFor('spectrum-Combobox'); + +/** + * @type { import('../../../tasks/spectrum-css-converter').SpectrumCSSConverter } + */ +const config = { + conversions: [ + { + inPackage: '@spectrum-css/combobox', + outPackage: 'combobox', + fileName: 'combobox', + components: [ + converter.classToHost(), + // Default to `size='m'` without needing the attribute + converter.classToHost('spectrum-Combobox--sizeM'), + ...converter.enumerateAttributes( + [ + ['spectrum-Combobox--sizeS', 's'], + ['spectrum-Combobox--sizeL', 'l'], + ['spectrum-Combobox--sizeXL', 'xl'], + ], + 'size' + ), + converter.classToAttribute('spectrum-Combobox--quiet'), + converter.classToClass('spectrum-Combobox-button'), + converter.classToAttribute('is-focused', 'focused'), + converter.classToAttribute('is-invalid', 'invalid'), + converter.classToAttribute( + 'is-keyboardFocused', + 'keyboard-focused' + ), + converter.classToAttribute('is-disabled', 'disabled'), + { + find: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.class('is-focused')]], + }, + replace: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.attribute('focused')]], + }, + hoist: true, + }, + { + find: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.class('is-invalid')]], + }, + replace: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.attribute('invalid')]], + }, + hoist: true, + }, + { + find: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.class('is-keyboardFocused')]], + }, + replace: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.attribute('keyboard-focused')]], + }, + hoist: true, + }, + { + find: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.class('is-disabled')]], + }, + replace: { + type: 'pseudo-class', + kind: 'not', + selectors: [[builder.attribute('disabled')]], + }, + hoist: true, + }, + converter.classToId('spectrum-Combobox-input'), + { + find: [builder.class('spectrum-Combobox-textfield')], + replace: [], + collapseSelector: true, + }, + ], + excludeByComponents: [ + { + type: 'class', + name: 'regex', + regex: /Datepicker/, + }, + builder.class('🤫'), + // builder.class('spectrum-PickerButton-fill'), + ], + }, + ], +}; + +export default config; diff --git a/packages/combobox/src/spectrum-textfield.css b/packages/combobox/src/spectrum-textfield.css new file mode 100644 index 0000000000..18b86eaf8c --- /dev/null +++ b/packages/combobox/src/spectrum-textfield.css @@ -0,0 +1,130 @@ +/* stylelint-disable */ /* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. + +THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ +:host([dir='ltr']) .spectrum-InputGroup .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup .spectrum-InputGroup-button */ + border-top-left-radius: var( + --spectrum-combobox-fieldbutton-border-top-left-radius, + 0 + ); +} +:host([dir='rtl']) .spectrum-InputGroup .spectrum-InputGroup-button { + /* [dir=rtl] .spectrum-InputGroup .spectrum-InputGroup-button */ + border-top-right-radius: var( + --spectrum-combobox-fieldbutton-border-top-left-radius, + 0 + ); +} +:host([dir='ltr']) .spectrum-InputGroup .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup .spectrum-InputGroup-button */ + border-bottom-left-radius: var( + --spectrum-combobox-fieldbutton-border-bottom-left-radius, + 0 + ); +} +:host([dir='rtl']) .spectrum-InputGroup .spectrum-InputGroup-button { + /* [dir=rtl] .spectrum-InputGroup .spectrum-InputGroup-button */ + border-bottom-right-radius: var( + --spectrum-combobox-fieldbutton-border-bottom-left-radius, + 0 + ); +} +:host([dir='ltr']) #input { + /* [dir=ltr] .spectrum-InputGroup-input */ + border-top-right-radius: var( + --spectrum-combobox-textfield-border-top-right-radius, + 0 + ); +} +:host([dir='rtl']) #input { + /* [dir=rtl] .spectrum-InputGroup-input */ + border-top-left-radius: var( + --spectrum-combobox-textfield-border-top-right-radius, + 0 + ); +} +:host([dir='ltr']) #input { + /* [dir=ltr] .spectrum-InputGroup-input */ + border-bottom-right-radius: var( + --spectrum-combobox-textfield-border-bottom-right-radius, + 0 + ); +} +:host([dir='rtl']) #input { + /* [dir=rtl] .spectrum-InputGroup-input */ + border-bottom-left-radius: var( + --spectrum-combobox-textfield-border-bottom-right-radius, + 0 + ); +} +:host([dir='ltr']) #input { + /* [dir=ltr] .spectrum-InputGroup-input */ + border-right-width: var(--spectrum-combobox-field-border-width-right); +} +:host([dir='rtl']) #input { + /* [dir=rtl] .spectrum-InputGroup-input */ + border-left-width: var(--spectrum-combobox-field-border-width-right); +} +#input { + /* .spectrum-InputGroup-input */ + flex: 1; +} +:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + padding-left: var(--spectrum-combobox-quiet-button-offset); +} +:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + padding-right: var(--spectrum-combobox-quiet-button-offset); +} +:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + padding-right: var(--spectrum-combobox-quiet-fieldbutton-padding-right); +} +:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + padding-left: var(--spectrum-combobox-quiet-fieldbutton-padding-right); +} +:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + border-left: 0; +} +:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button, +:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button, + * [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + border-right: 0; +} +:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { + /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ + border-left: 0; +} +:host([dir='ltr']) + .spectrum-InputGroup--quiet + .spectrum-InputGroup-button:after { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button:after */ + right: calc(var(--spectrum-combobox-quiet-button-offset) * -1); +} +:host([dir='rtl']) + .spectrum-InputGroup--quiet + .spectrum-InputGroup-button:after { + /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button:after */ + left: calc(var(--spectrum-combobox-quiet-button-offset) * -1); +} +:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-icon { + /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-icon */ + right: 0; +} +:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-icon { + /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-icon */ + left: 0; +} diff --git a/packages/combobox/stories/combobox.stories.ts b/packages/combobox/stories/combobox.stories.ts new file mode 100644 index 0000000000..eb194fa7f9 --- /dev/null +++ b/packages/combobox/stories/combobox.stories.ts @@ -0,0 +1,161 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { html, TemplateResult } from '@spectrum-web-components/base'; + +import { ComboboxOption } from '..'; +import '../sp-combobox.js'; +import '../sp-combobox-item.js'; + +export default { + title: 'Combobox', + component: 'sp-combobox', +}; + +export const Default = (): TemplateResult => { + const options: ComboboxOption[] = [ + { id: 'thing1', value: 'Abc Thing 1' }, + { id: 'thing1a', value: 'Bde Thing 2' }, + { id: 'thing1b', value: 'Bef Thing 3' }, + { id: 'thing4', value: 'Efg Thing 4' }, + { id: 'athing1', value: 'Abc Thing 1' }, + { id: 'athing1a', value: 'Bde Thing 2' }, + { id: 'athing1b', value: 'Bef Thing 3' }, + { id: 'athing4', value: 'Efg Thing 4' }, + ]; + return html` + + Things + + + `; +}; + +export const matches = (): TemplateResult => { + const options: ComboboxOption[] = [ + { id: 'o1', value: 'Aaaaaaaaaaaaa' }, + { id: 'o2', value: 'Abaaaaaaaaaaa' }, + { id: 'o3', value: 'Abcaaaaaaaaaa' }, + { id: 'o4', value: 'Abcdaaaaaaaaa' }, + { id: 'o5', value: 'Abcdeaaaaaaaa' }, + { id: 'o6', value: 'Abcdefaaaaaaa' }, + { id: 'o7', value: 'Abcdefgaaaaaa' }, + { id: 'o8', value: 'Abcdefghaaaaa' }, + { id: 'o9', value: 'Abcdefghiaaaa' }, + { id: 'o10', value: 'Abcdefghijaaa' }, + { id: 'o11', value: 'Abcdefghijkaa' }, + { id: 'o12', value: 'Abcdefghijkla' }, + { id: 'o13', value: 'Abcdefghijklm' }, + ]; + return html` + Things + + `; +}; + +const optionsK: ComboboxOption[] = [ + { id: 'o1', value: 'Auto' }, + { id: 'o2', value: '-100' }, + { id: 'o3', value: '-75' }, + { id: 'o4', value: '-50' }, + { id: 'o5', value: '-25' }, + { id: 'o6', value: '-10' }, + { id: 'o7', value: '-5' }, + { id: 'o8', value: '0' }, + { id: 'o9', value: '5' }, + { id: 'o10', value: '10' }, + { id: 'o11', value: '25' }, + { id: 'o12', value: '50' }, + { id: 'o13', value: '75' }, + { id: 'o14', value: '100' }, + { id: 'o15', value: '200' }, +]; + +const optionsL: ComboboxOption[] = [ + { id: 'o1', value: 'Auto' }, + { id: 'o2', value: '6 pt' }, + { id: 'o3', value: '8 pt' }, + { id: 'o4', value: '9 pt' }, + { id: 'o5', value: '10 pt' }, + { id: 'o6', value: '11 pt' }, + { id: 'o7', value: '12 pt' }, + { id: 'o8', value: '14 pt' }, + { id: 'o9', value: '16 pt' }, + { id: 'o10', value: '18 pt' }, + { id: 'o11', value: '24 pt' }, + { id: 'o12', value: '30 pt' }, + { id: 'o13', value: '36 pt' }, + { id: 'o14', value: '48 pt' }, + { id: 'o15', value: '60 pt' }, + { id: 'o16', value: '72 pt' }, +]; + +export const kerning = (): TemplateResult => { + return html` + K + + L + + `; +}; + +export const kerningLightDOM = (): TemplateResult => { + return html` + K + + ${optionsK.map( + (option) => html` + + ${option.value} + + ` + )} + + K + + ${optionsL.map( + (option) => html` + + ${option.value} + + ` + )} + + `; +}; diff --git a/packages/combobox/test/benchmark/basic-test.ts b/packages/combobox/test/benchmark/basic-test.ts new file mode 100644 index 0000000000..5260daa761 --- /dev/null +++ b/packages/combobox/test/benchmark/basic-test.ts @@ -0,0 +1,19 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import '@spectrum-web-components/combobox/sp-combobox.js'; +import { html } from '@spectrum-web-components/base'; +import { measureFixtureCreation } from '../../../../test/benchmark/helpers.js'; + +measureFixtureCreation(html` + +`); diff --git a/packages/combobox/test/combobox.data.test.ts b/packages/combobox/test/combobox.data.test.ts new file mode 100644 index 0000000000..791b2cf27c --- /dev/null +++ b/packages/combobox/test/combobox.data.test.ts @@ -0,0 +1,320 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, + waitUntil, +} from '@open-wc/testing'; + +import '../sp-combobox.js'; +import '../sp-combobox-item.js'; +import { Combobox, ComboboxItem, ComboboxOption } from '..'; +import { TestableCombobox } from './combobox.test.js'; +import { SpectrumElement, TemplateResult } from '@spectrum-web-components/base'; +import { customElement } from '@spectrum-web-components/base/src/decorators.js'; + +const options: ComboboxOption[] = [ + { id: 'thing1', value: 'Abc Thing 1' }, + { id: 'thing1a', value: 'Bde Thing 2' }, + { id: 'thing1b', value: 'Bef Thing 3' }, + { id: 'thing4', value: 'Efg Thing 4' }, +]; + +const comboboxFixture = async (): Promise => { + const el = await fixture( + html` + Combobox Test + ` + ); + await elementUpdated(el); + + return el; +}; + +@customElement('combobox-slot-test-el') +export class TestEl extends SpectrumElement { + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +describe('Combobox Data', () => { + afterEach(() => { + const overlays = document.querySelectorAll('active-overlay'); + overlays.forEach((overlay) => overlay.remove()); + }); + it('accepts options as property', async () => { + const el = await comboboxFixture(); + + expect(el.options).to.deep.equal(options); + }); + it('accepts options as html', async () => { + const el = await fixture( + html` + + Combobox Test + + Pineapple + + Yuzu + Kumquat + Lychee + Durian + + ` + ); + await elementUpdated(el); + + expect(el.options).to.deep.equal([ + { + id: 'pineapple', + value: 'Pineapple', + }, + { + id: 'yuzu', + value: 'Yuzu', + }, + { + id: 'kumquat', + value: 'Kumquat', + }, + { + id: 'lychee', + value: 'Lychee', + }, + { + id: 'durian', + value: 'Durian', + }, + ]); + }); + it('accepts additional options as html', async () => { + const el = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + await elementUpdated(el); + + expect(el.options).to.deep.equal(options); + + const newOption = { + id: 'another-option', + value: 'Another Option', + }; + + const item = document.createElement('sp-combobox-item'); + item.id = newOption.id; + item.textContent = newOption.value; + el.append(item); + + await elementUpdated(el); + await nextFrame(); + + expect(el.options).to.deep.equal([...options, newOption]); + }); + it('accepts updated value as html', async () => { + const el = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + await elementUpdated(el); + + expect(el.options).to.deep.equal(options); + + const newOption = { + id: 'another-option', + value: 'Another Option', + }; + + const option1 = el.querySelector( + 'sp-combobox-item:first-of-type' + ) as ComboboxItem; + option1.textContent = newOption.value; + + await elementUpdated(el); + await nextFrame(); + + const newOptions = options.slice(); + newOptions[0].value = newOption.value; + + expect(el.options).to.deep.equal(newOptions); + }); + it('accepts updated id as html', async () => { + const el = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + await elementUpdated(el); + + expect(el.options).to.deep.equal(options); + + const newOption = { + id: 'another-option', + value: 'Another Option', + }; + + const option1 = el.querySelector( + 'sp-combobox-item:first-of-type' + ) as ComboboxItem; + option1.id = newOption.id; + + await elementUpdated(el); + await nextFrame(); + + const newOptions = options.slice(); + newOptions[0].id = newOption.id; + + expect(el.options).to.deep.equal(newOptions); + }); + it('accepts replacement options as html', async () => { + const el = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + await elementUpdated(el); + + expect(el.options).to.deep.equal(options); + + const newOption = { + id: 'another-option', + value: 'Another Option', + }; + + const option1 = el.querySelector( + 'sp-combobox-item:first-of-type' + ) as ComboboxItem; + option1.remove(); + const item = document.createElement('sp-combobox-item'); + item.id = newOption.id; + item.textContent = newOption.value; + el.insertAdjacentElement('afterbegin', item); + + await elementUpdated(el); + await nextFrame(); + + const newOptions = options.slice(); + newOptions[0].id = newOption.id; + newOptions[0].value = newOption.value; + + expect(el.options).to.deep.equal(newOptions); + }); + it('accepts options through slots', async () => { + const test = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + const el = test.shadowRoot.querySelector('sp-combobox') as Combobox; + await elementUpdated(test); + await elementUpdated(el); + + await waitUntil(() => !!el.options?.length); + + expect(el.options).to.deep.equal(options); + }); + it('accepts adding through slots', async () => { + const test = await fixture( + html` + + Combobox Test + ${options.map((option) => { + return html` + + ${option.value} + + `; + })} + + ` + ); + const el = test.shadowRoot.querySelector('sp-combobox') as Combobox; + await elementUpdated(test); + await elementUpdated(el); + + await waitUntil(() => !!el.options?.length); + + expect(el.options).to.deep.equal(options); + + const newOption = { + id: 'another-option', + value: 'Another Option', + }; + + const item = document.createElement('sp-combobox-item'); + item.id = newOption.id; + item.textContent = newOption.value; + test.append(item); + + await elementUpdated(test); + await elementUpdated(el); + + expect(el.options).to.deep.equal([...options, newOption]); + }); +}); diff --git a/packages/combobox/test/combobox.placement.test.ts b/packages/combobox/test/combobox.placement.test.ts new file mode 100644 index 0000000000..f9d9b2b45a --- /dev/null +++ b/packages/combobox/test/combobox.placement.test.ts @@ -0,0 +1,239 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, + oneEvent, + waitUntil, +} from '@open-wc/testing'; +import { executeServerCommand } from '@web/test-runner-commands'; + +import '../sp-combobox.js'; +import '../sp-combobox-item.js'; +import '@spectrum-web-components/theme/sp-theme.js'; +import '@spectrum-web-components/theme/src/themes.js'; +import type { Theme } from '@spectrum-web-components/theme'; +import { ComboboxOption } from '..'; +import { arrowUpEvent } from '../../../test/testing-helpers.js'; +import { TestableCombobox, testActiveElement } from './combobox.test.js'; + +const comboboxFixture = async (): Promise => { + const options: ComboboxOption[] = [ + { id: 'thing1', value: 'Abc Thing 1' }, + { id: 'thing1a', value: 'Bde Thing 2' }, + { id: 'thing1b', value: 'Bef Thing 3' }, + { id: 'thing4', value: 'Efg Thing 4' }, + ]; + + const test = await fixture( + html` + + Combobox + + ` + ); + + const el = test.querySelector('sp-combobox') as unknown as TestableCombobox; + + return el; +}; + +describe('Combobox Placement', () => { + let el!: TestableCombobox; + let listbox!: HTMLElement; + let overlay!: HTMLElement; + let listboxRect!: DOMRect; + let overlayRect!: DOMRect; + afterEach(async () => { + const overlays = document.querySelectorAll('active-overlay'); + overlays.forEach((overlay) => overlay.remove()); + await executeServerCommand('set-viewport', { width: 800, height: 600 }); + }); + beforeEach(async () => { + el = await comboboxFixture(); + await elementUpdated(el); + const { offsetHeight } = el; + el.style.marginTop = `calc(100vh - ${offsetHeight}px)`; + el.style.marginBottom = `100vh`; + + listbox = el.shadowRoot.querySelector('#listbox') as HTMLElement; + overlay = el.shadowRoot.querySelector('#overlay') as HTMLElement; + const opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + + await elementUpdated(el); + expect(el.open).to.be.true; + + await nextFrame(); + + listboxRect = listbox.getBoundingClientRect(); + overlayRect = overlay.getBoundingClientRect(); + }); + it.only('opens with visible and accessible content in same place', async () => { + await waitUntil(() => { + listboxRect = listbox.getBoundingClientRect(); + overlayRect = overlay.getBoundingClientRect(); + return listboxRect.y === overlayRect.y; + }, `does the list box start y: 1, ${listboxRect.y} and ${overlayRect.y}`); + expect(listboxRect.x, 'does the list box start x').to.equal( + overlayRect.x + ); + + const firstPosition = [overlayRect.x, overlayRect.y]; + + window.scroll(0, overlayRect.height * 2); + + await oneEvent(overlay, 'transitionend'); + + listboxRect = listbox.getBoundingClientRect(); + overlayRect = overlay.getBoundingClientRect(); + const secondPosition = [overlayRect.x, overlayRect.y]; + + expect(firstPosition, 'does the overlay move').to.not.deep.equal( + secondPosition + ); + await waitUntil(() => { + listboxRect = listbox.getBoundingClientRect(); + overlayRect = overlay.getBoundingClientRect(); + return listboxRect.y === overlayRect.y; + }, `does the list box start y: 2, ${listboxRect.y} and ${overlayRect.y}`); + expect(listboxRect.x, 'does the list box follow x').to.equal( + overlayRect.x + ); + }); + describe('Resized/repositioned placement', () => { + let height!: number; + let lastOverlayItem!: HTMLElement; + let lastListboxItem!: HTMLElement; + let lastOverlayItemRect!: DOMRect; + let lastListboxItemRect!: DOMRect; + let lastListboxItemInitialPosition!: [number, number]; + let lastOverlayItemInitialPosition!: [number, number]; + const baseline = async (): Promise => { + await waitUntil(() => { + lastOverlayItemRect = lastOverlayItem.getBoundingClientRect(); + lastListboxItemRect = lastListboxItem.getBoundingClientRect(); + return lastListboxItemRect.y === lastOverlayItemRect.y; + }, 'same y listbox/overlay'); + expect(lastListboxItemRect.x, 'same x listbox/overlay').to.equal( + lastOverlayItemRect.x + ); + // These not being the same means that the listbox and overlay elements + // will need to scroll when interacting with items outside of their height. + expect( + overlay.scrollHeight, + 'different times heights' + ).to.not.equal(height); + const precision = 0.001; + expect( + Math.abs(listboxRect.height - overlayRect.height) <= precision, + 'different things heights almost the same' + ).to.equal(true); + }; + beforeEach(async () => { + lastOverlayItem = overlay.querySelector('#thing4') as HTMLElement; + lastListboxItem = listbox.querySelector( + '#thing4-sr' + ) as HTMLElement; + + // Ensure the last item is in the same place in both listbox and overlay + // this also means that the listbox and the overlay are the same height. + lastOverlayItemRect = lastOverlayItem.getBoundingClientRect(); + lastListboxItemRect = lastListboxItem.getBoundingClientRect(); + + lastListboxItemInitialPosition = [ + lastListboxItemRect.x, + lastListboxItemRect.y, + ]; + lastOverlayItemInitialPosition = [ + lastOverlayItemRect.x, + lastOverlayItemRect.y, + ]; + + ({ height } = overlayRect); + // Resize the window to require scrolling of the overlay + await executeServerCommand('set-viewport', { + width: 800, + height: Math.ceil(height), + }); + + await nextFrame(); + const { offsetHeight } = el.focusElement; + window.scroll(0, window.innerHeight - offsetHeight); + + await nextFrame(); + + overlayRect = overlay.getBoundingClientRect(); + listboxRect = listbox.getBoundingClientRect(); + }); + it('positions the last item the same on open', async () => { + await baseline(); + overlay.scroll(0, height * 2); + + await nextFrame(); + + lastOverlayItemRect = lastOverlayItem.getBoundingClientRect(); + lastListboxItemRect = lastListboxItem.getBoundingClientRect(); + + const lastOverlayItemPosition = [ + lastOverlayItemRect.x, + lastOverlayItemRect.y, + ]; + const lastListboxItemPosition = [ + lastListboxItemRect.x, + lastListboxItemRect.y, + ]; + + expect( + lastListboxItemPosition, + 'last items same place' + ).to.deep.equal(lastOverlayItemPosition); + }); + it('matches the listbox scroll position to the overlay scroll position on key press', async () => { + await baseline(); + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + await nextFrame(); + + testActiveElement(el, 'thing4'); + + lastOverlayItemRect = lastOverlayItem.getBoundingClientRect(); + lastListboxItemRect = lastListboxItem.getBoundingClientRect(); + + const lastOverlayItemFinalPosition = [ + lastOverlayItemRect.x, + lastOverlayItemRect.y, + ]; + const lastListboxItemFinalPosition = [ + lastListboxItemRect.x, + lastListboxItemRect.y, + ]; + expect( + lastOverlayItemFinalPosition, + 'same place item' + ).to.deep.equal(lastListboxItemFinalPosition); + expect(lastOverlayItemFinalPosition).to.not.deep.equal( + lastOverlayItemInitialPosition + ); + expect(lastListboxItemFinalPosition).to.not.deep.equal( + lastListboxItemInitialPosition + ); + }); + }); +}); diff --git a/packages/combobox/test/combobox.test.ts b/packages/combobox/test/combobox.test.ts new file mode 100644 index 0000000000..8fd8a9d6be --- /dev/null +++ b/packages/combobox/test/combobox.test.ts @@ -0,0 +1,1059 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, + oneEvent, +} from '@open-wc/testing'; + +import '../sp-combobox.js'; +import '../sp-combobox-item.js'; +import '@spectrum-web-components/theme/sp-theme.js'; +import '@spectrum-web-components/theme/src/themes.js'; +import type { Theme } from '@spectrum-web-components/theme'; +import { Combobox, ComboboxItem, ComboboxOption } from '..'; +import { + arrowDownEvent, + arrowLeftEvent, + arrowUpEvent, + endEvent, + enterEvent, + escapeEvent, + homeEvent, +} from '../../../test/testing-helpers.js'; +import { + a11ySnapshot, + executeServerCommand, + findAccessibilityNode, +} from '@web/test-runner-commands'; +import { PickerButton } from '@spectrum-web-components/picker-button'; + +export type TestableCombobox = Combobox & { + activeDescendent: ComboboxOption; + availableOptions: ComboboxOption[]; +}; + +const comboboxFixture = async (): Promise => { + const options: ComboboxOption[] = [ + { id: 'thing1', value: 'Abc Thing 1' }, + { id: 'thing1a', value: 'Bde Thing 2' }, + { id: 'thing1b', value: 'Bef Thing 3' }, + { id: 'thing4', value: 'Efg Thing 4' }, + ]; + + const test = await fixture( + html` + + Combobox + + ` + ); + + const el = test.querySelector('sp-combobox') as unknown as TestableCombobox; + + return el; +}; + +export const testActiveElement = ( + el: TestableCombobox, + testId: string +): void => { + expect(el.activeDescendent?.id).to.equal(testId); + const activeElement = el.shadowRoot.querySelector( + `#${el.activeDescendent.id}-sr` + ) as ComboboxItem; + expect(activeElement.getAttribute('aria-selected')).to.equal('true'); +}; + +describe('Combobox', () => { + afterEach(() => { + const overlays = document.querySelectorAll('active-overlay'); + overlays.forEach((overlay) => overlay.remove()); + }); + describe('renders accessibly', () => { + it('renders initially', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + await expect(el).to.be.accessible(); + }); + it('renders open', async () => { + const el = await comboboxFixture(); + + const opened = oneEvent(el, 'sp-opened'); + el.open = true; + await opened; + + await elementUpdated(el); + await expect(el).to.be.accessible(); + }); + it('renders with an active descendent', async () => { + const el = await comboboxFixture(); + + const opened = oneEvent(el, 'sp-opened'); + el.open = true; + await opened; + + el.activeDescendent = el.availableOptions[0]; + await elementUpdated(el); + + await expect(el).to.be.accessible(); + }); + it('manages its "name" value in the accessibility tree', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + type NamedNode = { name: string; role: string; value?: string }; + let snapshot = (await a11ySnapshot({})) as unknown as NamedNode & { + children: NamedNode[]; + }; + + expect( + findAccessibilityNode( + snapshot, + (node) => + node.name === 'Combobox' && + !node.value && + node.role === 'combobox' + ), + '`name` is the label text' + ).to.not.be.null; + + el.value = 'Bde Thing 2'; + await elementUpdated(el); + snapshot = (await a11ySnapshot({})) as unknown as NamedNode & { + children: NamedNode[]; + }; + + expect( + findAccessibilityNode( + snapshot, + (node) => + node.name === 'Combobox' && + node.value === 'Bde Thing 2' && + node.role === 'combobox' + ), + '`name` is the label text plus the selected item text' + ).to.not.be.null; + }); + it('manages aria-activedescendant', async () => { + type ActiveDescendentFindableNode = { + name: string; + role: string; + value?: string; + activedescendent?: boolean; + expanded?: boolean; + }; + type ActiveNodeWithChildren = ActiveDescendentFindableNode & { + children: ActiveDescendentFindableNode[]; + }; + const el = await comboboxFixture(); + await elementUpdated(el); + + let snapshot = (await a11ySnapshot( + {} + )) as unknown as ActiveNodeWithChildren; + expect(el.activeDescendent).to.be.undefined; + el.click(); + await elementUpdated(el); + el.focus(); + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + expect(el.activeDescendent.id).to.equal('thing1'); + expect( + findAccessibilityNode( + snapshot, + (node) => !!node.activedescendent + ) + ).to.be.null; + snapshot = (await a11ySnapshot( + {} + )) as unknown as ActiveNodeWithChildren; + const activeDescendent = findAccessibilityNode( + snapshot, + (node) => node.role === 'combobox' + ); + // Doesn't actually test active descendent yet. + expect(activeDescendent).to.not.be.null; + }); + it('manages aria-selected', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + type SelectedNode = { selected?: boolean }; + let snapshot = (await a11ySnapshot( + {} + )) as unknown as SelectedNode & { children: SelectedNode[] }; + + expect( + findAccessibilityNode( + snapshot, + (node) => !!node.selected + ) + ).to.be.null; + + const opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + await elementUpdated(el); + expect(el.open).to.be.true; + + el.focus(); + await elementUpdated(el); + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + await nextFrame(); + await nextFrame(); + await nextFrame(); + + expect(el.activeDescendent.id).to.equal('thing1'); + snapshot = (await a11ySnapshot({})) as unknown as SelectedNode & { + children: SelectedNode[]; + }; + expect( + findAccessibilityNode( + snapshot, + (node) => !!node.selected + ), + JSON.stringify(snapshot, null, ' ') + ).to.not.be.null; + }); + it('manages aria-expanded', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + type ExpandedNode = { expanded?: boolean }; + let snapshot = (await a11ySnapshot( + {} + )) as unknown as ExpandedNode & { children: ExpandedNode[] }; + + expect( + findAccessibilityNode( + snapshot, + (node) => !!node.expanded + ) + ).to.be.null; + + el.click(); + await elementUpdated(el); + expect(el.open).to.be.true; + + snapshot = (await a11ySnapshot({})) as unknown as ExpandedNode & { + children: ExpandedNode[]; + }; + expect( + findAccessibilityNode( + snapshot, + (node) => !!node.expanded + ) + ).to.not.be.null; + }); + }); + it('loads with list closed', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + expect(el.open).to.be.false; + }); + describe('manages focus', () => { + it('responds to focus()', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.focus(); + + await elementUpdated(el); + expect(el.shadowRoot.activeElement).to.equal(el.focusElement); + }); + it('responds to click()', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.open).to.be.false; + + el.click(); + + await elementUpdated(el); + expect(el.shadowRoot.activeElement).to.equal(el.focusElement); + expect(el.open).to.be.true; + + el.click(); + + await elementUpdated(el); + expect(el.shadowRoot.activeElement).to.equal(el.focusElement); + expect(el.open).to.be.false; + }); + }); + describe('keyboard events', () => { + it('opens on ArrowDown', async () => { + const el = await comboboxFixture(); + await elementUpdated(el); + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.activeDescendent).to.not.be.undefined; + }); + it('opens on Alt+ArrowDown', async () => { + const el = await comboboxFixture(); + await elementUpdated(el); + + el.focusElement.focus(); + + await executeServerCommand('send-keys', { + press: 'Alt+ArrowDown', + }); + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.activeDescendent).to.be.undefined; + }); + it('opens on ArrowUp', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + expect(el.open).to.be.true; + }); + it('does not open on ArrowLeft', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(arrowLeftEvent()); + + await elementUpdated(el); + expect(el.open).to.be.false; + }); + it('does not close on ArrowLeft', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(arrowLeftEvent()); + + await elementUpdated(el); + expect(el.open).to.be.true; + }); + it('moves the carat/removes activeDescendent on ArrowLeft', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + el.value = 'Abc Thing 1'; + await elementUpdated(el); + + el.focusElement.setSelectionRange(4, 4); + el.focusElement.focus(); + expect(el.focusElement.selectionStart).to.equal(4); + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + + testActiveElement(el, 'thing1'); + expect(el.open).to.be.true; + + await executeServerCommand('send-keys', { + press: 'ArrowLeft', + }); + + await elementUpdated(el); + expect(el.focusElement.selectionStart).to.equal(3); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.true; + }); + it('moves the carat/removes activeDescendent on ArrowRight', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + el.value = 'Abc Thing 1'; + await elementUpdated(el); + + el.focusElement.setSelectionRange(1, 1); + el.focusElement.focus(); + expect(el.focusElement.selectionStart).to.equal(1); + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + + testActiveElement(el, 'thing1'); + expect(el.open).to.be.true; + + await executeServerCommand('send-keys', { + press: 'ArrowRight', + }); + + await elementUpdated(el); + expect(el.focusElement.selectionStart).to.equal(2); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.true; + }); + it('moves carat to 0 with Home key', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + el.value = 'Abc Thing 1'; + + await elementUpdated(el); + el.focusElement.focus(); + el.focusElement.setSelectionRange(4, 4); + await elementUpdated(el); + expect(el.focusElement.selectionStart, 'start 1').to.equal(4); + expect(el.focusElement.selectionEnd, 'end 1').to.equal(4); + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + + testActiveElement(el, 'thing1'); + expect(el.open).to.be.true; + + el.focusElement.dispatchEvent(homeEvent()); + await elementUpdated(el); + expect(el.focusElement.selectionStart, 'start 2').to.equal(0); + expect(el.focusElement.selectionEnd, 'end 2').to.equal(0); + expect(el.activeDescendent).to.be.undefined; + expect(el.shadowRoot.querySelector('[aria-selected="true"]')).to.be + .null; + expect(el.open).to.be.true; + }); + it('moves carat to end with End key', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + el.value = 'Abc Thing 1'; + await elementUpdated(el); + + el.focusElement.focus(); + el.focusElement.setSelectionRange(1, 1); + await elementUpdated(el); + expect(el.focusElement.selectionStart, 'start 1').to.equal(1); + expect(el.focusElement.selectionEnd, 'end 1').to.equal(1); + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + + expect(el.activeDescendent.id).to.equal('thing1'); + expect(el.open).to.be.true; + + el.focusElement.dispatchEvent(endEvent()); + await elementUpdated(el); + expect(el.focusElement.selectionStart, 'start 2').to.equal(11); + expect(el.focusElement.selectionEnd, 'end 2').to.equal(11); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.true; + }); + it('closes on Escape', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.open = true; + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(escapeEvent()); + + await elementUpdated(el); + expect(el.open).to.be.false; + }); + it('clears on Escape', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + el.value = 'Test'; + + el.focusElement.focus(); + + el.focusElement.dispatchEvent(escapeEvent()); + + await elementUpdated(el); + expect(el.open).to.be.false; + expect(el.value).to.equal(''); + }); + }); + describe('mouse events', () => { + it('opens on input click', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const opened = oneEvent(el.focusElement, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + await elementUpdated(el); + expect(el.open).to.be.true; + }); + it('closes on input click', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const opened = oneEvent(el.focusElement, 'sp-opened'); + el.open = true; + await opened; + expect(el.open).to.be.true; + + const closed = oneEvent(el.focusElement, 'sp-closed'); + el.focusElement.click(); + await closed; + + await elementUpdated(el); + expect(el.open).to.be.false; + }); + it('opens on button click', async () => { + const el = await comboboxFixture(); + + const button = el.shadowRoot.querySelector( + 'sp-picker-button' + ) as PickerButton; + + await elementUpdated(el); + + const opened = oneEvent(el.focusElement, 'sp-opened'); + button.click(); + await opened; + + await elementUpdated(el); + expect(el.open).to.be.true; + }); + it('closes on button click', async () => { + const el = await comboboxFixture(); + + const button = el.shadowRoot.querySelector( + 'sp-picker-button' + ) as PickerButton; + + await elementUpdated(el); + + const opened = oneEvent(el.focusElement, 'sp-opened'); + el.open = true; + await opened; + expect(el.open).to.be.true; + + const closed = oneEvent(el.focusElement, 'sp-closed'); + button.click(); + await closed; + + await elementUpdated(el); + expect(el.open).to.be.false; + }); + }); + describe('manage active decendent', () => { + it('sets activeDescendent to first descendent on ArrowDown', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1'); + }); + it('updates activeDescendent on ArrowDown', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1'); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1a'); + }); + it('cycles activeDescendent on ArrowDown', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1'); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1'); + }); + it('sets activeDescendent to last descendent on ArrowUp', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing4'); + }); + it('updates activeDescendent on ArrowUp', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing4'); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1b'); + }); + it('cycles activeDescendent on ArrowUp', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.focus(); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing4'); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowUpEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing4'); + }); + it('sets the activeDescendent on pointerenter of an item', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const descendent = 'thing1b'; + const item = el.shadowRoot.querySelector( + `#${descendent}` + ) as HTMLElement; + + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + + item.dispatchEvent( + new PointerEvent('pointerenter', { + bubbles: true, + }) + ); + + await elementUpdated(el); + + expect(el.open).to.be.true; + testActiveElement(el, descendent); + }); + it('clears the activeDescendent on pointerleave of an item', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const descendent = 'thing1b'; + const item = el.shadowRoot.querySelector( + `#${descendent}` + ) as HTMLElement; + + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + + item.dispatchEvent( + new PointerEvent('pointerenter', { + bubbles: true, + }) + ); + + await elementUpdated(el); + + expect(el.open).to.be.true; + testActiveElement(el, descendent); + item.dispatchEvent( + new PointerEvent('pointerleave', { + bubbles: true, + }) + ); + + await elementUpdated(el); + + expect(el.open).to.be.true; + expect(el.activeDescendent).to.be.undefined; + }); + }); + describe('item selection', () => { + it('sets the value when descendent is active and `enter` is pressed', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + el.focusElement.focus(); + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.dispatchEvent(arrowDownEvent()); + await opened; + + expect(el.open).to.be.true; + + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowDownEvent()); + + await elementUpdated(el); + testActiveElement(el, 'thing1a'); + el.focusElement.dispatchEvent(enterEvent()); + + await elementUpdated(el); + expect(el.open).to.be.false; + expect(el.activeDescendent).to.be.undefined; + expect(el.value).to.equal('Bde Thing 2'); + expect(el.focusElement.value).to.equal(el.value); + }); + it('does not set the value when `enter` is pressed and no active descendent', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + expect(el.activeDescendent).to.be.undefined; + + el.focusElement.dispatchEvent(enterEvent()); + + await elementUpdated(el); + expect(el.open).to.be.false; + expect(el.activeDescendent).to.be.undefined; + expect(el.value).to.equal(''); + expect(el.focusElement.value).to.equal(el.value); + }); + it('sets the value when an item is clicked', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const item = el.shadowRoot.querySelector('#thing1b') as HTMLElement; + + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + + const itemValue = (item.textContent as string).trim(); + + item.dispatchEvent( + new PointerEvent('pointerenter', { + bubbles: true, + }) + ); + await elementUpdated(el); + testActiveElement(el, 'thing1b'); + + item.click(); + + await elementUpdated(el); + + expect(el.value).to.equal(itemValue); + expect(el.open).to.be.false; + expect(el.activeDescendent).to.be.undefined; + }); + it('sets the value when an item is clicked programatically', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + + const item = el.shadowRoot.querySelector('#thing1b') as HTMLElement; + + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + + expect(el.open).to.be.true; + + const itemValue = (item.textContent as string).trim(); + + item.click(); + + await elementUpdated(el); + + expect(el.value).to.equal(itemValue); + expect(el.open).to.be.false; + expect(el.activeDescendent).to.be.undefined; + }); + }); + describe('responds to value changes', () => { + it('sets the value when descendent is active and `enter` is pressed', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + el.focus(); + + const opened = oneEvent(el, 'sp-opened'); + executeServerCommand('send-keys', { + press: 'g', + }); + await opened; + + expect(el.open).to.be.true; + expect(el.focusElement.value, ' has value').to.equal('g'); + expect(el.value, 'el has value').to.equal('g'); + + await executeServerCommand('send-keys', { + press: 'r', + }); + + expect(el.open).to.be.true; + expect(el.focusElement.value, ' has value').to.equal('gr'); + expect(el.value, 'el has value').to.equal('gr'); + }); + it('filters options when the value changes and is not found', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + expect(el.availableOptions.length).equal(4); + + const opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + + await executeServerCommand('send-keys', { + press: 'g', + }); + + expect(el.open).to.be.true; + expect(el.availableOptions.length).equal(0); + const options = [...el.shadowRoot.querySelectorAll('sp-menu-item')]; + expect(options.length).to.equal(0); + }); + it('filters options when the value typed and is found', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + expect(el.availableOptions.length).equal(4); + expect(el.options?.length).equal(4); + let items = [ + ...el.shadowRoot.querySelectorAll('#listbox sp-menu-item'), + ]; + expect(items.length).to.equal(4); + + const opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + + await executeServerCommand('send-keys', { + press: 'B', + }); + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.value).to.equal('B'); + expect(el.availableOptions.length).equal(2); + expect(el.options?.length).equal(4); + items = [ + ...el.shadowRoot.querySelectorAll('#listbox sp-menu-item'), + ]; + expect(items.length).to.equal(2); + + await executeServerCommand('send-keys', { + press: 'D', + }); + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.value).to.equal('BD'); + expect(el.availableOptions.length).equal(1); + items = [ + ...el.shadowRoot.querySelectorAll('#listbox sp-menu-item'), + ]; + expect(items.length).to.equal(1); + }); + it('filters options when the value is applied and is found', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + expect(el.availableOptions.length).equal(4); + + const opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + + el.value = 'B'; + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.availableOptions.length).equal(2); + + el.value = 'Bd'; + + await elementUpdated(el); + expect(el.open).to.be.true; + expect(el.availableOptions.length).equal(1); + }); + it('filtered items only can be accessed by ArrowUp/Down events', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + el.value = 'Bde Thing 2'; + await elementUpdated(el); + + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + expect(el.availableOptions.length).equal(1); + + el.focus(); + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.dispatchEvent(arrowDownEvent()); + await opened; + await elementUpdated(el); + + expect(el.activeDescendent?.value).to.equal(el.value); + }); + it('deactives descendent on input', async () => { + const el = await comboboxFixture(); + + await elementUpdated(el); + expect(el.value).to.equal(''); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.false; + + el.focus(); + await elementUpdated(el); + + const opened = oneEvent(el, 'sp-opened'); + executeServerCommand('send-keys', { + press: 'B', + }); + await opened; + await elementUpdated(el); + + expect(el.value).to.equal('B'); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.true; + + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + el.focusElement.dispatchEvent(arrowDownEvent()); + await elementUpdated(el); + + expect(el.value).to.equal('B'); + testActiveElement(el, 'thing1b'); + expect(el.open).to.be.true; + + await executeServerCommand('send-keys', { + press: 'd', + }); + await elementUpdated(el); + + expect(el.value).to.equal('Bd'); + expect(el.activeDescendent).to.be.undefined; + expect(el.open).to.be.true; + }); + }); +}); diff --git a/packages/combobox/test/index.html b/packages/combobox/test/index.html new file mode 100644 index 0000000000..7ceefeafc2 --- /dev/null +++ b/packages/combobox/test/index.html @@ -0,0 +1,23 @@ + + Thing + Thing + Thing + Thing + + + + + + Thing + // I've rendered, do I have an id? NO, give me one. + Thing + Thing + Thing + diff --git a/packages/combobox/tsconfig.json b/packages/combobox/tsconfig.json new file mode 100644 index 0000000000..ea47de31ac --- /dev/null +++ b/packages/combobox/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./" + }, + "include": ["*.ts", "src/*.ts"], + "exclude": ["test/*.ts", "stories/*.ts"], + "references": [{ "path": "../../tools/base" }, { "path": "../textfield" }] +} diff --git a/packages/number-field/src/NumberField.ts b/packages/number-field/src/NumberField.ts index 5660fc1a0d..a85afc41a2 100644 --- a/packages/number-field/src/NumberField.ts +++ b/packages/number-field/src/NumberField.ts @@ -381,8 +381,8 @@ export class NumberField extends TextfieldBase { this.addEventListener('wheel', this.onScroll, { passive: false }); } - protected override onBlur(): void { - super.onBlur(); + protected override onBlur(_event: FocusEvent): void { + super.onBlur(_event); this.keyboardFocused = !this.readonly && false; this.removeEventListener('wheel', this.onScroll); } diff --git a/packages/picker-button/exports.json b/packages/picker-button/exports.json new file mode 100644 index 0000000000..4c49d29a90 --- /dev/null +++ b/packages/picker-button/exports.json @@ -0,0 +1,4 @@ +{ + "./src/*": "./src/*", + "./sp-picker-button.js": "./sp-picker-button.js" +} diff --git a/packages/picker-button/package.json b/packages/picker-button/package.json index 341c85c320..60af233947 100644 --- a/packages/picker-button/package.json +++ b/packages/picker-button/package.json @@ -29,6 +29,11 @@ "development": "./src/PickerButton.dev.js", "default": "./src/PickerButton.js" }, + "./src/index.js": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./src/picker-button.css.js": "./src/picker-button.css.js", "./sp-picker-button.js": { "development": "./sp-picker-button.dev.js", "default": "./sp-picker-button.js" diff --git a/packages/picker-button/stories/picker-button-sizes.stories.ts b/packages/picker-button/stories/picker-button-sizes.stories.ts index 92adeac55e..f97155b4f1 100644 --- a/packages/picker-button/stories/picker-button-sizes.stories.ts +++ b/packages/picker-button/stories/picker-button-sizes.stories.ts @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { TemplateResult } from '@spectrum-web-components/base'; import { argTypes, StoryArgs, Template } from './index.js'; -import '../sp-picker-button.js'; +import '@spectrum-web-components/picker-button/sp-picker-button.js'; export default { title: 'Picker Button/Sizes', diff --git a/packages/picker-button/stories/picker-button.stories.ts b/packages/picker-button/stories/picker-button.stories.ts index 614e30aac1..e1d9864467 100644 --- a/packages/picker-button/stories/picker-button.stories.ts +++ b/packages/picker-button/stories/picker-button.stories.ts @@ -13,7 +13,7 @@ governing permissions and limitations under the License. import { html, TemplateResult } from '@spectrum-web-components/base'; import { argTypes, StoryArgs, Template } from './index.js'; -import '../sp-picker-button.js'; +import '@spectrum-web-components/picker-button/sp-picker-button.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-add.js'; diff --git a/packages/textfield/src/Textfield.ts b/packages/textfield/src/Textfield.ts index 686dfa0634..9de3d2573c 100644 --- a/packages/textfield/src/Textfield.ts +++ b/packages/textfield/src/Textfield.ts @@ -205,7 +205,7 @@ export class TextfieldBase extends ManageHelpText( this.focused = !this.readonly && true; } - protected onBlur(): void { + protected onBlur(_event: FocusEvent): void { this.focused = !this.readonly && false; } diff --git a/tsconfig-all.json b/tsconfig-all.json index ebc980301f..9aa9da96b9 100644 --- a/tsconfig-all.json +++ b/tsconfig-all.json @@ -38,6 +38,7 @@ { "path": "packages/color-loupe" }, { "path": "packages/color-slider" }, { "path": "packages/color-wheel" }, + { "path": "packages/combobox" }, { "path": "packages/dialog" }, { "path": "packages/divider" }, { "path": "packages/dropzone" }, diff --git a/web-test-runner.config.js b/web-test-runner.config.js index ee900cada8..7c36c79593 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { a11ySnapshotPlugin, sendKeysPlugin, + setViewportPlugin, } from '@web/test-runner-commands/plugins'; import { sendMousePlugin } from './test/plugins/send-mouse-plugin.js'; import { @@ -61,6 +62,7 @@ export default { context.set('Cross-Origin-Embedder-Policy', 'credentialless'); }, }, + setViewportPlugin(), ], mimeTypes: { '**/*.json': 'js', diff --git a/yarn.lock b/yarn.lock index 1c76808a63..153619952e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6743,6 +6743,11 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/colorwheel/-/colorwheel-3.1.0.tgz#c765fa896eaad6e446e859f045df43c0586d31af" integrity sha512-vPSy6FiCc5oMQ9QDjSgXUwATZfgCog97zaB+w1M/urBwKNsXWXeZe4p+jENVC5g0TKq6uZwYaLFrDP2ot5yIdw== +"@spectrum-css/combobox@^2.0.46": + version "2.0.46" + resolved "https://registry.yarnpkg.com/@spectrum-css/combobox/-/combobox-2.0.46.tgz#22864122d07820e6d86c67ce37c04a2028d99ba4" + integrity sha512-OkPct7gGkLU1PvJ/FPSOZ0XUVQktC9WooVD/5GyaS3e3uVnaDqJJgndo29PZUjQK8rq11MItn0f06P+2BWlhRg== + "@spectrum-css/commons@^9.0.2": version "9.0.2" resolved "https://registry.yarnpkg.com/@spectrum-css/commons/-/commons-9.0.2.tgz#a1b79d7080388da0f90b2b89244776623cc73e7a" From 57169910b6a9dda73d411c116283651fb3c82796 Mon Sep 17 00:00:00 2001 From: Najika Halsema Yoo <44980010+najikahalsema@users.noreply.github.com> Date: Tue, 19 Dec 2023 13:42:17 -0800 Subject: [PATCH 02/23] feat(combobox): add size attribute (#3887) * feat(combobox): wip * chore: update sizes and stories * chore: add isoverlayopen decorator to stories --------- Co-authored-by: Westbrook Johnson Co-authored-by: Najika Yoo --- packages/combobox/src/Combobox.ts | 9 +- .../stories/combobox-sizes.stories.ts | 83 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 packages/combobox/stories/combobox-sizes.stories.ts diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts index c41d47f754..5bc659ca79 100644 --- a/packages/combobox/src/Combobox.ts +++ b/packages/combobox/src/Combobox.ts @@ -384,12 +384,8 @@ export class Combobox extends Textfield { ? 'focus-visible is-keyboardFocused' : ''}" ?focused=${this.focused} - > - - + size=${this.size} + > ${repeat( this.availableOptions, (option) => option.id, @@ -434,7 +394,10 @@ export class Combobox extends Textfield { `; } )} - + @@ -463,24 +426,7 @@ export class Combobox extends Textfield { protected async manageListOverlay(): Promise { if (this.open) { this.focused = true; - // this._returnItems = await openOverlay( - // this.shadowRoot.querySelector('#input') as HTMLElement, - // 'click', - // this.overlay, - // { - // offset: 0, - // placement: 'bottom-start', - // receivesFocus: 'false', - // } - // ); this.focus(); - } else { - // this._returnItems(); - // this._returnItems = () => { - // return; - // }; - // this.overlayObserver.disconnect(); - // this.overlay.removeEventListener('scroll', this.onOverlayScroll); } } @@ -527,11 +473,6 @@ export class Combobox extends Textfield { public override connectedCallback(): void { super.connectedCallback(); - // if (!this.overlayObserver) { - // this.overlayObserver = new MutationObserver( - // this.positionListboxFromEntries.bind(this) - // ); - // } if (!this.itemObserver) { this.itemObserver = new MutationObserver( this.setOptionsFromSlottedItems.bind(this) @@ -540,43 +481,10 @@ export class Combobox extends Textfield { } public override disconnectedCallback(): void { - // this.overlayObserver.disconnect(); this.itemObserver.disconnect(); this.open = false; super.disconnectedCallback(); } - // private overlayObserver!: MutationObserver; private itemObserver!: MutationObserver; } - -/** - * - - #shadow-root - this.shadowRoot.querySelector('#listbox').children; - this.shadowRoot.querySelectorAll('li'); -
- -
- - - * - */ - -/** - * - * Public API - * popover requirement - * - * Aria-Spectrum consumption - * - * visual delivery - Spectrum CSS - * - * - * does test:watch build the plugins correctly? - */ diff --git a/packages/combobox/src/ComboboxItem.ts b/packages/combobox/src/ComboboxItem.ts index dd70cca217..f6c002aca1 100644 --- a/packages/combobox/src/ComboboxItem.ts +++ b/packages/combobox/src/ComboboxItem.ts @@ -58,12 +58,6 @@ export class ComboboxItem extends SpectrumElement { (this.getRootNode() as ShadowRoot).host?.localName === 'sp-combobox' ) { return; - - /* - - - - */ } this.slot = 'option'; } diff --git a/packages/combobox/src/spectrum-textfield.css b/packages/combobox/src/spectrum-textfield.css deleted file mode 100644 index 18b86eaf8c..0000000000 --- a/packages/combobox/src/spectrum-textfield.css +++ /dev/null @@ -1,130 +0,0 @@ -/* stylelint-disable */ /* -Copyright 2020 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. - -THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ -:host([dir='ltr']) .spectrum-InputGroup .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup .spectrum-InputGroup-button */ - border-top-left-radius: var( - --spectrum-combobox-fieldbutton-border-top-left-radius, - 0 - ); -} -:host([dir='rtl']) .spectrum-InputGroup .spectrum-InputGroup-button { - /* [dir=rtl] .spectrum-InputGroup .spectrum-InputGroup-button */ - border-top-right-radius: var( - --spectrum-combobox-fieldbutton-border-top-left-radius, - 0 - ); -} -:host([dir='ltr']) .spectrum-InputGroup .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup .spectrum-InputGroup-button */ - border-bottom-left-radius: var( - --spectrum-combobox-fieldbutton-border-bottom-left-radius, - 0 - ); -} -:host([dir='rtl']) .spectrum-InputGroup .spectrum-InputGroup-button { - /* [dir=rtl] .spectrum-InputGroup .spectrum-InputGroup-button */ - border-bottom-right-radius: var( - --spectrum-combobox-fieldbutton-border-bottom-left-radius, - 0 - ); -} -:host([dir='ltr']) #input { - /* [dir=ltr] .spectrum-InputGroup-input */ - border-top-right-radius: var( - --spectrum-combobox-textfield-border-top-right-radius, - 0 - ); -} -:host([dir='rtl']) #input { - /* [dir=rtl] .spectrum-InputGroup-input */ - border-top-left-radius: var( - --spectrum-combobox-textfield-border-top-right-radius, - 0 - ); -} -:host([dir='ltr']) #input { - /* [dir=ltr] .spectrum-InputGroup-input */ - border-bottom-right-radius: var( - --spectrum-combobox-textfield-border-bottom-right-radius, - 0 - ); -} -:host([dir='rtl']) #input { - /* [dir=rtl] .spectrum-InputGroup-input */ - border-bottom-left-radius: var( - --spectrum-combobox-textfield-border-bottom-right-radius, - 0 - ); -} -:host([dir='ltr']) #input { - /* [dir=ltr] .spectrum-InputGroup-input */ - border-right-width: var(--spectrum-combobox-field-border-width-right); -} -:host([dir='rtl']) #input { - /* [dir=rtl] .spectrum-InputGroup-input */ - border-left-width: var(--spectrum-combobox-field-border-width-right); -} -#input { - /* .spectrum-InputGroup-input */ - flex: 1; -} -:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - padding-left: var(--spectrum-combobox-quiet-button-offset); -} -:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - padding-right: var(--spectrum-combobox-quiet-button-offset); -} -:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - padding-right: var(--spectrum-combobox-quiet-fieldbutton-padding-right); -} -:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - padding-left: var(--spectrum-combobox-quiet-fieldbutton-padding-right); -} -:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - border-left: 0; -} -:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button, -:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button, - * [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - border-right: 0; -} -:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-button { - /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button */ - border-left: 0; -} -:host([dir='ltr']) - .spectrum-InputGroup--quiet - .spectrum-InputGroup-button:after { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-button:after */ - right: calc(var(--spectrum-combobox-quiet-button-offset) * -1); -} -:host([dir='rtl']) - .spectrum-InputGroup--quiet - .spectrum-InputGroup-button:after { - /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-button:after */ - left: calc(var(--spectrum-combobox-quiet-button-offset) * -1); -} -:host([dir='ltr']) .spectrum-InputGroup--quiet .spectrum-InputGroup-icon { - /* [dir=ltr] .spectrum-InputGroup--quiet .spectrum-InputGroup-icon */ - right: 0; -} -:host([dir='rtl']) .spectrum-InputGroup--quiet .spectrum-InputGroup-icon { - /* [dir=rtl] .spectrum-InputGroup--quiet .spectrum-InputGroup-icon */ - left: 0; -} diff --git a/packages/combobox/test/index.html b/packages/combobox/test/index.html deleted file mode 100644 index 7ceefeafc2..0000000000 --- a/packages/combobox/test/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - Thing - Thing - Thing - Thing - - - - - - Thing - // I've rendered, do I have an id? NO, give me one. - Thing - Thing - Thing - From f5cde240930bd4b4c7b9e836d112365354e02fae Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Tue, 9 Jan 2024 18:53:17 -0500 Subject: [PATCH 07/23] fix(combobox): add support for external tooltip elements (#3930) * fix(combobox): add support for external tooltip elements * chore(combobox): remove unused code paths * ci: update golden images cache * docs(combobox): include slot present in API docs --- packages/combobox/src/Combobox.ts | 57 ++++++++--------- packages/combobox/stories/combobox.stories.ts | 27 +++++++- packages/combobox/test/combobox.data.test.ts | 6 +- packages/combobox/test/combobox.test.ts | 64 +++++++++++++++---- 4 files changed, 108 insertions(+), 46 deletions(-) diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts index 3cc9629945..2d05672e0e 100644 --- a/packages/combobox/src/Combobox.ts +++ b/packages/combobox/src/Combobox.ts @@ -35,6 +35,7 @@ import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/picker-button/sp-picker-button.js'; import { Textfield } from '@spectrum-web-components/textfield'; +import type { Tooltip } from '@spectrum-web-components/tooltip'; import styles from './combobox.css.js'; import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js'; @@ -47,6 +48,7 @@ export type ComboboxOption = { /** * @element sp-combobox + * @slot tooltip - Tooltip to to be applied to the the Picker Button */ export class Combobox extends Textfield { public static override get styles(): CSSResultArray { @@ -94,6 +96,8 @@ export class Combobox extends Textfield { @property({ type: Array }) public optionEls: MenuItem[] = []; + protected tooltipEl?: Tooltip; + // { value: "String thing", id: "string1" } public override focus(): void { this.focusElement.focus(); @@ -165,6 +169,14 @@ export class Combobox extends Textfield { }); } + protected handleTooltipSlotchange( + event: Event & { target: HTMLSlotElement } + ): void { + this.tooltipEl = event.target.assignedElements()[0] as + | Tooltip + | undefined; + } + public setOptionsFromSlottedItems(): void { const elements = this.optionSlot.assignedElements({ flatten: true, @@ -223,18 +235,7 @@ export class Combobox extends Textfield { this.open = true; } - public handleListPointerenter(event: PointerEvent): void { - const descendent = event - .composedPath() - .find((el) => typeof (el as MenuItem).value !== 'undefined'); - if (descendent) this.activeDescendent = descendent as MenuItem; - } - - public handleListPointerleave(): void { - this.activeDescendent = undefined; - } - - public handleMenuChange(event: PointerEvent & { target: Menu }): void { + protected handleMenuChange(event: PointerEvent & { target: Menu }): void { const { target } = event; this.value = target.selected[0]; event.preventDefault(); @@ -243,6 +244,10 @@ export class Combobox extends Textfield { this.focus(); } + public handleClosed(): void { + this.open = false; + } + public handleOpened(): void { // Do stuff here? } @@ -261,16 +266,6 @@ export class Combobox extends Textfield { return super.shouldUpdate(changed); } - private positionListbox(): void { - const targetRect = this.overlay.getBoundingClientRect(); - const rootRect = this.getBoundingClientRect(); - this.listbox.style.transform = `translate(${ - targetRect.x - rootRect.x - }px, ${targetRect.y - rootRect.y}px)`; - this.listbox.style.height = `${targetRect.height}px`; - this.listbox.style.maxHeight = `${targetRect.height}px`; - } - protected override onBlur(event: FocusEvent): void { if ( event.relatedTarget && @@ -308,9 +303,7 @@ export class Combobox extends Textfield { type="text" .value=${live(this.displayValue)} tabindex="0" - @sp-closed=${() => { - this.open = false; - }} + @sp-closed=${this.handleClosed} @sp-opened=${this.handleOpened} type=${this.type} aria-describedby=${this.helpTextId} @@ -337,6 +330,9 @@ export class Combobox extends Textfield { protected override render(): TemplateResult { const width = (this.input || this).offsetWidth; + if (this.tooltipEl) { + this.tooltipEl.disabled = this.open; + } return html` ${super.render()} + `; } @@ -437,9 +439,6 @@ export class Combobox extends Textfield { if (!this.focused && this.open) { this.open = false; } - if (changed.has('value')) { - if (this.overlay && this.open) this.positionListbox(); - } if (changed.has('activeDescendent')) { if (changed.get('activeDescendent')) { (changed.get('activeDescendent') as MenuItem).focused = false; diff --git a/packages/combobox/stories/combobox.stories.ts b/packages/combobox/stories/combobox.stories.ts index eb194fa7f9..46f920700f 100644 --- a/packages/combobox/stories/combobox.stories.ts +++ b/packages/combobox/stories/combobox.stories.ts @@ -13,8 +13,9 @@ governing permissions and limitations under the License. import { html, TemplateResult } from '@spectrum-web-components/base'; import { ComboboxOption } from '..'; -import '../sp-combobox.js'; -import '../sp-combobox-item.js'; +import '@spectrum-web-components/combobox/sp-combobox.js'; +import '@spectrum-web-components/combobox/sp-combobox-item.js'; +import '@spectrum-web-components/tooltip/sp-tooltip.js'; export default { title: 'Combobox', @@ -159,3 +160,25 @@ export const kerningLightDOM = (): TemplateResult => {
`; }; + +export const withTooltip = (): TemplateResult => { + return html` + + ${optionsL.map( + (option) => html` + + ${option.value} + + ` + )} + + Kerning + + + `; +}; diff --git a/packages/combobox/test/combobox.data.test.ts b/packages/combobox/test/combobox.data.test.ts index 8a216d7eae..1167cd2277 100644 --- a/packages/combobox/test/combobox.data.test.ts +++ b/packages/combobox/test/combobox.data.test.ts @@ -13,14 +13,14 @@ governing permissions and limitations under the License. import { elementUpdated, expect, - fixture, html, nextFrame, waitUntil, } from '@open-wc/testing'; -import '../sp-combobox.js'; -import '../sp-combobox-item.js'; +import '@spectrum-web-components/combobox/sp-combobox.js'; +import '@spectrum-web-components/combobox/sp-combobox-item.js'; +import { fixture } from '../../../test/testing-helpers.js'; import { Combobox, ComboboxOption } from '..'; import { TestableCombobox } from './index.js'; import { SpectrumElement, TemplateResult } from '@spectrum-web-components/base'; diff --git a/packages/combobox/test/combobox.test.ts b/packages/combobox/test/combobox.test.ts index 59c878f648..4134bb40eb 100644 --- a/packages/combobox/test/combobox.test.ts +++ b/packages/combobox/test/combobox.test.ts @@ -13,17 +13,13 @@ governing permissions and limitations under the License. import { elementUpdated, expect, - fixture, html, nextFrame, oneEvent, } from '@open-wc/testing'; -import '../sp-combobox.js'; -import '../sp-combobox-item.js'; -import '@spectrum-web-components/theme/sp-theme.js'; -import '@spectrum-web-components/theme/src/themes.js'; -import type { Theme } from '@spectrum-web-components/theme'; +import '@spectrum-web-components/combobox/sp-combobox.js'; +import '@spectrum-web-components/combobox/sp-combobox-item.js'; import { ComboboxOption } from '..'; import { arrowDownEvent, @@ -32,16 +28,20 @@ import { endEvent, enterEvent, escapeEvent, + fixture, homeEvent, } from '../../../test/testing-helpers.js'; import { a11ySnapshot, executeServerCommand, findAccessibilityNode, + sendKeys, } from '@web/test-runner-commands'; import { PickerButton } from '@spectrum-web-components/picker-button'; import { TestableCombobox, testActiveElement } from './index.js'; import { sendMouse } from '../../../test/plugins/browser.js'; +import { withTooltip } from '../stories/combobox.stories.js'; +import type { Tooltip } from '@spectrum-web-components/tooltip'; const comboboxFixture = async (): Promise => { const options: ComboboxOption[] = [ @@ -51,16 +51,12 @@ const comboboxFixture = async (): Promise => { { id: 'thing4', value: 'Efg Thing 4' }, ]; - const test = await fixture( + const el = await fixture( html` - - Combobox - + Combobox ` ); - const el = test.querySelector('sp-combobox') as unknown as TestableCombobox; - return el; }; @@ -976,4 +972,48 @@ describe('Combobox', () => { expect(el.open).to.be.true; }); }); + + it('closes tooltip on button blur', async () => { + const el = await fixture(withTooltip()); + await elementUpdated(el); + const input1 = document.createElement('input'); + const input2 = document.createElement('input'); + const tooltipEl = el.querySelector('sp-tooltip') as Tooltip; + el.insertAdjacentElement('beforebegin', input1); + el.insertAdjacentElement('afterend', input2); + input1.focus(); + expect(document.activeElement === input1).to.be.true; + const tooltipOpened = oneEvent(el, 'sp-opened'); + await sendKeys({ + press: 'Tab', + }); + await tooltipOpened; + expect( + document.activeElement === el, + `Actually, ${document.activeElement?.localName}` + ).to.be.true; + expect(tooltipEl.open).to.be.true; + expect(el.open).to.be.false; + expect(el.focused).to.be.true; + + const menuOpen = oneEvent(el, 'sp-opened'); + const tooltipClosed = oneEvent(el, 'sp-closed'); + await sendKeys({ + press: 'ArrowDown', + }); + await menuOpen; + await tooltipClosed; + expect(document.activeElement === el).to.be.true; + expect(tooltipEl.open).to.be.false; + expect(el.open).to.be.true; + + const menuClosed = oneEvent(el, 'sp-closed'); + await sendKeys({ + press: 'Tab', + }); + await menuClosed; + expect(document.activeElement === el).to.be.false; + expect(tooltipEl.open).to.be.false; + expect(el.open).to.be.false; + }); }); From 1afa07345437725658da553933d2607fb16d25ad Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Thu, 18 Jan 2024 22:07:47 -0500 Subject: [PATCH 08/23] fix(combobox): allow intern Menu to hold a selection when autocomplete === "none" (#3951) --- packages/combobox/src/Combobox.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts index 2d05672e0e..6764c08b7b 100644 --- a/packages/combobox/src/Combobox.ts +++ b/packages/combobox/src/Combobox.ts @@ -367,6 +367,12 @@ export class Combobox extends Textfield { aria-labelledby="label" id="listbox-menu" role="listbox" + selects=${ifDefined( + this.autocomplete === 'none' ? 'single' : undefined + )} + .selected=${this.autocomplete === 'none' + ? [this.value] + : []} style="min-width: ${width}px;" size=${this.size} > From 03570a9dbd23648efe95d67246a1ad197a6ea131 Mon Sep 17 00:00:00 2001 From: Najika Halsema Yoo <44980010+najikahalsema@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:38:58 -0800 Subject: [PATCH 09/23] test(combobox): add accessibility tests (#3953) * chore: add labels to combobox input * chore: get tests passing * test(combobox): get a11y tests passing * chore: remove unused positionlistbox method * test: get tests passing, change spelling of activeDescendant * chore: missed some descendents * chore: add help text demo and test * ci: update hash * chore: address review comments * chore: abstract shared data to index files * test(combobox): update tests and stories to use legible data * ci: update hash * chore: label menu and rename stories * ci: update hash --------- Co-authored-by: Najika Yoo --- packages/combobox/src/Combobox.ts | 136 +++--- packages/combobox/src/combobox.css | 13 + packages/combobox/stories/combobox.stories.ts | 155 +++---- packages/combobox/stories/index.ts | 274 ++++++++++++ .../combobox/test/benchmark/basic-test.ts | 4 +- packages/combobox/test/combobox-a11y.test.ts | 280 +++++++++++++ packages/combobox/test/combobox.data.test.ts | 28 +- packages/combobox/test/combobox.test.ts | 394 ++++-------------- packages/combobox/test/index.ts | 28 +- 9 files changed, 834 insertions(+), 478 deletions(-) create mode 100644 packages/combobox/stories/index.ts create mode 100644 packages/combobox/test/combobox-a11y.test.ts diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts index 6764c08b7b..0755724546 100644 --- a/packages/combobox/src/Combobox.ts +++ b/packages/combobox/src/Combobox.ts @@ -19,6 +19,7 @@ import { import { property, query, + state, } from '@spectrum-web-components/base/src/decorators.js'; import { ifDefined, @@ -56,10 +57,13 @@ export class Combobox extends Textfield { } /** - * The currently active ComboboxItem descendent, when available. + * The currently active ComboboxItem descendant, when available. */ @property({ attribute: false }) - public activeDescendent?: ComboboxOption | MenuItem; + public activeDescendant?: ComboboxOption | MenuItem; + + @state() + override appliedLabel?: string; @property({ attribute: false }) public availableOptions: (ComboboxOption | MenuItem)[] = []; @@ -67,9 +71,6 @@ export class Combobox extends Textfield { @property() public ariaAutocomplete: 'list' | 'none' = 'none'; - @property({ attribute: 'label-position' }) - public labelPosition: 'inline-start' | undefined; - /** * Whether the listbox is visible. **/ @@ -114,9 +115,9 @@ export class Combobox extends Textfield { } else if (event.code === 'ArrowDown') { event.preventDefault(); this.open = true; - this.activateNextDescendent(); + this.activateNextDescendant(); const activeEl = this.querySelector( - `#${(this.activeDescendent as ComboboxOption).id}` + `#${(this.activeDescendant as ComboboxOption).id}` ) as HTMLElement; if (activeEl) { activeEl.scrollIntoView({ block: 'nearest' }); @@ -124,9 +125,9 @@ export class Combobox extends Textfield { } else if (event.code === 'ArrowUp') { event.preventDefault(); this.open = true; - this.activatePreviousDescendent(); + this.activatePreviousDescendant(); const activeEl = this.querySelector( - `#${(this.activeDescendent as ComboboxOption).id}` + `#${(this.activeDescendant as ComboboxOption).id}` ) as HTMLElement; if (activeEl) { activeEl.scrollIntoView({ block: 'nearest' }); @@ -137,19 +138,19 @@ export class Combobox extends Textfield { } this.open = false; } else if (event.code === 'Enter') { - this.selectDescendent(); + this.selectDescendant(); this.open = false; } else if (event.code === 'Home') { this.focusElement.setSelectionRange(0, 0); - this.activeDescendent = undefined; + this.activeDescendant = undefined; } else if (event.code === 'End') { const { length } = this.value; this.focusElement.setSelectionRange(length, length); - this.activeDescendent = undefined; + this.activeDescendant = undefined; } else if (event.code === 'ArrowLeft') { - this.activeDescendent = undefined; + this.activeDescendant = undefined; } else if (event.code === 'ArrowRight') { - this.activeDescendent = undefined; + this.activeDescendant = undefined; } } @@ -185,31 +186,31 @@ export class Combobox extends Textfield { this.optionEls = elements; } - public activateNextDescendent(): void { - const activeIndex = !this.activeDescendent + public activateNextDescendant(): void { + const activeIndex = !this.activeDescendant ? -1 - : this.availableOptions.indexOf(this.activeDescendent); + : this.availableOptions.indexOf(this.activeDescendant); const nextActiveIndex = (this.availableOptions.length + activeIndex + 1) % this.availableOptions.length; - this.activeDescendent = this.availableOptions[nextActiveIndex]; + this.activeDescendant = this.availableOptions[nextActiveIndex]; } - public activatePreviousDescendent(): void { - const activeIndex = !this.activeDescendent + public activatePreviousDescendant(): void { + const activeIndex = !this.activeDescendant ? 0 - : this.availableOptions.indexOf(this.activeDescendent); + : this.availableOptions.indexOf(this.activeDescendant); const previousActiveIndex = (this.availableOptions.length + activeIndex - 1) % this.availableOptions.length; - this.activeDescendent = this.availableOptions[previousActiveIndex]; + this.activeDescendant = this.availableOptions[previousActiveIndex]; } - public selectDescendent(): void { - if (!this.activeDescendent) { + public selectDescendant(): void { + if (!this.activeDescendant) { return; } - this.value = this.activeDescendent.value; + this.value = this.activeDescendant.value; } public filterAvailableOptions(): void { @@ -218,9 +219,9 @@ export class Combobox extends Textfield { } const valueLowerCase = this.value.toLowerCase(); this.availableOptions = (this.options || this.optionEls).filter( - (descendent) => { - const descendentValueLowerCase = descendent.value.toLowerCase(); - return descendentValueLowerCase.startsWith(valueLowerCase); + (descendant) => { + const descendantValueLowerCase = descendant.value.toLowerCase(); + return descendantValueLowerCase.startsWith(valueLowerCase); } ); Overlay.update(); @@ -231,7 +232,7 @@ export class Combobox extends Textfield { }: Event & { target: HTMLInputElement }): void { // Element data. this.value = target.value; - this.activeDescendent = undefined; + this.activeDescendant = undefined; this.open = true; } @@ -258,7 +259,7 @@ export class Combobox extends Textfield { protected override shouldUpdate(changed: PropertyValues): boolean { if (changed.has('open') && !this.open) { - this.activeDescendent = undefined; + this.activeDescendant = undefined; } if (changed.has('value')) { this.filterAvailableOptions(); @@ -277,21 +278,53 @@ export class Combobox extends Textfield { super.onBlur(event); } + protected renderAppliedLabel(): TemplateResult { + /** + * appliedLabel corresponds to `