diff --git a/CHANGELOG.md b/CHANGELOG.md index 59eb2a1f085..cd781fa253e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,11 +96,14 @@ - Added `createTextInput()` and `createCopyTextPrompt()` to the `@craftcms/ui/factory` module. ([#19333](https://github.com/craftcms/cms/pull/19333)) - Added `turnOn()`, `turnOff()`, and `turnIndeterminate()` methods to the `` web component. ([#19323](https://github.com/craftcms/cms/pull/19323)) - Added a `group` property to the `` web component, for rendering a subnav as a non-collapsible semantic grouping. ([#19350](https://github.com/craftcms/cms/pull/19350)) +- Added `Garnish.CustomSelect` and `Garnish.MenuBtn` to `@craftcms/garnish`, jQuery-free TypeScript ports of the legacy floating listbox menu and menu-button classes. ([#19352](https://github.com/craftcms/cms/pull/19352)) - Moved the `Craft.ComponentSelectInput` control panel JavaScript class out of the core bundle into a `yii2-adapter` compatibility asset, since `` is now used everywhere in core; the `componentSelect.twig` `jsClass` escape hatch still works for plugin subclasses. ([#19333](https://github.com/craftcms/cms/pull/19333)) +- Moved the `Craft.AssetMover`, `Craft.AssetSelectorModal`, `Craft.BaseElementSelectInput`, `Craft.BaseElementSelectorModal`, `Craft.BaseUploader`, `Craft.Chart`, `Craft.CpModal`, `Craft.CustomizeSourcesModal`, `Craft.DataTableSorter`, `Craft.ElementActionTrigger`, `Craft.ElementDeletionManager`, `Craft.ElementTableSorter`, `Craft.EntrySelectInput`, `Craft.Grid`, `Craft.PreviewFileModal`, `Craft.Tabs`, `Craft.TagSelectInput`, `Craft.Uploader`, and `Craft.VolumeFolderSelectorModal` control panel JavaScript classes from the legacy jQuery bundle to TypeScript modules. ([#19352](https://github.com/craftcms/cms/pull/19352)) - Changed `` to render as a `` instead of an `` when it has no `href`, dropping `aria-current` in that case. ([#19350](https://github.com/craftcms/cms/pull/19350)) - Changed element index table rows and cards so clicking anywhere on them (other than an interactive control) selects them, extending the selection range on shift-click just like clicking a row’s checkbox. ([#19351](https://github.com/craftcms/cms/pull/19351)) - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. ([#19323](https://github.com/craftcms/cms/pull/19323)) - Removed the `Craft.Accordion` and `Craft.EnvVarGenerator` control panel JavaScript classes. ([#19323](https://github.com/craftcms/cms/pull/19323)) +- Removed the `Craft.DeleteUserModal` control panel JavaScript class. It was deprecated in 5.10.0 and unused. ([#19352](https://github.com/craftcms/cms/pull/19352)) - Fixed a bug where Blade templates rendered through Craft used path-based view names, preventing named Laravel view composers from running. ([#19177](https://github.com/craftcms/cms/issues/19177)) - Fixed a bug where the `accent` semantic color used by colorable elements (e.g. `craft-callout`, `[data-color]`) rendered red instead of blue, due to a drifted color mapping in `@craftcms/ui`. ([#19306](https://github.com/craftcms/cms/pull/19306)) - Fixed a styling issue. ([#19296](https://github.com/craftcms/cms/pull/19296)) diff --git a/packages/craftcms-garnish/src/custom-select.ts b/packages/craftcms-garnish/src/custom-select.ts new file mode 100644 index 00000000000..c867e8153f5 --- /dev/null +++ b/packages/craftcms-garnish/src/custom-select.ts @@ -0,0 +1,400 @@ +import {Base} from './base'; +import {bod, globals, win} from './globals'; +import {ESC_KEY, FX_DURATION, noop} from './constants'; +import {getUiLayerManager} from './managers/registry'; +import {getElement} from './utils'; +import {prefersReducedMotion} from './utils/animation'; +import type {ElementInput, GarnishBaseSettings} from './types'; + +export interface CustomSelectSettings extends GarnishBaseSettings { + /** Element the menu positions itself against. */ + anchor: Element | null; + /** @deprecated Use {@link anchor} instead. */ + attachToElement: Element | null; + /** Gap (px) to keep between the menu and the window edge. */ + windowSpacing: number; + /** Called with the selected option element. */ + onOptionSelect: (option: HTMLElement) => void; +} + +const DEFAULTS: CustomSelectSettings = { + anchor: null, + attachToElement: null, + windowSpacing: 5, + onOptionSelect: noop, +}; + +/** + * CustomSelect — the jQuery-free TypeScript port of the legacy + * `Garnish.CustomSelect` (the floating listbox menu; `Garnish.Menu` is a + * deprecated alias). A menu of ``/`.menu-item`/`.menu-option` options that + * positions itself relative to an anchor, manages `aria-selected`, and fires + * `onOptionSelect` / `optionselect`. + * + * Following the modern `Modal` convention, the `$`-prefixed fields hold native + * DOM (an `HTMLElement` / `HTMLElement[]`), not jQuery collections. + */ +export class CustomSelect extends Base { + static defaults = DEFAULTS; + + visible = false; + + $container!: HTMLElement; + $options: HTMLElement[] = []; + $ariaOptions: HTMLElement[] = []; + $anchor: HTMLElement | null = null; + + menuId!: string; + + private _observers = new Map(); + private _optionSearchText = new WeakMap(); + private _anim: Animation | null = null; + + constructor(container?: ElementInput, settings?: Partial) { + super(); + if (new.target === CustomSelect) { + this.init(container, settings); + } + } + + init( + container?: ElementInput, + settings?: Partial + ): void { + this.setSettings(settings, CustomSelect.defaults); + + this.$container = getElement(container) as HTMLElement; + + this.$options = []; + this.$ariaOptions = []; + + // Menu List + this.menuId = 'menu' + this._namespace; + this.$container.setAttribute('role', 'listbox'); + this.$container.setAttribute('id', this.menuId); + + this.$container + .querySelectorAll('ul') + .forEach((ul) => ul.setAttribute('role', 'group')); + this.addOptions( + this.$container.querySelectorAll('a,.menu-item,.menu-option') + ); + + // Deprecated + if (this.settings!.attachToElement) { + this.settings!.anchor = this.settings!.attachToElement; + console.warn( + "The 'attachToElement' setting is deprecated. Use 'anchor' instead." + ); + } + + if (this.settings!.anchor) { + this.$anchor = getElement(this.settings!.anchor) as HTMLElement; + } + + // Prevent clicking on the container from hiding the menu + this.addListener(this.$container, 'mousedown', (ev) => { + const e = ev as unknown as MouseEvent; + e.stopPropagation(); + + if ((e.target as HTMLElement).nodeName !== 'INPUT') { + // Prevent this from causing the menu button to blur + e.preventDefault(); + } + }); + } + + addOptions(options: ArrayLike): void { + const added = Array.from(options); + this.$options = this.$options.concat(added); + + added.forEach((option, i) => { + const index = this.$options.length - added.length + i; + const li = option.parentElement; + const ariaOption = li && li.tagName === 'LI' ? li : null; + + option.setAttribute('tabindex', '-1'); + if (!option.getAttribute('id')) { + option.setAttribute('id', `${this.menuId}-option-${index + 1}`); + } + + if (ariaOption) { + ariaOption.setAttribute('role', 'option'); + ariaOption.setAttribute( + 'aria-selected', + option.classList.contains('sel') ? 'true' : 'false' + ); + if (!ariaOption.getAttribute('id')) { + ariaOption.setAttribute( + 'id', + `${this.menuId}-aria-option-${index + 1}` + ); + } + this.$ariaOptions.push(ariaOption); + + // keep aria-selected in-line with .sel + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'class' + ) { + const optionHasHover = this.$options.some((o) => + o.classList.contains('hover') + ); + ariaOption.setAttribute( + 'aria-selected', + (!optionHasHover && option.classList.contains('sel')) || + option.classList.contains('hover') + ? 'true' + : 'false' + ); + break; + } + } + }); + observer.observe(option, {attributes: true}); + this._observers.set(option, observer); + } + }); + + this.removeAllListeners(added); + this.addListener(added, 'click', (ev) => { + this.selectOption( + (ev as unknown as Event).currentTarget as HTMLElement + ); + }); + } + + setPositionRelativeToAnchor(): void { + if (!this.$anchor) { + return; + } + + const windowWidth = win.innerWidth; + const windowHeight = win.innerHeight; + const windowScrollLeft = win.scrollX; + const windowScrollTop = win.scrollY; + + const anchorRect = this.$anchor.getBoundingClientRect(); + const anchorOffset = { + left: anchorRect.left + windowScrollLeft, + top: anchorRect.top + windowScrollTop, + }; + const anchorWidth = anchorRect.width; + const anchorHeight = anchorRect.height; + // NB: legacy computes these from the height (a long-standing quirk kept for + // parity). + const anchorOffsetBottom = anchorOffset.top + anchorHeight; + + const container = this.$container; + container.style.minWidth = '0'; + + // outerWidth − width: the container's horizontal padding + border. + const cs = getComputedStyle(container); + const horizontalChrome = + parseFloat(cs.paddingLeft) + + parseFloat(cs.paddingRight) + + parseFloat(cs.borderLeftWidth) + + parseFloat(cs.borderRightWidth); + container.style.minWidth = `${anchorWidth - horizontalChrome}px`; + + const menuWidth = container.getBoundingClientRect().width; + const menuHeight = container.getBoundingClientRect().height; + + // Is there room for the menu below the anchor? + const topClearance = anchorOffset.top - windowScrollTop; + const bottomClearance = + windowHeight + windowScrollTop - anchorOffsetBottom; + + if ( + bottomClearance >= menuHeight || + (topClearance < menuHeight && bottomClearance >= topClearance) + ) { + container.style.top = `${anchorOffsetBottom}px`; + container.style.maxHeight = `${ + bottomClearance - this.settings!.windowSpacing + }px`; + } else { + container.style.top = `${ + anchorOffset.top - + Math.min(menuHeight, topClearance - this.settings!.windowSpacing) + }px`; + container.style.maxHeight = `${ + topClearance - this.settings!.windowSpacing + }px`; + } + + // Figure out how we're aligning it + let align = container.dataset.align; + + if (align !== 'left' && align !== 'center' && align !== 'right') { + align = 'left'; + } + + if (align === 'center') { + this._alignCenter(anchorOffset.left, anchorWidth, menuWidth); + } else { + // Figure out which alignments are actually possible + const rightClearance = + windowWidth + windowScrollLeft - (anchorOffset.left + menuWidth); + const leftClearance = anchorOffset.left + anchorHeight - menuWidth; + + if ( + ((align === 'right' && leftClearance >= 0) || rightClearance < 0) && + menuWidth < anchorOffset.left + anchorWidth + ) { + this._alignRight(anchorOffset.left, anchorWidth, menuWidth, windowWidth); + } else { + this._alignLeft(anchorOffset.left, menuWidth, windowWidth); + } + } + } + + show(): void { + if (this.visible) { + return; + } + + // Move the menu to the end of the DOM + bod.appendChild(this.$container); + + if (this.$anchor) { + this.setPositionRelativeToAnchor(); + } + + this._anim?.cancel(); + this.$container.classList.add('visible'); + this.$container.style.opacity = '1'; + + const manager = getUiLayerManager(); + manager?.addLayer(this.$container); + manager?.registerShortcut(ESC_KEY, () => this.hide()); + + this.addListener( + globals.scrollContainer, + 'scroll', + 'setPositionRelativeToAnchor' + ); + this.addListener(win, 'resize', 'setPositionRelativeToAnchor'); + + this.visible = true; + this.trigger('show'); + } + + hide(): void { + if (!this.visible) { + return; + } + + this.$options.forEach((o) => o.classList.remove('hover')); + this.$options + .filter((o) => o.classList.contains('sel')) + .forEach((o) => { + const li = o.parentElement; + if (li && li.tagName === 'LI') { + li.setAttribute('aria-selected', 'true'); + } + }); + + const finalize = (): void => { + this.$container.classList.remove('visible'); + this.$container.style.display = ''; + this.$container.style.opacity = ''; + this.$container.remove(); + this._anim = null; + }; + + this._anim?.cancel(); + if (prefersReducedMotion() || typeof this.$container.animate !== 'function') { + finalize(); + } else { + const anim = this.$container.animate([{opacity: 1}, {opacity: 0}], { + duration: FX_DURATION, + fill: 'forwards', + }); + this._anim = anim; + anim.onfinish = finalize; + anim.oncancel = (): void => { + this._anim = null; + }; + } + + getUiLayerManager()?.removeLayer(this.$container); + this.removeListener(globals.scrollContainer, 'scroll'); + this.removeListener(win, 'resize'); + this.visible = false; + this.trigger('hide'); + } + + selectOption(option: HTMLElement): void { + this.settings!.onOptionSelect(option); + this.trigger('optionselect', {selectedOption: option}); + this.hide(); + } + + /** Search text (lowercased, SVG-stripped) for type-ahead; lazily cached. */ + getOptionSearchText(option: HTMLElement): string { + let text = this._optionSearchText.get(option); + if (text === undefined) { + const clone = option.cloneNode(true) as HTMLElement; + clone.querySelectorAll('svg').forEach((svg) => svg.remove()); + text = (clone.textContent ?? '').toLowerCase().trimStart(); + this._optionSearchText.set(option, text); + } + return text; + } + + private _alignLeft( + anchorLeft: number, + menuWidth: number, + windowWidth: number + ): void { + this.$container.style.left = `${anchorLeft}px`; + this.$container.style.right = 'auto'; + + // if menuWidth is larger than the screen estate we have + // - set max-width with a slight margin (10) + if (menuWidth > windowWidth - anchorLeft) { + this.$container.style.maxWidth = `${windowWidth - anchorLeft - 10}px`; + } + } + + private _alignRight( + anchorLeft: number, + anchorWidth: number, + menuWidth: number, + windowWidth: number + ): void { + this.$container.style.right = `${ + windowWidth - (anchorLeft + anchorWidth) + }px`; + this.$container.style.left = 'auto'; + + // if menuWidth is larger than the screen estate we have + // - set max-width with a slight margin (10) + if (menuWidth > anchorLeft + anchorWidth) { + this.$container.style.maxWidth = `${anchorLeft + anchorWidth - 10}px`; + } + } + + private _alignCenter( + anchorLeft: number, + anchorWidth: number, + menuWidth: number + ): void { + let left = Math.round(anchorLeft + anchorWidth / 2 - menuWidth / 2); + + if (left < 0) { + left = 0; + } + + this.$container.style.left = `${left}px`; + } + + override destroy(): void { + this._observers.forEach((observer) => observer.disconnect()); + this._observers.clear(); + super.destroy(); + } +} diff --git a/packages/craftcms-garnish/src/index.ts b/packages/craftcms-garnish/src/index.ts index 2a6203ddc29..a30bb5b6629 100644 --- a/packages/craftcms-garnish/src/index.ts +++ b/packages/craftcms-garnish/src/index.ts @@ -35,6 +35,8 @@ import {setUiLayerManager} from './managers/registry'; import {Modal, type ModalSettings} from './modal'; import {HUD, type HUDSettings} from './hud'; import {DisclosureMenu, type DisclosureMenuSettings} from './disclosure-menu'; +import {CustomSelect, type CustomSelectSettings} from './custom-select'; +import {MenuBtn, type MenuBtnSettings} from './menu-btn'; import {BaseDrag, type BaseDragSettings} from './drag/base-drag'; import {Drag, type DragSettings} from './drag/drag'; import {DragDrop, type DragDropSettings} from './drag/drag-drop'; @@ -73,6 +75,8 @@ export type { DisclosureMenuItem, DisclosureMenuItemConfig, } from './disclosure-menu'; +export {CustomSelect, type CustomSelectSettings}; +export {MenuBtn, type MenuBtnSettings}; export {BaseDrag, type BaseDragSettings}; export {Drag, type DragSettings}; export {DragDrop, type DragDropSettings}; @@ -182,6 +186,8 @@ export const Garnish = { Modal, HUD, DisclosureMenu, + CustomSelect, + MenuBtn, BaseDrag, Drag, DragDrop, diff --git a/packages/craftcms-garnish/src/menu-btn.ts b/packages/craftcms-garnish/src/menu-btn.ts new file mode 100644 index 00000000000..1823b6b7153 --- /dev/null +++ b/packages/craftcms-garnish/src/menu-btn.ts @@ -0,0 +1,488 @@ +import {Base} from './base'; +import {doc} from './globals'; +import { + DOWN_KEY, + END_KEY, + HOME_KEY, + PAGE_DOWN_KEY, + PAGE_UP_KEY, + RETURN_KEY, + SPACE_KEY, + TAB_KEY, + UP_KEY, + noop, +} from './constants'; +import {CustomSelect} from './custom-select'; +import { + getElement, + hasAttr, + isCtrlKeyPressed, + isPlainObject, + isPrimaryClick, + requestAnimationFrame, + scrollContainerToElement, +} from './utils'; +import type {ElementInput, GarnishBaseSettings} from './types'; + +export interface MenuBtnSettings extends GarnishBaseSettings { + /** Element the menu anchors to (defaults to the button). */ + menuAnchor: Element | null; + /** Called with the selected option element. */ + onOptionSelect: (option: HTMLElement) => void; +} + +const DEFAULTS: MenuBtnSettings = { + menuAnchor: null, + onOptionSelect: noop, +}; + +/** Registry backing the legacy `$btn.data('menubtn')` double-instantiation guard. */ +const menuBtnRegistry = new WeakMap(); + +/** + * MenuBtn — the jQuery-free TypeScript port of the legacy `Garnish.MenuBtn`. A + * trigger button (`role="combobox"`) that owns a {@link CustomSelect} menu, + * with full keyboard navigation, type-ahead search, and disabled-state syncing. + * + * Following the modern `Modal` convention, `$btn` holds a native `HTMLElement`. + */ +export class MenuBtn extends Base { + static defaults = DEFAULTS; + + $btn!: HTMLElement; + menu!: CustomSelect; + showingMenu = false; + #disabled = true; + observer: MutationObserver | null = null; + + /** + * Whether the button is disabled. Overrides the base accessor: MenuBtn tracks + * disabled state via the button's `disabled` attribute (see + * {@link handleStatusChange}), not the base `_disabled` flag. + */ + override get disabled(): boolean { + return this.#disabled; + } + searchStr = ''; + clearSearchStrTimeout: ReturnType | null = null; + + constructor( + btn?: ElementInput, + menu?: CustomSelect | Partial | null, + settings?: Partial + ) { + super(); + if (new.target === MenuBtn) { + this.init(btn, menu, settings); + } + } + + init( + btn?: ElementInput, + menu?: CustomSelect | Partial | null, + settings?: Partial + ): void { + // Param mapping + if (typeof settings === 'undefined' && isPlainObject(menu)) { + // (btn, settings) + settings = menu as Partial; + menu = null; + } + + this.$btn = getElement(btn) as HTMLElement; + + if (!this.$btn) { + console.warn('Menu button instantiated without a DOM element.'); + return; + } + + let $menu: HTMLElement | undefined; + const menuObj = menu as CustomSelect | null; + + // Is this already a menu button? + const existing = menuBtnRegistry.get(this.$btn); + if (existing) { + // Grab the old MenuBtn's menu container + if (!menuObj) { + $menu = existing.menu.$container; + } + + console.warn('Double-instantiating a menu button on an element'); + existing.destroy(); + } else if (!menuObj) { + const next = this.$btn.nextElementSibling; + if (next && next.classList.contains('menu')) { + $menu = next as HTMLElement; + next.remove(); + } + } + + menuBtnRegistry.set(this.$btn, this); + + this.setSettings(settings, MenuBtn.defaults); + + this.menu = menuObj || new CustomSelect($menu); + this.menu.$anchor = getElement( + this.settings!.menuAnchor || this.$btn + ) as HTMLElement; + this.menu.on('optionselect', (ev) => { + this.onOptionSelect( + (ev as unknown as {selectedOption: HTMLElement}).selectedOption + ); + }); + this.menu.on('hide', () => { + this.clearSearchStr(); + }); + this.menu.on('show', () => { + this.clearSearchStr(); + }); + + this.$btn.setAttribute('role', 'combobox'); + this.$btn.setAttribute('aria-controls', this.menu.menuId); + this.$btn.setAttribute('aria-haspopup', 'listbox'); + this.$btn.setAttribute('aria-expanded', 'false'); + + // If no label is set on the listbox, set one based on the combobox label + const comboboxLabel = this.$btn.getAttribute('aria-labelledby'); + + if (!this.menu.$container.getAttribute('aria-labelledby') && comboboxLabel) { + this.menu.$container.setAttribute('aria-labelledby', comboboxLabel); + } + + this.menu.on('hide', () => this.onMenuHide()); + this.addListener(this.$btn, 'mousedown', 'onMouseDown'); + this.addListener(this.$btn, 'keydown', 'onKeyDown'); + this.addListener(this.$btn, 'blur', 'onBlur'); + + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'disabled' + ) { + this.handleStatusChange(); + break; + } + } + }); + + this.observer.observe(this.$btn, {attributes: true}); + + this.handleStatusChange(); + } + + onBlur(): void { + if (this.showingMenu) { + requestAnimationFrame(() => { + if (!this.menu.$container.contains(document.activeElement)) { + this.hideMenu(); + } + }); + } + } + + onKeyDown(ev: KeyboardEvent): void { + if (isCtrlKeyPressed(ev)) { + return; + } + + // Searching for an option? + if ( + ev.key && + (ev.key.match(/^[^ ]$/) || (this.searchStr.length && ev.key === ' ')) + ) { + // show the menu and set visual focus to the first matching option + let option: HTMLElement | undefined; + + if (!this.showingMenu) { + this.showMenu(); + // go with the selected option by default + option = + this.menu.$options.find((o) => o.classList.contains('sel')) ?? + this.menu.$options[0]; + } + + // see if there's a matching option + this.searchStr += ev.key.toLowerCase(); + for (let i = 0; i < this.menu.$options.length; i++) { + const o = this.menu.$options[i]!; + if (this.menu.getOptionSearchText(o).startsWith(this.searchStr)) { + option = o; + break; + } + } + + if (option) { + this.focusOption(option); + } + + // update the timeout + if (this.clearSearchStrTimeout) { + clearTimeout(this.clearSearchStrTimeout); + } + this.clearSearchStrTimeout = setTimeout(() => { + this.clearSearchStr(); + }, 1000); + + return; + } + + if (this.showingMenu) { + switch (ev.keyCode) { + case RETURN_KEY: + case SPACE_KEY: + case TAB_KEY: { + // select the visually-focused option and close the menu + if (ev.keyCode !== TAB_KEY) { + ev.preventDefault(); + } + const currentOption = this.menu.$options.find((o) => + o.classList.contains('hover') + ); + if (currentOption) { + currentOption.click(); + } else { + this.hideMenu(); + } + break; + } + + case UP_KEY: + case PAGE_UP_KEY: { + // move visual focus up + ev.preventDefault(); + const dist = ev.keyCode === UP_KEY ? 1 : 10; + this.moveFocusUp(dist); + break; + } + + case DOWN_KEY: + case PAGE_DOWN_KEY: { + // move visual focus down + ev.preventDefault(); + const dist = ev.keyCode === DOWN_KEY ? 1 : 10; + this.moveFocusDown(dist); + break; + } + + case HOME_KEY: { + // move visual focus to the first option + ev.preventDefault(); + this.focusFirstOption(); + break; + } + + case END_KEY: { + // move visual focus to the last option + ev.preventDefault(); + this.focusLastOption(); + break; + } + } + } else { + switch (ev.keyCode) { + case RETURN_KEY: + case SPACE_KEY: + case DOWN_KEY: { + // show the menu and set visual focus to the selected option + ev.preventDefault(); + this.showMenu(); + this.focusSelectedOption(); + break; + } + + case UP_KEY: + case HOME_KEY: { + // show the menu and set visual focus to the first option + ev.preventDefault(); + this.showMenu(); + this.focusFirstOption(); + break; + } + + case END_KEY: { + // show the menu and set visual focus to the last option + ev.preventDefault(); + this.showMenu(); + this.focusLastOption(); + break; + } + } + } + } + + clearSearchStr(): void { + this.searchStr = ''; + if (this.clearSearchStrTimeout) { + clearTimeout(this.clearSearchStrTimeout); + this.clearSearchStrTimeout = null; + } + } + + focusOption(option: HTMLElement): void { + if (option.classList.contains('hover')) { + return; + } + + this.menu.$options.forEach((o) => o.classList.remove('hover')); + this.menu.$ariaOptions.forEach((o) => + o.setAttribute('aria-selected', 'false') + ); + + option.classList.add('hover'); + const li = option.parentElement; + if (li && li.tagName === 'LI' && li.getAttribute('id')) { + this.$btn.setAttribute('aria-activedescendant', li.getAttribute('id')!); + } + + scrollContainerToElement(this.menu.$container, option); + } + + focusSelectedOption(): void { + const option = this.menu.$options.find((o) => o.classList.contains('sel')); + if (option) { + this.focusOption(option); + } else { + this.focusFirstOption(); + } + } + + focusFirstOption(): void { + const option = this.menu.$options[0]; + if (option) { + this.focusOption(option); + } + } + + focusLastOption(): void { + const option = this.menu.$options[this.menu.$options.length - 1]; + if (option) { + this.focusOption(option); + } + } + + moveFocusUp(dist = 1): void { + const options = this.menu.$options; + const focused = options.find((o) => o.classList.contains('hover')); + if (focused) { + const index = options.indexOf(focused); + let option = options[Math.max(index - dist, 0)]!; + while (option.classList.contains('disabled') && index - dist >= 0) { + dist++; + option = options[Math.max(index - dist, 0)]!; + } + this.focusOption(option); + } else { + this.focusFirstOption(); + } + } + + moveFocusDown(dist = 1): void { + const options = this.menu.$options; + const focused = options.find((o) => o.classList.contains('hover')); + if (focused) { + const index = options.indexOf(focused); + let option = options[Math.min(index + dist, options.length - 1)]!; + while ( + option.classList.contains('disabled') && + index + dist <= options.length - 1 + ) { + dist++; + option = options[Math.min(index + dist, options.length - 1)]!; + } + this.focusOption(option); + } else { + this.focusFirstOption(); + } + } + + onMouseDown(ev: MouseEvent): void { + if (!isPrimaryClick(ev) || (ev.target as HTMLElement).nodeName === 'INPUT') { + return; + } + + ev.preventDefault(); + + if (this.showingMenu) { + this.hideMenu(); + } else { + this.showMenu(); + } + } + + showMenu(): void { + if (this.disabled) { + return; + } + + this.menu.show(); + this.$btn.classList.add('active'); + this.$btn.focus(); + this.$btn.setAttribute('aria-expanded', 'true'); + + this.showingMenu = true; + + setTimeout(() => { + this.addListener(doc, 'mousedown', 'onMouseDown'); + }, 1); + } + + hideMenu(): void { + this.menu.hide(); + } + + onMenuHide(): void { + this.$btn.classList.remove('active'); + this.$btn.setAttribute('aria-expanded', 'false'); + this.$btn.removeAttribute('aria-activedescendant'); + this.showingMenu = false; + + this.removeListener(doc, 'mousedown'); + } + + onOptionSelect(option: HTMLElement): void { + this.settings!.onOptionSelect(option); + this.trigger('optionSelect', {option}); + } + + override enable(): void { + if (!this.$btn) { + return; + } + + this.$btn.removeAttribute('disabled'); + } + + override disable(): void { + if (!this.$btn) { + return; + } + + this.$btn.setAttribute('disabled', 'disabled'); + } + + handleStatusChange(): void { + if (!this.$btn) { + return; + } + + if ( + hasAttr(this.$btn, 'disabled') || + this.$btn.getAttribute('aria-disabled') === 'true' + ) { + this.#disabled = true; + this.$btn.classList.add('disabled'); + } else { + this.#disabled = false; + this.$btn.classList.remove('disabled'); + } + } + + override destroy(): void { + this.menu.destroy(); + menuBtnRegistry.delete(this.$btn); + this.observer?.disconnect(); + this.observer = null; + super.destroy(); + } +} diff --git a/packages/craftcms-garnish/tests/custom-select.test.ts b/packages/craftcms-garnish/tests/custom-select.test.ts new file mode 100644 index 00000000000..82538fb897d --- /dev/null +++ b/packages/craftcms-garnish/tests/custom-select.test.ts @@ -0,0 +1,129 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {CustomSelect} from '../src/custom-select'; + +function buildMenu(labels = ['Apple', 'Banana', 'Cherry']): HTMLElement { + const menu = document.createElement('div'); + const ul = document.createElement('ul'); + labels.forEach((label) => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.textContent = label; + li.appendChild(a); + ul.appendChild(li); + }); + menu.appendChild(ul); + document.body.appendChild(menu); + return menu; +} + +describe('CustomSelect init / ARIA', () => { + let menu: HTMLElement; + beforeEach(() => { + document.body.innerHTML = ''; + menu = buildMenu(); + }); + + it('marks the container as a listbox with an id', () => { + const select = new CustomSelect(menu); + expect(menu.getAttribute('role')).toBe('listbox'); + expect(menu.getAttribute('id')).toBe(select.menuId); + }); + + it('marks nested