diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 000000000..da2bd1707 --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,5 @@ +import { addons } from '@storybook/manager-api'; + +addons.setConfig({ + enableShortcuts: false, +}); diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 7f4e38934..6c14a1c4b 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -4,6 +4,12 @@ import { html } from 'lit'; import { configureTheme } from '../src/theming/config'; import type { Decorator } from '@storybook/web-components'; import { withActions } from '@storybook/addon-actions/decorator'; +import { configureActions } from '@storybook/addon-actions'; + +configureActions({ + clearOnStoryChange: true, + limit: 5, +}); type ThemeImport = { default: string }; diff --git a/src/components/common/controllers/key-bindings.ts b/src/components/common/controllers/key-bindings.ts index 2ae528b4f..1eb98f445 100644 --- a/src/components/common/controllers/key-bindings.ts +++ b/src/components/common/controllers/key-bindings.ts @@ -8,6 +8,7 @@ export const arrowUp = 'ArrowUp' as const; export const arrowDown = 'ArrowDown' as const; export const enterKey = 'Enter' as const; export const spaceBar = ' ' as const; +export const escapeKey = 'Escape' as const; export const homeKey = 'Home' as const; export const endKey = 'End' as const; export const pageUpKey = 'PageUp' as const; @@ -22,6 +23,7 @@ export const shiftKey = 'Shift' as const; /* Types */ export type KeyBindingHandler = (event: KeyboardEvent) => void; +export type KeyBindingObserverCleanup = { unsubscribe: () => void }; /** * Whether the current event should be ignored by the controller. @@ -169,14 +171,39 @@ export function parseKeys(keys: string | string[]) { class KeyBindingController implements ReactiveController { protected _host: ReactiveControllerHost & Element; protected _ref?: Ref; + protected _observedElement?: Element; protected _options?: KeyBindingControllerOptions; private bindings = new Set(); private pressedKeys = new Set(); protected get _element() { + if (this._observedElement) { + return this._observedElement; + } return this._ref ? this._ref.value : this._host; } + /** + * Sets the controller to listen for keyboard events on an arbitrary `element` in the page context. + * All the configuration and event handlers are applied as well. + * + * Returns an object with an `unsubscribe` function which should be called when the observing of keyboard + * events on the `element` should cease. + */ + public observeElement(element: Element): KeyBindingObserverCleanup { + element.addEventListener('keydown', this); + element.addEventListener('keyup', this); + this._observedElement = element; + + return { + unsubscribe: () => { + this._observedElement?.removeEventListener('keydown', this); + this._observedElement?.removeEventListener('keyup', this); + this._observedElement = undefined; + }, + }; + } + constructor( host: ReactiveControllerHost & Element, options?: KeyBindingControllerOptions @@ -226,7 +253,7 @@ class KeyBindingController implements ReactiveController { return false; } - private _handleEvent(event: KeyboardEvent) { + public handleEvent(event: KeyboardEvent) { const key = event.key.toLowerCase(); const path = event.composedPath(); const skip = this._options?.skip; @@ -274,14 +301,6 @@ class KeyBindingController implements ReactiveController { } } - private onKeyUp = (event: Event) => { - this._handleEvent(event as KeyboardEvent); - }; - - private onKeyDown = (event: Event) => { - this._handleEvent(event as KeyboardEvent); - }; - /** * Registers a keybinding handler. */ @@ -317,13 +336,13 @@ class KeyBindingController implements ReactiveController { } public hostConnected(): void { - this._host.addEventListener('keyup', this.onKeyUp); - this._host.addEventListener('keydown', this.onKeyDown); + this._host.addEventListener('keyup', this); + this._host.addEventListener('keydown', this); } public hostDisconnected(): void { - this._host.removeEventListener('keyup', this.onKeyUp); - this._host.removeEventListener('keydown', this.onKeyDown); + this._host.removeEventListener('keyup', this); + this._host.removeEventListener('keydown', this); } } diff --git a/src/components/common/controllers/root-click.ts b/src/components/common/controllers/root-click.ts new file mode 100644 index 000000000..4afac1ce5 --- /dev/null +++ b/src/components/common/controllers/root-click.ts @@ -0,0 +1,78 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +type RootClickControllerConfig = { + hideCallback?: Function; + target?: HTMLElement; +}; + +type RootClickControllerHost = ReactiveControllerHost & + HTMLElement & { + open: boolean; + keepOpenOnOutsideClick: boolean; + hide(): void; + }; + +class RootClickController implements ReactiveController { + constructor( + private readonly host: RootClickControllerHost, + private config?: RootClickControllerConfig + ) { + this.host.addController(this); + } + + private addEventListeners() { + if (!this.host.keepOpenOnOutsideClick) { + document.addEventListener('click', this); + } + } + + private removeEventListeners() { + document.removeEventListener('click', this); + } + + private configureListeners() { + this.host.open ? this.addEventListeners() : this.removeEventListeners(); + } + + public handleEvent(event: MouseEvent) { + if (this.host.keepOpenOnOutsideClick) { + return; + } + + const path = event.composed ? event.composedPath() : [event.target]; + const target = this.config?.target || null; + if (path.includes(this.host) || path.includes(target)) { + return; + } + + this.hide(); + } + + private hide() { + this.config?.hideCallback + ? this.config.hideCallback.call(this.host) + : this.host.hide(); + } + + public update(config?: RootClickControllerConfig) { + if (config) { + this.config = { ...this.config, ...config }; + } + this.configureListeners(); + } + + public hostConnected() { + this.configureListeners(); + } + + public hostDisconnected() { + this.removeEventListeners(); + } +} + +export function addRootClickHandler( + host: RootClickControllerHost, + config?: RootClickControllerConfig +) { + return new RootClickController(host, config); +} diff --git a/src/components/common/controllers/root-scroll.ts b/src/components/common/controllers/root-scroll.ts new file mode 100644 index 000000000..9924fee86 --- /dev/null +++ b/src/components/common/controllers/root-scroll.ts @@ -0,0 +1,98 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +type RootScrollControllerConfig = { + hideCallback?: Function; + resetListeners?: boolean; +}; + +type RootScrollControllerHost = ReactiveControllerHost & { + open: boolean; + hide(): void; + scrollStrategy?: 'scroll' | 'close' | 'block'; +}; + +type ScrollRecord = { scrollTop: number; scrollLeft: number }; + +class RootScrollController implements ReactiveController { + private _cache: WeakMap; + + constructor( + private readonly host: RootScrollControllerHost, + private config?: RootScrollControllerConfig + ) { + this._cache = new WeakMap(); + this.host.addController(this); + } + + private configureListeners() { + this.host.open ? this.addEventListeners() : this.removeEventListeners(); + } + + private hide() { + this.config?.hideCallback + ? this.config.hideCallback.call(this.host) + : this.host.hide(); + } + + private addEventListeners() { + if (this.host.scrollStrategy !== 'scroll') { + document.addEventListener('scroll', this, { capture: true }); + } + } + + private removeEventListeners() { + document.removeEventListener('scroll', this, { capture: true }); + this._cache = new WeakMap(); + } + + public handleEvent(event: Event) { + this.host.scrollStrategy === 'close' ? this.hide() : this._block(event); + } + + private _block(event: Event) { + event.preventDefault(); + const element = event.target as Element; + const cache = this._cache; + + if (!cache.has(element)) { + cache.set(element, { + scrollTop: element.firstElementChild?.scrollTop ?? element.scrollTop, + scrollLeft: element.firstElementChild?.scrollLeft ?? element.scrollLeft, + }); + } + + const record = cache.get(element)!; + Object.assign(element, record); + + if (element.firstElementChild) { + Object.assign(element.firstElementChild, record); + } + } + + public update(config?: RootScrollControllerConfig) { + if (config) { + this.config = { ...this.config, ...config }; + } + + if (config?.resetListeners) { + this.removeEventListeners(); + } + + this.configureListeners(); + } + + public hostConnected() { + this.configureListeners(); + } + + public hostDisconnected() { + this.removeEventListeners(); + } +} + +export function addRootScrollHandler( + host: RootScrollControllerHost, + config?: RootScrollControllerConfig +) { + return new RootScrollController(host, config); +} diff --git a/src/components/common/mixins/combo-box.ts b/src/components/common/mixins/combo-box.ts new file mode 100644 index 000000000..500e3537a --- /dev/null +++ b/src/components/common/mixins/combo-box.ts @@ -0,0 +1,169 @@ +import { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { UnpackCustomEvent } from './event-emitter.js'; +import { iterNodes } from '../util.js'; + +interface IgcBaseComboBoxEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; +} + +export class IgcBaseComboBoxLikeComponent extends LitElement { + public declare emitEvent: < + K extends keyof IgcBaseComboBoxEventMap, + D extends UnpackCustomEvent, + >( + event: K, + eventInitDict?: CustomEventInit + ) => boolean; + + /** + * Whether the component dropdown should be kept open on selection. + * @attr keep-open-on-select + */ + @property({ type: Boolean, reflect: true, attribute: 'keep-open-on-select' }) + public keepOpenOnSelect = false; + + /** + * Whether the component dropdown should be kept open on clicking outside of it. + * @attr keep-open-on-outside-click + */ + @property({ + type: Boolean, + reflect: true, + attribute: 'keep-open-on-outside-click', + }) + public keepOpenOnOutsideClick = false; + + /** + * Sets the open state of the component. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + protected emitClosing() { + return this.emitEvent('igcClosing', { cancelable: true }); + } + + protected emitClosed() { + return this.emitEvent('igcClosed'); + } + + protected emitOpening() { + return this.emitEvent('igcOpening', { cancelable: true }); + } + + protected emitOpened() { + return this.emitEvent('igcOpened'); + } + + protected handleAnchorClick() { + this.open ? this._hide(true) : this._show(true); + } + + protected async _hide(emitEvent = false) { + if (!this.open || (emitEvent && !this.emitClosing())) { + return false; + } + + this.open = false; + + if (emitEvent) { + await this.updateComplete; + return this.emitClosed(); + } + + return false; + } + + protected async _show(emitEvent = false) { + if (this.open || (emitEvent && !this.emitOpening())) { + return false; + } + + this.open = true; + + if (emitEvent) { + await this.updateComplete; + return this.emitOpened(); + } + + return false; + } + + /** Shows the component. */ + public show() { + this._show(); + } + + /** Hides the component. */ + public hide() { + this._hide(); + } + + /** Toggles the open state of the component. */ + public toggle() { + this.open ? this.hide() : this.show(); + } +} + +export function getItems(root: Node, tagName: string) { + return iterNodes(root, 'SHOW_ELEMENT', (item) => item.matches(tagName)); +} + +export function getActiveItems( + root: Node, + tagName: string +) { + return iterNodes( + root, + 'SHOW_ELEMENT', + (item) => item.matches(tagName) && !item.disabled + ); +} + +export function getNextActiveItem< + T extends HTMLElement & { disabled: boolean }, +>(items: T[], from: T) { + const current = items.indexOf(from); + + for (let i = current + 1; i < items.length; i++) { + if (!items[i].disabled) { + return items[i]; + } + } + + return items[current]; +} + +export function getPreviousActiveItem< + T extends HTMLElement & { disabled: boolean }, +>(items: T[], from: T) { + const current = items.indexOf(from); + + for (let i = current - 1; i >= 0; i--) { + if (!items[i].disabled) { + return items[i]; + } + } + + return items[current]; +} + +export function setInitialSelectionState< + T extends HTMLElement & { selected: boolean }, +>(items: T[]) { + const lastSelected = items.filter((item) => item.selected).at(-1) ?? null; + + for (const item of items) { + if (!item.isSameNode(lastSelected)) { + item.selected = false; + } + } + + return lastSelected; +} diff --git a/src/components/common/mixins/option.ts b/src/components/common/mixins/option.ts new file mode 100644 index 000000000..bdbc7006d --- /dev/null +++ b/src/components/common/mixins/option.ts @@ -0,0 +1,92 @@ +import { LitElement, html } from 'lit'; +import { property, queryAssignedNodes } from 'lit/decorators.js'; + +import { watch } from '../decorators/watch.js'; + +export abstract class IgcBaseOptionLikeComponent extends LitElement { + protected _internals: ElementInternals; + protected _value!: string; + + @queryAssignedNodes({ flatten: true }) + protected _content!: Array; + + protected get _contentSlotText() { + return this._content.map((node) => node.textContent).join(''); + } + + /** + * Whether the item is active. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public active = false; + + /** + * Whether the item is disabled. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + /** + * Whether the item is selected. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public selected = false; + + /** + * The current value of the item. + * If not specified, the element's text content is used. + * + * @attr + */ + @property() + public get value(): string { + return this._value ? this._value : this._contentSlotText; + } + + public set value(value: string) { + const old = this._value; + this._value = value; + this.requestUpdate('value', old); + } + + @watch('disabled') + protected disabledChange() { + this._internals.ariaDisabled = `${this.disabled}`; + } + + @watch('selected') + protected selectedChange() { + this._internals.ariaSelected = `${this.selected}`; + this.active = this.selected; + } + + constructor() { + super(); + this._internals = this.attachInternals(); + this._internals.role = 'option'; + } + + public override connectedCallback(): void { + // R.K. Workaround for Axe accessibility unit tests. + // I guess it does not support ElementInternals ARIAMixin state yet + super.connectedCallback(); + this.role = 'option'; + } + + protected override render() { + return html` +
+ +
+
+ +
+
+ +
+ `; + } +} diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 21fd0ce75..0f044f2fe 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -151,3 +151,21 @@ export function* iterNodes( node = iter.nextNode() as T; } } + +export function getElementByIdFromRoot(root: HTMLElement, id: string) { + return (root.getRootNode() as Document | ShadowRoot).getElementById(id); +} + +export function groupBy(array: T[], key: keyof T | ((item: T) => any)) { + const result: Record = {}; + const _get = typeof key === 'function' ? key : (item: T) => item[key]; + + for (const item of array) { + const category = _get(item); + const group = result[category]; + + Array.isArray(group) ? group.push(item) : (result[category] = [item]); + } + + return result; +} diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index 5b61f8803..ab732141d 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -83,6 +83,14 @@ export class FormAssociatedTestBed< } } +export function simulateClick(node: Element, times = 1) { + for (let i = 0; i < times; i++) { + node.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + } +} + /** * Simulates keyboard interaction on a given element node. * diff --git a/src/components/dropdown/dropdown-group.ts b/src/components/dropdown/dropdown-group.ts index 2751b1926..48a1defb2 100644 --- a/src/components/dropdown/dropdown-group.ts +++ b/src/components/dropdown/dropdown-group.ts @@ -7,10 +7,11 @@ import { all } from './themes/group.js'; import { themes } from '../../theming/theming-decorator.js'; import { blazorSuppress } from '../common/decorators/blazorSuppress.js'; import { registerComponent } from '../common/definitions/register.js'; -import { SizableInterface } from '../common/mixins/sizable.js'; /** - * @element igc-dropdown-group - A container for a group of `igc-dropdown-item` components. + * A container for a group of `igc-dropdown-item` components. + * + * @element igc-dropdown-group * * @slot label - Contains the group's label. * @slot - Intended to contain the items belonging to this group. @@ -26,27 +27,24 @@ export default class IgcDropdownGroupComponent extends LitElement { registerComponent(this); } - protected parent!: SizableInterface; + private _internals: ElementInternals; /** All child `igc-dropdown-item`s. */ @blazorSuppress() @queryAssignedElements({ flatten: true, selector: 'igc-dropdown-item' }) public items!: Array; - public override connectedCallback() { - super.connectedCallback(); - - this.setAttribute('role', 'group'); - this.parent = this.getParent(); - } - - protected getParent() { - return this.closest('igc-dropdown')!; + constructor() { + super(); + this._internals = this.attachInternals(); + this._internals.role = 'group'; } protected override render() { return html` - + `; } diff --git a/src/components/dropdown/dropdown-header.ts b/src/components/dropdown/dropdown-header.ts index e4d96fc1f..379cb0c96 100644 --- a/src/components/dropdown/dropdown-header.ts +++ b/src/components/dropdown/dropdown-header.ts @@ -6,7 +6,9 @@ import { themes } from '../../theming/theming-decorator.js'; import { registerComponent } from '../common/definitions/register.js'; /** - * @element igc-dropdown-header - Represents a header item in a dropdown list. + * Represents a header item in a igc-dropdown list. + * + * @element igc-dropdown-header * * @slot - Renders the header. */ diff --git a/src/components/dropdown/dropdown-item.ts b/src/components/dropdown/dropdown-item.ts index e839d9dc3..2b67d1e25 100644 --- a/src/components/dropdown/dropdown-item.ts +++ b/src/components/dropdown/dropdown-item.ts @@ -1,11 +1,8 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators.js'; - import { styles } from './themes/dropdown-item.base.css.js'; import { all } from './themes/item.js'; import { themes } from '../../theming/theming-decorator.js'; -import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import { IgcBaseOptionLikeComponent } from '../common/mixins/option.js'; /** * Represents an item in a dropdown list. @@ -21,76 +18,13 @@ import { registerComponent } from '../common/definitions/register.js'; * @csspart suffix - The suffix wrapper. */ @themes(all) -export default class IgcDropdownItemComponent extends LitElement { - public static readonly tagName: string = 'igc-dropdown-item'; +export default class IgcDropdownItemComponent extends IgcBaseOptionLikeComponent { + public static readonly tagName = 'igc-dropdown-item'; public static override styles = styles; public static register() { registerComponent(this); } - - private _value!: string; - - /** - * Тhe current value of the item. - * If not specified, the element's text content is used. - * @attr - */ - @property() - public get value(): string { - return this._value ? this._value : this.textContent ?? ''; - } - - public set value(value: string) { - const oldVal = this._value; - this._value = value; - this.requestUpdate('value', oldVal); - } - - /** - * Determines whether the item is selected. - * @attr - */ - @property({ type: Boolean, reflect: true }) - public selected = false; - - /** - * Determines whether the item is active. - * @attr - */ - @property({ type: Boolean, reflect: true }) - public active = false; - - /** - * Determines whether the item is disabled. - * @attr - */ - @property({ type: Boolean, reflect: true }) - public disabled = false; - - @watch('selected') - protected selectedChange() { - this.toggleAttribute('aria-selected', this.selected); - this.active = this.selected; - } - - @watch('disabled') - protected disabledChange() { - this.toggleAttribute('aria-disabled', this.disabled); - } - - public override connectedCallback() { - super.connectedCallback(); - this.setAttribute('role', 'option'); - } - - protected override render() { - return html` -
-
-
- `; - } } declare global { diff --git a/src/components/dropdown/dropdown.spec.ts b/src/components/dropdown/dropdown.spec.ts index 659062c59..aeacd4202 100644 --- a/src/components/dropdown/dropdown.spec.ts +++ b/src/components/dropdown/dropdown.spec.ts @@ -1,27 +1,34 @@ -import { elementUpdated, expect, fixture } from '@open-wc/testing'; +import { elementUpdated, expect, fixture, nextFrame } from '@open-wc/testing'; import { html } from 'lit'; import { spy } from 'sinon'; -import IgcDropdownGroupComponent from './dropdown-group.js'; import IgcDropdownHeaderComponent from './dropdown-header.js'; -import IgcDropdownItemComponent from './dropdown-item.js'; +import type IgcDropdownItemComponent from './dropdown-item.js'; import IgcDropdownComponent from './dropdown.js'; import IgcButtonComponent from '../button/button.js'; +import { + arrowDown, + arrowUp, + endKey, + enterKey, + escapeKey, + homeKey, + tabKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import { simulateClick, simulateKeyboard } from '../common/utils.spec.js'; -describe('Dropdown component', () => { - before(() => { - defineComponents( - IgcDropdownComponent, - IgcButtonComponent, - IgcDropdownHeaderComponent, - IgcDropdownGroupComponent, - IgcDropdownItemComponent - ); - }); +type ItemState = { + active?: boolean; + disabled?: boolean; + selected?: boolean; +}; + +describe('Dropdown', () => { + before(() => defineComponents(IgcDropdownComponent, IgcButtonComponent)); - let dropdown: IgcDropdownComponent; - const items = [ + let dropDown: IgcDropdownComponent; + const Items = [ 'Specification', 'Implementation', 'Testing', @@ -30,967 +37,792 @@ describe('Dropdown component', () => { 'Builds', ]; - const ddListWrapper = (el: IgcDropdownComponent) => - el.shadowRoot!.querySelector('[part="base"]') as HTMLElement; - const ddList = (el: IgcDropdownComponent) => - ddListWrapper(el).querySelector('[part="list"]') as HTMLElement; - const ddItems = (el: IgcDropdownComponent) => - [...el.querySelectorAll('igc-dropdown-item')] as HTMLElement[]; - const ddHeaders = (el: IgcDropdownComponent) => - [...el.querySelectorAll('igc-dropdown-header')] as HTMLElement[]; - const target = (el: IgcDropdownComponent) => - el.querySelector('input') as HTMLElement; - - describe('', () => { - beforeEach(async () => { - dropdown = await fixture( - html` - - Tasks - ${items.map( - (item) => html`${item}` - )} - ` - ); - }); + const openDropdown = async (target?: HTMLElement | string) => { + dropDown.show(target); + await elementUpdated(dropDown); + }; + + const closeDropdown = async () => { + dropDown.hide(); + await elementUpdated(dropDown); + }; + + const getTarget = () => + dropDown.querySelector('[slot="target"]') as IgcButtonComponent; + + const getActiveItem = () => dropDown.items.find((item) => item.active); + + const getHeaders = () => + Array.from( + dropDown.querySelectorAll( + IgcDropdownHeaderComponent.tagName + ) + ); + + function checkItemState(item: IgcDropdownItemComponent, state: ItemState) { + for (const [key, value] of Object.entries(state)) { + expect((item as any)[key]).to.equal(value); + } + } + + function createBasicDropdown(isOpen = false) { + return html` + Open + ${Items.map( + (item) => + html`${item}` + )} + `; + } + + function createAdvancedDropdown(isOpen = false) { + return html` + + Open + Tasks + + Pre development + Specification + + + Development + Implementation + Testing + + + Post development + Samples + Documentation + Builds + + + `; + } + + function createDetachedDropdown() { + return html` +
+ Click - it('handles initial selection', async () => { - dropdown = await fixture(html` - - Languages - JavaScript - TypeScript - SCSS + 1 + 2 + 3 - `); +
+ `; + } + + function createScrollableDropdownParent() { + return html` +
+ + Open + ${Items.map( + (item) => + html`${item}` + )} + +
+ `; + } + + describe('Scroll strategy', () => { + let container: HTMLDivElement; - expect(dropdown.querySelectorAll('[selected]').length).to.equal(1); - expect(dropdown.querySelector('[selected]')?.textContent).to.equal( - 'TypeScript' - ); + const scrollBy = async (amount: number) => { + container.scrollTo({ top: amount }); + container.dispatchEvent(new Event('scroll')); + await elementUpdated(dropDown); + await nextFrame(); + }; + + beforeEach(async () => { + container = await fixture(createScrollableDropdownParent()); + dropDown = container.querySelector(IgcDropdownComponent.tagName)!; }); - it('is accessible.', async () => { - dropdown.open = true; - await elementUpdated(dropdown); - await expect(dropdown).to.be.accessible(); - await expect(dropdown).shadowDom.to.be.accessible(); + it('`scroll` behavior', async () => { + await openDropdown(); + await scrollBy(200); + + expect(dropDown.open).to.be.true; }); - it('is successfully created with default properties.', () => { - expect(document.querySelector('igc-dropdown')).to.exist; - expect(dropdown.open).to.be.false; - expect(dropdown.flip).to.be.false; - expect(dropdown.keepOpenOnOutsideClick).to.be.false; - expect(dropdown.keepOpenOnSelect).to.be.false; - expect(dropdown.placement).to.eq('bottom-start'); - expect(dropdown.positionStrategy).to.eq('absolute'); - expect(dropdown.scrollStrategy).to.eq('scroll'); - expect(dropdown.distance).to.eq(0); - expect(dropdown.sameWidth).to.be.false; + it('`close` behavior', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + + dropDown.scrollStrategy = 'close'; + await openDropdown(); + await scrollBy(200); + + expect(dropDown.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); }); - it('shows and hides the list when changing the value of `open`.', async () => { - expect(ddListWrapper(dropdown).getBoundingClientRect()).to.be.empty; + it('`block behavior`', async () => { + dropDown.scrollStrategy = 'block'; + await openDropdown(); + await scrollBy(200); - dropdown.open = true; - await elementUpdated(dropdown); + expect(dropDown.open).to.be.true; + }); + }); - expect(dropdown.open).to.be.true; + describe('Detached (non-slotted anchor)', () => { + const getButton = () => document.getElementById('btn')!; - dropdown.open = false; - await elementUpdated(dropdown); + function checkTargetARIA(state: 'true' | 'false') { + const btn = getButton(); + expect(btn.getAttribute('aria-haspopup')).to.equal('true'); + expect(btn.getAttribute('aria-expanded')).to.equal(state); + } - expect(dropdown.open).to.be.false; + beforeEach(async () => { + const dom = await fixture(createDetachedDropdown()); + dropDown = dom.querySelector('igc-dropdown')!; }); - describe('', () => { - const listRect = () => ddListWrapper(dropdown).getBoundingClientRect(); - const targetRect = () => target(dropdown).getBoundingClientRect(); + it('correctly shows the detached dropdown at a given target', async () => { + await openDropdown('btn'); - beforeEach(async () => { - dropdown.open = true; - await elementUpdated(dropdown); - }); + expect(dropDown.open).to.be.true; + checkTargetARIA('true'); - it('displays properly all declared items.', async () => { - const headers = ddHeaders(dropdown).map((h) => h.innerText); - const itemValues = ddItems(dropdown).map((h) => h.innerText); + await closeDropdown(); - expect(headers.length).to.eq(1); - expect(itemValues.length).to.eq(items.length); + expect(dropDown.open).to.be.false; + checkTargetARIA('false'); + }); - expect(headers[0]).to.eq('Tasks'); - expect(itemValues).to.deep.eq(items); - }); + it('keyboard navigation works in detached state', async () => { + const btn = getButton(); - it('places the list at the bottom start of the target.', async () => { - expect(listRect().x).to.eq(targetRect().x); - expect(Math.round(listRect().top)).to.eq( - Math.round(targetRect().bottom) - ); - }); + await openDropdown('btn'); - it('repositions the list immediately according to `placement` property.', async () => { - dropdown.placement = 'right-start'; - await elementUpdated(dropdown); + simulateKeyboard(btn, arrowDown, 2); + simulateKeyboard(btn, enterKey); + await elementUpdated(dropDown); - expect(dropdown.placement).to.eq('right-start'); + expect(dropDown.open).to.be.false; + checkItemState(dropDown.items[1], { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal('2'); + }); - expect(listRect().x).to.eq(targetRect().right); - expect(Math.round(listRect().top)).to.eq(Math.round(targetRect().top)); - }); + it('relevant events are fired in order', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); - it('honors `flip` value when positioning the list according to the specified `placement`.', async () => { - dropdown.placement = 'left'; - await elementUpdated(dropdown); + // No opening sequence of events since detached dropdowns are opened with API invocation - expect(dropdown.placement).to.eq('left'); + const btn = getButton(); - expect(Math.round(listRect().right)).to.eq(targetRect().x); + await openDropdown('btn'); - dropdown.flip = true; - await elementUpdated(dropdown); + simulateKeyboard(btn, arrowDown, 2); + simulateKeyboard(btn, enterKey); + await elementUpdated(dropDown); - expect(dropdown.flip).to.be.true; - expect(listRect().x).to.eq(targetRect().right); + expect(eventSpy.firstCall).calledWith('igcChange', { + detail: dropDown.selectedItem, }); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).calledWith('igcClosed'); + }); - it('honors `preventOverflow` option and keeps the list in view.', async () => { - dropdown.placement = 'right-end'; - await elementUpdated(dropdown); + it('outside click behavior is enforced', async () => { + const btn = getButton(); - expect(dropdown.placement).to.eq('right-end'); - expect(Math.round(listRect().top)).to.eq(0); - }); + await openDropdown('btn'); - it('offsets the list according to the `offset` property value.', async () => { - dropdown.distance = 5; - await elementUpdated(dropdown); + // By default clicking on the `target` should not close the dropdown. Application scenario to + // hook up additional logic. + simulateClick(btn); + await elementUpdated(dropDown); - expect(listRect().x).to.eq(targetRect().x); - expect(Math.round(listRect().top)).to.eq( - Math.round(targetRect().bottom + 5) - ); + expect(dropDown.open).to.be.true; - dropdown.placement = 'left-start'; - dropdown.distance = 20; - await elementUpdated(dropdown); + // No keep-open-on-outside-click + simulateClick(document.body); + await elementUpdated(dropDown); - expect(Math.round(listRect().right)).to.eq(targetRect().x - 20); - expect(Math.round(listRect().top)).to.eq(Math.round(targetRect().top)); - }); + expect(dropDown.open).to.be.false; - it('toggles the list on `show`/`hide` method calls.', async () => { - dropdown.show(); - await elementUpdated(dropdown); + dropDown.keepOpenOnOutsideClick = true; + await openDropdown(btn); - expect(dropdown.open).to.be.true; + // With keep-open-on-outside-click + simulateClick(document.body); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.true; + }); + }); - dropdown.hide(); - await elementUpdated(dropdown); + describe('DOM', () => { + beforeEach(async () => { + dropDown = await fixture(createBasicDropdown()); + }); - expect(dropdown.open).to.be.false; + it('is rendered correctly', async () => { + expect(dropDown).to.exist; + }); - dropdown.show(); - await elementUpdated(dropdown); + it('is accessible', async () => { + // Closed state + await expect(dropDown).dom.to.be.accessible(); + await expect(dropDown).shadowDom.to.be.accessible(); - expect(dropdown.open).to.be.true; - }); + dropDown.open = true; + await elementUpdated(dropDown); - it('toggles the list on `toggle` method calls.', async () => { - dropdown.toggle(); - await elementUpdated(dropdown); + // Open state + await expect(dropDown).dom.to.be.accessible(); + await expect(dropDown).shadowDom.to.be.accessible(); - expect(dropdown.open).to.be.false; + dropDown.open = false; + await elementUpdated(dropDown); - dropdown.toggle(); - await elementUpdated(dropdown); + // Closed state again + await expect(dropDown).dom.to.be.accessible(); + await expect(dropDown).shadowDom.to.be.accessible(); + }); + }); - expect(dropdown.open).to.be.true; + describe('Initial selection', () => { + it('multiple initially selected items', async () => { + dropDown = await fixture(html` + + Open + ${Items.map( + (item) => + html`${item}` + )} + + `); - dropdown.toggle(); - await elementUpdated(dropdown); + await elementUpdated(dropDown); + expect(dropDown.items.filter((item) => item.selected).length).to.equal(1); + expect(dropDown.selectedItem).to.equal(dropDown.items.at(-1)!); + }); + }); - expect(dropdown.open).to.be.false; - }); + describe('API', () => { + beforeEach(async () => { + dropDown = await fixture(createBasicDropdown()); + }); - it('`select` method successfully selects the item with the specified value.', async () => { - const itemValue = 'Samples'; - const selectedItem = dropdown.select(itemValue); - await elementUpdated(dropdown); + it('`toggle()` controls open state', async () => { + dropDown.toggle(); + await elementUpdated(dropDown); - expect(selectedItem).to.exist; - expect(selectedItem?.value).to.eq(itemValue); + expect(dropDown.open).to.be.true; - const item = ddItems(dropdown).find((i) => i.innerText === itemValue); - expect(item?.hasAttribute('active')).to.be.true; - expect(item?.attributes.getNamedItem('selected')).to.exist; - expect(item?.attributes.getNamedItem('aria-selected')).to.exist; + dropDown.toggle(); + await elementUpdated(dropDown); - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(1); - }); + expect(dropDown.open).to.be.false; + }); - it('`select` method successfully selects the item at the specified index.', async () => { - const itemValue = 'Samples'; - const selectedItem = dropdown.select(3); - await elementUpdated(dropdown); + it('`items` returns the correct collection', async () => { + expect(dropDown.items.length).to.equal(Items.length); + }); - expect(selectedItem).to.exist; - expect(selectedItem?.value).to.eq(itemValue); + it('`groups` returns the correct collection', async () => { + expect(dropDown.groups.length).to.equal(0); + }); - const item = ddItems(dropdown).find((i) => i.innerText === itemValue); - expect(item?.hasAttribute('active')).to.be.true; - expect(item?.attributes.getNamedItem('selected')).to.exist; - expect(item?.attributes.getNamedItem('aria-selected')).to.exist; + it('`select()` works', async () => { + // With value + dropDown.select('Implementation'); - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(1); - }); + let item = dropDown.items.find((item) => item.value === 'Implementation'); + expect(dropDown.selectedItem).to.equal(item); + checkItemState(item!, { selected: true, active: true }); - it('`select` method selects nothing if the specified value does not exist.', async () => { - const selectedItem = dropdown.select('Samples1'); - await elementUpdated(dropdown); + dropDown.clearSelection(); + item = dropDown.items[4]; - expect(selectedItem).to.be.null; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(0); - }); + expect(dropDown.selectedItem).to.be.null; - it('`select` method selects nothing if the specified index is out of bounds.', async () => { - const selectedItem = dropdown.select(-3); - await elementUpdated(dropdown); + // With index + dropDown.select(4); - expect(selectedItem).to.be.null; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(0); - }); + expect(dropDown.selectedItem).to.equal(item); + checkItemState(item, { selected: true, active: true }); - it('`select` method does not change the active/selected item if the specified value does not exist.', async () => { - dropdown.select('Samples'); - await elementUpdated(dropdown); + // Non-existent - dropdown.select('Test'); - expect( - ddItems(dropdown).find((i) => i.attributes.getNamedItem('selected')) - ?.textContent - ).to.eq('Samples'); - }); + dropDown.clearSelection(); + dropDown.select('Non-existent'); - it('clears current selection on `clearSelection` method calls.', async () => { - const item = dropdown.select('Samples'); - await elementUpdated(dropdown); + expect(dropDown.selectedItem).to.be.null; + checkItemState(item, { selected: false, active: true }); + }); - dropdown.clearSelection(); - await elementUpdated(dropdown); - expect(getSelectedItems().length).to.eq(0); - expect(item?.hasAttribute('active')).to.be.false; - }); + it('`navigateTo()` works', async () => { + // Non-existent + dropDown.navigateTo('Non-existent'); + expect(getActiveItem()).to.be.undefined; - it('navigates to the item with the specified value on `navigateTo` method calls.', async () => { - dropdown.navigateTo('Implementation'); - await elementUpdated(dropdown); + const item = dropDown.items.find( + (item) => item.value === 'Implementation' + )!; - expect(ddItems(dropdown)[1].hasAttribute('active')).to.be.true; + // With value + dropDown.navigateTo('Implementation'); + checkItemState(item, { active: true }); - dropdown.navigateTo('Implementations'); - await elementUpdated(dropdown); + // With index + dropDown.navigateTo(0); + checkItemState(item, { active: false }); + checkItemState(getActiveItem()!, { active: true }); + }); + }); - expect(ddItems(dropdown)[1].hasAttribute('active')).to.be.true; - }); + describe('With groups and headers', () => { + beforeEach(async () => { + dropDown = await fixture(createAdvancedDropdown()); + }); - it('navigates to the item at the specified index on `navigateTo` method calls.', async () => { - dropdown.navigateTo(1); - await elementUpdated(dropdown); + it('correct collections', async () => { + expect(dropDown.items.length).to.equal(6); + expect(dropDown.groups.length).to.equal(3); + }); - expect(ddItems(dropdown)[1].hasAttribute('active')).to.be.true; + it('keyboard navigation works', async () => { + await openDropdown(); - dropdown.navigateTo(10); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown, 3); + await elementUpdated(dropDown); - expect(ddItems(dropdown)[1].hasAttribute('active')).to.be.true; - }); + checkItemState(dropDown.items[2], { active: true }); + }); - it('activates the first item on pressing `arrowdown` key when no selection is available.', async () => { - pressKey('ArrowDown'); - await elementUpdated(dropdown); + it('clicking on a header is a no-op', async () => { + await openDropdown(); - const item = ddItems(dropdown)[0]; - expect(item?.hasAttribute('active')).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.hasAttribute('active')).length - ).to.eq(1); - }); + simulateClick(getHeaders()[0]); + await elementUpdated(dropDown); - it('activates the last item on pressing `End` key', async () => { - pressKey('End'); - await elementUpdated(dropdown); + expect(dropDown.open).to.be.true; + expect(dropDown.selectedItem).to.be.null; + }); - const item = ddItems(dropdown).at(-1)!; - expect(item.hasAttribute('active')).to.be.true; - expect(dropdown.querySelectorAll('[active]').length).to.equal(1); - }); + it('clicking on a group is a no-op', async () => { + await openDropdown(); - it('activates the first item on pressing `Home` key', async () => { - pressKey('Home'); - await elementUpdated(dropdown); + for (const each of dropDown.groups) { + simulateClick(each); + await elementUpdated(dropDown); + expect(dropDown.open).to.be.true; + expect(dropDown.selectedItem).to.be.null; + } + }); + }); - const item = ddItems(dropdown).at(0)!; - expect(item.hasAttribute('active')).to.be.true; - expect(dropdown.querySelectorAll('[active]').length).to.equal(1); - }); + describe('User interactions', () => { + beforeEach(async () => { + dropDown = await fixture(createBasicDropdown()); + }); - it('activates the next item on pressing `arrowdown` key.', async () => { - pressKey('ArrowDown', 2); + it('toggles open state on click', async () => { + const button = getTarget(); - await elementUpdated(dropdown); + button.click(); + await elementUpdated(dropDown); - const item = ddItems(dropdown)[1]; - expect(item?.hasAttribute('active')).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.hasAttribute('active')).length - ).to.eq(1); - }); + expect(dropDown.open).to.be.true; - it('does not change the activate item on pressing `arrowdown` key at the end of the list.', async () => { - pressKey('ArrowDown', ddItems(dropdown).length); - await elementUpdated(dropdown); + button.click(); + await elementUpdated(dropDown); - const item = ddItems(dropdown).pop(); - expect(item?.hasAttribute('active')).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.hasAttribute('active')).length - ).to.eq(1); - }); + expect(dropDown.open).to.be.false; + }); - it('activates the previous item on pressing `arrowup` key.', async () => { - pressKey('ArrowDown', 2); - pressKey('ArrowUp'); - await elementUpdated(dropdown); + it('selects an item on click and closes the dropdown', async () => { + const targetItem = dropDown.items[3]; - const item = ddItems(dropdown)[0]; - expect(item?.hasAttribute('active')).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.hasAttribute('active')).length - ).to.eq(1); - }); + await openDropdown(); - it('does not change the activate item on pressing `arrowup` key at the top of the list.', async () => { - pressKey('ArrowDown', 2); - pressKey('ArrowUp'); - await elementUpdated(dropdown); + simulateClick(targetItem); + await elementUpdated(dropDown); - const item = ddItems(dropdown)[0]; - expect(item?.hasAttribute('active')).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.hasAttribute('active')).length - ).to.eq(1); - }); + checkItemState(targetItem, { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(targetItem.value); + expect(dropDown.open).to.be.false; + }); - it('selects the currently active item on pressing `Enter` key and closes the dropdown.', async () => { - expect(dropdown.open).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(0); - - pressKey('ArrowDown', 2); - pressKey('Enter'); - await elementUpdated(dropdown); - - expect(dropdown.open).to.be.false; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(1); - expect(ddItems(dropdown)[1]?.hasAttribute('active')).to.be.true; - expect(ddItems(dropdown)[1]?.attributes.getNamedItem('selected')).to - .exist; - }); + it('selects an item on click and does not close when keep-open-on-select is set', async () => { + const targetItem = dropDown.items[3]; - it('does not select the currently active item on pressing `Escape` key and closes the dropdown.', async () => { - expect(dropdown.open).to.be.true; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(0); - - pressKey('ArrowDown', 2); - pressKey('Escape'); - await elementUpdated(dropdown); - - expect(dropdown.open).to.be.false; - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')) - .length - ).to.eq(0); - expect(ddItems(dropdown)[1]?.hasAttribute('active')).to.be.true; - expect(ddItems(dropdown)[1]?.attributes.getNamedItem('selected')).to.be - .null; - }); + dropDown.keepOpenOnSelect = true; + await openDropdown(); - it('preserves selection on closing & reopening.', async () => { - pressKey('ArrowDown', 2); - pressKey('Enter'); - dropdown.open = true; - await elementUpdated(dropdown); + simulateClick(targetItem); + await elementUpdated(dropDown); - expect(getSelectedItems()[0].textContent).to.eq('Implementation'); - }); + checkItemState(targetItem, { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(targetItem.value); + expect(dropDown.open).to.be.true; + }); - it('items are selected via mouse click.', async () => { - expect(getSelectedItems().length).to.eq(0); + it('clicking on a disabled item is a no-op', async () => { + const targetItem = dropDown.items[3]; + targetItem.disabled = true; - ddItems(dropdown)[3].click(); - await elementUpdated(dropdown); + await openDropdown(); - expect(dropdown.open).to.be.false; - expect(ddItems(dropdown)[3]?.hasAttribute('active')).to.be.true; - expect(getSelectedItems().length).to.eq(1); - expect(getSelectedItems()[0].textContent).to.eq('Samples'); + simulateClick(targetItem); + await elementUpdated(dropDown); + + checkItemState(targetItem, { + active: false, + selected: false, + disabled: true, }); + expect(dropDown.selectedItem).to.be.null; + expect(dropDown.open).to.be.true; + }); - it('preserves selection on closing the list.', async () => { - dropdown.select('Samples'); - dropdown.toggle(); - await elementUpdated(dropdown); + it('clicking outside of the dropdown DOM tree closes the dropdown', async () => { + await openDropdown(); - expect(dropdown.open).to.be.false; - dropdown.toggle(); - await elementUpdated(dropdown); + simulateClick(document.body); + await elementUpdated(dropDown); - expect(dropdown.open).to.be.true; - expect(getSelectedItems()[0].textContent).to.eq('Samples'); - }); + expect(dropDown.open).to.be.false; + }); - it('keeps the list open on selection if `closeOnSelect` is set to false.', async () => { - expect(getSelectedItems().length).to.eq(0); + it('clicking outside of the dropdown DOM tree with keep-open-on-outside-click', async () => { + dropDown.keepOpenOnOutsideClick = true; + await openDropdown(); - dropdown.keepOpenOnSelect = true; - ddItems(dropdown)[3].click(); - await elementUpdated(dropdown); + simulateClick(document.body); + await elementUpdated(dropDown); - expect(dropdown.open).to.be.true; - expect(getSelectedItems()[0].textContent).to.eq('Samples'); + expect(dropDown.open).to.be.true; + }); - pressKey('ArrowDown'); - pressKey('Enter'); - await elementUpdated(dropdown); + it('pressing Escape closes the dropdown without selection', async () => { + await openDropdown(); - expect(dropdown.open).to.be.true; - expect(getSelectedItems()[0].textContent).to.eq('Documentation'); - }); + simulateKeyboard(dropDown, arrowDown, 4); + await elementUpdated(dropDown); - it('allows disabling items.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - - expect(dropDownItems[0].disabled).to.eq(false); - dropDownItems[0].disabled = true; - await elementUpdated(dropdown); - - expect(dropDownItems[0].disabled).to.eq(true); - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('disabled')) - .length - ).to.eq(1); - - expect(dropDownItems[3].disabled).to.eq(false); - dropDownItems[3].disabled = true; - await elementUpdated(dropdown); - - expect(dropDownItems[3].disabled).to.eq(true); - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('disabled')) - .length - ).to.eq(2); - - dropDownItems[3].disabled = false; - await elementUpdated(dropdown); - - expect(dropDownItems[3].disabled).to.eq(false); - expect( - ddItems(dropdown).filter((i) => i.attributes.getNamedItem('disabled')) - .length - ).to.eq(1); - }); + simulateKeyboard(dropDown, escapeKey); + await elementUpdated(dropDown); - it('does not activate disabled items during keyboard navigation.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - dropDownItems[0].disabled = true; - await elementUpdated(dropdown); + checkItemState(dropDown.items[3], { active: true, selected: false }); + expect(dropDown.selectedItem).to.be.null; + expect(dropDown.open).to.be.false; + }); - pressKey('ArrowDown'); - await elementUpdated(dropdown); + it('pressing Enter selects the active item and closes the dropdown', async () => { + await openDropdown(); - expect(ddItems(dropdown)[0]?.hasAttribute('active')).to.be.false; - expect(ddItems(dropdown)[1]?.hasAttribute('active')).to.be.true; + simulateKeyboard(dropDown, arrowDown, 4); + await elementUpdated(dropDown); - pressKey('ArrowUp'); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, enterKey); + await elementUpdated(dropDown); - expect(ddItems(dropdown)[0]?.hasAttribute('active')).to.be.false; - expect(ddItems(dropdown)[1]?.hasAttribute('active')).to.be.true; - }); + checkItemState(dropDown.items[3], { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(dropDown.items[3].value); + expect(dropDown.open).to.be.false; + }); - it('does not activate disabled items on mouse click.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - dropDownItems[0].disabled = true; - await elementUpdated(dropdown); + it('pressing Enter selects the active item and does not close the dropdown with keep-open-on-select', async () => { + dropDown.keepOpenOnSelect = true; + await openDropdown(); - dropDownItems[0].click(); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown, 4); + await elementUpdated(dropDown); - expect(ddItems(dropdown)[0]?.hasAttribute('active')).to.be.false; - expect(dropdown.open).to.be.true; - expect(getSelectedItems().length).to.eq(0); - }); + simulateKeyboard(dropDown, enterKey); + await elementUpdated(dropDown); - it('does not emit `igcOpening` & `igcOpened` events on `show` method calls.', async () => { - dropdown.open = false; - await elementUpdated(dropdown); + checkItemState(dropDown.items[3], { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(dropDown.items[3].value); + expect(dropDown.open).to.be.true; + }); - const eventSpy = spy(dropdown, 'emitEvent'); - dropdown.show(); - await elementUpdated(dropdown); + it('pressing Tab with no active item closes the dropdown and does no selection', async () => { + await openDropdown(); - expect(dropdown.open).to.be.true; - expect(eventSpy).not.to.be.called; - }); + simulateKeyboard(dropDown, tabKey); + await elementUpdated(dropDown); - it('emits `igcOpening` & `igcOpened` events on clicking the target.', async () => { - dropdown.open = false; - await elementUpdated(dropdown); + expect(dropDown.open).to.be.false; + expect(dropDown.selectedItem).to.be.null; + expect(getActiveItem()).to.be.undefined; + }); - const eventSpy = spy(dropdown, 'emitEvent'); - target(dropdown).click(); - await elementUpdated(dropdown); + it('pressing Tab selects the active item and closes the dropdown', async () => { + await openDropdown(); - expect(dropdown.open).to.be.true; - expect(eventSpy).calledWith('igcOpening'); - expect(eventSpy).calledWith('igcOpened'); - }); + simulateKeyboard(dropDown, arrowDown, 4); + await elementUpdated(dropDown); - it('does not emit `igcOpened` event and does not show the list on canceling `igcOpening` event.', async () => { - dropdown.open = false; - dropdown.addEventListener('igcOpening', (event: CustomEvent) => { - event.preventDefault(); - }); - const eventSpy = spy(dropdown, 'emitEvent'); - await elementUpdated(dropdown); - - target(dropdown).click(); - await elementUpdated(dropdown); - - expect(dropdown.open).to.be.false; - expect(eventSpy).calledOnceWithExactly('igcOpening', { - cancelable: true, - }); - }); + simulateKeyboard(dropDown, tabKey); + await elementUpdated(dropDown); - it('does not emit `igcClosing` & `igcClosed` events on `hide` method calls.', async () => { - const eventSpy = spy(dropdown, 'emitEvent'); - dropdown.hide(); - await elementUpdated(dropdown); + checkItemState(dropDown.items[3], { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(dropDown.items[3].value); + expect(dropDown.open).to.be.false; + }); - expect(eventSpy).not.to.be.called; - }); + it('pressing Tab selects the active item and closes the dropdown regardless of keep-open-on-select', async () => { + dropDown.keepOpenOnSelect = true; + await openDropdown(); - it('emits `igcClosing` & `igcClosed` events on clicking the target.', async () => { - const eventSpy = spy(dropdown, 'emitEvent'); - target(dropdown).click(); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown, 4); + await elementUpdated(dropDown); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); - }); + simulateKeyboard(dropDown, tabKey); + await elementUpdated(dropDown); - it('does not emit `igcClosed` event and does not hide the list on canceling `igcClosing` event.', async () => { - dropdown.addEventListener('igcClosing', (event: CustomEvent) => - event.preventDefault() - ); - await elementUpdated(dropdown); + checkItemState(dropDown.items[3], { active: true, selected: true }); + expect(dropDown.selectedItem?.value).to.equal(dropDown.items[3].value); + expect(dropDown.open).to.be.false; + }); - const eventSpy = spy(dropdown, 'emitEvent'); + it('activates the first item on ArrowDown if no selection is present', async () => { + await openDropdown(); - target(dropdown).click(); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown); + await elementUpdated(dropDown); - expect(dropdown.open).to.be.true; - expect(eventSpy).calledOnceWithExactly('igcClosing', { - cancelable: true, - }); - }); + expect(dropDown.items[0].active).to.be.true; + }); - it('emits `igcChange`, `igcClosing` and `igcClosed` events on selecting an item via mouse click.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - const eventSpy = spy(dropdown, 'emitEvent'); + it('sets the active element to the currently selected one', async () => { + dropDown.select(3); + await openDropdown(); - ddItems(dropdown)[2].click(); - await elementUpdated(dropdown); + checkItemState(dropDown.items[3], { active: true, selected: true }); + }); - const args = { detail: dropDownItems[2] }; - expect(eventSpy).calledWithExactly('igcChange', args); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); - }); + it('moves only active state and not selection with arrow keys', async () => { + dropDown.select(3); + await openDropdown(); - it('emits `igcChange`, `igcClosing` and `igcClosed` events on selecting an item via `Enter` key.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - const eventSpy = spy(dropdown, 'emitEvent'); + const [prev, current, next] = dropDown.items.slice(2, 5); - pressKey('ArrowDown'); - pressKey('Enter'); - await elementUpdated(dropdown); + checkItemState(current, { active: true, selected: true }); - const args = { detail: dropDownItems[0] }; - expect(eventSpy).calledWithExactly('igcChange', args); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); - }); + simulateKeyboard(dropDown, arrowUp); + await elementUpdated(dropDown); - it('selects an item but does not close the dropdown on `Enter` key when `igcClosing` event is canceled.', async () => { - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - dropdown.addEventListener('igcClosing', (event: CustomEvent) => - event.preventDefault() - ); - await elementUpdated(dropdown); - const eventSpy = spy(dropdown, 'emitEvent'); - - pressKey('ArrowDown'); - pressKey('Enter'); - await elementUpdated(dropdown); - - const args = { detail: dropDownItems[0] }; - expect(eventSpy).calledWithExactly('igcChange', args); - expect(eventSpy).calledWith('igcClosing'); - expect(dropdown.open).to.be.true; - }); + checkItemState(current, { active: false, selected: true }); + checkItemState(prev, { active: true, selected: false }); - it('emits `igcChange` event with the correct arguments on selecting an item.', async () => { - ddItems(dropdown)[2].click(); - dropdown.open = true; - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown, 2); + await elementUpdated(dropDown); - const dropDownItems = [ - ...dropdown.querySelectorAll('igc-dropdown-item'), - ] as IgcDropdownItemComponent[]; - const eventSpy = spy(dropdown, 'emitEvent'); + checkItemState(current, { active: false, selected: true }); + checkItemState(next, { active: true, selected: false }); + }); - ddItems(dropdown)[0].click(); - await elementUpdated(dropdown); + it('moves to first item with Home key', async () => { + await openDropdown(); - let args = { detail: dropDownItems[0] }; - expect(eventSpy).calledWithExactly('igcChange', args); + simulateKeyboard(dropDown, homeKey); + await elementUpdated(dropDown); - dropdown.open = true; - await elementUpdated(dropdown); + checkItemState(dropDown.items[0], { active: true }); + }); - pressKey('ArrowDown'); - pressKey('Enter'); + it('moves to last item with End key', async () => { + await openDropdown(); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, endKey); + await elementUpdated(dropDown); - args = { detail: dropDownItems[1] }; - expect(eventSpy).calledWithExactly('igcChange', args); - }); + checkItemState(dropDown.items.at(-1)!, { active: true }); + }); - it('by default closes the list on clicking outside.', async () => { - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + it('does not lose active state at start/end bounds', async () => { + await openDropdown(); - expect(dropdown.open).to.be.false; - }); + simulateKeyboard(dropDown, homeKey); + await elementUpdated(dropDown); - it('emits closing events when clicking outside', async () => { - const eventSpy = spy(dropdown, 'emitEvent'); + simulateKeyboard(dropDown, arrowUp); + await elementUpdated(dropDown); - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + checkItemState(dropDown.items[0], { active: true }); - expect(dropdown.open).to.be.false; - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); - }); + simulateKeyboard(dropDown, endKey); + await elementUpdated(dropDown); - it('cleans up document event listeners', async () => { - const eventSpy = spy(dropdown, 'emitEvent'); + simulateKeyboard(dropDown, arrowDown); + await elementUpdated(dropDown); - dropdown.open = true; - await elementUpdated(dropdown); + checkItemState(dropDown.items.at(-1)!, { active: true }); + }); - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + it('skips disabled items on keyboard navigation', async () => { + const [first, second] = [dropDown.items[2], dropDown.items[4]]; + first.disabled = true; + second.disabled = true; + await elementUpdated(dropDown); - expect(dropdown.open).to.be.false; - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); - expect(eventSpy).callCount(2); + await openDropdown(); - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + for (let i = 0; i < 4; i++) { + simulateKeyboard(dropDown, arrowDown); + await elementUpdated(dropDown); + expect([first, second]).not.to.include(getActiveItem()); + } + }); + }); - expect(dropdown.open).to.be.false; - expect(eventSpy).callCount(2); - }); + describe('Events', () => { + beforeEach(async () => { + dropDown = await fixture(createBasicDropdown()); + }); + + it('does not emit events on API calls', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + + await openDropdown(); + expect(eventSpy).not.to.be.called; + + await closeDropdown(); + expect(eventSpy).not.to.be.called; - it('can cancel `igcClosing` event when clicking outside', async () => { - const eventSpy = spy(dropdown, 'emitEvent'); + dropDown.select('Testing'); + await elementUpdated(dropDown); + expect(eventSpy).not.to.be.called; + }); + + it('emits correct order of events on opening', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + + simulateClick(getTarget()); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.secondCall).calledWith('igcOpened'); + }); + + it('emits correct order of events on closing', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + + await openDropdown(); + + simulateClick(getTarget()); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).calledWith('igcClosed'); + }); - dropdown.addEventListener('igcClosing', (e) => e.preventDefault()); + it('emits correct order of events on selection', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + let targetItem = dropDown.items[3]; - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + // Selection through click + await openDropdown(); - expect(dropdown.open).to.be.true; - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).not.calledWith('igcClosed'); + simulateClick(targetItem); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.false; + expect(eventSpy.firstCall).calledWithExactly('igcChange', { + detail: targetItem, }); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).calledWith('igcClosed'); + + eventSpy.resetHistory(); + + // Selection through keyboard + targetItem = dropDown.items[2]; - it('does not close the list on clicking outside when `closeOnOutsideClick` is false.', async () => { - dropdown.keepOpenOnOutsideClick = true; - await elementUpdated(dropdown); + await openDropdown(); - document.dispatchEvent(new MouseEvent('click')); - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowUp); + simulateKeyboard(dropDown, enterKey); + await elementUpdated(dropDown); - expect(dropdown.open).to.be.true; + expect(dropDown.open).to.be.false; + expect(eventSpy.firstCall).calledWithExactly('igcChange', { + detail: targetItem, }); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).calledWith('igcClosed'); + }); - it('the list renders at the proper position relative to the target element when `open` is initially set', async () => { - dropdown = await fixture(html` - - - Languages - JavaScript - TypeScript - SCSS - - `); - await elementUpdated(dropdown); - - expect(dropdown.open).to.be.true; - expect(dropdown.positionStrategy).to.eq('absolute'); - expect(dropdown.placement).to.eq('bottom-start'); - expect(dropdown.distance).to.eq(0); - - const listRect = ddListWrapper(dropdown).getBoundingClientRect(); - const targetRect = target(dropdown).getBoundingClientRect(); - - expect(listRect.x).to.eq(targetRect.x); - expect(listRect.y).to.eq(targetRect.bottom); + it('can halt opening event sequence', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + dropDown.addEventListener('igcOpening', (e) => e.preventDefault(), { + once: true, }); + + simulateClick(getTarget()); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.secondCall).to.be.null; }); - }); - describe('', () => { - const ddGroups = (el: IgcDropdownComponent) => - [...el.querySelectorAll('igc-dropdown-group')] as HTMLElement[]; - let groups: HTMLElement[]; - beforeEach(async () => { - dropdown = await fixture( - html` - - -

Research & Development

- ${items - .slice(0, 3) - .map( - (item) => html`${item}` - )} -
- -

Product Guidance

- ${items - .slice(3, 5) - .map( - (item) => - html`${item}` - )} -
- -

Release Engineering

- ${items[5]} -
-
` - ); - - dropdown.open = true; - await elementUpdated(dropdown); - groups = ddGroups(dropdown); - }); - - it('displays grouped items properly.', () => { - expect(groups.length).to.eq(3); - - expect(groups[0].querySelectorAll('igc-dropdown-item').length).to.eq(3); - expect(groups[1].querySelectorAll('igc-dropdown-item').length).to.eq(2); - expect(groups[2].querySelectorAll('igc-dropdown-item').length).to.eq(1); - }); - - it('displays group headers properly.', () => { - expect(groups[0].querySelector('h3')!.textContent).to.eq( - 'Research & Development' - ); - expect(groups[1].querySelector('h3')!.textContent).to.eq( - 'Product Guidance' - ); - expect(groups[2].querySelector('h3')!.textContent).to.eq( - 'Release Engineering' - ); - }); - - it('navigates properly through grouped items.', async () => { - pressKey('ArrowDown', 2); - await elementUpdated(dropdown); - - const groupItems = [...groups[0].querySelectorAll('igc-dropdown-item')]; - - expect(groupItems[1]?.hasAttribute('active')).to.be.true; - expect(groupItems.filter((i) => i.hasAttribute('active')).length).to.eq( - 1 - ); - - pressKey('ArrowUp'); - await elementUpdated(dropdown); - - expect(groupItems[0]?.hasAttribute('active')).to.be.true; - expect(groupItems.filter((i) => i.hasAttribute('active')).length).to.eq( - 1 - ); - }); - - it('skips disabled items when navigating through grouped items.', async () => { - pressKey('ArrowDown', 4); - await elementUpdated(dropdown); - - let groupItems = [...groups[2].querySelectorAll('igc-dropdown-item')]; - - expect(groupItems[0]?.hasAttribute('active')).to.be.true; - expect(groupItems.filter((i) => i.hasAttribute('active')).length).to.eq( - 1 - ); - - pressKey('ArrowUp'); - await elementUpdated(dropdown); - - groupItems = [...groups[1].querySelectorAll('igc-dropdown-item')]; - expect(groupItems.pop()?.hasAttribute('active')).to.be.false; - expect( - [...groups[0].querySelectorAll('igc-dropdown-item')] - .pop() - ?.hasAttribute('active') - ).to.be.true; - }); - - it('does nothing on clicking group labels.', async () => { - groups[0].querySelector('h3')?.click(); - await elementUpdated(dropdown); - - expect(dropdown.open).to.be.true; - }); - - describe('', () => { - beforeEach(async () => { - const styles: Partial = { - height: `150px`, - }; - Object.assign( - (dropdown?.shadowRoot?.children[1] as HTMLElement).style, - styles - ); - await elementUpdated(dropdown); + it('can halt closing event sequence', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); + dropDown.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, }); - it('scrolls to item on activation via keyboard.', async () => { - expect(ddListWrapper(dropdown).scrollTop).to.eq(0); + // No selection + await openDropdown(); - pressKey('ArrowDown', 3); - await elementUpdated(dropdown); - expect(ddListWrapper(dropdown).scrollTop).to.eq(0); + simulateKeyboard(dropDown, escapeKey); + await elementUpdated(dropDown); - pressKey('ArrowDown', 1); - await elementUpdated(dropdown); + expect(dropDown.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).to.be.null; - const wrapper = ddListWrapper(dropdown); - const lastItem = ddItems(dropdown).pop(); - const itemRect = lastItem?.getBoundingClientRect(); - expect( - Math.round(wrapper.getBoundingClientRect().bottom) - ).to.be.greaterThanOrEqual(Math.round(itemRect?.bottom as number)); + eventSpy.resetHistory(); + + // With selection + dropDown.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, }); - it('scrolls to the selected item on opening the list.', async () => { - pressKey('ArrowDown', 4); - pressKey('Enter'); - await elementUpdated(dropdown); + await openDropdown(); - dropdown.open = true; - await elementUpdated(dropdown); + simulateKeyboard(dropDown, arrowDown, 3); + simulateKeyboard(dropDown, enterKey); + await elementUpdated(dropDown); - const wrapper = ddListWrapper(dropdown); - const selectedItem = ddItems(dropdown)[3]; - const itemRect = selectedItem?.getBoundingClientRect(); - expect( - Math.round(wrapper.getBoundingClientRect().bottom) - ).to.be.greaterThanOrEqual(Math.round(itemRect?.bottom as number)); - }); + expect(dropDown.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcChange'); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).to.be.null; }); - }); - const getSelectedItems = () => { - return [ - ...ddItems(dropdown).filter((i) => i.attributes.getNamedItem('selected')), - ]; - }; + it('can halt closing event sequence on outside click', async () => { + const eventSpy = spy(dropDown, 'emitEvent'); - const pressKey = (key: string, times = 1) => { - for (let i = 0; i < times; i++) { - ddList(dropdown).dispatchEvent( - new KeyboardEvent('keydown', { - key: key, - bubbles: true, - composed: true, - }) - ); - } - }; + await openDropdown(); + + dropDown.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + simulateClick(document.body); + await elementUpdated(dropDown); + + expect(dropDown.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).to.be.null; + }); + }); }); diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index a8a29e844..743044fad 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -1,11 +1,5 @@ -import { LitElement, html } from 'lit'; -import { - property, - query, - queryAssignedElements, - state, -} from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; import IgcDropdownGroupComponent from './dropdown-group.js'; import IgcDropdownHeaderComponent from './dropdown-header.js'; @@ -13,21 +7,44 @@ import IgcDropdownItemComponent from './dropdown-item.js'; import { all } from './themes/container.js'; import { styles } from './themes/dropdown.base.css.js'; import { themes } from '../../theming/theming-decorator.js'; +import { + KeyBindingObserverCleanup, + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + endKey, + enterKey, + escapeKey, + homeKey, + tabKey, +} from '../common/controllers/key-bindings.js'; +import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { addRootScrollHandler } from '../common/controllers/root-scroll.js'; import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js'; import { blazorSuppress } from '../common/decorators/blazorSuppress.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import { Constructor } from '../common/mixins/constructor.js'; +import { + IgcBaseComboBoxLikeComponent, + getActiveItems, + getItems, + getNextActiveItem, + getPreviousActiveItem, + setInitialSelectionState, +} from '../common/mixins/combo-box.js'; +import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { SizableMixin } from '../common/mixins/sizable.js'; -import { IgcToggleController } from '../toggle/toggle.controller.js'; -import type { - IgcPlacement, - IgcToggleComponent, - IgcToggleEventMap, -} from '../toggle/types'; - -export interface IgcDropdownEventMap extends IgcToggleEventMap { +import { getElementByIdFromRoot } from '../common/util.js'; +import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; + +export interface IgcDropdownEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; igcChange: CustomEvent; } @@ -50,14 +67,14 @@ export interface IgcDropdownEventMap extends IgcToggleEventMap { */ @themes(all) @blazorAdditionalDependencies( - 'IgcDropdownItemComponent, IgcDropdownHeaderComponent, IgcDropdownGroupComponent' + 'IgcDropdownItemComponent, IgcDropdownHeaderComponent, IgcDropdownGroupComponent, IgcPopoverComponent' ) -export default class IgcDropdownComponent - extends SizableMixin( - EventEmitterMixin>(LitElement) - ) - implements IgcToggleComponent -{ +export default class IgcDropdownComponent extends SizableMixin( + EventEmitterMixin< + IgcDropdownEventMap, + Constructor + >(IgcBaseComboBoxLikeComponent) +) { public static readonly tagName = 'igc-dropdown'; public static styles = styles; @@ -66,63 +83,43 @@ export default class IgcDropdownComponent this, IgcDropdownGroupComponent, IgcDropdownHeaderComponent, - IgcDropdownItemComponent + IgcDropdownItemComponent, + IgcPopoverComponent ); } - protected toggleController!: IgcToggleController; - protected selectedItem!: IgcDropdownItemComponent | null; + private _keyBindings: ReturnType; - @state() - protected activeItem!: IgcDropdownItemComponent; - - protected target!: HTMLElement; - - private readonly keyDownHandlers: Map = new Map( - Object.entries({ - Escape: this.onEscapeKey, - Enter: this.onEnterKey, - ArrowUp: this.onArrowUpKeyDown, - ArrowDown: this.onArrowDownKeyDown, - ArrowLeft: this.onArrowUpKeyDown, - ArrowRight: this.onArrowDownKeyDown, - Home: this.onHomeKey, - End: this.onEndKey, - }) - ); - - protected get allItems() { - return [...this.items, ...this.groups.flatMap((group) => group.items)]; - } + private _rootScrollController = addRootScrollHandler(this, { + hideCallback: () => this._hide(true), + }); - @queryAssignedElements({ slot: 'target' }) - private targetNodes!: Array; + private _rootClickController = addRootClickHandler(this, { + hideCallback: () => this._hide(true), + }); - @query('[part="base"]') - protected content!: HTMLElement; - - @query('[part="list"]') - protected scrollContainer!: HTMLElement; + @state() + protected _selectedItem: IgcDropdownItemComponent | null = null; - @queryAssignedElements({ flatten: true, selector: 'igc-dropdown-item' }) - protected items!: Array; + @state() + protected _activeItem!: IgcDropdownItemComponent; + + private get _activeItems() { + return Array.from( + getActiveItems( + this, + IgcDropdownItemComponent.tagName + ) + ); + } - @queryAssignedElements({ flatten: true, selector: 'igc-dropdown-group' }) - protected groups!: Array; + private _targetListeners!: KeyBindingObserverCleanup; - /** - * Whether the dropdown should be kept open on selection. - * @attr keep-open-on-select - */ - @property({ type: Boolean, attribute: 'keep-open-on-select' }) - public keepOpenOnSelect = false; + @state() + private _target?: HTMLElement; - /** - * Sets the open state of the component. - * @attr - */ - @property({ type: Boolean }) - public open = false; + @query('slot[name="target"]', true) + protected trigger!: HTMLSlotElement; /** The preferred placement of the component around the target element. * @type {'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end'} @@ -139,7 +136,7 @@ export default class IgcDropdownComponent public positionStrategy: 'absolute' | 'fixed' = 'absolute'; /** - * Determines the behavior of the component during scrolling the container. + * Determines the behavior of the component during scrolling of the parent container. * @attr scroll-strategy */ @property({ attribute: 'scroll-strategy' }) @@ -160,13 +157,6 @@ export default class IgcDropdownComponent @property({ type: Number }) public distance = 0; - /** - * Whether the component should be kept open on clicking outside of it. - * @attr keep-open-on-outside-click - */ - @property({ type: Boolean, attribute: 'keep-open-on-outside-click' }) - public keepOpenOnOutsideClick = false; - /** * Whether the dropdown's width should be the same as the target's one. * @attr same-width @@ -174,296 +164,197 @@ export default class IgcDropdownComponent @property({ type: Boolean, attribute: 'same-width' }) public sameWidth = false; - @watch('open') - protected toggleDirectiveChange() { - if (!this.target) return; - this.toggleController.target = this.target; - this.requestUpdate(); + /** Returns the items of the dropdown. */ + public get items() { + return Array.from( + getItems(this, IgcDropdownItemComponent.tagName) + ); + } - if (this.open) { - document.addEventListener('keydown', this.handleKeyDown); - this.target.addEventListener('focusout', this.handleFocusout); - this.selectedItem = this.allItems.find((i) => i.selected) ?? null; - } else { - document.removeEventListener('keydown', this.handleKeyDown); - this.target.removeEventListener('focusout', this.handleFocusout); - } + /** Returns the group items of the dropdown. */ + public get groups() { + return Array.from( + getItems( + this, + IgcDropdownGroupComponent.tagName + ) + ); + } + + /** Returns the selected item from the dropdown or null. */ + public get selectedItem() { + return this._selectedItem; + } - this.target.setAttribute('aria-expanded', this.open ? 'true' : 'false'); + @watch('scrollStrategy', { waitUntilFirstUpdate: true }) + protected scrollStrategyChanged() { + this._rootScrollController.update({ resetListeners: true }); } - @watch('placement') - @watch('flip') - @watch('positionStrategy') - @watch('closeOnOutsideClick') - @watch('distance') - @watch('sameWidth') - protected updateOptions() { - if (!this.toggleController) return; + @watch('open', { waitUntilFirstUpdate: true }) + @watch('keepOpenOnOutsideClick', { waitUntilFirstUpdate: true }) + protected openStateChange() { + this._updateAnchorAccessibility(this._target); + this._rootClickController.update(); + this._rootScrollController.update(); - this.toggleController.update(); + if (!this.open) { + this._target = undefined; + this._targetListeners?.unsubscribe(); + this._rootClickController.update({ target: undefined }); + } } constructor() { super(); - this.toggleController = new IgcToggleController(this, { - target: this.target, - closeCallback: () => this._hide(), - }); + + this._keyBindings = addKeybindings(this, { + skip: () => !this.open, + bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] }, + }) + .set(tabKey, this.onTabKey, { + preventDefault: false, + }) + .set(escapeKey, this.onEscapeKey) + .set(arrowUp, this.onArrowUp) + .set(arrowLeft, this.onArrowUp) + .set(arrowDown, this.onArrowDown) + .set(arrowRight, this.onArrowDown) + .set(enterKey, this.onEnterKey) + .set(homeKey, this.onHomeKey) + .set(endKey, this.onEndKey); } protected override async firstUpdated() { - if (this.targetNodes.length) { - this.target = this.targetNodes[0]; - this.target.setAttribute('aria-haspopup', 'listbox'); - } - await this.updateComplete; - this.toggleDirectiveChange(); - this.setInitialSelection(); + const selected = setInitialSelectionState(this.items); + if (selected) { + this._selectItem(selected, false); + } } - protected override async getUpdateComplete() { - const result = await super.getUpdateComplete(); - await this.toggleController.rendered; - return result; + public override disconnectedCallback() { + this._targetListeners?.unsubscribe(); + super.disconnectedCallback(); } - protected setInitialSelection() { - const item = this.allItems.filter((item) => item.selected).at(-1); - this.allItems.forEach((item) => (item.selected = false)); - if (item) { - this.selectItem(item, false); + private handleListBoxClick(event: MouseEvent) { + const item = event.target as IgcDropdownItemComponent; + if (this._activeItems.includes(item)) { + this._selectItem(item); } } - protected handleKeyDown = (event: KeyboardEvent) => { - const path = event.composedPath(); - if (!(path.includes(this.target) || path.includes(this.content))) return; - - if (this.keyDownHandlers.has(event.key)) { - event.preventDefault(); - event.stopPropagation(); - this.keyDownHandlers.get(event.key)?.call(this); - } - }; + private handleChange(item: IgcDropdownItemComponent) { + this.emitEvent('igcChange', { detail: item }); + } - protected onHomeKey() { - this.navigateTo( - this.allItems.filter((item) => !item.disabled).at(0)!.value - ); + private handleSlotChange() { + this._updateAnchorAccessibility(); } - protected onEndKey() { - this.navigateTo( - this.allItems.filter((item) => !item.disabled).at(-1)!.value + private onArrowUp() { + this._navigateToActiveItem( + getPreviousActiveItem(this.items, this._activeItem) ); } - protected onEscapeKey() { - this._hide(); + private onArrowDown() { + this._navigateToActiveItem(getNextActiveItem(this.items, this._activeItem)); } - protected onEnterKey() { - this.selectItem(this.activeItem); + protected onHomeKey() { + this._navigateToActiveItem(this._activeItems.at(0)); } - protected handleClick(event: MouseEvent) { - const item = event - .composedPath() - .find( - (e) => e instanceof IgcDropdownItemComponent - ) as IgcDropdownItemComponent; - - if (!item || item.disabled) return; - - this.selectItem(item); + protected onEndKey() { + this._navigateToActiveItem(this._activeItems.at(-1)); } - protected handleTargetClick = async () => { - if (!this.open) { - if (!this.handleOpening()) return; - this.show(); - await this.updateComplete; - this.emitEvent('igcOpened'); - } else { - this._hide(); + protected onTabKey() { + if (this._activeItem) { + this._selectItem(this._activeItem); + } + if (this.open) { + this._hide(true); } - }; - - protected handleOpening() { - const args = { cancelable: true }; - return this.emitEvent('igcOpening', args); - } - - protected handleClosing(): boolean { - const args = { cancelable: true }; - return this.emitEvent('igcClosing', args); - } - - protected handleChange(item: IgcDropdownItemComponent) { - const args = { detail: item }; - this.emitEvent('igcChange', args); - } - - protected handleSlotChange() { - if (!this.target) return; - this.target.setAttribute('aria-expanded', this.open ? 'true' : 'false'); - } - - protected handleFocusout(event: Event) { - event.preventDefault(); - (event.target as HTMLElement).focus(); } - protected getItem(value: string) { - let itemIndex = -1; - let item!: IgcDropdownItemComponent; - this.allItems.find((i, index) => { - if (i.value === value) { - item = i; - itemIndex = index; - } - }); - return { item: item, index: itemIndex }; + protected onEscapeKey() { + this._hide(true); } - protected activateItem(value: IgcDropdownItemComponent) { - if (!value) return; - - if (this.activeItem) { - this.activeItem.active = false; - } - - this.activeItem = value; - this.activeItem.active = true; + protected onEnterKey() { + this._selectItem(this._activeItem); } - protected selectItem( - item: IgcDropdownItemComponent, - emit = true - ): IgcDropdownItemComponent | null { - if (!item) return null; - - if (this.selectedItem) { - this.selectedItem.selected = false; + private activateItem(item: IgcDropdownItemComponent) { + if (this._activeItem) { + this._activeItem.active = false; } - this.activateItem(item); - this.selectedItem = item; - this.selectedItem.selected = true; - if (emit) this.handleChange(this.selectedItem); - if (emit && !this.keepOpenOnSelect) this._hide(); - - return this.selectedItem; + this._activeItem = item; + this._activeItem.active = true; } - protected navigate(direction: -1 | 1, currentIndex?: number) { - let index = -1; - if (this.activeItem) { - index = currentIndex - ? currentIndex - : [...this.allItems].indexOf(this.activeItem) ?? index; + private _navigateToActiveItem(item?: IgcDropdownItemComponent) { + if (item) { + this.activateItem(item); + item.scrollIntoView({ behavior: 'auto', block: 'nearest' }); } - - const newIndex = this.getNearestSiblingFocusableItemIndex(index, direction); - this.navigateItem(newIndex); } - private navigateItem(newIndex: number): IgcDropdownItemComponent | null { - if (!this.allItems) { - return null; - } - - if (newIndex < 0 || newIndex >= this.allItems.length) { - return null; + private _selectItem(item: IgcDropdownItemComponent, emit = true) { + if (this._selectedItem) { + this._selectedItem.selected = false; } - const newItem = this.allItems[newIndex]; - - this.activateItem(newItem); - this.scrollToHiddenItem(newItem); - - return newItem; - } + this.activateItem(item); + this._selectedItem = item; + this._selectedItem.selected = true; - private scrollToHiddenItem(newItem: IgcDropdownItemComponent) { - const elementRect = newItem.getBoundingClientRect(); - const parentRect = this.content.getBoundingClientRect(); - if (parentRect.top > elementRect.top) { - this.content.scrollTop -= parentRect.top - elementRect.top; - } + if (emit) this.handleChange(this._selectedItem); + if (emit && !this.keepOpenOnSelect) this._hide(true); - if (parentRect.bottom < elementRect.bottom) { - this.content.scrollTop += elementRect.bottom - parentRect.bottom; - } + return this._selectedItem; } - protected getNearestSiblingFocusableItemIndex( - startIndex: number, - direction: -1 | 1 - ): number { - let index = startIndex; - const items = this.allItems; - if (!items) { - return -1; - } + private _updateAnchorAccessibility(anchor?: HTMLElement | null) { + const target = + anchor ?? this.trigger.assignedElements({ flatten: true }).at(0); - while (items[index + direction] && items[index + direction].disabled) { - index += direction; + // Find tabbable elements ? + if (target) { + target.setAttribute('aria-haspopup', 'true'); + target.setAttribute('aria-expanded', this.open ? 'true' : 'false'); } - - index += direction; - - return index > -1 && index < items.length ? index : -1; } - private navigateNext() { - this.navigate(1); + private getItem(value: string) { + return this.items.find((item) => item.value === value); } - private navigatePrev() { - this.navigate(-1); - } - - protected onArrowDownKeyDown() { - this.navigateNext(); - } + private _setTarget(anchor: HTMLElement | string) { + const target = + typeof anchor === 'string' + ? (getElementByIdFromRoot(this, anchor) as HTMLElement) + : anchor; - protected onArrowUpKeyDown() { - this.navigatePrev(); + this._target = target; + this._targetListeners = this._keyBindings.observeElement(target); + this._rootClickController.update({ target }); } - private async _hide(emit = true) { - if (!this.open) return; - if (emit && !this.handleClosing()) return; - - this.open = false; - - if (emit) { - await this.updateComplete; - this.emitEvent('igcClosed'); + /** Shows the component. */ + public override show(target?: HTMLElement | string) { + if (target) { + this._setTarget(target); } + super.show(); } - /** Shows the dropdown. */ - @blazorSuppress() - public show(target?: HTMLElement) { - if (this.open && !target) return; - - if (target) this.target = target; - - this.open = true; - } - - /** Hides the dropdown. */ - public hide(): void { - this._hide(false); - } - - /** Toggles the open state of the dropdown. */ - @blazorSuppress() - public toggle(target?: HTMLElement): void { + /** Toggles the open state of the component. */ + public override toggle(target?: HTMLElement | string) { this.open ? this.hide() : this.show(target); } @@ -474,10 +365,14 @@ export default class IgcDropdownComponent /** Navigates to the specified item. If it exists, returns the found item, otherwise - null. */ @blazorSuppress() public navigateTo(value: string | number): IgcDropdownItemComponent | null { - const index = - typeof value === 'string' ? this.getItem(value).index : (value as number); + const item = + typeof value === 'string' ? this.getItem(value) : this.items[value]; + + if (item) { + this._navigateToActiveItem(item); + } - return this.navigateItem(index); + return item ?? null; } /** Selects the item with the specified value. If it exists, returns the found item, otherwise - null. */ @@ -488,41 +383,47 @@ export default class IgcDropdownComponent @blazorSuppress() public select(value: string | number): IgcDropdownItemComponent | null { const item = - typeof value === 'string' - ? this.getItem(value).item - : this.allItems[value as number]; - - return this.selectItem(item, false); + typeof value === 'string' ? this.getItem(value) : this.items[value]; + return item ? this._selectItem(item, false) : null; } /** Clears the current selection of the dropdown. */ public clearSelection() { - if (this.selectedItem) { - this.selectedItem.selected = false; + if (this._selectedItem) { + this._selectedItem.selected = false; } - this.selectedItem = null; + this._selectedItem = null; } protected override render() { - return html` + return html` - -
-
+ > +
+
- `; + `; } } diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 5ea8a115a..37ee2d220 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -285,7 +285,7 @@ export default class IgcInputComponent extends IgcInputBaseComponent { } protected override handleBlur(): void { - this.invalid = !this.checkValidity(); + this.checkValidity(); super.handleBlur(); } diff --git a/src/components/popover/popover.spec.ts b/src/components/popover/popover.spec.ts new file mode 100644 index 000000000..e700ef173 --- /dev/null +++ b/src/components/popover/popover.spec.ts @@ -0,0 +1,315 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; + +import IgcPopoverComponent from './popover.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; + +async function waitForPaint(popover: IgcPopoverComponent) { + await elementUpdated(popover); + await nextFrame(); + await nextFrame(); +} + +function getFloater(popover: IgcPopoverComponent) { + return popover.shadowRoot!.querySelector('#container') as HTMLElement; +} + +function togglePopover() { + const popover = document.querySelector( + IgcPopoverComponent.tagName + ) as IgcPopoverComponent; + popover.open = !popover.open; +} + +function createSlottedPopover(isOpen = false) { + return html` + + +

Message

+
+ `; +} + +function createNonSlottedPopover(isOpen = false) { + return html` +
+ + + +

Message

+
+
+ `; +} + +describe('Popover', () => { + before(() => { + defineComponents(IgcPopoverComponent); + }); + + describe('Slotted anchor element', async () => { + let popover: IgcPopoverComponent; + let anchor: HTMLButtonElement; + + describe('With initial open state', () => { + beforeEach(async () => { + popover = await fixture( + createSlottedPopover(true) + ); + }); + + it('should render a component', async () => { + expect(popover).to.exist; + }); + + it('should be accessible', async () => { + await expect(popover).shadowDom.to.be.accessible(); + await expect(popover).dom.to.be.accessible(); + }); + + it('should be in open state on first render', async () => { + expect(popover.open).to.be.true; + }); + }); + + describe('With initial closed state', () => { + beforeEach(async () => { + popover = await fixture(createSlottedPopover()); + anchor = popover.querySelector('#btn')!; + }); + + it('should render a component', async () => { + expect(popover).to.exist; + }); + + it('should be accessible', async () => { + await expect(popover).shadowDom.to.be.accessible(); + await expect(popover).dom.to.be.accessible(); + }); + + it('should be in closed state on first render', async () => { + expect(popover.open).to.be.false; + }); + + it('should update open state on trigger action', async () => { + anchor.click(); + await waitForPaint(popover); + + expect(popover.open).to.be.true; + }); + + it('`offset` updates are reflected', async () => { + const floater = getFloater(popover); + + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + + popover.offset = 100; + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + + expect(delta.top - initial.top).to.equal(100); + }); + + it('`same-width` updates are reflected', async () => { + const floater = getFloater(popover); + + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + + popover.sameWidth = true; + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + + expect(delta.width).to.be.greaterThan(initial.width); + expect(delta.width).to.equal(anchor.getBoundingClientRect().width); + }); + + it('`strategy` updates are reflected', async () => { + const floater = getFloater(popover); + const getPosition = () => + getComputedStyle(floater).getPropertyValue('position'); + + anchor.click(); + await waitForPaint(popover); + + expect(getPosition()).to.equal('absolute'); + + popover.strategy = 'fixed'; + await waitForPaint(popover); + + expect(getPosition()).to.equal('fixed'); + }); + + it('`anchor` slot changes are reflected', async () => { + const floater = getFloater(popover); + const newAnchor = document.createElement('button'); + newAnchor.textContent = 'New Show Message'; + newAnchor.style.height = '100px'; + newAnchor.slot = 'anchor'; + + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + expect(initial.top).to.equal(anchor.getBoundingClientRect().bottom); + + anchor.replaceWith(newAnchor); + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + + expect(delta.top).to.be.greaterThan(initial.top); + expect(delta.top).to.equal(newAnchor.getBoundingClientRect().bottom); + }); + }); + }); + + describe('Non-slotted anchor element', async () => { + let popover: IgcPopoverComponent; + let anchor: HTMLButtonElement; + + describe('With initial open state', () => { + beforeEach(async () => { + const root = await fixture(createNonSlottedPopover(true)); + popover = root.querySelector('igc-popover') as IgcPopoverComponent; + anchor = root.querySelector('#btn') as HTMLButtonElement; + // await waitForPaint(popover); + }); + + it('should render a component', async () => { + expect(popover).to.exist; + }); + + it('is accessible', async () => { + await expect(popover).shadowDom.to.be.accessible(); + await expect(popover).dom.to.be.accessible(); + }); + + it('should be in open state on first render', async () => { + expect(popover.open).to.be.true; + }); + + it('should update to closed state on trigger action', async () => { + anchor.click(); + await waitForPaint(popover); + + expect(popover.open).to.be.false; + }); + }); + + describe('With initial closed state', () => { + beforeEach(async () => { + const root = await fixture(createNonSlottedPopover()); + popover = root.querySelector('igc-popover') as IgcPopoverComponent; + anchor = root.querySelector('#btn') as HTMLButtonElement; + }); + + it('should render a component', async () => { + expect(popover).to.exist; + }); + + it('is accessible', async () => { + await expect(popover).shadowDom.to.be.accessible(); + await expect(popover).dom.to.be.accessible(); + }); + + it('should be in closed state on first render', async () => { + expect(popover.open).to.be.false; + }); + + it('should update open state on trigger action', async () => { + anchor.click(); + await waitForPaint(popover); + + expect(popover.open).to.be.true; + }); + + it('`offset` updates are reflected', async () => { + const floater = getFloater(popover); + + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + + popover.offset = 100; + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + + expect(delta.top - initial.top).to.equal(100); + }); + + it('`same-width` updates are reflected', async () => { + const floater = getFloater(popover); + + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + + popover.sameWidth = true; + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + + expect(delta.width).to.be.greaterThan(initial.width); + expect(delta.width).to.equal(anchor.getBoundingClientRect().width); + }); + + it('`strategy` updates are reflected', async () => { + const floater = getFloater(popover); + const getPosition = () => + getComputedStyle(floater).getPropertyValue('position'); + + anchor.click(); + await waitForPaint(popover); + + expect(getPosition()).to.equal('absolute'); + + popover.strategy = 'fixed'; + await waitForPaint(popover); + + expect(getPosition()).to.equal('fixed'); + }); + + it('`anchor` property updates are reflected', async () => { + const floater = getFloater(popover); + const fixture = popover.parentElement as HTMLElement; + const newAnchor = document.createElement('button'); + newAnchor.textContent = 'New Anchor'; + newAnchor.id = 'newAnchor'; + newAnchor.style.display = 'block'; + newAnchor.style.height = '200px'; + + fixture.prepend(newAnchor); + anchor.click(); + await waitForPaint(popover); + + const initial = floater.getBoundingClientRect(); + + popover.anchor = 'newAnchor'; + await waitForPaint(popover); + + const delta = floater.getBoundingClientRect(); + expect(delta.top).to.be.lessThan(initial.top); + }); + }); + }); +}); diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts new file mode 100644 index 000000000..3b881613f --- /dev/null +++ b/src/components/popover/popover.ts @@ -0,0 +1,260 @@ +import { + Middleware, + autoUpdate, + computePosition, + flip, + limitShift, + offset, + shift, + size, +} from '@floating-ui/dom'; +import { LitElement, html } from 'lit'; +import { property, query, queryAssignedElements } from 'lit/decorators.js'; + +import { styles } from './themes/light/popover.base.css.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { getElementByIdFromRoot } from '../common/util.js'; + +function roundByDPR(value: number) { + const dpr = window.devicePixelRatio || 1; + return Math.round(value * dpr) / dpr; +} + +/** + * Describes the preferred placement of a toggle component. + */ +export type IgcPlacement = + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; + +/** + * @element igc-popover + * + * @slot - Content of the popover. + * @slot anchor - The element the popover will be anchored to. + */ +export default class IgcPopoverComponent extends LitElement { + public static readonly tagName = 'igc-popover'; + public static override styles = styles; + + public static register() { + registerComponent(this); + } + + private dispose?: ReturnType; + private target?: Element; + + @query('#container', true) + private _container!: HTMLElement; + + @queryAssignedElements({ slot: 'anchor', flatten: true }) + private _anchors!: HTMLElement[]; + + /** + * Pass an IDREF or an DOM element reference to use as the + * anchor target for the floating element. + */ + @property() + public anchor?: Element | string; + + /** + * When enabled this changes the placement of the floating element in order to keep it + * in view along the main axis. + */ + @property({ type: Boolean, reflect: true }) + public flip = false; + + /** + * Placement modifier which translates the floating element along the main axis. + */ + @property({ type: Number }) + public offset = 0; + + /** + * The visibility state of the popover component. + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + /** + * Where to place the floating element relative to the parent anchor element. + */ + @property() + public placement: IgcPlacement = 'bottom-start'; + + /** + * When enabled the floating element will match the width of its parent anchor element. + */ + @property({ type: Boolean, reflect: true, attribute: 'same-width' }) + public sameWidth = false; + + /** + * When enabled this tries to shift the floating element along the main axis + * keeping it in view, preventing overflow while maintaining the desired placement. + */ + @property({ type: Boolean, reflect: true }) + public shift = false; + + /** + * The type of CSS position property to use. + */ + @property() + public strategy: 'absolute' | 'fixed' = 'absolute'; + + @watch('anchor') + protected async anchorChange() { + const newTarget = + typeof this.anchor === 'string' + ? getElementByIdFromRoot(this, this.anchor) + : this.anchor; + + if (newTarget) { + this.target = newTarget; + this._updateState(); + } + } + + @watch('open', { waitUntilFirstUpdate: true }) + protected openChange() { + this.open ? this.show() : this.hide(); + } + + @watch('flip', { waitUntilFirstUpdate: true }) + @watch('offset', { waitUntilFirstUpdate: true }) + @watch('placement', { waitUntilFirstUpdate: true }) + @watch('sameWidth', { waitUntilFirstUpdate: true }) + @watch('shift', { waitUntilFirstUpdate: true }) + @watch('strategy', { waitUntilFirstUpdate: true }) + protected floatingPropChange() { + this._updateState(); + } + + public override async connectedCallback() { + super.connectedCallback(); + + await this.updateComplete; + if (this.open) { + this.show(); + } + } + + public override disconnectedCallback() { + this.hide(); + super.disconnectedCallback(); + } + + protected show() { + if (!this.target) return; + + this.dispose = autoUpdate( + this.target, + this._container, + this._updatePosition.bind(this) + ); + } + + protected async hide(): Promise { + return new Promise((resolve) => { + if (this.dispose) { + this.dispose(); + this.dispose = undefined; + resolve(); + } else { + resolve(); + } + }); + } + + private _createMiddleware(): Middleware[] { + const middleware: Middleware[] = []; + const styles = this._container.style; + + if (this.offset) { + middleware.push(offset(this.offset)); + } + + if (this.shift) { + middleware.push( + shift({ + limiter: limitShift(), + }) + ); + } + + if (this.flip) { + middleware.push(flip()); + } + + if (this.sameWidth) { + middleware.push( + size({ + apply: ({ rects }) => { + Object.assign(styles, { width: `${rects.reference.width}px` }); + }, + }) + ); + } else { + Object.assign(styles, { width: '' }); + } + + return middleware; + } + + private async _updateState() { + if (this.open) { + await this.hide(); + this.show(); + } + } + + private async _updatePosition() { + if (!this.open || !this.target) { + return; + } + + const { x, y } = await computePosition(this.target, this._container, { + placement: this.placement ?? 'bottom-start', + middleware: this._createMiddleware(), + strategy: this.strategy ?? 'absolute', + }); + + Object.assign(this._container.style, { + left: 0, + top: 0, + transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`, + }); + } + + private _anchorSlotChange() { + if (this.anchor || this._anchors.length < 1) return; + + this.target = this._anchors[0]; + this._updateState(); + } + + protected override render() { + return html` + +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-popover': IgcPopoverComponent; + } +} diff --git a/src/components/popover/themes/light/popover.base.scss b/src/components/popover/themes/light/popover.base.scss new file mode 100644 index 000000000..125e82b85 --- /dev/null +++ b/src/components/popover/themes/light/popover.base.scss @@ -0,0 +1,22 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: contents; +} + +:host(:not([open])) #container { + display: none; +} + +[part='absolute'] { + position: absolute; + isolation: isolate; + z-index: 10005; +} + +[part='fixed'] { + position: fixed; + isolation: isolate; + z-index: 10005; +} diff --git a/src/components/select/select-group.ts b/src/components/select/select-group.ts index c784b244c..25f62319d 100644 --- a/src/components/select/select-group.ts +++ b/src/components/select/select-group.ts @@ -1,9 +1,12 @@ +import { LitElement, html } from 'lit'; import { property, queryAssignedElements } from 'lit/decorators.js'; -import IgcSelectItemComponent from './select-item.js'; +import type IgcSelectItemComponent from './select-item.js'; +import { themes } from '../../theming/theming-decorator.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import IgcDropdownGroupComponent from '../dropdown/dropdown-group.js'; +import { styles } from '../dropdown/themes/dropdown-group.base.css.js'; +import { all } from '../dropdown/themes/group.js'; /** * @element igc-select-group - A container for a group of `igc-select-item` components. @@ -13,19 +16,22 @@ import IgcDropdownGroupComponent from '../dropdown/dropdown-group.js'; * * @csspart label - The native label element. */ -export default class IgcSelectGroupComponent extends IgcDropdownGroupComponent { - public static override readonly tagName = 'igc-select-group'; +@themes(all) +export default class IgcSelectGroupComponent extends LitElement { + public static readonly tagName = 'igc-select-group'; + public static override styles = styles; - public static override register() { + public static register() { registerComponent(this); } + private _internals: ElementInternals; private observer!: MutationObserver; private controlledItems!: Array; /** All child `igc-select-item`s. */ @queryAssignedElements({ flatten: true, selector: 'igc-select-item' }) - public override items!: Array; + public items!: Array; @queryAssignedElements({ flatten: true, @@ -42,6 +48,8 @@ export default class IgcSelectGroupComponent extends IgcDropdownGroupComponent { constructor() { super(); + this._internals = this.attachInternals(); + this._internals.role = 'group'; this.observer = new MutationObserver(this.updateControlledItems.bind(this)); } @@ -51,14 +59,9 @@ export default class IgcSelectGroupComponent extends IgcDropdownGroupComponent { super.disconnectedCallback(); } - protected override getParent() { - return this.closest('igc-select')!; - } - protected override async firstUpdated() { await this.updateComplete; this.controlledItems = this.activeItems; - this.setAttribute('role', 'presentation'); this.items.forEach((i) => { this.observer.observe(i, { @@ -67,7 +70,7 @@ export default class IgcSelectGroupComponent extends IgcDropdownGroupComponent { }); }); - this.updateDisabled(); + this.disabledChange(); } protected updateControlledItems(mutations: MutationRecord[]) { @@ -85,12 +88,21 @@ export default class IgcSelectGroupComponent extends IgcDropdownGroupComponent { } @watch('disabled', { waitUntilFirstUpdate: true }) - protected updateDisabled() { - this.disabled - ? this.setAttribute('aria-disabled', 'true') - : this.removeAttribute('aria-disabled'); + protected disabledChange() { + this._internals.ariaDisabled = `${this.disabled}`; + + for (const item of this.controlledItems) { + item.disabled = this.disabled; + } + } - this.controlledItems?.forEach((i) => (i.disabled = this.disabled)); + protected override render() { + return html` + + + `; } } diff --git a/src/components/select/select-header.ts b/src/components/select/select-header.ts index 429525a34..87b66a3c0 100644 --- a/src/components/select/select-header.ts +++ b/src/components/select/select-header.ts @@ -1,17 +1,29 @@ +import { LitElement, html } from 'lit'; + +import { themes } from '../../theming/theming-decorator.js'; import { registerComponent } from '../common/definitions/register.js'; -import IgcDropdownHeaderComponent from '../dropdown/dropdown-header.js'; +import { styles } from '../dropdown/themes/dropdown-header.base.css.js'; +import { all } from '../dropdown/themes/header.js'; /** - * @element igc-select-header - Represents a header item in a select component. + * Represents a header item in an igc-select component. + * + * @element igc-select-header * * @slot - Renders the header. */ -export default class IgcSelectHeaderComponent extends IgcDropdownHeaderComponent { - public static override readonly tagName = 'igc-select-header'; +@themes(all) +export default class IgcSelectHeaderComponent extends LitElement { + public static readonly tagName = 'igc-select-header'; + public static override styles = styles; - public static override register() { + public static register() { registerComponent(this); } + + protected override render() { + return html``; + } } declare global { diff --git a/src/components/select/select-item.ts b/src/components/select/select-item.ts index 3641f61b1..bf3fa7cfc 100644 --- a/src/components/select/select-item.ts +++ b/src/components/select/select-item.ts @@ -1,9 +1,9 @@ -import { queryAssignedNodes } from 'lit/decorators.js'; - +import { themes } from '../../theming/theming-decorator.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import { extractText } from '../common/util.js'; -import IgcDropdownItemComponent from '../dropdown/dropdown-item.js'; +import { IgcBaseOptionLikeComponent } from '../common/mixins/option.js'; +import { styles } from '../dropdown/themes/dropdown-item.base.css.js'; +import { all } from '../dropdown/themes/item.js'; /** * Represents an item in a select list. @@ -18,16 +18,15 @@ import IgcDropdownItemComponent from '../dropdown/dropdown-item.js'; * @csspart content - The main content wrapper. * @csspart suffix - The suffix wrapper. */ -export default class IgcSelectItemComponent extends IgcDropdownItemComponent { - public static override readonly tagName = 'igc-select-item'; +@themes(all) +export default class IgcSelectItemComponent extends IgcBaseOptionLikeComponent { + public static readonly tagName = 'igc-select-item'; + public static override styles = styles; - public static override register() { + public static register() { registerComponent(this); } - @queryAssignedNodes({ flatten: true }) - private content!: Array; - @watch('active') protected activeChange() { this.tabIndex = this.active ? 0 : -1; @@ -36,18 +35,6 @@ export default class IgcSelectItemComponent extends IgcDropdownItemComponent { this.focus(); } } - - /** Returns the text of the item without the prefix and suffix content. */ - public override get textContent() { - return extractText(this.content).join(' '); - } - - /** Sets the textContent of the item without touching the prefix and suffix content. */ - public override set textContent(value: string) { - const text = new Text(value); - this.content.forEach((n) => n.remove()); - this.appendChild(text); - } } declare global { diff --git a/src/components/select/select.spec.ts b/src/components/select/select.spec.ts index e5790f292..fa4acd626 100644 --- a/src/components/select/select.spec.ts +++ b/src/components/select/select.spec.ts @@ -1,21 +1,73 @@ -import { elementUpdated, expect, fixture } from '@open-wc/testing'; -import { html } from 'lit'; +import { + aTimeout, + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; import { spy } from 'sinon'; -import IgcSelectGroupComponent from './select-group.js'; import IgcSelectHeaderComponent from './select-header.js'; -import IgcSelectItemComponent from './select-item.js'; +import type IgcSelectItemComponent from './select-item.js'; import IgcSelectComponent from './select.js'; +import { + altKey, + arrowDown, + arrowUp, + endKey, + enterKey, + escapeKey, + homeKey, + spaceBar, + tabKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { FormAssociatedTestBed } from '../common/utils.spec.js'; +import { + FormAssociatedTestBed, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; import IgcInputComponent from '../input/input.js'; -describe('Select component', () => { +type ItemState = { + active?: boolean; + disabled?: boolean; + selected?: boolean; +}; + +type SelectSlots = + | '' + | 'prefix' + | 'suffix' + | 'header' + | 'footer' + | 'helper-text' + | 'toggle-icon' + | 'toggle-icon-expanded'; + +describe('Select', () => { + before(() => defineComponents(IgcSelectComponent)); + let select: IgcSelectComponent; - let input: IgcInputComponent; - let target: HTMLElement; - const items = [ + const getActiveItem = () => select.items.find((item) => item.active); + + const getInput = () => + select.shadowRoot!.querySelector(IgcInputComponent.tagName)!; + + const getSlot = (name: SelectSlots) => { + return select.shadowRoot!.querySelector( + `slot${name ? `[name=${name}]` : ':not([name])'}` + ) as HTMLSlotElement; + }; + + const openSelect = async () => { + select.show(); + await elementUpdated(select); + }; + + const Items = [ { value: 'spec', text: 'Specification', @@ -48,870 +100,1144 @@ describe('Select component', () => { }, ]; - const selectOpts = (el: IgcSelectComponent) => - [...el.querySelectorAll('igc-select-item')] as IgcSelectItemComponent[]; + function checkItemState(item: IgcSelectItemComponent, state: ItemState) { + for (const [key, value] of Object.entries(state)) { + expect((item as any)[key]).to.equal(value); + } + } - before(() => { - defineComponents( - IgcSelectComponent, - IgcSelectGroupComponent, - IgcSelectItemComponent, - IgcSelectHeaderComponent, - IgcInputComponent - ); - }); + function createBasicSelect() { + return html` + + ${Items.map( + (item) => + html`${item.text}` + )} + + `; + } - describe('', () => { - beforeEach(async () => { - select = await fixture( - html` - ${items.map( + function createSlottedSelect() { + return html` + + + + + + +

Header

+

Footer

+ +

Helper Text

+ + 1 + 2 + 3 +
`; + } + + function createSelectWithGroups() { + return html` + Tasks + + Pre development + Specification + + + Development + Implementation + Testing + + + Post development + Samples + Documentation + Builds + + `; + } + + function createInitialSelectionValueOnly() { + return html` + + 1 + 2 + 3 + + `; + } + + function createInitialMultipleSelection() { + return html` + + 1 + 2 + 3 + + `; + } + + function createInitialSelectionWithValueAndAttribute() { + return html` + + 1 + 2 + 3 + + `; + } + + function createScrollableSelectParent() { + return html` +
+ + ${Items.map( (item) => html` - FR - ${item.text} - FR - ` + value=${item.value} + >${item.text}` )} - ` - ); + +
+ `; + } - input = select.shadowRoot!.querySelector( - 'igc-input' - ) as IgcInputComponent; - target = select.shadowRoot!.querySelector( - 'div[role="combobox"]' - ) as HTMLElement; - }); + describe('DOM', () => { + describe('Default', () => { + beforeEach(async () => { + select = await fixture(createBasicSelect()); + }); - it('is accessible.', async () => { - select.open = true; - select.label = 'Simple Select'; - await elementUpdated(select); - await expect(select).to.be.accessible(); - }); + it('is rendered correctly', async () => { + expect(select).to.exist; + }); - it('is successfully created with default properties.', () => { - expect(document.querySelector('igc-select')).to.exist; - expect(select.open).to.be.false; - expect(select.name).to.be.undefined; - expect(select.value).to.be.undefined; - expect(select.disabled).to.be.false; - expect(select.required).to.be.false; - expect(select.invalid).to.be.false; - expect(select.autofocus).to.be.undefined; - expect(select.label).to.be.undefined; - expect(select.placeholder).to.be.undefined; - expect(select.outlined).to.be.false; - expect(select.size).to.equal('medium'); + it('is accessible', async () => { + // Closed state + await expect(select).dom.to.be.accessible(); + await expect(select).shadowDom.to.be.accessible(); + + select.open = true; + await elementUpdated(select); + + // Open state + await expect(select).dom.to.be.accessible(); + await expect(select).shadowDom.to.be.accessible(); + + select.open = false; + await elementUpdated(select); + + // Closed state again + await expect(select).dom.to.be.accessible(); + await expect(select).shadowDom.to.be.accessible(); + }); + + it('relevant props are passed to the underlying input', async () => { + const props = { + required: true, + label: 'New Label', + disabled: true, + placeholder: 'Select placeholder', + outlined: true, + }; + + const input = getInput(); + + Object.assign(select, props); + select.value = 'testing'; + await elementUpdated(select); + + for (const [prop, value] of Object.entries(props)) { + expect((input as any)[prop]).to.equal(value); + } + expect(input.value).to.equal(select.selectedItem?.textContent); + + select.value = '123151'; + await elementUpdated(select); + + expect(input.value).to.be.null; + }); }); - it('correctly applies input related properties to encapsulated input', async () => { - select.value = items[0].value; - select.disabled = true; - select.required = true; - select.invalid = false; - select.label = 'Select Label'; - select.placeholder = 'Select Placeholder'; - select.outlined = true; - select.size = 'large'; - await elementUpdated(select); + describe('Slotted content', () => { + beforeEach(async () => { + select = await fixture(createSlottedSelect()); + }); + + it('correctly projects elements', async () => { + const slots: SelectSlots[] = [ + 'header', + 'footer', + 'helper-text', + 'prefix', + 'suffix', + 'toggle-icon', + 'toggle-icon-expanded', + ]; + + for (const slot of slots) { + expect(getSlot(slot).assignedElements()).not.to.be.empty; + } + }); + + it('toggle-icon slots reflect open state', async () => { + expect(getSlot('toggle-icon')).to.not.have.attribute('hidden'); + expect(getSlot('toggle-icon-expanded')).to.have.attribute('hidden'); - const selectedItem = selectOpts(select).find((i) => i.selected); + await openSelect(); + + expect(getSlot('toggle-icon')).to.have.attribute('hidden'); + expect(getSlot('toggle-icon-expanded')).to.not.have.attribute('hidden'); + }); + }); + }); - expect(input.value).to.equal(selectedItem?.textContent); - expect(input.disabled).to.equal(select.disabled); - expect(input.required).to.equal(select.required); - expect(input.invalid).to.equal(select.invalid); - expect(input.label).to.equal(select.label); - expect(input.placeholder).to.equal(select.placeholder); - expect(input.outlined).to.equal(select.outlined); - expect(input.size).to.equal(select.size); - expect(input.dir).to.equal(select.dir); + describe('Autofocus', () => { + beforeEach(async () => { + select = await fixture( + html`` + ); }); - it('initializes items with default properties', () => { - const options = [ - ...select.querySelectorAll('igc-select-item'), - ] as IgcSelectItemComponent[]; - expect(options[0].disabled).to.be.false; - expect(options[0].value).to.equal(items[0].value); - expect(options[0].selected).to.be.false; + it('correctly applies autofocus on init', async () => { + expect(document.activeElement).to.equal(select); }); + }); - it('navigates to the item with the specified value on `navigateTo` method calls.', async () => { - select.navigateTo('implementation'); + describe('Scroll strategy', () => { + let container: HTMLDivElement; + + const scrollBy = async (amount: number) => { + container.scrollTo({ top: amount }); + container.dispatchEvent(new Event('scroll')); await elementUpdated(select); + await nextFrame(); + }; - expect(selectOpts(select)[1].hasAttribute('active')).to.be.true; + beforeEach(async () => { + container = await fixture(createScrollableSelectParent()); + select = container.querySelector(IgcSelectComponent.tagName)!; }); - it('navigates to the item at the specified index on `navigateTo` method calls.', async () => { - select.navigateTo(1); - await elementUpdated(select); + it('`scroll` behavior', async () => { + await openSelect(); + await scrollBy(200); - expect(selectOpts(select)[1].hasAttribute('active')).to.be.true; + expect(select.open).to.be.true; }); - it('opens the list of options when Enter or Spacebar keys are pressed', () => { - const allowedKeys = [' ', 'Enter']; + it('`close` behavior', async () => { + const eventSpy = spy(select, 'emitEvent'); - allowedKeys.forEach((key) => { - pressKey(target, key); - expect(select.open).to.be.true; - select.hide(); - }); + select.scrollStrategy = 'close'; + await openSelect(); + await scrollBy(200); + + expect(select.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); }); - it('assings the value of the selected item as its own', async () => { - const selected = select.select(0); - await elementUpdated(select); + it('`block behavior`', async () => { + select.scrollStrategy = 'block'; + await openSelect(); + await scrollBy(200); - expect(select.value).to.equal(selected?.value); + expect(select.open).to.be.true; }); + }); - it('should retrieve and replace only the text content of the content part in an item', () => { - const replaceWith = 'Some other display text'; - const item = selectOpts(select)[0]; - const prefix = item.querySelector('[slot="prefix"]'); - const suffix = item.querySelector('[slot="suffix"]'); + describe('Initial selection', () => { + it('value only', async () => { + select = await fixture(createInitialSelectionValueOnly()); - expect(item.textContent).to.equal(items[0].text); - expect(prefix?.textContent).to.equal('FR'); - expect(suffix?.textContent).to.equal('FR'); + expect(select.selectedItem?.value).to.equal('2'); + }); - item.textContent = replaceWith; + it('multiple items with selected attribute are coerced to the last one', async () => { + select = await fixture(createInitialMultipleSelection()); - expect(item.textContent).to.equal(replaceWith); - expect(prefix?.textContent).to.equal('FR'); - expect(suffix?.textContent).to.equal('FR'); + expect(select.selectedItem?.value).to.equal('3'); }); - it('toggles the list of options when Alt+Up or Alt+Down keys are pressed', async () => { - select.show(); - select.value = ''; + it('attribute takes precedence over value on first render', async () => { + select = await fixture(createInitialSelectionWithValueAndAttribute()); + + expect(select.selectedItem?.value).to.equal('3'); + expect(select.value).to.equal('3'); + }); + }); + + describe('API', () => { + beforeEach(async () => { + select = await fixture(createBasicSelect()); + }); + + it('`toggle()` controls the open state', async () => { + select.toggle(); await elementUpdated(select); expect(select.open).to.be.true; - pressKey(target, 'ArrowUp', 1, { altKey: true }); + select.toggle(); + await elementUpdated(select); + expect(select.open).to.be.false; - expect(select.value).to.be.undefined; + }); - pressKey(target, 'ArrowUp', 1, { altKey: true }); - expect(select.open).to.be.true; - expect(select.value).to.be.undefined; + it('`items` returns the correct collection', async () => { + expect(select.items.length).to.equal(Items.length); + }); - pressKey(target, 'ArrowDown', 1, { altKey: true }); - expect(select.open).to.be.false; + it('`groups` returns the correct collection', async () => { + expect(select.groups).to.be.empty; + }); + + it('`value` works', async () => { + select.value = 'implementation'; + expect(select.selectedItem?.value).to.equal(select.value); + + select.value = 'testing'; + expect(select.selectedItem?.value).to.equal(select.value); + checkItemState(select.selectedItem!, { + active: false, + disabled: true, + selected: true, + }); + + select.value = ''; + expect(select.selectedItem).to.be.null; + }); + + it('`select()` works', async () => { + // With value + select.select('implementation'); + + checkItemState(select.selectedItem!, { selected: true, active: true }); + expect(select.selectedItem?.value).to.equal('implementation'); + expect(select.value).to.equal(select.selectedItem?.value); + select.clearSelection(); + + // With index + select.select(4); + checkItemState(select.selectedItem!, { selected: true, active: true }); + expect(select.selectedItem?.value).to.equal(select.items[4].value); + expect(select.value).to.equal(select.items[4].value); + select.clearSelection(); + + // Selecting a disabled item + select.select('testing'); + checkItemState(select.selectedItem!, { selected: true }); + expect(select.selectedItem?.value).to.equal('testing'); + expect(select.value).to.equal('testing'); + + // Trying to select non-existent index with selection present + select.select(-1); + expect(select.selectedItem).to.not.be.null; + expect(select.value).to.equal('testing'); + + // Trying to select non-existent value with selection present + select.select('non-existent'); + expect(select.selectedItem).to.not.be.null; + expect(select.value).to.equal('testing'); + + // Non-existent index with no selection present + select.clearSelection(); + select.select(-1); + expect(select.items.every((item) => item.selected)).to.be.false; + expect(select.selectedItem).to.be.null; expect(select.value).to.be.undefined; - pressKey(target, 'ArrowDown', 1, { altKey: true }); - expect(select.open).to.be.true; + // Non-existent value with no selection present + select.select('81313213'); + expect(select.items.every((item) => item.selected)).to.be.false; + expect(select.selectedItem).to.be.null; expect(select.value).to.be.undefined; }); - it('select the first non-disabled item when the Home button is pressed', async () => { - const options = selectOpts(select); - options[0].setAttribute('disabled', ''); - const activeItems = selectOpts(select).filter( - (item) => !item.hasAttribute('disabled') - ); - const index = selectOpts(select).indexOf(activeItems[0]); - select.open = false; - await elementUpdated(select); + it('`navigateTo() works`', async () => { + // Non-existent + for (const each of [-1, 100, 'Nope']) { + select.navigateTo(each as any); + expect(getActiveItem()).to.be.undefined; + } - pressKey(target, 'Home'); - await elementUpdated(select); - expect(select.value).to.equal(items[index].value); - expect(selectOpts(select)[index].hasAttribute('selected')).to.be.true; + // With value + select.navigateTo('implementation'); + expect(getActiveItem()?.value).to.equal('implementation'); + + // With index + select.navigateTo(0); + checkItemState(select.items[0], { active: true }); + + // Only one active item + expect(select.items.filter((item) => item.active).length).to.equal(1); }); - it('select the last non-disabled item when the End button is pressed', async () => { - const activeItems = selectOpts(select).filter( - (item) => !item.hasAttribute('disabled') - ); - const index = selectOpts(select).indexOf( - activeItems[activeItems.length - 1] - ); - select.open = false; - await elementUpdated(select); + it('reports validity when required', async () => { + const validity = spy(select, 'reportValidity'); - pressKey(target, 'End'); + select.value = ''; + select.required = true; await elementUpdated(select); - expect(select.value).to.equal(items[index].value); - expect(selectOpts(select)[index].hasAttribute('selected')).to.be.true; + + select.reportValidity(); + expect(validity).to.have.returned(false); + expect(select.invalid).to.be.true; + + select.value = Items[0].value; + select.reportValidity(); + + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; }); - it('should not fire the igcChange event when the Home button is pressed and the first item is already selected', async () => { - const eventSpy = spy(select, 'emitEvent'); - select.select(0); - await elementUpdated(select); + it('reports validity when not required', async () => { + const validity = spy(select, 'reportValidity'); - pressKey(target, 'Home'); + select.value = ''; + select.required = false; await elementUpdated(select); - expect(eventSpy).not.be.calledWith('igcChange'); - }); - it('should not fire the igcChange event when the End button is pressed and the last item is already selected', async () => { - const activeItems = selectOpts(select).filter( - (item) => !item.hasAttribute('disabled') - ); - const index = selectOpts(select).indexOf( - activeItems[activeItems.length - 1] - ); - select.open = false; - select.select(index); + select.reportValidity(); + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; + select.value = Items[0].value; await elementUpdated(select); - const eventSpy = spy(select, 'emitEvent'); + select.reportValidity(); - pressKey(target, 'End'); - expect(eventSpy).not.be.calledWith('igcChange'); + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; }); - it('should select the next selectable item using ArrowDown or ArrowRight when the list of options is closed', async () => { - select.hide(); - await elementUpdated(select); + it('checks validity when required', async () => { + const validity = spy(select, 'checkValidity'); - pressKey(target, 'ArrowDown'); + select.value = ''; + select.required = true; await elementUpdated(select); - expect(selectOpts(select)[0].hasAttribute('selected')).to.be.true; - expect(selectOpts(select)[0].hasAttribute('active')).to.be.true; - expect(select.value).to.equal(items[0].value); + select.checkValidity(); + expect(validity).to.have.returned(false); + expect(select.invalid).to.be.true; - pressKey(target, 'ArrowRight'); + select.value = Items[0].value; await elementUpdated(select); + select.checkValidity(); - expect(selectOpts(select)[1].hasAttribute('selected')).to.be.true; - expect(selectOpts(select)[1].hasAttribute('active')).to.be.true; - expect(select.value).to.equal(items[1].value); + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; }); - it('should select the previous selectable item using ArrowUp or ArrowLeft when the list of options is closed', async () => { - select.hide(); - select.select(items.length - 1); - await elementUpdated(select); + it('checks validity when not required', async () => { + const validity = spy(select, 'checkValidity'); - pressKey(target, 'ArrowUp'); + select.value = ''; + select.required = false; await elementUpdated(select); - expect(selectOpts(select)[items.length - 2].hasAttribute('selected')).to - .be.true; - expect(selectOpts(select)[items.length - 2].hasAttribute('active')).to.be - .true; - expect(select.value).to.equal(items[items.length - 2].value); + select.checkValidity(); + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; - pressKey(target, 'ArrowLeft'); + select.value = Items[0].value; await elementUpdated(select); + select.checkValidity(); - expect(selectOpts(select)[items.length - 3].hasAttribute('selected')).to - .be.true; - expect(selectOpts(select)[items.length - 3].hasAttribute('active')).to.be - .true; - expect(select.value).to.equal(items[items.length - 3].value); + expect(validity).to.have.returned(true); + expect(select.invalid).to.be.false; }); - it('should fire the igcChange event when using ArrowUp, ArrowDown, ArrowLeft, or ArrowRight and a new item is selected', async () => { - const eventSpy = spy(select, 'emitEvent'); - select.hide(); - select.select(0); - await elementUpdated(select); + it('`focus()`', async () => { + select.focus(); + expect(document.activeElement).to.equal(select); + expect(select.shadowRoot?.activeElement).to.equal(getInput()); + }); - pressKey(target, 'ArrowRight'); - await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + it('`blur()`', async () => { + select.focus(); - pressKey(target, 'ArrowDown'); - await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + select.blur(); + expect(document.activeElement).to.not.equal(select); + expect(select.shadowRoot?.activeElement).to.be.null; + }); + }); - pressKey(target, 'ArrowUp'); - await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + describe('Groups', () => { + beforeEach(async () => { + select = await fixture(createSelectWithGroups()); + }); - pressKey(target, 'ArrowLeft'); - await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + it('has correct collections', async () => { + expect(select.items.length).to.equal(6); + expect(select.groups.length).to.equal(3); }); - it('should not fire the igcChange event when using ArrowUp or ArrowLeft and the first item is already selected', async () => { - const eventSpy = spy(select, 'emitEvent'); - select.hide(); - select.select(0); + it('clicking on a header is a no-op', async () => { + await openSelect(); + + simulateClick(select.querySelector(IgcSelectHeaderComponent.tagName)!); await elementUpdated(select); - expect(selectOpts(select)[0].hasAttribute('selected')).to.be.true; - expect(selectOpts(select)[0].hasAttribute('active')).to.be.true; + expect(select.open).to.be.true; + expect(select.selectedItem).to.be.null; + }); - pressKey(target, 'ArrowLeft'); - await elementUpdated(select); - expect(eventSpy).not.be.calledWith('igcChange'); + it('clicking on a group is a no-op', async () => { + await openSelect(); - pressKey(target, 'ArrowUp'); - await elementUpdated(select); - expect(eventSpy).not.be.calledWith('igcChange'); + for (const group of select.groups) { + simulateClick(group); + await elementUpdated(select); + + expect(select.open).to.be.true; + expect(select.selectedItem).to.be.null; + } }); - it('should not fire the igcChange event when using ArrowDown or ArrowRight and the last item is already selected', async () => { - const itemIndex = items.length - 1; - const eventSpy = spy(select, 'emitEvent'); - select.hide(); + it('group disabled state', async () => { + const group = select.groups[0]; + + group.disabled = true; + await elementUpdated(select); + checkItemState(select.items[0], { disabled: true }); - select.select(itemIndex); + simulateKeyboard(select, arrowDown); await elementUpdated(select); - expect(selectOpts(select)[itemIndex].hasAttribute('selected')).to.be.true; - expect(selectOpts(select)[itemIndex].hasAttribute('active')).to.be.true; + checkItemState(select.items[1], { selected: true }); + expect(select.selectedItem?.value).to.equal('Implementation'); - pressKey(target, 'ArrowDown'); + group.disabled = false; await elementUpdated(select); - expect(eventSpy).not.be.calledWith('igcChange'); - pressKey(target, 'ArrowRight'); + checkItemState(select.items[0], { disabled: false }); + + simulateKeyboard(select, arrowUp); await elementUpdated(select); - expect(eventSpy).not.be.calledWith('igcChange'); + + checkItemState(select.items[0], { selected: true }); + expect(select.selectedItem?.value).to.equal('Specification'); }); - it('should select an item upon typing a valid term while the select is on focus', async () => { - const term = items[0].text; + it('keyboard navigation works (open)', async () => { + await openSelect(); - Array.from(term).forEach((char) => { - pressKey(target, char); - }); + for (const item of select.items) { + simulateKeyboard(select, arrowDown); + await elementUpdated(select); - await elementUpdated(select); - const item = selectOpts(select).find((i) => i.value === items[0].value); + checkItemState(item, { active: true }); + } + }); + + it('keyboard selection works (closed)', async () => { + const eventSpy = spy(select, 'emitEvent'); - expect(item?.selected).to.be.true; + for (const item of select.items) { + simulateKeyboard(select, arrowDown); + await elementUpdated(select); + + expect(eventSpy).calledWith('igcChange', { detail: item }); + checkItemState(item, { selected: true }); + } }); + }); - it('should not change selection upon typing an invalid term while the select is on focus', async () => { - const itemIndex = 1; - const term = 'infra'; - select.select(itemIndex); + describe('User interactions', () => { + beforeEach(async () => { + select = await fixture(createBasicSelect()); + }); + + it('toggles open state on click', async () => { + simulateClick(getInput()); await elementUpdated(select); - Array.from(term).forEach((char) => { - pressKey(target, char); - }); + expect(select.open).to.be.true; + simulateClick(getInput()); await elementUpdated(select); - expect(selectOpts(select)[itemIndex]?.selected).to.be.true; + + expect(select.open).to.be.false; }); - it('should select the first item that matches the term', async () => { - const term = 's'; + it('toggles open state on Enter keys', async () => { + simulateKeyboard(select, enterKey); await elementUpdated(select); - Array.from(term).forEach((char) => { - pressKey(target, char); - }); + expect(select.open).to.be.true; + expect(select.selectedItem).to.be.null; + simulateKeyboard(select, enterKey); await elementUpdated(select); - const item = selectOpts(select).find( - (i) => i.textContent?.toLocaleLowerCase()?.startsWith(term) - ); - expect(item?.selected).to.be.true; + + expect(select.open).to.be.false; + expect(select.selectedItem).to.be.null; }); - it('should select another valid item if the user starts typing after pausing', async () => { - let term = 'sp'; + it('does not toggle open state on Spacebar key', async () => { + simulateKeyboard(select, spaceBar); + await elementUpdated(select); - Array.from(term).forEach((char) => { - pressKey(target, char); - }); + expect(select.open).to.be.true; + expect(select.selectedItem).to.be.null; + simulateKeyboard(select, spaceBar); await elementUpdated(select); - let item = selectOpts(select).find( - (i) => i.textContent?.toLocaleLowerCase()?.startsWith(term) - ); - expect(item?.selected).to.be.true; + expect(select.open).to.be.true; + expect(select.selectedItem).to.be.null; + }); - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); + it('selects an item and closes the dropdown', async () => { + const targetItem = select.items[4]; - term = 'sa'; - Array.from(term).forEach((char) => { - pressKey(target, char); - }); + await openSelect(); + simulateClick(targetItem); await elementUpdated(select); - item = selectOpts(select).find( - (i) => i.textContent?.toLocaleLowerCase()?.startsWith(term) - ); - expect(item?.selected).to.be.true; + checkItemState(targetItem, { active: true, selected: true }); + expect(select.selectedItem).to.equal(targetItem); + expect(select.value).to.equal(targetItem.value); + expect(select.open).to.be.false; }); - it('should not select an item upon typing a valid term while the select is on focus and the item is disabled', async () => { - const disabledItem = items.find((i) => i.disabled); - const term = disabledItem?.text; + it('selects an item on click and does not close when keep-open-on-select is set', async () => { + const targetItem = select.items[4]; - Array.from(term!).forEach((char) => { - pressKey(target, char); - }); + select.keepOpenOnSelect = true; + await openSelect(); + simulateClick(targetItem); await elementUpdated(select); - const item = selectOpts(select).find( - (i) => i.value === disabledItem?.value - ); - - expect(item?.selected).to.be.false; + checkItemState(targetItem, { active: true, selected: true }); + expect(select.selectedItem).to.equal(targetItem); + expect(select.value).to.equal(targetItem.value); + expect(select.open).to.be.true; }); - it('should close the list of options when Tab or Shift + Tab are pressed', () => { - select.show(); + it('clicking on a disabled item is a no-op', async () => { + const targetItem = select.items[4]; + const initialValue = select.value; - pressKey(target, 'Tab'); - expect(select.open).to.be.false; + targetItem.disabled = true; + await openSelect(); - select.show(); + simulateClick(targetItem); + await elementUpdated(select); - pressKey(target, 'Tab', 1, { shiftKey: true }); - expect(select.open).to.be.false; + checkItemState(targetItem, { + active: false, + selected: false, + disabled: true, + }); + expect(select.selectedItem).to.be.null; + expect(select.value).to.equal(initialValue); + expect(select.open).to.be.true; }); - it('should focus when the focus method is called', async () => { - select.focus(); + it('clicking outside of the igc-select DOM tree closes the dropdown', async () => { + await openSelect(); + + simulateClick(document.body); await elementUpdated(select); - expect(document.activeElement).to.equal(select); + expect(select.open).to.be.false; }); - it('should blur when the blur method is called', async () => { - select.focus(); - await elementUpdated(select); + it('clicking outside of the igc-select DOM tree with keep-open-on-outside-click', async () => { + select.keepOpenOnOutsideClick = true; + await openSelect(); - select.blur(); + simulateClick(document.body); await elementUpdated(select); - expect(document.activeElement).to.not.equal(select); + expect(select.open).to.be.true; }); - it('does not emit `igcOpening` & `igcOpened` events on `show` method calls.', async () => { - select.open = false; - await elementUpdated(select); + it('pressing Escape closes the dropdown without selection', async () => { + await openSelect(); - const eventSpy = spy(select, 'emitEvent'); - select.show(); + simulateKeyboard(select, arrowDown, 4); + simulateKeyboard(select, escapeKey); await elementUpdated(select); - expect(select.open).to.be.true; - expect(eventSpy).not.to.be.called; + checkItemState(select.items[4], { active: true, selected: false }); + expect(select.selectedItem).to.be.null; + expect(select.value).to.be.undefined; + expect(select.open).to.be.false; }); - it('emits `igcOpening` & `igcOpened` events on clicking the target.', async () => { - select.open = false; + it('pressing Enter selects the active item and closes the dropdown', async () => { + await openSelect(); + + simulateKeyboard(select, arrowDown, 4); + simulateKeyboard(select, enterKey); await elementUpdated(select); - const eventSpy = spy(select, 'emitEvent'); - target.click(); + const item = select.items[4]; + + checkItemState(item, { active: true, selected: true }); + expect(select.selectedItem).to.equal(item); + expect(select.value).to.equal(item.value); + expect(select.open).to.be.false; + }); + + it('pressing Enter selects the active item and does not close the dropdown with keep-open-on-select', async () => { + select.keepOpenOnSelect = true; + await openSelect(); + + simulateKeyboard(select, arrowDown, 4); + simulateKeyboard(select, enterKey); await elementUpdated(select); + const item = select.items[4]; + + checkItemState(item, { active: true, selected: true }); + expect(select.selectedItem).to.equal(item); + expect(select.value).to.equal(item.value); expect(select.open).to.be.true; - expect(eventSpy).calledWith('igcOpening'); - expect(eventSpy).calledWith('igcOpened'); }); - it('does not emit `igcOpened` event and does not show the list on canceling `igcOpening` event.', async () => { - select.open = false; - select.addEventListener('igcOpening', (event: CustomEvent) => { - event.preventDefault(); - }); - const eventSpy = spy(select, 'emitEvent'); - await elementUpdated(select); + it('pressing Tab selects the active item and closes the dropdown', async () => { + await openSelect(); - target.click(); + simulateKeyboard(select, arrowDown, 4); + simulateKeyboard(select, tabKey); await elementUpdated(select); + const item = select.items[4]; + + checkItemState(item, { active: true, selected: true }); + expect(select.selectedItem).to.equal(item); + expect(select.value).to.equal(item.value); expect(select.open).to.be.false; - expect(eventSpy).calledOnceWithExactly('igcOpening', { - cancelable: true, - }); }); - it('does not emit `igcClosing` & `igcClosed` events on `hide` method calls.', async () => { - const eventSpy = spy(select, 'emitEvent'); - select.hide(); + it('pressing Tab selects the active item and closes the dropdown regardless of keep-open-on-select', async () => { + select.keepOpenOnSelect = true; + await openSelect(); + + simulateKeyboard(select, arrowDown, 4); + simulateKeyboard(select, tabKey); await elementUpdated(select); - expect(eventSpy).not.to.be.called; + const item = select.items[4]; + + checkItemState(item, { active: true, selected: true }); + expect(select.selectedItem).to.equal(item); + expect(select.value).to.equal(item.value); + expect(select.open).to.be.false; }); - it('emits `igcClosing` & `igcClosed` events on clicking the target.', async () => { - select.show(); + // Search selection + + it('does not select disabled items when searching (closed state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + + simulateKeyboard(select, 'tes'.split('')); await elementUpdated(select); + const item = select.items[2]; + expect(eventSpy).not.calledWith('igcChange', { detail: item }); + + checkItemState(item, { active: false, selected: false }); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; + }); + + it('does not activates disabled items when searching (open state)', async () => { const eventSpy = spy(select, 'emitEvent'); - target.click(); + await openSelect(); + + simulateKeyboard(select, 'tes'.split('')); await elementUpdated(select); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); + const item = select.items[2]; + expect(eventSpy).not.calledWith('igcChange', { detail: item }); + + checkItemState(item, { active: false, selected: false }); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; }); - it('does not emit `igcClosed` event and does not hide the list on canceling `igcClosing` event.', async () => { - select.show(); + it('resumes search after default timeout', async () => { + simulateKeyboard(select, 'null'.split('')); await elementUpdated(select); - select.addEventListener('igcClosing', (event: CustomEvent) => - event.preventDefault() - ); - const eventSpy = spy(select, 'emitEvent'); + expect(select.selectedItem).to.be.null; - selectOpts(select)[0].click(); + await aTimeout(501); + simulateKeyboard(select, 'impl'.split('')); await elementUpdated(select); - expect(select.open).to.be.true; - expect(eventSpy).calledWith('igcChange'); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).not.be.calledWith('igcClosed'); + expect(select.selectedItem?.value).to.equal('implementation'); }); - it('emits `igcChange`, `igcClosing` and `igcClosed` events on selecting an item via mouse click.', async () => { - select.show(); - await elementUpdated(select); + it('activates the correct item when searching with character keys (open state)', async () => { const eventSpy = spy(select, 'emitEvent'); + await openSelect(); - selectOpts(select)[1].click(); + simulateKeyboard(select, 'doc'.split('')); await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).calledWith('igcClosed'); + const item = select.items.at(-2)!; + expect(eventSpy).not.calledWith('igcChange', { detail: item }); + + checkItemState(item, { active: true, selected: false }); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; + expect(select.open).to.be.true; }); - it('emits `igcChange` events on selecting an item via `Arrow` keys.', async () => { + it('selects the correct item when searching (closed state)', async () => { const eventSpy = spy(select, 'emitEvent'); - pressKey(target, 'ArrowDown', 2); + + simulateKeyboard(select, 'doc'.split('')); await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + const item = select.items.at(-2)!; + expect(eventSpy).calledWith('igcChange', { detail: item }); - pressKey(target, 'ArrowRight'); - await elementUpdated(select); + checkItemState(item, { active: true, selected: true }); + expect(select.value).to.equal(item.value); + expect(select.selectedItem).to.equal(item); + expect(select.open).to.be.false; + }); - expect(eventSpy).calledWith('igcChange'); + // Navigation - pressKey(target, 'ArrowLeft'); + it('opens dropdown on Alt + ArrowDown', async () => { + simulateKeyboard(select, [altKey, arrowDown]); await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + expect(select.open).to.be.true; + }); - pressKey(target, 'ArrowUp'); + it('closes dropdown on Alt + ArrowUp', async () => { + await openSelect(); + + simulateKeyboard(select, [altKey, arrowUp]); await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); + expect(select.open).to.be.false; }); - it('selects an item but does not close the select on `Enter` key when `igcClosing` event is canceled.', async () => { - select.show(); - await elementUpdated(select); + it('Home key (closed state)', async () => { + const eventSpy = spy(select, 'emitEvent'); - select.addEventListener('igcClosing', (event: CustomEvent) => - event.preventDefault() - ); + simulateKeyboard(select, homeKey); await elementUpdated(select); + + const item = select.items[0]; + expect(eventSpy).calledOnceWith('igcChange', { detail: item }); + + checkItemState(item, { active: true, selected: true }); + expect(select.value).to.equal(item.value); + expect(select.selectedItem).to.equal(item); + expect(select.open).to.be.false; + }); + + it('Home key (open state)', async () => { const eventSpy = spy(select, 'emitEvent'); + await openSelect(); - pressKey(target, 'ArrowDown', 2); - pressKey(target, 'Enter'); + simulateKeyboard(select, homeKey); await elementUpdated(select); - expect(eventSpy).calledWith('igcChange'); - expect(eventSpy).calledWith('igcClosing'); - expect(eventSpy).not.be.calledWith('igcClosed'); + const item = select.items[0]; + expect(eventSpy).not.calledWith('igcChange', { detail: item }); + + checkItemState(item, { active: true, selected: false }); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; expect(select.open).to.be.true; }); - it('reports validity when required', async () => { - const validity = spy(select, 'reportValidity'); + it('End key (closed state)', async () => { + const eventSpy = spy(select, 'emitEvent'); - select.value = undefined; - select.required = true; + simulateKeyboard(select, endKey); await elementUpdated(select); - select.reportValidity(); - expect(validity).to.have.returned(false); - expect(select.invalid).to.be.true; + const item = select.items.at(-2)!; + expect(eventSpy).calledOnceWith('igcChange', { detail: item }); - select.value = items[0].value; + checkItemState(item, { active: true, selected: true }); + expect(select.value).to.equal(item.value); + expect(select.selectedItem).to.equal(item); + expect(select.open).to.be.false; + }); + + it('End key (open state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + await openSelect(); + + simulateKeyboard(select, endKey); await elementUpdated(select); - select.reportValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + const item = select.items.at(-2)!; + expect(eventSpy).not.calledWith('igcChange', { detail: item }); + + checkItemState(item, { active: true, selected: false }); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; + expect(select.open).to.be.true; }); - it('reports validity when not required', async () => { - const validity = spy(select, 'reportValidity'); + it('ArrowDown (closed state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + const activeItems = Items.filter((item) => !item.disabled); + const { value: lastValue } = activeItems.at(-1)!; - select.value = undefined; - select.required = false; - await elementUpdated(select); + // navigate through active items + for (const { value } of activeItems) { + simulateKeyboard(select, arrowDown); + await elementUpdated(select); - select.reportValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + expect(eventSpy).calledWith('igcChange', { detail: getActiveItem() }); + expect(getActiveItem()?.value).to.equal(value); + expect(select.value).to.equal(value); + expect(select.selectedItem).to.equal(getActiveItem()); + } - select.value = items[0].value; + // out-of-bounds + simulateKeyboard(select, arrowDown); await elementUpdated(select); - select.reportValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + expect(eventSpy.callCount).to.equal(activeItems.length); + expect(getActiveItem()?.value).to.equal(lastValue); + expect(select.value).to.equal(lastValue); + expect(select.selectedItem?.value).to.equal(lastValue); }); - it('checks validity when required', async () => { - const validity = spy(select, 'checkValidity'); + it('ArrowDown (open state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + const activeItems = Items.filter((item) => !item.disabled); + await openSelect(); - select.value = undefined; - select.required = true; - await elementUpdated(select); + // navigate through active items + for (const { value } of activeItems) { + simulateKeyboard(select, arrowDown); + await elementUpdated(select); - select.checkValidity(); - expect(validity).to.have.returned(false); - expect(select.invalid).to.be.true; + expect(eventSpy).not.calledWith('igChange'); + expect(getActiveItem()?.value).to.equal(value); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; + } - select.value = items[0].value; + // out-of-bounds - do not move active state + simulateKeyboard(select, arrowDown); await elementUpdated(select); - select.checkValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + expect(eventSpy).not.calledWith('igChange'); + expect(getActiveItem()?.value).to.equal(activeItems.at(-1)?.value); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; }); - it('checks validity when not required', async () => { - const validity = spy(select, 'checkValidity'); + // TODO + it('ArrowUp (closed state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + const activeItems = Items.filter((item) => !item.disabled).reverse(); + const { value: lastValue } = activeItems.at(-1)!; - select.value = undefined; - select.required = false; - await elementUpdated(select); + select.navigateTo('builds'); - select.checkValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + // navigate through active items + for (const { value } of activeItems) { + simulateKeyboard(select, arrowUp); + await elementUpdated(select); - select.value = items[0].value; + expect(eventSpy).calledWith('igcChange', { detail: getActiveItem() }); + expect(getActiveItem()?.value).to.equal(value); + expect(select.value).to.equal(value); + expect(select.selectedItem).to.equal(getActiveItem()); + } + + // out-of-bounds + simulateKeyboard(select, arrowUp); await elementUpdated(select); - select.checkValidity(); - expect(validity).to.have.returned(true); - expect(select.invalid).to.be.false; + expect(eventSpy.callCount).to.equal(activeItems.length); + expect(getActiveItem()?.value).to.equal(lastValue); + expect(select.value).to.equal(lastValue); + expect(select.selectedItem?.value).to.equal(lastValue); }); - it('displays the list of options at the proper position when `open` is initially set', async () => { - select = await fixture( - html` - ${items.map( - (item) => - html` - FR - ${item.text} - FR - ` - )} - This is helper text - ` - ); + it('ArrowUp (open state)', async () => { + const eventSpy = spy(select, 'emitEvent'); + const activeItems = Items.filter((item) => !item.disabled).reverse(); + await openSelect(); - await elementUpdated(select); + select.navigateTo('builds'); - expect(select.positionStrategy).to.eq('fixed'); - expect(select.placement).to.eq('bottom-start'); - expect(select.open).to.be.true; + // navigate through active items + for (const { value } of activeItems) { + simulateKeyboard(select, arrowUp); + await elementUpdated(select); - const selectListWrapperRect = ( - select.shadowRoot!.querySelector('[part="base"]') as HTMLElement - ).getBoundingClientRect(); - const selectWrapperRect = ( - select.shadowRoot!.querySelector('div[role="combobox"]') as HTMLElement - ).getBoundingClientRect(); - const helperTextEl = select.shadowRoot!.querySelector( - 'div[id="helper-text"]' - ) as HTMLSlotElement; - const helperTextElRect = helperTextEl.getBoundingClientRect(); - - // Check that the list options is displayed over the helper text - expect(selectListWrapperRect.x).to.eq(selectWrapperRect.x); - expect(helperTextElRect.x).to.eq(selectWrapperRect.x); - expect(selectListWrapperRect.y).to.eq(selectWrapperRect.bottom); - // the list options's Y coordinate is less than or equal to the helper text's Y value => it is proeprly displayed above it - expect(selectListWrapperRect.y).to.be.lessThanOrEqual(helperTextElRect.y); - - const elementUnder = document.elementFromPoint( - selectWrapperRect.x, - helperTextElRect.bottom - 1 - ); - expect(elementUnder).not.to.eq(helperTextEl); + expect(eventSpy).not.calledWith('igChange'); + expect(getActiveItem()?.value).to.equal(value); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; + } + + // out-of-bounds - do not move active state + simulateKeyboard(select, arrowUp); + await elementUpdated(select); + + expect(eventSpy).not.calledWith('igChange'); + expect(getActiveItem()?.value).to.equal(activeItems.at(-1)?.value); + expect(select.value).to.be.undefined; + expect(select.selectedItem).to.be.null; }); }); - describe('', () => { - const selectGroup = (el: IgcSelectComponent) => - [...el.querySelectorAll('igc-select-group')] as IgcSelectGroupComponent[]; + describe('Events', () => { + beforeEach(async () => { + select = await fixture(createBasicSelect()); + }); - let groups: IgcSelectGroupComponent[]; + it('correct sequence of events', async () => { + const eventSpy = spy(select, 'emitEvent'); - beforeEach(async () => { - select = await fixture( - html` - - Research & Development - ${items - .slice(0, 3) - .map( - (item) => - html`${item.text}` - )} - - - Product Guidance - ${items - .slice(3, 5) - .map( - (item) => - html`${item.text}` - )} - - - Release Engineering - ${items[5].text} - - ` - ); + simulateClick(getInput()); + await elementUpdated(select); + + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.secondCall).calledWith('igcOpened'); - select.open = true; + eventSpy.resetHistory(); + + simulateClick(select.items[0]); await elementUpdated(select); - groups = selectGroup(select); - }); - it('is successfully created with default properties.', () => { - expect(document.querySelector('igc-select-group')).to.exist; - expect(groups[0].disabled).to.be.false; + expect(eventSpy.firstCall).calledWith('igcChange', { + detail: select.items[0], + }); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).calledWith('igcClosed'); }); - it('displays grouped items properly.', () => { - expect(groups.length).to.eq(3); + it('does not emit events on API calls', async () => { + const eventSpy = spy(select, 'emitEvent'); - expect(groups[0].querySelectorAll('igc-select-item').length).to.eq(3); - expect(groups[1].querySelectorAll('igc-select-item').length).to.eq(2); - expect(groups[2].querySelectorAll('igc-select-item').length).to.eq(1); - }); + select.show(); + await elementUpdated(select); + expect(eventSpy).not.to.be.called; - it('displays group headers properly.', () => { - expect(groups[0].querySelector('igc-select-header')!.textContent).to.eq( - 'Research & Development' - ); - expect(groups[1].querySelector('igc-select-header')!.textContent).to.eq( - 'Product Guidance' - ); - expect(groups[2].querySelector('igc-select-header')!.textContent).to.eq( - 'Release Engineering' - ); + select.hide(); + await elementUpdated(select); + expect(eventSpy).not.to.be.called; + + select.select('testing'); + expect(eventSpy).not.to.be.called; }); - it('should disable all group items when the group is disabled', async () => { - groups[0].disabled = true; + it('can halt opening event sequence', async () => { + const eventSpy = spy(select, 'emitEvent'); + select.addEventListener('igcOpening', (e) => e.preventDefault(), { + once: true, + }); + + simulateClick(getInput()); await elementUpdated(select); - groups[0].items.forEach((i) => expect(i.disabled).to.be.true); + expect(select.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.secondCall).to.be.null; }); - it('should restore disabled items to inital disabled state when group is no longer disabled', async () => { - const item = groups[0].items[0]; - item.disabled = true; + it('can halt closing event sequence', async () => { + const eventSpy = spy(select, 'emitEvent'); + select.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + // No selection + select.show(); await elementUpdated(select); - groups[0].disabled = true; + simulateKeyboard(select, escapeKey); await elementUpdated(select); - groups[0].items.forEach((i) => expect(i.disabled).to.be.true); + expect(select.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).to.be.null; + + eventSpy.resetHistory(); - groups[0].disabled = false; + // With selection + select.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + select.show(); await elementUpdated(select); - expect(item.disabled).to.be.true; + simulateKeyboard(select, arrowDown, 2); + simulateKeyboard(select, enterKey); + await elementUpdated(select); + + expect(select.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcChange'); + expect(eventSpy.secondCall).calledWith('igcClosing'); + expect(eventSpy.thirdCall).to.be.null; }); - it('should not let items to be programatically enabled in a disabled group', async () => { - groups[0].disabled = true; - await elementUpdated(select); + it('can halt closing event sequence on outside click', async () => { + const eventSpy = spy(select, 'emitEvent'); + select.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); - groups[0].items.forEach((i) => expect(i.disabled).to.be.true); + select.show(); + await elementUpdated(select); - groups[0].items[0].disabled = false; + simulateClick(document.body); await elementUpdated(select); - groups[0].items.forEach((i) => expect(i.disabled).to.be.true); + expect(select.open).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).to.be.null; }); }); describe('Form integration', () => { - const spec = new FormAssociatedTestBed(html` - ${items.map( - (item) => - html` - ${item.text} - ` - )} - `); + const spec = new FormAssociatedTestBed( + createBasicSelect() + ); beforeEach(async () => { await spec.setup(IgcSelectComponent.tagName); }); it('is form associated', async () => { - expect(spec.element.form).to.eql(spec.form); + expect(spec.element.form).to.equal(spec.form); }); - it('is not associated on submit if no value', async () => { + it('is not associated on submit if not value is present', async () => { expect(spec.submit()?.get(spec.element.name)).to.be.null; }); it('is associated on submit', async () => { spec.element.value = 'spec'; - await elementUpdated(spec.element); expect(spec.submit()?.get(spec.element.name)).to.equal( spec.element.value ); }); - it('is correctly reset of form reset', async () => { + it('is correctly reset on form reset', async () => { spec.element.value = 'spec'; - await elementUpdated(spec.element); spec.reset(); expect(spec.element.value).to.equal(undefined); @@ -931,8 +1257,6 @@ describe('Select component', () => { expect(bed.element.value).to.eq('3'); bed.element.value = '1'; - await elementUpdated(bed.element); - expect(bed.element.value).to.eq('1'); bed.reset(); @@ -966,20 +1290,3 @@ describe('Select component', () => { }); }); }); -const pressKey = ( - target: HTMLElement, - key: string, - times = 1, - options?: Object -) => { - for (let i = 0; i < times; i++) { - target.dispatchEvent( - new KeyboardEvent('keydown', { - key: key, - bubbles: true, - composed: true, - ...options, - }) - ); - } -}; diff --git a/src/components/select/select.ts b/src/components/select/select.ts index a0bf689d8..799da5b88 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -6,8 +6,6 @@ import { state, } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { live } from 'lit/directives/live.js'; -import { styleMap } from 'lit/directives/style-map.js'; import IgcSelectGroupComponent from './select-group.js'; import IgcSelectHeaderComponent from './select-header.js'; @@ -16,25 +14,52 @@ import { styles } from './themes/select.base.css.js'; import { all } from './themes/themes.js'; import { themeSymbol, themes } from '../../theming/theming-decorator.js'; import type { Theme } from '../../theming/types.js'; +import { + addKeybindings, + altKey, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + endKey, + enterKey, + escapeKey, + homeKey, + spaceBar, + tabKey, +} from '../common/controllers/key-bindings.js'; +import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { addRootScrollHandler } from '../common/controllers/root-scroll.js'; import { alternateName } from '../common/decorators/alternateName.js'; import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js'; +import { blazorSuppress } from '../common/decorators/blazorSuppress.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import { + IgcBaseComboBoxLikeComponent, + getActiveItems, + getItems, + getNextActiveItem, + getPreviousActiveItem, + setInitialSelectionState, +} from '../common/mixins/combo-box.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { partNameMap } from '../common/util.js'; import { Validator, requiredValidator } from '../common/validators.js'; -import IgcDropdownItemComponent from '../dropdown/dropdown-item.js'; -import IgcDropdownComponent, { - IgcDropdownEventMap, -} from '../dropdown/dropdown.js'; import IgcIconComponent from '../icon/icon.js'; import IgcInputComponent from '../input/input.js'; +import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; -export interface IgcSelectEventMap extends IgcDropdownEventMap { - igcFocus: CustomEvent; +export interface IgcSelectEventMap { + igcChange: CustomEvent; igcBlur: CustomEvent; + igcFocus: CustomEvent; + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; } /** @@ -49,6 +74,7 @@ export interface IgcSelectEventMap extends IgcDropdownEventMap { * @slot footer - Renders a container after the list of options. * @slot helper-text - Renders content below the input. * @slot toggle-icon - Renders content inside the suffix container. + * @slot toggle-icon-expanded - Renders content for the toggle icon when the component is in open state. * * @fires igcFocus - Emitted when the select gains focus. * @fires igcBlur - Emitted when the select loses focus. @@ -68,12 +94,13 @@ export interface IgcSelectEventMap extends IgcDropdownEventMap { */ @themes(all, true) @blazorAdditionalDependencies( - 'IgcIconComponent, IgcInputComponent, IgcSelectGroupComponent, IgcSelectHeaderComponent, IgcSelectItemComponent' + 'IgcIconComponent, IgcInputComponent, IgcPopoverComponent, IgcSelectGroupComponent, IgcSelectHeaderComponent, IgcSelectItemComponent' ) export default class IgcSelectComponent extends FormAssociatedRequiredMixin( - EventEmitterMixin>( - IgcDropdownComponent - ) + EventEmitterMixin< + IgcSelectEventMap, + Constructor + >(IgcBaseComboBoxLikeComponent) ) { public static readonly tagName = 'igc-select'; public static styles = styles; @@ -83,38 +110,49 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( this, IgcIconComponent, IgcInputComponent, + IgcPopoverComponent, IgcSelectGroupComponent, IgcSelectHeaderComponent, IgcSelectItemComponent ); } - private searchTerm = ''; - private lastKeyTime = Date.now(); - - protected override validators: Validator[] = [requiredValidator]; private declare readonly [themeSymbol]: Theme; + private _value!: string; + private _searchTerm = ''; + private _lastKeyTime = 0; - private readonly targetKeyHandlers: Map = new Map( - Object.entries({ - ' ': this.onTargetEnterKey, - Tab: this.onTargetTabKey, - Escape: this.onEscapeKey, - Enter: this.onTargetEnterKey, - ArrowLeft: this.onTargetArrowLeftKeyDown, - ArrowRight: this.onTargetArrowRightKeyDown, - ArrowUp: this.onTargetArrowUpKeyDown, - ArrowDown: this.onTargetArrowDownKeyDown, - Home: this.onTargetHomeKey, - End: this.onTargetEndKey, - }) - ); + private _rootClickController = addRootClickHandler(this, { + hideCallback: () => this._hide(true), + }); + + private _rootScrollController = addRootScrollHandler(this, { + hideCallback: () => this._hide(true), + }); + + private get isMaterialTheme() { + return this[themeSymbol] === 'material'; + } + + private get _activeItems() { + return Array.from( + getActiveItems( + this, + IgcSelectItemComponent.tagName + ) + ); + } + + @state() + protected _selectedItem: IgcSelectItemComponent | null = null; - @queryAssignedElements({ flatten: true, selector: 'igc-select-item' }) - protected override items!: Array; + @state() + protected _activeItem!: IgcSelectItemComponent; + + protected override validators: Validator[] = [requiredValidator]; - @queryAssignedElements({ flatten: true, selector: 'igc-select-group' }) - protected override groups!: Array; + @query(IgcInputComponent.tagName, true) + protected input!: IgcInputComponent; @queryAssignedElements({ slot: 'helper-text' }) protected helperText!: Array; @@ -125,18 +163,39 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( @queryAssignedElements({ slot: 'prefix' }) protected inputPrefix!: Array; - @state() - protected override selectedItem!: IgcSelectItemComponent | null; + @queryAssignedElements({ slot: 'toggle-icon-expanded' }) + protected _expandedIconSlot!: Array; + + protected get hasExpandedIcon() { + return this._expandedIconSlot.length > 0; + } - @query('div[role="combobox"]') - protected override target!: IgcInputComponent; + protected get hasPrefixes() { + return this.inputPrefix.length > 0; + } + + protected get hasSuffixes() { + return this.inputSuffix.length > 0; + } + + protected get hasHelperText() { + return this.helperText.length > 0; + } /** * The value attribute of the control. * @attr */ - @property({ reflect: false }) - public value?: string; + @property() + public get value(): string { + return this._value; + } + + public set value(value: string) { + this._updateValue(value); + const item = this.getItem(this._value); + item ? this.setSelectedItem(item) : this.clearSelectedItem(); + } /** * The outlined attribute of the control. @@ -152,6 +211,13 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( @property({ type: Boolean }) public override autofocus!: boolean; + /** + * The distance of the select dropdown from its input. + * @attr + */ + @property({ type: Number }) + public distance = 0; + /** * The label attribute of the control. * @attr @@ -166,11 +232,25 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( @property() public placeholder!: string; + /** The preferred placement of the select dropdown around its input. + * @type {'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end'} + * @attr + */ + @property() + public placement: IgcPlacement = 'bottom-start'; + /** * @deprecated since version 4.3.0 * @hidden @internal @private */ - public override positionStrategy: 'absolute' | 'fixed' = 'fixed'; + public positionStrategy: 'absolute' | 'fixed' = 'fixed'; + + /** + * Determines the behavior of the component during scrolling of the parent container. + * @attr scroll-strategy + */ + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: 'scroll' | 'block' | 'close' = 'scroll'; /** * Whether the dropdown's width should be the same as the target's one. @@ -179,7 +259,7 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( * @attr same-width */ @property({ type: Boolean, attribute: 'same-width' }) - public override sameWidth = true; + public sameWidth = true; /** * Whether the component should be flipped to the opposite side of the target once it's about to overflow the visible area. @@ -189,315 +269,456 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( * @attr */ @property({ type: Boolean }) - public override flip = true; - - constructor() { - super(); - this.size = 'medium'; + public flip = true; - /** Return the focus to the target element when closing the list of options. */ - this.addEventListener('igcChange', () => { - if (this.open) this.target.focus(); - }); + /** Returns the items of the igc-select component. */ + public get items() { + return Array.from( + getItems(this, IgcSelectItemComponent.tagName) + ); } - /** Override the dropdown target focusout behavior to prevent the focus from - * being returned to the target element when the select loses focus. */ - protected override handleFocusout() {} + /** Returns the groups of the igc-select component. */ + public get groups() { + return Array.from( + getItems(this, IgcSelectGroupComponent.tagName) + ); + } - /** Monitor input slot changes and request update */ - protected inputSlotChanged() { - this.requestUpdate(); + /** Returns the selected item from the dropdown or null. */ + public get selectedItem() { + return this._selectedItem; } - /** Sets focus on the component. */ - @alternateName('focusComponent') - public override focus(options?: FocusOptions) { - this.target.focus(options); + @watch('scrollStrategy', { waitUntilFirstUpdate: true }) + protected scrollStrategyChanged() { + this._rootScrollController.update({ resetListeners: true }); } - /** Removes focus from the component. */ - @alternateName('blurComponent') - public override blur() { - this.target.blur(); - super.blur(); + @watch('open', { waitUntilFirstUpdate: true }) + protected openChange() { + this._rootClickController.update(); + this._rootScrollController.update(); } - /** Checks the validity of the control and moves the focus to it if it is not valid. */ - public override reportValidity() { - const valid = super.reportValidity(); - if (!valid) this.target.focus(); - return valid; + constructor() { + super(); + + addKeybindings(this, { + skip: () => this.disabled, + bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] }, + }) + .set([altKey, arrowDown], this.altArrowDown) + .set([altKey, arrowUp], this.altArrowUp) + .set(arrowDown, this.onArrowDown) + .set(arrowUp, this.onArrowUp) + .set(arrowLeft, this.onArrowUp) + .set(arrowRight, this.onArrowDown) + .set(tabKey, this.onTabKey, { preventDefault: false }) + .set(escapeKey, this.onEscapeKey) + .set(homeKey, this.onHomeKey) + .set(endKey, this.onEndKey) + .set(spaceBar, this.onSpaceBarKey) + .set(enterKey, this.onEnterKey); + + this.addEventListener('keydown', this.handleSearch); + this.addEventListener('focusin', this.handleFocusIn); + this.addEventListener('focusout', this.handleFocusOut); } protected override async firstUpdated() { - super.firstUpdated(); await this.updateComplete; + const selected = setInitialSelectionState(this.items); + + if (this.value && !selected) { + this._selectItem(this.getItem(this.value), false); + } - if (!this.selectedItem && this.value) { - this.updateSelected(); - } else if (this.selectedItem && !this.value) { - this._defaultValue = this.selectedItem.value; + if (selected && selected.value !== this.value) { + this._defaultValue = selected.value; + this._selectItem(selected, false); } if (this.autofocus) { - this.target.focus(); + this.focus(); } this.updateValidity(); } - @watch('selectedItem') - protected updateValue() { - this.value = this.selectedItem?.value; - } + private handleFocusIn({ relatedTarget }: FocusEvent) { + this._dirty = true; - @watch('value') - protected updateSelected() { - if (this.allItems.length === 0) { - this.setFormValue(null); - this.updateValidity(); - this.setInvalidState(); + if (this.contains(relatedTarget as Node) || this.open) { return; } - if (this.selectedItem?.value !== this.value) { - const matches = this.allItems.filter((i) => i.value === this.value); - const index = this.allItems.indexOf( - matches.at(-1) as IgcSelectItemComponent - ); - - if (index === -1) { - this.value = undefined; - this.clearSelection(); - this.setFormValue(null); - this.updateValidity(); - this.setInvalidState(); - return; - } - - this.select(index); + this.emitEvent('igcFocus'); + } + + private handleFocusOut({ relatedTarget }: FocusEvent) { + if (this.contains(relatedTarget as Node)) { + return; } - this.setFormValue(this.value!); - this.updateValidity(); - this.setInvalidState(); + + this.checkValidity(); + this.emitEvent('igcBlur'); } - protected selectNext() { - const activeItemIndex = [...this.allItems].indexOf( - this.selectedItem ?? this.activeItem - ); + private handleClick(event: MouseEvent) { + const item = event.target as IgcSelectItemComponent; + if (this._activeItems.includes(item)) { + this._selectItem(item); + } + } - const next = this.getNearestSiblingFocusableItemIndex( - activeItemIndex ?? -1, - 1 - ); - this.selectItem(this.allItems[next], true); + private handleChange(item: IgcSelectItemComponent) { + return this.emitEvent('igcChange', { detail: item }); } - protected selectPrev() { - const activeItemIndex = [...this.allItems].indexOf( - this.selectedItem ?? this.activeItem - ); - const prev = this.getNearestSiblingFocusableItemIndex( - activeItemIndex ?? -1, - -1 + private handleSearch(event: KeyboardEvent) { + if (!/^.$/u.test(event.key)) { + return; + } + + event.preventDefault(); + const now = Date.now(); + + if (now - this._lastKeyTime > 500) { + this._searchTerm = ''; + } + + this._lastKeyTime = now; + this._searchTerm += event.key.toLocaleLowerCase(); + + const item = this._activeItems.find( + (item) => + item.textContent + ?.trim() + .toLocaleLowerCase() + .startsWith(this._searchTerm) ); - this.selectItem(this.allItems[prev], true); + + if (item) { + this.open ? this.activateItem(item) : this._selectItem(item); + } } - protected selectInteractiveItem(index: number) { - const item = this.allItems - .filter((i) => !i.disabled) - .at(index) as IgcDropdownItemComponent; + private onEnterKey() { + this.open && this._activeItem + ? this._selectItem(this._activeItem) + : this.handleAnchorClick(); + } - if (item.value !== this.value) { - this.selectItem(item, true); + private onSpaceBarKey() { + if (!this.open) { + this.handleAnchorClick(); } } - protected searchItem(event: KeyboardEvent): void { - // ignore longer keys ('Alt', 'ArrowDown', etc) - if (!/^.$/u.test(event.key)) { - return; + private onArrowDown() { + const item = getNextActiveItem(this.items, this._activeItem); + this.open ? this._navigateToActiveItem(item) : this._selectItem(item); + } + + private onArrowUp() { + const item = getPreviousActiveItem(this.items, this._activeItem); + this.open ? this._navigateToActiveItem(item) : this._selectItem(item); + } + + private altArrowDown() { + if (!this.open) { + this._show(true); } + } - const currentTime = Date.now(); + private async altArrowUp() { + if (this.open && (await this._hide(true))) { + this.input.focus(); + } + } - if (currentTime - this.lastKeyTime > 500) { - this.searchTerm = ''; + protected async onEscapeKey() { + if (await this._hide(true)) { + this.input.focus(); } + } - this.searchTerm += event.key; - this.lastKeyTime = currentTime; - - const item = this.allItems - .filter((i) => !i.disabled) - .find( - (i) => - i.textContent - ?.trim() - .toLowerCase() - .startsWith(this.searchTerm.toLowerCase()) - ); - - if (item && this.value !== item.value) { - this.open ? this.activateItem(item) : this.selectItem(item); + private onTabKey(event: KeyboardEvent) { + if (this.open) { + event.preventDefault(); + this._selectItem(this._activeItem); + this._hide(true); } } - protected handleFocus() { - this._dirty = true; - if (this.open) return; - this.emitEvent('igcFocus'); + protected onHomeKey() { + const item = this._activeItems.at(0); + this.open ? this._navigateToActiveItem(item) : this._selectItem(item); } - protected handleBlur() { - this.setInvalidState(); - if (this.open) return; - this.emitEvent('igcBlur'); + protected onEndKey() { + const item = this._activeItems.at(-1); + this.open ? this._navigateToActiveItem(item) : this._selectItem(item); } - protected onTargetTabKey() { - this.target.blur(); - if (this.open) this.hide(); + /** Monitor input slot changes and request update */ + protected inputSlotChanged() { + this.requestUpdate(); } - protected onTargetEnterKey() { - !this.open ? this.target.click() : this.onEnterKey(); + private activateItem(item: IgcSelectItemComponent) { + if (this._activeItem) { + this._activeItem.active = false; + } + + this._activeItem = item; + this._activeItem.active = true; } - protected onTargetArrowLeftKeyDown() { - !this.open ? this.selectPrev() : this.onArrowUpKeyDown(); + private setSelectedItem(item: IgcSelectItemComponent) { + if (this._selectedItem) { + this._selectedItem.selected = false; + } + + this._selectedItem = item; + this._selectedItem.selected = true; + + return this._selectedItem; } - protected onTargetArrowRightKeyDown() { - !this.open ? this.selectNext() : this.onArrowDownKeyDown(); + private _selectItem(item?: IgcSelectItemComponent, emit = true) { + if (!item) { + this.clearSelectedItem(); + this._updateValue(); + return null; + } + + const items = this.items; + const [previous, current] = [ + items.indexOf(this._selectedItem!), + items.indexOf(item), + ]; + + if (previous === current) { + return this._selectedItem; + } + + const newItem = this.setSelectedItem(item); + this.activateItem(newItem); + this._updateValue(newItem.value); + + if (emit) this.handleChange(newItem); + if (emit && this.open) this.input.focus(); + if (emit && !this.keepOpenOnSelect) this._hide(true); + + return this._selectedItem; } - protected onTargetArrowUpKeyDown(event: KeyboardEvent) { - if (event.altKey) { - this.toggle(); - } else { - !this.open ? this.selectPrev() : this.onArrowUpKeyDown(); + private _navigateToActiveItem(item?: IgcSelectItemComponent) { + if (item) { + this.activateItem(item); + item.scrollIntoView({ behavior: 'auto', block: 'nearest' }); } } - protected onTargetArrowDownKeyDown(event: KeyboardEvent) { - if (event.altKey) { - this.toggle(); - } else { - !this.open ? this.selectNext() : this.onArrowDownKeyDown(); + private _updateValue(value?: string) { + this._value = value as string; + this.setFormValue(this._value ? this._value : null); + this.updateValidity(); + this.setInvalidState(); + } + + private clearSelectedItem() { + if (this._selectedItem) { + this._selectedItem.selected = false; } + this._selectedItem = null; } - protected onTargetHomeKey() { - !this.open ? this.selectInteractiveItem(0) : this.onHomeKey(); + protected getItem(value: string) { + return this.items.find((item) => item.value === value); } - protected onTargetEndKey() { - !this.open ? this.selectInteractiveItem(-1) : this.onEndKey(); + private _stopPropagation(e: Event) { + e.stopPropagation(); } - protected handleTargetKeyDown(event: KeyboardEvent) { - event.stopPropagation(); + /** Sets focus on the component. */ + @alternateName('focusComponent') + public override focus(options?: FocusOptions) { + this.input.focus(options); + } - if (this.targetKeyHandlers.has(event.key)) { - event.preventDefault(); - this.targetKeyHandlers.get(event.key)?.call(this, event); - } else { - this.searchItem(event); + /** Removes focus from the component. */ + @alternateName('blurComponent') + public override blur() { + this.input.blur(); + super.blur(); + } + + /** Checks the validity of the control and moves the focus to it if it is not valid. */ + public override reportValidity() { + const valid = super.reportValidity(); + if (!valid) this.input.focus(); + return valid; + } + + /** Navigates to the item with the specified value. If it exists, returns the found item, otherwise - null. */ + public navigateTo(value: string): IgcSelectItemComponent | null; + /** Navigates to the item at the specified index. If it exists, returns the found item, otherwise - null. */ + public navigateTo(index: number): IgcSelectItemComponent | null; + /** Navigates to the specified item. If it exists, returns the found item, otherwise - null. */ + @blazorSuppress() + public navigateTo(value: string | number): IgcSelectItemComponent | null { + const item = + typeof value === 'string' ? this.getItem(value) : this.items[value]; + + if (item) { + this._navigateToActiveItem(item); } + + return item ?? null; } - protected get hasPrefixes() { - return this.inputPrefix.length > 0; + /** Selects the item with the specified value. If it exists, returns the found item, otherwise - null. */ + public select(value: string): IgcSelectItemComponent | null; + /** Selects the item at the specified index. If it exists, returns the found item, otherwise - null. */ + public select(index: number): IgcSelectItemComponent | null; + /** Selects the specified item. If it exists, returns the found item, otherwise - null. */ + @blazorSuppress() + public select(value: string | number): IgcSelectItemComponent | null { + const item = + typeof value === 'string' ? this.getItem(value) : this.items[value]; + return item ? this._selectItem(item, false) : null; } - protected get hasSuffixes() { - return this.inputSuffix.length > 0; + /** Resets the current value and selection of the component. */ + public clearSelection() { + this._updateValue(); + this.clearSelectedItem(); } - protected override render() { - const openIcon = - this[themeSymbol] === 'material' ? 'keyboard_arrow_up' : 'arrow_drop_up'; - const closeIcon = - this[themeSymbol] === 'material' - ? 'keyboard_arrow_down' - : 'arrow_drop_down'; + protected renderInputSlots() { + const prefixName = this.hasPrefixes ? 'prefix' : ''; + const suffixName = this.hasSuffixes ? 'suffix' : ''; return html` -
- e.stopPropagation()} - @igcFocus=${(e: Event) => e.stopPropagation()} + + + + + + + + `; + } + + protected renderToggleIcon() { + const parts = partNameMap({ 'toggle-icon': true, filled: this.value! }); + const iconHidden = this.open && this.hasExpandedIcon; + const iconExpandedHidden = !this.hasExpandedIcon || !this.open; + + const openIcon = this.isMaterialTheme + ? 'keyboard_arrow_up' + : 'arrow_drop_up'; + const closeIcon = this.isMaterialTheme + ? 'keyboard_arrow_down' + : 'arrow_drop_down'; + + return html` + -
+ + + + + `; + } + + protected renderHelperText() { + return html`
- +
+ `; + } + + protected renderInputAnchor() { + const value = this.selectedItem?.textContent?.trim(); + + return html` + + ${this.renderInputSlots()} ${this.renderToggleIcon()} + + + ${this.renderHelperText()} + `; + } + + protected renderDropdown() { + return html`
- `; +
`; + } + + protected override render() { + return html`${this.renderInputAnchor()} ${this.renderDropdown()} + `; } } diff --git a/src/components/toggle/types.ts b/src/components/toggle/types.ts index 7e7e8fd85..8cbf4ac91 100644 --- a/src/components/toggle/types.ts +++ b/src/components/toggle/types.ts @@ -50,7 +50,7 @@ export interface IgcToggleComponent extends IgcToggleOptions { /** * Describes the preferred placement of a toggle component. */ -export type IgcPlacement = +type IgcPlacement = | 'top' | 'top-start' | 'top-end' diff --git a/stories/dropdown.stories.ts b/stories/dropdown.stories.ts index 9d1f9e3af..7630c7a00 100644 --- a/stories/dropdown.stories.ts +++ b/stories/dropdown.stories.ts @@ -1,10 +1,12 @@ import { github, whiteHouse1 } from '@igniteui/material-icons-extended'; import { Meta, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; +import { range } from 'lit/directives/range.js'; import { + IgcButtonComponent, IgcDropdownComponent, - IgcDropdownItemComponent, + IgcIconComponent, IgcInputComponent, defineComponents, registerIconFromText, @@ -16,7 +18,12 @@ icons.forEach((icon) => { registerIconFromText(icon.name, icon.value); }); -defineComponents(IgcDropdownComponent, IgcInputComponent); +defineComponents( + IgcDropdownComponent, + IgcInputComponent, + IgcButtonComponent, + IgcIconComponent +); // region default const metadata: Meta = { @@ -35,18 +42,6 @@ const metadata: Meta = { }, }, argTypes: { - keepOpenOnSelect: { - type: 'boolean', - description: 'Whether the dropdown should be kept open on selection.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, - open: { - type: 'boolean', - description: 'Sets the open state of the component.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, placement: { type: '"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "right" | "right-start" | "right-end" | "left" | "left-start" | "left-end"', description: @@ -78,7 +73,7 @@ const metadata: Meta = { scrollStrategy: { type: '"scroll" | "block" | "close"', description: - 'Determines the behavior of the component during scrolling the container.', + 'Determines the behavior of the component during scrolling of the parent container.', options: ['scroll', 'block', 'close'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'scroll' } }, @@ -96,41 +91,50 @@ const metadata: Meta = { control: 'number', table: { defaultValue: { summary: 0 } }, }, - keepOpenOnOutsideClick: { + sameWidth: { type: 'boolean', description: - 'Whether the component should be kept open on clicking outside of it.', + "Whether the dropdown's width should be the same as the target's one.", control: 'boolean', table: { defaultValue: { summary: false } }, }, - sameWidth: { + keepOpenOnSelect: { type: 'boolean', description: - "Whether the dropdown's width should be the same as the target's one.", + 'Whether the component dropdown should be kept open on selection.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + keepOpenOnOutsideClick: { + type: 'boolean', + description: + 'Whether the component dropdown should be kept open on clicking outside of it.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + open: { + type: 'boolean', + description: 'Sets the open state of the component.', control: 'boolean', table: { defaultValue: { summary: false } }, }, }, args: { - keepOpenOnSelect: false, - open: false, placement: 'bottom-start', positionStrategy: 'absolute', scrollStrategy: 'scroll', flip: false, distance: 0, - keepOpenOnOutsideClick: false, sameWidth: false, + keepOpenOnSelect: false, + keepOpenOnOutsideClick: false, + open: false, }, }; export default metadata; interface IgcDropdownArgs { - /** Whether the dropdown should be kept open on selection. */ - keepOpenOnSelect: boolean; - /** Sets the open state of the component. */ - open: boolean; /** The preferred placement of the component around the target element. */ placement: | 'top' @@ -147,7 +151,7 @@ interface IgcDropdownArgs { | 'left-end'; /** Sets the component's positioning strategy. */ positionStrategy: 'absolute' | 'fixed'; - /** Determines the behavior of the component during scrolling the container. */ + /** Determines the behavior of the component during scrolling of the parent container. */ scrollStrategy: 'scroll' | 'block' | 'close'; /** * Whether the component should be flipped to the opposite side of the target once it's about to overflow the visible area. @@ -156,269 +160,273 @@ interface IgcDropdownArgs { flip: boolean; /** The distance from the target element. */ distance: number; - /** Whether the component should be kept open on clicking outside of it. */ - keepOpenOnOutsideClick: boolean; /** Whether the dropdown's width should be the same as the target's one. */ sameWidth: boolean; + /** Whether the component dropdown should be kept open on selection. */ + keepOpenOnSelect: boolean; + /** Whether the component dropdown should be kept open on clicking outside of it. */ + keepOpenOnOutsideClick: boolean; + /** Sets the open state of the component. */ + open: boolean; } type Story = StoryObj; // endregion -const toggleDDL = (ev: Event, ddlId: string) => { - const ddl = document.getElementById(ddlId) as IgcDropdownComponent; - if (ddlId === 'ddl2') { - const target = ev.target as HTMLElement; - ddl.placement = target.id === 'ddlButton2' ? 'top-end' : 'bottom-start'; - ddl.toggle(ev.target as HTMLElement); - } else { - ev.stopPropagation(); - ddl.toggle(); - } -}; - -const items = [ +const Items = [ 'Specification', 'Implementation', 'Testing', 'Samples', 'Documentation', 'Builds', -]; -const Template = ({ - open = false, - flip = false, - keepOpenOnOutsideClick = false, - positionStrategy = 'absolute', - placement = 'bottom-start', - scrollStrategy = 'block', - keepOpenOnSelect = false, - sameWidth = false, - distance = 0, -}: IgcDropdownArgs) => html` -
+].map( + (each) => html`${each}` +); + +const overflowItems = Array.from(range(1, 51)).map( + (each) => + html`Item ${each}` +); + +export const Basic: Story = { + render: ({ + open, + flip, + keepOpenOnOutsideClick, + keepOpenOnSelect, + sameWidth, + placement, + positionStrategy, + distance, + scrollStrategy, + }) => html` - Dropdown 1 - Tasks - - ${items - .slice(0, 2) - .map( - (item) => - html`${item}` - )} - ${html`${items[2]}`} - ${html`${items[3]}`} - ${html`${items[4]}`} - ${html`${items[5]}`} + Tasks + Available tasks: + ${Items} + `, +}; + +export const Overflow: Story = { + args: { + sameWidth: true, + }, + render: ({ + distance, + open, + flip, + keepOpenOnOutsideClick, + keepOpenOnSelect, + placement, + positionStrategy, + sameWidth, + scrollStrategy, + }) => html` + - Dropdown 2.1 - Dropdown 2.2 - - -

Research & Development

- ${items - .slice(0, 3) - .map( - (item) => html`${item}` - )} -
- -

Product Guidance

- ${items - .slice(3, 5) - .map( - (item) => html`${item}` - )} -
- -

Release Engineering

- ${items[5]}- -
-
-
+ #overflowing::part(list) { + max-height: 50vh; + } + + + `, +}; - - Dropdown 3 - ${items.map( - (item) => html`${item}` - )} - +const gdpEurope = [ + { + country: 'Luxembourg', + value: '135,605', + flag: `https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Flag_of_Luxembourg.svg/23px-Flag_of_Luxembourg.svg.png`, + }, + { + country: 'Ireland', + value: '112,248', + flag: `https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Flag_of_Ireland.svg/23px-Flag_of_Ireland.svg.png`, + }, + { + country: 'Switzerland', + value: '102,865', + flag: `https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Flag_of_Switzerland_%28Pantone%29.svg/15px-Flag_of_Switzerland_%28Pantone%29.svg.png`, + }, +].map( + ({ country, flag, value }) => + html` + Flag of ${country} + ${country} ${value} + ` +); +const gdpAmericas = [ + { + country: 'United States', + value: '80,412', + flag: `https://upload.wikimedia.org/wikipedia/en/thumb/a/a4/Flag_of_the_United_States.svg/23px-Flag_of_the_United_States.svg.png`, + }, + { + country: 'Canada', + value: '53,247', + flag: `https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Canada_%28Pantone%29.svg/23px-Flag_of_Canada_%28Pantone%29.svg.png`, + }, + { + country: 'Puerto Rico', + value: '37,093', + flag: `https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/Flag_of_Puerto_Rico.svg/23px-Flag_of_Puerto_Rico.svg.png`, + }, +].map( + ({ country, flag, value }) => + html` + Flag of ${country} + ${country} ${value} + ` +); + +export const GroupsAndHeaders: Story = { + args: { + sameWidth: true, + }, + render: ({ + open, + keepOpenOnOutsideClick, + keepOpenOnSelect, + distance, + flip, + placement, + positionStrategy, + sameWidth, + }) => html` + - - - ${items.map( - (item) => html`${item}` - )} + GDP (in USD) per capita by country (IMF) + + +

+ UN Region: Europe +

+ Estimate for 2023 + ${gdpEurope} +
+ + +

+ UN Region: Americas +

+ Estimate for 2023 + ${gdpAmericas} +
+ `, +}; + +export const WithNonSlottedTarget: Story = { + render: ({ + distance, + open, + flip, + keepOpenOnOutsideClick, + keepOpenOnSelect, + placement, + positionStrategy, + sameWidth, + }) => html` + +
+ First + Second + Third + Fourth +
+ + - - - ${items.map( - (item) => html`${item}` - )} + 1 + 2 + 3 -
-`; - -const FormTemplate = () => checkoutForm; -const countries = [ - 'Bulgaria', - 'United Kingdom', - 'USA', - 'Canada', - 'Japan', - 'India', -]; -const scrollStrategy = 'block'; -const checkoutForm = html` - - -
- { - (document.getElementById('txtCountry') as IgcInputComponent).value = ( - _ev.detail as IgcDropdownItemComponent - ).value; - }} - .scrollStrategy=${scrollStrategy} - > - - - Europe - ${countries - .slice(0, 2) - .map( - (item) => html`${item}` - )} - - - North America - ${countries - .slice(2, 4) - .map( - (item) => html`${item}` - )} - - - Asia - ${countries - .slice(4) - .map( - (item) => html`${item}` - )} - - -
-
- -`; - -export const Basic: Story = Template.bind({}); -export const Form: Story = FormTemplate.bind({}); + `, +}; diff --git a/stories/select.stories.ts b/stories/select.stories.ts index a828f2895..a2c510062 100644 --- a/stories/select.stories.ts +++ b/stories/select.stories.ts @@ -1,13 +1,17 @@ -import { github } from '@igniteui/material-icons-extended'; +import { + arrowDownLeft, + arrowUpLeft, + github, +} from '@igniteui/material-icons-extended'; import { Meta, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; import { disableStoryControls, formControls, formSubmitHandler, } from './story.js'; +import { groupBy } from '../src/components/common/util.js'; import { IgcIconComponent, IgcSelectComponent, @@ -16,7 +20,10 @@ import { } from '../src/index.js'; defineComponents(IgcSelectComponent, IgcIconComponent); -registerIconFromText(github.name, github.value); + +for (const each of [github, arrowDownLeft, arrowUpLeft]) { + registerIconFromText(each.name, each.value); +} // region default const metadata: Meta = { @@ -58,6 +65,12 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: false } }, }, + distance: { + type: 'number', + description: 'The distance of the select dropdown from its input.', + control: 'number', + table: { defaultValue: { summary: 0 } }, + }, label: { type: 'string', description: 'The label attribute of the control.', @@ -68,45 +81,10 @@ const metadata: Meta = { description: 'The placeholder attribute of the control.', control: 'text', }, - required: { - type: 'boolean', - description: 'Makes the control a required field in a form context.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, - name: { - type: 'string', - description: 'The name attribute of the control.', - control: 'text', - }, - disabled: { - type: 'boolean', - description: 'The disabled state of the component', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, - invalid: { - type: 'boolean', - description: 'Control the validity of the control.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, - keepOpenOnSelect: { - type: 'boolean', - description: 'Whether the dropdown should be kept open on selection.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, - open: { - type: 'boolean', - description: 'Sets the open state of the component.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, placement: { type: '"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "right" | "right-start" | "right-end" | "left" | "left-start" | "left-end"', description: - 'The preferred placement of the component around the target element.', + 'The preferred placement of the select dropdown around its input.', options: [ 'top', 'top-start', @@ -124,45 +102,54 @@ const metadata: Meta = { control: { type: 'select' }, table: { defaultValue: { summary: 'bottom-start' } }, }, - positionStrategy: { - type: '"absolute" | "fixed"', - description: "Sets the component's positioning strategy.", - options: ['absolute', 'fixed'], - control: { type: 'inline-radio' }, - table: { defaultValue: { summary: 'absolute' } }, - }, scrollStrategy: { type: '"scroll" | "block" | "close"', description: - 'Determines the behavior of the component during scrolling the container.', + 'Determines the behavior of the component during scrolling of the parent container.', options: ['scroll', 'block', 'close'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'scroll' } }, }, - flip: { + required: { type: 'boolean', - description: - "Whether the component should be flipped to the opposite side of the target once it's about to overflow the visible area.\nWhen true, once enough space is detected on its preferred side, it will flip back.", + description: 'Makes the control a required field in a form context.', control: 'boolean', table: { defaultValue: { summary: false } }, }, - distance: { - type: 'number', - description: 'The distance from the target element.', - control: 'number', - table: { defaultValue: { summary: 0 } }, + name: { + type: 'string', + description: 'The name attribute of the control.', + control: 'text', }, - keepOpenOnOutsideClick: { + disabled: { + type: 'boolean', + description: 'The disabled state of the component', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + invalid: { + type: 'boolean', + description: 'Control the validity of the control.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + keepOpenOnSelect: { type: 'boolean', description: - 'Whether the component should be kept open on clicking outside of it.', + 'Whether the component dropdown should be kept open on selection.', control: 'boolean', table: { defaultValue: { summary: false } }, }, - sameWidth: { + keepOpenOnOutsideClick: { type: 'boolean', description: - "Whether the dropdown's width should be the same as the target's one.", + 'Whether the component dropdown should be kept open on clicking outside of it.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + open: { + type: 'boolean', + description: 'Sets the open state of the component.', control: 'boolean', table: { defaultValue: { summary: false } }, }, @@ -170,18 +157,15 @@ const metadata: Meta = { args: { outlined: false, autofocus: false, + distance: 0, + placement: 'bottom-start', + scrollStrategy: 'scroll', required: false, disabled: false, invalid: false, keepOpenOnSelect: false, - open: false, - placement: 'bottom-start', - positionStrategy: 'absolute', - scrollStrategy: 'scroll', - flip: false, - distance: 0, keepOpenOnOutsideClick: false, - sameWidth: false, + open: false, }, }; @@ -194,23 +178,13 @@ interface IgcSelectArgs { outlined: boolean; /** The autofocus attribute of the control. */ autofocus: boolean; + /** The distance of the select dropdown from its input. */ + distance: number; /** The label attribute of the control. */ label: string; /** The placeholder attribute of the control. */ placeholder: string; - /** Makes the control a required field in a form context. */ - required: boolean; - /** The name attribute of the control. */ - name: string; - /** The disabled state of the component */ - disabled: boolean; - /** Control the validity of the control. */ - invalid: boolean; - /** Whether the dropdown should be kept open on selection. */ - keepOpenOnSelect: boolean; - /** Sets the open state of the component. */ - open: boolean; - /** The preferred placement of the component around the target element. */ + /** The preferred placement of the select dropdown around its input. */ placement: | 'top' | 'top-start' @@ -224,21 +198,22 @@ interface IgcSelectArgs { | 'left' | 'left-start' | 'left-end'; - /** Sets the component's positioning strategy. */ - positionStrategy: 'absolute' | 'fixed'; - /** Determines the behavior of the component during scrolling the container. */ + /** Determines the behavior of the component during scrolling of the parent container. */ scrollStrategy: 'scroll' | 'block' | 'close'; - /** - * Whether the component should be flipped to the opposite side of the target once it's about to overflow the visible area. - * When true, once enough space is detected on its preferred side, it will flip back. - */ - flip: boolean; - /** The distance from the target element. */ - distance: number; - /** Whether the component should be kept open on clicking outside of it. */ + /** Makes the control a required field in a form context. */ + required: boolean; + /** The name attribute of the control. */ + name: string; + /** The disabled state of the component */ + disabled: boolean; + /** Control the validity of the control. */ + invalid: boolean; + /** Whether the component dropdown should be kept open on selection. */ + keepOpenOnSelect: boolean; + /** Whether the component dropdown should be kept open on clicking outside of it. */ keepOpenOnOutsideClick: boolean; - /** Whether the dropdown's width should be the same as the target's one. */ - sameWidth: boolean; + /** Sets the open state of the component. */ + open: boolean; } type Story = StoryObj; @@ -281,107 +256,225 @@ const items = [ disabled: true, selected: false, }, -]; -const Template = ({ - label = 'Sample Label', - placeholder, - name, - value = 'docs', - open = false, - disabled = false, - outlined = false, - invalid = false, - required = false, - autofocus = false, -}: IgcSelectArgs) => html` - -
Sample Header
-
Sample Footer
- Sample helper text. - Tasks - ${items.map( - (item) => - html` - ${item.text} - - ` - )} -
-`; +].map( + (item) => + html`${item.text}` +); -const countries = [ - { - continent: 'Europe', - country: 'Bulgaria', - value: 'bg', - selected: true, - disabled: false, - }, - { - continent: 'Europe', - country: 'United Kingdom', - value: 'uk', - selected: false, - disabled: true, - }, - { - continent: 'North America', - country: 'United States of America', - value: 'us', - selected: false, - disabled: false, - }, - { - continent: 'North America', - country: 'Canada', - value: 'ca', - selected: false, - disabled: false, - }, - { - continent: 'Asia', - country: 'Japan', - value: 'ja', - selected: false, - disabled: false, +const countries = Object.entries( + groupBy( + [ + { + continent: 'Europe', + country: 'Bulgaria', + value: 'bg', + selected: true, + disabled: false, + }, + { + continent: 'Europe', + country: 'United Kingdom', + value: 'uk', + selected: false, + disabled: true, + }, + { + continent: 'North America', + country: 'United States of America', + value: 'us', + selected: false, + disabled: false, + }, + { + continent: 'North America', + country: 'Canada', + value: 'ca', + selected: false, + disabled: false, + }, + { + continent: 'Asia', + country: 'Japan', + value: 'ja', + selected: false, + disabled: false, + }, + { + continent: 'Asia', + country: 'India', + value: 'in', + selected: false, + disabled: true, + }, + ], + 'continent' + ) +); + +export const Basic: Story = { + args: { + label: 'Assign task', + value: 'docs', }, - { - continent: 'Asia', - country: 'India', - value: 'in', - selected: false, - disabled: true, + + render: (args) => html` + + Available tasks: + ${items} +

Choose a task to assign.

+
+ `, +}; + +export const WithGroups: Story = { + args: { + label: 'Select a country', }, -]; -function groupBy(objectArray: any, property: string) { - return objectArray.reduce(function (acc: any, obj: any) { - const key = obj[property]; + render: (args) => html` + + ${countries.map( + ([continent, countries]) => html` + + ${continent} + ${countries.map( + (item) => html` + ${item.country} + ` + )} + + ` + )} +

Choose a country.

+
+ `, +}; + +export const InitialValue: Story = { + args: { value: '1' }, + render: ({ value }) => html` + + + First + Second + Third + - if (!acc[key]) { - acc[key] = []; - } - acc[key].push(obj); - return acc; - }, {}); -} + + First + Second + Third + +

+ If there are multiple items with the selected attribute, + the last one will take precedence and set the initial value of the + component. +

+
+ + + First + Second + Third + +

+ If both are set on initial render, then the + selected attribute of the child (if any) item will take + precedence over the value attribute of the select. +

+
+ `, +}; + +export const Slots: Story = { + render: () => html` + + + + + + + + +
This is a header
+
This is a footer
-export const Basic: Story = Template.bind({}); +

Helper text

+ + Tasks + ${items} + + Countries + ${countries.map( + ([continent, countries]) => html` + + ${continent} + ${countries.map( + (item) => html` + ${item.country} + ` + )} + + ` + )} +
+ `, +}; export const Form: Story = { argTypes: disableStoryControls(metadata), @@ -395,14 +488,14 @@ export const Form: Story = { name="default-select" label="Countries (value through attribute)" > - ${Object.entries(groupBy(countries, 'continent')).map( + ${countries.map( ([continent, countries]) => html` ${continent} - ${(countries as any).map( - (item: any) => html` + ${countries.map( + (item) => html` - ${Object.entries(groupBy(countries, 'continent')).map( + ${countries.map( ([continent, countries]) => html` ${continent} - ${(countries as any).map( - (item: any) => html` + ${countries.map( + (item) => html` Required select - ${Object.entries(groupBy(countries, 'continent')).map( + ${countries.map( ([continent, countries]) => html` ${continent} - ${(countries as any).map( - (item: any) => html` + ${countries.map( + (item) => html` Disabled form group - ${Object.entries(groupBy(countries, 'continent')).map( + ${countries.map( ([continent, countries]) => html` ${continent} - ${(countries as any).map( - (item: any) => html` + ${countries.map( + (item) => html`