diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index 63b56aedf76..9dd5b60ac99 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -86,6 +86,13 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: npm run build:ui + # `build:cp` does not produce the Custom Elements Manifest (only the + # Storybook pre-hooks do), so generate it explicitly — it lands in the + # packages/craftcms-cp/dist cache path above for the downstream jobs. + - name: Build Custom Elements Manifest + if: steps.cache.outputs.cache-hit != 'true' + run: cd ./packages/craftcms-ui && npm run build:manifest + - name: Generate TypeScript types if: steps.cache.outputs.cache-hit != 'true' run: npm run generate:types diff --git a/.github/workflows/laravel-ci.yml b/.github/workflows/laravel-ci.yml index 9a913005e37..8c5f8ac493b 100644 --- a/.github/workflows/laravel-ci.yml +++ b/.github/workflows/laravel-ci.yml @@ -129,6 +129,23 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # ComponentManifestDriftTest needs the Custom Elements Manifest, which is + # gitignored (packages/craftcms-ui/dist). The cache key hashes everything + # the analyzer reads, so a hit is never stale; the cache itself is shared + # repo-wide, so whichever workflow generates it first serves the rest. On + # a miss the pinned npx run (~seconds) needs no npm install. + - name: Restore Custom Elements Manifest + id: cem + uses: actions/cache@v4 + with: + path: packages/craftcms-ui/dist/custom-elements.json + key: cem-${{ hashFiles('packages/craftcms-ui/src/components/**', 'packages/craftcms-ui/custom-elements-manifest.config.mjs', 'packages/craftcms-ui/package.json', 'packages/craftcms-ui/tsconfig.json') }} + + - name: Generate Custom Elements Manifest + if: steps.cem.outputs.cache-hit != 'true' + working-directory: packages/craftcms-ui + run: npx --yes @custom-elements-manifest/analyzer@0.11.0 analyze + - name: Run tests uses: ./.github/actions/run-tests with: diff --git a/AGENTS.md b/AGENTS.md index f4ee338de38..9adbee3dc06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,8 @@ partially migrated; full migration means converting the inner form to a Vue comp **Custom elements** (anything with a hyphen in the tag name) are treated as native web components by the Vue compiler — they pass through to the browser without Vue trying to resolve them as Vue components. +When porting behavior out of the legacy jQuery bundle (`packages/craftcms-legacy/cp/src/js/*.js`) into modern TypeScript, follow the shared module pattern documented in `resources/js/modules/README.md` — a logic class (`.ts` on `@craftcms/garnish` `Base`), a `ControllerElement` custom element (`.ce.ts`), an instance-registry `support.ts` WeakMap, and an `index.ts` shim that registers the element and assigns the legacy `window.Craft.*` global. Note the source-vs-`dist` gotcha in that README: after editing `packages/craftcms-garnish/src` (or `@craftcms/cp`), rebuild the package's `dist` or `npm run typecheck` won't see the change. + ## Adapter Work `yii2-adapter` is compatibility code, not the implementation path for new core behavior. If you need to add adapter classes, follow its Composer autoload mapping. Do not put general adapter classes in `yii2-adapter/lib/`; that area is for vendored or library-style code. diff --git a/CHANGELOG.md b/CHANGELOG.md index caeb9e78014..19ab8154a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ - Plugins should no longer define `extra.laravel.providers` in `composer.json`. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Removed automatic plugin trait lifecycle hooks. ([#19263](https://github.com/craftcms/cms/pull/19263)) +- Added `CraftCms\Cms\Cp\Components\Button`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\ButtonGroup`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\Callout`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\Checkbox`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\CheckboxGroup`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\CheckboxSelect`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\ChoiceGroup`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\ComponentRegistry`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\Field`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\FieldGroup`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\Lightswitch`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\Radio`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\RadioGroup`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Components\ViewComponent`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\EvaluatesClosures`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\HasAppearance`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\HasDisabled`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\HasId`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\HasSize`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Concerns\HasVariant`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Enums\Appearance`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Enums\Size`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\Enums\Variant`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::buttonFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::buttonGroupFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::checkboxFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::checkboxGroupFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::checkboxSelectFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::lightswitchFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::radioFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::radioGroupFieldHtml()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Cp\FormFields::radioGroupFromConfig()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::button()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::buttonGroup()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::checkbox()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::checkboxGroup()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::checkboxSelect()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::lightswitch()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::radio()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\Twig\Variables\Cp::radioGroup()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) +- Added `CraftCms\Cms\ui()`. ([#19248](https://github.com/craftcms/cms/pull/19248)) - Removed `CraftCms\Cms\Plugin\Events\PluginUnregistered`. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Removed `CraftCms\Cms\Plugin\Plugin::bootPlugin()`. `boot()` should be used instead. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Removed `CraftCms\Cms\Plugin\Plugin::registerPlugin()`. `register()` should be used instead. ([#19263](https://github.com/craftcms/cms/pull/19263)) diff --git a/packages/craftcms-garnish/README.md b/packages/craftcms-garnish/README.md index a8740ec27d7..9c310866311 100644 --- a/packages/craftcms-garnish/README.md +++ b/packages/craftcms-garnish/README.md @@ -234,6 +234,29 @@ then the menu hides. `DisclosureMenu.getInstance(triggerOrContainer)` is the nat replacement for the legacy `$el.data('disclosureMenu')`. A legacy `new Garnish.DisclosureMenu($trigger, settings)` call ports across unchanged. +#### Item selection — `Select` + +`Select` is the selection interface over a set of sibling items: click to select, +shift-click to extend a contiguous range, and ctrl/⌘-click to toggle individual +items (the ctrl/shift roles swap under `checkboxMode`). Selected items get the +`selectedClass` (`sel` by default); arrow keys move the selection two-dimensionally +by measuring item geometry, so it works for vertical lists and wrapping grids alike. +It pairs naturally with `DragSort` for drag-a-multi-selection lists (see +``). + +```ts +import {Select} from '@craftcms/garnish'; + +const select = new Select({multi: true, filter: (t) => !(t as Element)?.closest('button')}); +select.addItems(list.querySelectorAll('li')); +select.on('selectionChange', () => console.log(select.getSelectedItems())); +``` + +The constructor mirrors the legacy shape — `new Select(container, items?, settings?)`, +with `new Select(settings)` / `new Select(container, settings)` param shifts — so a +legacy `new Garnish.Select({...})` call ports across unchanged. `getSelectedItems()` +returns a native `HTMLElement[]` (not a jQuery collection). + **No-jQuery guarantee:** importing from `@craftcms/garnish` (the `.` entry) never pulls in jQuery, never reads `window.jQuery`/`$`, and never assigns `window.Garnish`. Those behaviors live exclusively in the `compat` entry. diff --git a/packages/craftcms-garnish/docs/api-reference.md b/packages/craftcms-garnish/docs/api-reference.md index 912708e1445..dabd134f392 100644 --- a/packages/craftcms-garnish/docs/api-reference.md +++ b/packages/craftcms-garnish/docs/api-reference.md @@ -533,6 +533,61 @@ are recomputed per insertion; lists `> 200` items use a viewport filter. Unlike --- +## `Select` + +`class Select extends Base` — the +selection interface over a set of sibling items: click to select, shift-click to extend +a contiguous range, ctrl/⌘-click to toggle individual items (ctrl/shift roles swap under +`checkboxMode`). Selected items get the `selectedClass`; arrow keys move the selection +two-dimensionally by measuring item geometry, so it spans vertical lists and wrapping +grids. Commonly paired with `DragSort` to drag a multi-selection as a group. + +**Constructor:** `new Select(container?, items?, settings?)` — with `new Select(settings)` +and `new Select(container, settings)` param-shifts (a plain-object arg is treated as the +settings). + +### Statics + +| Member | Type | Description | +| --- | --- | --- | +| `Select.defaults` | `SelectSettings` | Default settings (below). | + +### Settings (`SelectSettings`) + +| Key | Default | Description | +| --- | --- | --- | +| `selectedClass` | `'sel'` | Class added to selected items. | +| `checkboxClass` | `'checkbox'` | Class of a checkbox affordance inside an item; gets `aria-checked` toggled. | +| `multi` | `false` | Allow more than one item selected at once. | +| `allowEmpty` | `true` | Allow the selection to be emptied (container click / last-item deselect). | +| `vertical` / `horizontal` | `false` | Constrain arrow-key nav to a single column / row. | +| `handle` | `null` | What within each item receives the pointer listeners: selector / fn / element(s) / `null` (the item). | +| `filter` | `null` | Gate before selecting: a selector string or `(target) => boolean` predicate. | +| `checkboxMode` | `false` | Whether a checkbox affordance (not ctrl/shift) drives selection. | +| `makeFocusable` | `false` | Give the focused item a roving `tabindex` (keyboard-navigable list). | +| `waitForDoubleClicks` | `false` | Defer single-click (de)selection briefly so a double-click can pre-empt it. | +| `onSelectionChange` | no-op | Fired (RAF-deferred) whenever the selection changes. | + +### Methods & properties + +| Member | Signature | Description | +| --- | --- | --- | +| `$items` / `$selectedItems` | `HTMLElement[]` | Tracked items / the selected subset (native arrays). | +| `addItems` / `removeItems` | `(items) => void` | Track / untrack items (binds/unbinds their listeners). | +| `selectItem` / `selectRange` / `selectAll` | `(item?, …) => void` | Select one / a contiguous range / everything (range & all are `multi`-only). | +| `deselectItem` / `deselectAll` / `deselectOthers` | `(item?) => void` | Deselect one / all / all-but-one. | +| `toggleItem` | `(item, preventScroll?) => void` | Toggle a single item (honoring `allowEmpty`). | +| `isSelected` | `(item) => boolean` | Whether an item is selected. | +| `getSelectedItems` | `() => HTMLElement[]` | The selected items, as a fresh array. | +| `totalSelected` | `get => number` | How many are selected. | +| `resetItemOrder` | `() => void` | Re-sort `$items` / `$selectedItems` into current DOM order (after a reorder). | +| `focusItem` / `setFocusableItem` | `(item, …) => void` | Move DOM focus / set the roving-`tabindex` item. | + +**Events:** `selectionChange` (RAF-deferred), `focusItem` (`{item}`), plus `Base`'s +`destroy`. + +--- + ## `UiLayerManager` `class UiLayerManager extends Base` — manages the stack of UI layers (document, diff --git a/packages/craftcms-garnish/src/compat.ts b/packages/craftcms-garnish/src/compat.ts index db1a0685878..2d79f695f20 100644 --- a/packages/craftcms-garnish/src/compat.ts +++ b/packages/craftcms-garnish/src/compat.ts @@ -135,6 +135,8 @@ export function toJq(value: unknown): JQueryCollection { * * @param value - The (possibly jQuery) value. * @returns The native element, or the value unchanged. + * + * @TODO replace instances of this with `toHtmlElement` */ export function unwrapJq(value: unknown): unknown { if (value == null) { @@ -147,6 +149,20 @@ export function unwrapJq(value: unknown): unknown { return value; } +/** + * Unwrap a jQuery collection (truthy `.jquery`) to its first native element, + * pass a native `Element` through, and return `null` for anything else. + * + * This has some overlap with `unwrapJq` but the types are better here. + */ +export function toHtmlElement(value: unknown): HTMLElement | null { + if (isJquery(value)) { + return value.length && value[0] instanceof HTMLElement ? value[0] : null; + } + + return value instanceof HTMLElement ? value : null; +} + /* ------------------------------------------------------------------------- * * compatify(ModernClass) — the legacy `.extend()` / `this.base()` shim * ------------------------------------------------------------------------- */ diff --git a/packages/craftcms-garnish/src/index.ts b/packages/craftcms-garnish/src/index.ts index f865e963c12..2a6203ddc29 100644 --- a/packages/craftcms-garnish/src/index.ts +++ b/packages/craftcms-garnish/src/index.ts @@ -40,6 +40,7 @@ import {Drag, type DragSettings} from './drag/drag'; import {DragDrop, type DragDropSettings} from './drag/drag-drop'; import {DragSort, type DragSortSettings} from './drag/drag-sort'; import {DragMove} from './drag-move'; +import {Select, type SelectSettings} from './select'; import {ResizeHandle} from './icons/resize-handle'; import {garnishClassBus, globals, win, doc, bod} from './globals'; import type {Callback, Constructor, GarnishBaseSettings} from './types'; @@ -77,6 +78,8 @@ export {Drag, type DragSettings}; export {DragDrop, type DragDropSettings}; export {DragSort, type DragSortSettings}; export {DragMove}; +export {Select, type SelectSettings}; +export type {SelectHandle, SelectFilter} from './select'; export {ResizeHandle}; export {win, doc, bod}; @@ -184,6 +187,7 @@ export const Garnish = { DragDrop, DragSort, DragMove, + Select, /** @deprecated Use UiLayerManager instead. */ ShortcutManager: UiLayerManager, diff --git a/packages/craftcms-garnish/src/select.ts b/packages/craftcms-garnish/src/select.ts new file mode 100644 index 00000000000..22be33c9488 --- /dev/null +++ b/packages/craftcms-garnish/src/select.ts @@ -0,0 +1,1194 @@ +/** + * Select — modern, jQuery-free TypeScript port of Garnish's `Select`. + * + * A selection interface over a set of sibling items: click to select, shift-click + * to extend a range, and ctrl/⌘-click to toggle individual items (the ctrl/shift + * roles swap under `checkboxMode`). Selected items get the `selectedClass` (`sel` + * by default); arrow keys move the selection two-dimensionally by measuring item + * geometry, so it works for both vertical lists and wrapping grids. + * + * jQuery removals (mirroring the `Drag`/`DragSort` ports): + * + * - `$()` collections → native `HTMLElement[]` (`$items`, `$selectedItems`). + * - `$.data(item, 'select')` cross-selector guard → a module-scoped + * {@link itemOwners} `WeakMap`; `$.data(item, 'select-handle')` / + * `$handle.data('select-item')` → per-instance {@link itemHandles} / + * {@link itemCheckboxes} `WeakMap`s + closures that capture the item. + * - `$item.offset()` / `.outerWidth()` / `.outerHeight()` (the 2D nav geometry) + * → `getOffset` / `getOuterWidth` / `getOuterHeight`. + * - `$item.is(':focusable')` / `.find(':focusable:first')` → `isFocusable` / + * `getFocusableElements(item)[0]`. + * - `Garnish.ltr/rtl` → `globals.ltr/rtl`; `Garnish.requestAnimationFrame` → + * `utils/animation`; `$.noop` → `() => {}`. + * + * Unlike the legacy `Base.extend({init})` trampoline, this is a native + * `class extends Base` with a real constructor (see {@link Base}). + */ + +import {Base} from './base'; +import {globals} from './globals'; +import { + X_AXIS, + Y_AXIS, + DOWN_KEY, + LEFT_KEY, + RIGHT_KEY, + RETURN_KEY, + SPACE_KEY, + UP_KEY, + A_KEY, +} from './constants'; +import { + coerceElements, + getOffset, + getOuterHeight, + getOuterWidth, +} from './utils/dom'; +import {isFocusable, getFocusableElements} from './utils/focusable'; +import {isCtrlKeyPressed, isPrimaryClick} from './utils/env'; +import {requestAnimationFrame, cancelAnimationFrame} from './utils/animation'; +import {isPlainObject} from './utils/misc'; +import type {GarnishEvent} from './events'; +import type {ElementInput, GarnishBaseSettings} from './types'; + +// `keyCode` is deprecated but still populated on native KeyboardEvents in every +// browser Craft supports, and the rest of the garnish core (HUD, DisclosureMenu, +// EscManager, …) reads it against the numeric key constants — so this port does +// too, for consistency. + +/** The handle for selecting an item: a selector, a function, an element, or `null` (the item itself). */ +export type SelectHandle = + | string + | ((item: HTMLElement) => HTMLElement | HTMLElement[] | null) + | HTMLElement + | HTMLElement[] + | null; + +/** The click/keydown filter: a selector string or a predicate over the event target. */ +export type SelectFilter = + | string + | ((target: EventTarget | null) => boolean) + | null; + +/** Settings accepted by {@link Select} (extends {@link GarnishBaseSettings}). */ +export interface SelectSettings extends GarnishBaseSettings { + /** Class added to selected items. */ + selectedClass: string; + /** Class marking a checkbox affordance inside an item (gets `aria-checked`). */ + checkboxClass: string; + /** Allow more than one item selected at a time. */ + multi: boolean; + /** Allow the selection to be emptied (container click / last-item deselect). */ + allowEmpty: boolean; + /** Constrain arrow-key navigation to a single vertical column. */ + vertical: boolean; + /** Constrain arrow-key navigation to a single horizontal row. */ + horizontal: boolean; + /** What within each item receives the pointer listeners; `null` → the item. */ + handle: SelectHandle; + /** Gate: only start selecting when the pointer/key target passes this. */ + filter: SelectFilter; + /** Whether a checkbox affordance (not ctrl/shift) drives selection. */ + checkboxMode: boolean; + /** Give the focused item a roving `tabindex` (keyboard-navigable list). */ + makeFocusable: boolean; + /** Defer single-click (de)selection briefly so a double-click can pre-empt it. */ + waitForDoubleClicks: boolean; + /** Called (RAF-deferred) whenever the selection changes. */ + onSelectionChange: () => void; +} + +const noop = (): void => {}; + +/** + * Item → owning {@link Select}. Module-scoped (not per-instance) so adding an + * item already claimed by another selector can warn and hand it over, matching + * the legacy `$.data(item, 'select')` guard. + */ +const itemOwners = new WeakMap(); + +/** Element the container is bound to → its {@link Select} (legacy `$container.data('select')`). */ +const containerOwners = new WeakMap(); + +/** Geometry lookups for {@link Select.getClosestItem}, keyed by axis. */ +const closestItemAxisProps = { + [X_AXIS]: { + midpointOffset: 'top', + midpointSize: getOuterHeight, + rowOffset: 'left', + }, + [Y_AXIS]: { + midpointOffset: 'left', + midpointSize: getOuterWidth, + rowOffset: 'top', + }, +} as const; + +/** Direction lookups for {@link Select.getClosestItem}, keyed by `'<'` / `'>'`. */ +const closestItemDirectionProps = { + '<': { + step: -1, + isNextRow: (a: number, b: number): boolean => a < b, + isWrongDirection: (a: number, b: number): boolean => a > b, + }, + '>': { + step: 1, + isNextRow: (a: number, b: number): boolean => a > b, + isWrongDirection: (a: number, b: number): boolean => a < b, + }, +} as const; + +/** + * Selection interface over a set of sibling items — the modern, jQuery-free + * port of `Garnish.Select`. See the file header for the porting notes. + * + * @typeParam S - The settings shape; defaults to {@link SelectSettings}. + * + * @fires selectionChange - (RAF-deferred) whenever the selected set changes. + * @fires focusItem - `{item}` when keyboard/pointer focus moves to an item. + */ +export class Select extends Base { + /** Default {@link SelectSettings}. */ + static readonly defaults: SelectSettings = { + selectedClass: 'sel', + checkboxClass: 'checkbox', + multi: false, + allowEmpty: true, + vertical: false, + horizontal: false, + handle: null, + filter: null, + checkboxMode: false, + makeFocusable: false, + waitForDoubleClicks: false, + onSelectionChange: noop, + }; + + /** The container element (its click deselects, when `allowEmpty`). */ + $container: HTMLElement | null = null; + + /** All tracked items, in the order they were added. */ + $items: HTMLElement[] = []; + + /** The currently-selected items (a subset of {@link $items}). */ + $selectedItems: HTMLElement[] = []; + + /** The item that last received focus. */ + $focusedItem: HTMLElement | null = null; + + /** The anchor item/index of the current selection range (shift-select origin). */ + $first: HTMLElement | null = null; + first: number | null = null; + + /** The far end item/index of the current selection range. */ + $last: HTMLElement | null = null; + last: number | null = null; + + /** The single roving-`tabindex` item, when `makeFocusable`. */ + $focusable: HTMLElement | null = null; + + /** The handle whose `mousedown` began a potential click (resolved in `mouseup`). */ + private mousedownTarget: HTMLElement | null = null; + /** Pending single-click handler timer (see `waitForDoubleClicks`). */ + private mouseUpTimeout: ReturnType | null = null; + /** RAF handle coalescing `selectionChange` emissions. */ + private callbackFrame: number | null = null; + /** Set by an item's own `click` so the container's deselect click can be ignored. */ + private ignoreClick = false; + + /** Handles bound for each item (the item itself when no `handle` setting). */ + private readonly itemHandles = new WeakMap(); + /** The `.checkbox` affordance within each item, if any. */ + private readonly itemCheckboxes = new WeakMap< + HTMLElement, + HTMLElement | null + >(); + + /** + * @param container - The container element (or, via param-shift, the settings + * or items when later args are omitted). + * @param items - Items to track right away (selector / element / list). + * @param settings - Settings overrides. + */ + constructor( + container?: ElementInput | Partial, + items?: ElementInput | Partial, + settings?: Partial + ) { + super(); + + // Param mapping (legacy parity): + // (settings) — first arg is a plain object + // (container, settings) — second arg is a plain object + // (container, items, settings) + let resolvedContainer: ElementInput = container as ElementInput; + let resolvedItems: ElementInput = items as ElementInput; + if ( + items === undefined && + settings === undefined && + isPlainObject(container) + ) { + settings = container as Partial; + resolvedContainer = null; + resolvedItems = null; + } else if (settings === undefined && isPlainObject(items)) { + settings = items as Partial; + resolvedItems = null; + } + + this.$container = + (coerceElements(resolvedContainer)[0] as HTMLElement) ?? null; + + // Is this already a select? + if (this.$container && containerOwners.has(this.$container)) { + console.warn('Double-instantiating a select on an element'); + containerOwners.get(this.$container)!.destroy(); + } + if (this.$container) { + containerOwners.set(this.$container, this); + } + + this.setSettings(settings, Select.defaults as Partial); + + this.$items = []; + this.$selectedItems = []; + + this.addItems(resolvedItems); + + if ( + this.$container && + this.settings!.allowEmpty && + !this.settings!.checkboxMode + ) { + this.addListener(this.$container, 'click', () => { + if (this.ignoreClick) { + this.ignoreClick = false; + } else { + // Deselect all items on container click. + this.deselectAll(true); + } + }); + } + } + + // --- Queries ---------------------------------------------------------------- + + /** The index of an item within {@link $items}, or `-1`. */ + getItemIndex(item: HTMLElement): number { + return this.$items.indexOf(item); + } + + /** Whether an item is currently selected. */ + isSelected(item: HTMLElement | null): boolean { + if (!item) { + return false; + } + return this.$selectedItems.indexOf(item) !== -1; + } + + /** The selected items, as a fresh array (legacy returned a fresh jQuery set). */ + getSelectedItems(): HTMLElement[] { + return [...this.$selectedItems]; + } + + /** `totalSelected` getter (legacy parity). */ + get totalSelected(): number { + return this.getTotalSelected(); + } + + /** How many items are selected. */ + getTotalSelected(): number { + return this.$selectedItems.length; + } + + // --- Selection -------------------------------------------------------------- + + /** Select a single item, collapsing the range to it. */ + selectItem( + item: HTMLElement, + focus?: boolean, + preventScroll?: boolean + ): void { + if (!this.settings!.multi) { + this.deselectAll(); + } + + this.$first = this.$last = item; + this.first = this.last = this.getItemIndex(item); + + if (focus) { + this.focusItem(item, preventScroll); + } + + this._selectItems([item]); + } + + /** Select every item (multi only). */ + selectAll(): void { + if (!this.settings!.multi || !this.$items.length) { + return; + } + + this.first = 0; + this.last = this.$items.length - 1; + this.$first = this.$items[this.first]!; + this.$last = this.$items[this.last]!; + + this._selectItems([...this.$items]); + } + + /** Extend the selection from the anchor to `item` (multi only). */ + selectRange(item: HTMLElement, preventScroll?: boolean): void { + if (!this.settings!.multi) { + this.selectItem(item, true, true); + return; + } + + this.deselectAll(); + + this.$last = item; + this.last = this.getItemIndex(item); + + this.focusItem(item, preventScroll); + + let sliceFrom: number; + let sliceTo: number; + if (this.first! < this.last) { + sliceFrom = this.first!; + sliceTo = this.last + 1; + } else { + sliceFrom = this.last; + sliceTo = this.first! + 1; + } + + this._selectItems(this.$items.slice(sliceFrom, sliceTo)); + } + + /** Deselect a single item, clearing the range anchor/end if it was one. */ + deselectItem(item: HTMLElement): void { + const index = this.getItemIndex(item); + if (this.first === index) { + this.$first = null; + this.first = null; + } + if (this.last === index) { + this.$last = null; + this.last = null; + } + + this._deselectItems([item]); + } + + /** Deselect everything; pass `clearFirst` to also drop the range anchor/end. */ + deselectAll(clearFirst?: boolean): void { + if (clearFirst) { + this.$first = this.first = this.$last = this.last = null; + } + + this._deselectItems([...this.$items]); + } + + /** Deselect everything else and select just `item`. */ + deselectOthers(item: HTMLElement): void { + this.deselectAll(); + this.selectItem(item, true, true); + } + + /** Toggle a single item's selection (respecting `_canDeselect`). */ + toggleItem(item: HTMLElement, preventScroll?: boolean): void { + if (!this.isSelected(item)) { + this.selectItem(item, true, preventScroll); + } else if (this._canDeselect([item])) { + this.deselectItem(item); + } + } + + // --- Navigation getters ----------------------------------------------------- + + getFirstItem(): HTMLElement | undefined { + return this.$items[0]; + } + + getLastItem(): HTMLElement | undefined { + return this.$items[this.$items.length - 1]; + } + + isPreviousItem(index: number): boolean { + return index > 0; + } + + isNextItem(index: number): boolean { + return index < this.$items.length - 1; + } + + getPreviousItem(index: number): HTMLElement | undefined { + return this.isPreviousItem(index) ? this.$items[index - 1] : undefined; + } + + getNextItem(index: number): HTMLElement | undefined { + return this.isNextItem(index) ? this.$items[index + 1] : undefined; + } + + getItemToTheLeft(index: number): HTMLElement | undefined { + const next = globals.ltr + ? this.getPreviousItem(index) + : this.getNextItem(index); + const has = globals.ltr + ? this.isPreviousItem(index) + : this.isNextItem(index); + if (!has) { + return undefined; + } + if (this.settings!.horizontal) { + return next; + } + if (!this.settings!.vertical) { + return this.getClosestItem(index, X_AXIS, '<'); + } + return undefined; + } + + getItemToTheRight(index: number): HTMLElement | undefined { + const next = globals.ltr + ? this.getNextItem(index) + : this.getPreviousItem(index); + const has = globals.ltr + ? this.isNextItem(index) + : this.isPreviousItem(index); + if (!has) { + return undefined; + } + if (this.settings!.horizontal) { + return next; + } + if (!this.settings!.vertical) { + return this.getClosestItem(index, X_AXIS, '>'); + } + return undefined; + } + + getItemAbove(index: number): HTMLElement | undefined { + if (!this.isPreviousItem(index)) { + return undefined; + } + if (this.settings!.vertical) { + return this.getPreviousItem(index); + } + if (!this.settings!.horizontal) { + return this.getClosestItem(index, Y_AXIS, '<'); + } + return undefined; + } + + getItemBelow(index: number): HTMLElement | undefined { + if (!this.isNextItem(index)) { + return undefined; + } + if (this.settings!.vertical) { + return this.getNextItem(index); + } + if (!this.settings!.horizontal) { + return this.getClosestItem(index, Y_AXIS, '>'); + } + return undefined; + } + + /** + * The nearest item to `index` on the next row/column in a direction — the 2D + * geometry that lets arrow keys traverse a wrapping grid. Ported from the + * legacy midpoint-scan; reads live layout via `getOffset`/`getOuter*`. + */ + getClosestItem( + index: number, + axis: typeof X_AXIS | typeof Y_AXIS, + dir: '<' | '>' + ): HTMLElement | undefined { + const axisProps = closestItemAxisProps[axis]; + const dirProps = closestItemDirectionProps[dir]; + + const thisItem = this.$items[index]!; + const thisOffset = getOffset(thisItem); + const thisMidpoint = + thisOffset[axisProps.midpointOffset] + + Math.round(axisProps.midpointSize(thisItem) / 2); + + let otherRowPos: number | null = null; + let smallestMidpointDiff: number | null = null; + let closestItem: HTMLElement | undefined; + + // Go the other way if this is the X axis on an RTL page. + const step = + globals.rtl && axis === X_AXIS ? dirProps.step * -1 : dirProps.step; + + for ( + let i = index + step; + typeof this.$items[i] !== 'undefined'; + i += step + ) { + const otherItem = this.$items[i]!; + const otherOffset = getOffset(otherItem); + + // Are we on the next row yet? + if ( + dirProps.isNextRow( + otherOffset[axisProps.rowOffset], + thisOffset[axisProps.rowOffset] + ) + ) { + // Is this the first time we've seen this row? + if (otherRowPos === null) { + otherRowPos = otherOffset[axisProps.rowOffset]; + } else if (otherOffset[axisProps.rowOffset] !== otherRowPos) { + // Have we gone too far? + break; + } + + const otherMidpoint = + otherOffset[axisProps.midpointOffset] + + Math.round(axisProps.midpointSize(otherItem) / 2); + const midpointDiff = Math.abs(thisMidpoint - otherMidpoint); + + // Are we getting warmer? + if ( + smallestMidpointDiff === null || + midpointDiff < smallestMidpointDiff + ) { + smallestMidpointDiff = midpointDiff; + closestItem = otherItem; + } else { + // Getting colder? + break; + } + } else if ( + dirProps.isWrongDirection( + otherOffset[axisProps.rowOffset], + thisOffset[axisProps.rowOffset] + ) + ) { + // Getting colder? + break; + } + } + + return closestItem; + } + + getFurthestItemToTheLeft(index: number): HTMLElement | undefined { + return this.getFurthestItem(index, 'getItemToTheLeft'); + } + + getFurthestItemToTheRight(index: number): HTMLElement | undefined { + return this.getFurthestItem(index, 'getItemToTheRight'); + } + + getFurthestItemAbove(index: number): HTMLElement | undefined { + return this.getFurthestItem(index, 'getItemAbove'); + } + + getFurthestItemBelow(index: number): HTMLElement | undefined { + return this.getFurthestItem(index, 'getItemBelow'); + } + + private getFurthestItem( + index: number, + getter: + | 'getItemToTheLeft' + | 'getItemToTheRight' + | 'getItemAbove' + | 'getItemBelow' + ): HTMLElement | undefined { + let item: HTMLElement | undefined; + let testItem: HTMLElement | undefined; + + while ((testItem = this[getter](index))) { + item = testItem; + index = this.getItemIndex(item); + } + + return item; + } + + // --- Item management -------------------------------------------------------- + + /** Track item(s) for selection and bind their pointer/keyboard listeners. */ + addItems(items: ElementInput): void { + const elements = coerceElements(items).filter( + (el): el is HTMLElement => el instanceof HTMLElement + ); + + for (const item of elements) { + // Make sure this element doesn't belong to another selector (and isn't + // already ours — re-adding would duplicate it in `$items`). + const owner = itemOwners.get(item); + if (owner === this) { + continue; + } + if (owner) { + console.warn('Element was added to more than one selector'); + owner.removeItems(item); + } + + itemOwners.set(item, this); + + const handles = this._resolveHandles(item); + this.itemHandles.set(item, handles); + + const checkbox = this.settings!.checkboxClass + ? item.querySelector(`.${this.settings!.checkboxClass}`) + : null; + this.itemCheckboxes.set(item, checkbox); + + for (const handle of handles) { + this.addListener(handle, 'mousedown', (ev: GarnishEvent) => { + this.onMouseDown(ev as unknown as MouseEvent, item, handle); + }); + this.addListener(handle, 'mouseup', (ev: GarnishEvent) => { + this.onMouseUp(ev as unknown as MouseEvent, item, handle); + }); + this.addListener(handle, 'click', () => { + this.ignoreClick = true; + }); + } + + if (checkbox) { + this.addListener(checkbox, 'keydown', (ev: GarnishEvent) => { + const kev = ev as unknown as KeyboardEvent; + if ( + (kev.keyCode === RETURN_KEY || kev.keyCode === SPACE_KEY) && + !kev.shiftKey && + !isCtrlKeyPressed(kev) + ) { + kev.preventDefault(); + this.onCheckboxActivate(kev, item); + } + }); + } + + this.addListener(item, 'keydown', (ev: GarnishEvent) => { + this.onKeyDown(ev as unknown as KeyboardEvent, item); + }); + + this.$items.push(item); + } + + this.updateIndexes(); + } + + /** Stop tracking item(s), unbinding listeners and dropping them from the selection. */ + removeItems(items: ElementInput): void { + const elements = coerceElements(items).filter( + (el): el is HTMLElement => el instanceof HTMLElement + ); + + let itemsChanged = false; + let selectionChanged = false; + + for (const item of elements) { + const index = this.$items.indexOf(item); + if (index !== -1) { + this._deinitItem(item); + this.$items.splice(index, 1); + itemsChanged = true; + + const selectedIndex = this.$selectedItems.indexOf(item); + if (selectedIndex !== -1) { + this.$selectedItems.splice(selectedIndex, 1); + selectionChanged = true; + } + } + } + + if (itemsChanged) { + this.updateIndexes(); + + if (selectionChanged) { + for (const item of elements) { + item.classList.remove(this.settings!.selectedClass); + } + this.onSelectionChange(); + } + } + } + + /** Stop tracking every item. */ + removeAllItems(): void { + for (const item of this.$items) { + this._deinitItem(item); + } + + this.$items = []; + this.$selectedItems = []; + this.updateIndexes(); + } + + /** Recompute the first/last indexes and the roving-focus item. */ + updateIndexes(): void { + if (this.first !== null && this.$first) { + this.first = this.getItemIndex(this.$first); + this.setFocusableItem(this.$first); + } else if (this.$items.length) { + this.setFocusableItem(this.$items[0]!); + } + + if (this.$focusedItem) { + this.focusItem(this.$focusedItem, true); + } + + if (this.last !== null && this.$last) { + this.last = this.getItemIndex(this.$last); + } + } + + /** + * Re-sort {@link $items} / {@link $selectedItems} into current DOM order — call + * after the items have been reordered in the DOM (legacy `resetItemOrder`). + */ + resetItemOrder(): void { + this.$items = this._sortByDomOrder(this.$items); + this.$selectedItems = this._sortByDomOrder(this.$selectedItems); + this.updateIndexes(); + } + + /** + * Give a single item the roving `tabindex="0"` (when `makeFocusable`), so the + * list is a single tab stop rather than one stop per item. + */ + setFocusableItem(item: HTMLElement): void { + if (this.settings!.makeFocusable) { + if (this.$focusable) { + this.$focusable.removeAttribute('tabindex'); + } + item.setAttribute('tabindex', '0'); + this.$focusable = item; + } + } + + /** Move DOM focus onto an item (or its first focusable descendant). */ + focusItem(item: HTMLElement, preventScroll?: boolean): void { + let focusableElement: HTMLElement | null; + if (this.settings!.makeFocusable) { + this.setFocusableItem(item); + focusableElement = item; + } else if (isFocusable(item)) { + focusableElement = item; + } else { + focusableElement = getFocusableElements(item)[0] ?? null; + } + + if (focusableElement) { + focusableElement.focus({preventScroll: !!preventScroll}); + } + + this.$focusedItem = item; + this.trigger('focusItem', {item}); + } + + // --- Events ----------------------------------------------------------------- + + /** Pointer-down on a handle: begin a shift-range, checkbox-toggle, or pending click. */ + onMouseDown(ev: MouseEvent, item: HTMLElement, handle: HTMLElement): void { + this.mousedownTarget = null; + + // Ignore right/ctrl-clicks. + if (!isPrimaryClick(ev) && !isCtrlKeyPressed(ev)) { + return; + } + + // Enforce the filter. + if (this.settings!.filter && !this._passesFilter(ev.target)) { + return; + } + + if (this.first !== null && ev.shiftKey) { + // Shift key is consistent for both selection modes. + this.selectRange(item, true); + } else if ( + this._actAsCheckbox(ev) && + (!this.settings!.waitForDoubleClicks || !this.isSelected(item)) + ) { + // Checkbox-style deselection is handled from onMouseUp(). + this.toggleItem(item, true); + } else { + // Prepare for click handling in onMouseUp(). + this.mousedownTarget = handle; + } + } + + /** Pointer-up on a handle: resolve a plain click into (de)selection. */ + onMouseUp(ev: MouseEvent, item: HTMLElement, handle: HTMLElement): void { + // Ignore right clicks. + if (!isPrimaryClick(ev) && !isCtrlKeyPressed(ev)) { + return; + } + + // Enforce the filter (legacy applied jQuery `.is()` semantics here). + if (this.settings!.filter && !this._passesFilterIs(ev.target)) { + return; + } + + // Was this a click? + if (!ev.shiftKey && handle === this.mousedownTarget) { + if (this.isSelected(item)) { + const handler = (): void => { + if (this._actAsCheckbox(ev)) { + this.deselectItem(item); + } else { + this.deselectOthers(item); + } + }; + + if (this.settings!.waitForDoubleClicks) { + // Wait a moment to see if this is a double click before deciding. + this.clearMouseUpTimeout(); + this.mouseUpTimeout = setTimeout(handler, 300); + } else { + handler(); + } + } else if (!this._actAsCheckbox(ev)) { + // Checkbox-style selection is handled from onMouseDown(). + this.deselectAll(); + this.selectItem(item, true, true); + } + } + } + + /** Return/Space on a checkbox affordance: toggle that item's selection. */ + onCheckboxActivate(ev: KeyboardEvent, item: HTMLElement): void { + ev.stopImmediatePropagation(); + + if (!this.isSelected(item)) { + this.selectItem(item); + } else { + this.deselectItem(item); + } + } + + /** Keyboard navigation/selection over the items (arrows, space, ctrl+A). */ + onKeyDown(ev: KeyboardEvent, item: HTMLElement): void { + // Ignore if the focus isn't on this item, its handle, or its checkbox. + const itemIsTarget = ev.target === item; + const handleIsTarget = (this.itemHandles.get(item) ?? []).includes( + ev.target as HTMLElement + ); + const checkboxIsTarget = + !!this.settings!.checkboxClass && + ev.target instanceof Element && + ev.target.classList.contains(this.settings!.checkboxClass); + + if (!itemIsTarget && !handleIsTarget && !checkboxIsTarget) { + return; + } + + const ctrlKey = isCtrlKeyPressed(ev); + const shiftKey = ev.shiftKey; + + let anchor: number; + if (!this.settings!.checkboxMode || !this.$focusable) { + anchor = (ev.shiftKey ? this.last : this.first) ?? 0; + } else { + anchor = this.$items.indexOf(this.$focusable); + if (anchor === -1) { + anchor = 0; + } + } + + let nextItem: HTMLElement | undefined; + + switch (ev.keyCode) { + case LEFT_KEY: { + ev.preventDefault(); + if (this.first === null) { + nextItem = globals.ltr ? this.getLastItem() : this.getFirstItem(); + } else if (ctrlKey) { + nextItem = this.getFurthestItemToTheLeft(anchor); + } else { + nextItem = this.getItemToTheLeft(anchor); + } + break; + } + + case RIGHT_KEY: { + ev.preventDefault(); + if (this.first === null) { + nextItem = globals.ltr ? this.getFirstItem() : this.getLastItem(); + } else if (ctrlKey) { + nextItem = this.getFurthestItemToTheRight(anchor); + } else { + nextItem = this.getItemToTheRight(anchor); + } + break; + } + + case UP_KEY: { + ev.preventDefault(); + if (this.first === null) { + if (this.$focusable) { + nextItem = + (this.$focusable.previousElementSibling as HTMLElement | null) ?? + undefined; + } + if (!this.$focusable || !nextItem) { + nextItem = this.getLastItem(); + } + } else { + nextItem = ctrlKey + ? this.getFurthestItemAbove(anchor) + : this.getItemAbove(anchor); + if (!nextItem) { + nextItem = this.getFirstItem(); + } + } + break; + } + + case DOWN_KEY: { + ev.preventDefault(); + if (this.first === null) { + if (this.$focusable) { + nextItem = + (this.$focusable.nextElementSibling as HTMLElement | null) ?? + undefined; + } + if (!this.$focusable || !nextItem) { + nextItem = this.getFirstItem(); + } + } else { + nextItem = ctrlKey + ? this.getFurthestItemBelow(anchor) + : this.getItemBelow(anchor); + if (!nextItem) { + nextItem = this.getLastItem(); + } + } + break; + } + + case SPACE_KEY: { + if (!ctrlKey && !shiftKey) { + ev.preventDefault(); + if (this.isSelected(this.$focusable)) { + if (this.$focusable && this._canDeselect([this.$focusable])) { + this.deselectItem(this.$focusable); + } + } else if (this.$focusable) { + this.selectItem(this.$focusable, true, false); + } + } + break; + } + + case A_KEY: { + if (ctrlKey) { + ev.preventDefault(); + this.selectAll(); + } + break; + } + } + + // Is there an item queued up for focus/selection? + if (nextItem) { + if (!this.settings!.checkboxMode) { + if (this.first !== null && ev.shiftKey) { + this.selectRange(nextItem, false); + } else { + this.deselectAll(); + this.selectItem(nextItem, true, false); + } + } else { + // Just set the new item to be focusable. + this.setFocusableItem(nextItem); + if (this.settings!.makeFocusable) { + nextItem.focus(); + } + this.$focusedItem = nextItem; + this.trigger('focusItem', {item: nextItem}); + } + } + } + + /** Emit `selectionChange` on the next frame, coalescing bursts. */ + onSelectionChange(): void { + if (this.callbackFrame) { + cancelAnimationFrame(this.callbackFrame); + this.callbackFrame = null; + } + + this.callbackFrame = requestAnimationFrame(() => { + this.callbackFrame = null; + this.trigger('selectionChange'); + this.settings!.onSelectionChange(); + }); + } + + /** Cancel a pending single-click timer (see `waitForDoubleClicks`). */ + clearMouseUpTimeout(): void { + if (this.mouseUpTimeout) { + clearTimeout(this.mouseUpTimeout); + this.mouseUpTimeout = null; + } + } + + /** Tear down: release the container/items and run the base teardown. */ + override destroy(): void { + if (this.$container) { + containerOwners.delete(this.$container); + } + this.removeAllItems(); + super.destroy(); + } + + // --- Private ---------------------------------------------------------------- + + /** Whether this event should act as a checkbox toggle (ctrl inverts `checkboxMode`). */ + private _actAsCheckbox(ev: MouseEvent): boolean { + if (isCtrlKeyPressed(ev)) { + return !this.settings!.checkboxMode; + } + return this.settings!.checkboxMode; + } + + /** Whether the given items may be deselected without violating `allowEmpty`. */ + private _canDeselect(items: HTMLElement[]): boolean { + return this.settings!.allowEmpty || this.totalSelected > items.length; + } + + private _selectItems(items: HTMLElement[]): void { + for (const item of items) { + item.classList.add(this.settings!.selectedClass); + + if (this.settings!.checkboxClass) { + for (const checkbox of item.querySelectorAll( + `.${this.settings!.checkboxClass}` + )) { + checkbox.setAttribute('aria-checked', 'true'); + } + } + + if (this.$selectedItems.indexOf(item) === -1) { + this.$selectedItems.push(item); + } + } + + this.onSelectionChange(); + } + + private _deselectItems(items: HTMLElement[]): void { + for (const item of items) { + item.classList.remove(this.settings!.selectedClass); + + if (this.settings!.checkboxClass) { + for (const checkbox of item.querySelectorAll( + `.${this.settings!.checkboxClass}` + )) { + checkbox.setAttribute('aria-checked', 'false'); + } + } + + const index = this.$selectedItems.indexOf(item); + if (index !== -1) { + this.$selectedItems.splice(index, 1); + } + } + + this.onSelectionChange(); + } + + private _deinitItem(item: HTMLElement): void { + const handles = this.itemHandles.get(item) ?? []; + for (const handle of handles) { + this.removeAllListeners(handle); + } + + const checkbox = this.itemCheckboxes.get(item); + if (checkbox) { + this.removeAllListeners(checkbox); + } + + this.removeAllListeners(item); + + this.itemHandles.delete(item); + this.itemCheckboxes.delete(item); + if (itemOwners.get(item) === this) { + itemOwners.delete(item); + } + + if (this.$focusedItem === item) { + this.$focusedItem = null; + } + if (this.$focusable === item) { + this.$focusable = null; + } + } + + /** Resolve the handle element(s) for an item from the `handle` setting. */ + private _resolveHandles(item: HTMLElement): HTMLElement[] { + const handle = this.settings!.handle; + if (!handle) { + return [item]; + } + if (typeof handle === 'string') { + return Array.from(item.querySelectorAll(handle)); + } + if (typeof handle === 'function') { + const resolved = handle(item); + return this._toElements(resolved); + } + return this._toElements(handle); + } + + private _toElements( + value: HTMLElement | HTMLElement[] | null + ): HTMLElement[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value.filter(Boolean) : [value]; + } + + /** Filter check for `mousedown`/`keydown` — the direct `filter(target)` form. */ + private _passesFilter(target: EventTarget | null): boolean { + const filter = this.settings!.filter; + if (!filter) { + return true; + } + if (typeof filter === 'function') { + return filter(target); + } + return target instanceof Element && target.matches(filter); + } + + /** + * Filter check for `mouseup`, replicating legacy `$(target).is(filter)`: + * jQuery `.is(fn)` invokes `fn(index, element)` with `this === element`, a + * different call shape than {@link _passesFilter}. Preserved for exact parity. + */ + private _passesFilterIs(target: EventTarget | null): boolean { + const filter = this.settings!.filter; + if (!filter) { + return true; + } + if (typeof filter === 'function') { + return !!(filter as (this: unknown, ...a: unknown[]) => unknown).call( + target, + 0, + target + ); + } + return target instanceof Element && target.matches(filter); + } + + /** Sort a subset of items into current DOM order (for {@link resetItemOrder}). */ + private _sortByDomOrder(items: HTMLElement[]): HTMLElement[] { + return [...items].sort((a, b) => { + const pos = a.compareDocumentPosition(b); + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (pos & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + } +} diff --git a/packages/craftcms-garnish/src/utils/dom.ts b/packages/craftcms-garnish/src/utils/dom.ts index 12cff613dad..c200fc8bce5 100644 --- a/packages/craftcms-garnish/src/utils/dom.ts +++ b/packages/craftcms-garnish/src/utils/dom.ts @@ -61,6 +61,47 @@ export function hasAttr(elem: Element, attr: string): boolean { return elem.hasAttribute(attr); } +/** + * The nearest sibling matching `selector` in the given direction, or `null`. + * Skips any intervening siblings that don't match — a directional analogue of + * `Element.closest()` scoped to siblings. + */ +export function nearestSibling( + elem: Element, + selector: string, + direction: 'previous' | 'next' +): HTMLElement | null { + const step = (node: Element): Element | null => + direction === 'previous' + ? node.previousElementSibling + : node.nextElementSibling; + + for (let sibling = step(elem); sibling; sibling = step(sibling)) { + if (sibling.matches(selector)) { + return sibling as HTMLElement; + } + } + return null; +} + +/** + * The value registered in `registry` for the nearest ancestor of `elem` + * (self excluded), or `null` — the native, WeakMap-keyed counterpart to + * jQuery's `$el.closest(sel).data(key)` back-reference lookup. + */ +export function closestRegistered( + elem: Element, + registry: WeakMap +): T | null { + for (let node = elem.parentElement; node; node = node.parentElement) { + const value = registry.get(node); + if (value !== undefined) { + return value; + } + } + return null; +} + /** * Offset of an element relative to the document, adjusted for a non-window * scroll container (parity with legacy `getOffset`). diff --git a/packages/craftcms-garnish/src/utils/index.ts b/packages/craftcms-garnish/src/utils/index.ts index 2031091f153..5a47b47ffbd 100644 --- a/packages/craftcms-garnish/src/utils/index.ts +++ b/packages/craftcms-garnish/src/utils/index.ts @@ -6,6 +6,8 @@ export { coerceElements, getElement, hasAttr, + nearestSibling, + closestRegistered, getOffset, getOuterWidth, getOuterHeight, diff --git a/packages/craftcms-garnish/tests/select.test.ts b/packages/craftcms-garnish/tests/select.test.ts new file mode 100644 index 00000000000..eb5a34e9e7d --- /dev/null +++ b/packages/craftcms-garnish/tests/select.test.ts @@ -0,0 +1,164 @@ +import {afterEach, describe, expect, it} from 'vitest'; + +import {Select} from '../src/select'; + +// happy-dom has no layout, so the 2D-geometry navigation (`getClosestItem`, +// which reads `getBoundingClientRect`/`offset*`) isn't exercised here — these +// tests cover the pointer-driven selection model (click / shift / ctrl), item +// membership, filtering, and teardown, which is what `` +// relies on. + +function makeItem(): HTMLElement { + const el = document.createElement('div'); + document.body.appendChild(el); + return el; +} + +function fireMouse( + el: EventTarget, + type: string, + opts: MouseEventInit = {} +): void { + el.dispatchEvent(new MouseEvent(type, {bubbles: true, button: 0, ...opts})); +} + +/** A plain click = mousedown then mouseup (Select resolves clicks across both). */ +function click(el: HTMLElement, opts: MouseEventInit = {}): void { + fireMouse(el, 'mousedown', opts); + fireMouse(el, 'mouseup', opts); +} + +describe('Select', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('selects an item on click', () => { + const a = makeItem(); + const select = new Select({multi: true}); + select.addItems([a]); + + click(a); + + expect(a.classList.contains('sel')).toBe(true); + expect(select.getSelectedItems()).toEqual([a]); + + select.destroy(); + }); + + it('extends a contiguous range with shift-click (multi)', () => { + const a = makeItem(); + const b = makeItem(); + const c = makeItem(); + const select = new Select({multi: true}); + select.addItems([a, b, c]); + + click(a); + click(c, {shiftKey: true}); + + expect(select.getSelectedItems()).toEqual([a, b, c]); + + select.destroy(); + }); + + it('toggles individual items with ⌘/ctrl-click (multi)', () => { + const a = makeItem(); + const b = makeItem(); + const select = new Select({multi: true}); + select.addItems([a, b]); + + click(a); + click(b, {metaKey: true, ctrlKey: true}); + expect(select.getSelectedItems()).toEqual([a, b]); + + click(b, {metaKey: true, ctrlKey: true}); + expect(select.getSelectedItems()).toEqual([a]); + + select.destroy(); + }); + + it('replaces the selection when multi is off', () => { + const a = makeItem(); + const b = makeItem(); + const select = new Select({multi: false}); + select.addItems([a, b]); + + click(a); + click(b); + + expect(select.getSelectedItems()).toEqual([b]); + expect(a.classList.contains('sel')).toBe(false); + + select.destroy(); + }); + + it('drops removed items from the selection and item list', () => { + const a = makeItem(); + const b = makeItem(); + const select = new Select({multi: true}); + select.addItems([a, b]); + + click(a); + click(b, {shiftKey: true}); + expect(select.getSelectedItems()).toEqual([a, b]); + + select.removeItems(b); + + expect(select.getSelectedItems()).toEqual([a]); + expect(select.$items).toEqual([a]); + + select.destroy(); + }); + + it('honors a function filter (clicks on excluded targets do not select)', () => { + const a = makeItem(); + const btn = document.createElement('button'); + a.appendChild(btn); + + const select = new Select({ + multi: true, + filter: (target) => + !(target instanceof Element && target.closest('button')), + }); + select.addItems([a]); + + // A click originating on the button is filtered out. + click(btn); + expect(select.getSelectedItems()).toEqual([]); + + // A click on the item body selects it. + click(a); + expect(select.getSelectedItems()).toEqual([a]); + + select.destroy(); + }); + + it('deselectAll clears the selection and its classes', () => { + const a = makeItem(); + const b = makeItem(); + const select = new Select({multi: true}); + select.addItems([a, b]); + + click(a); + click(b, {shiftKey: true}); + + select.deselectAll(); + + expect(select.getSelectedItems()).toEqual([]); + expect(a.classList.contains('sel')).toBe(false); + expect(b.classList.contains('sel')).toBe(false); + + select.destroy(); + }); + + it('does not re-add an item this select already owns', () => { + const a = makeItem(); + const select = new Select({multi: true}); + select.addItems([a]); + select.addItems([a]); + + expect(select.$items).toEqual([a]); + + select.destroy(); + }); +}); diff --git a/packages/craftcms-garnish/tests/utils.test.ts b/packages/craftcms-garnish/tests/utils.test.ts index 8dd976b2efd..524c22f3602 100644 --- a/packages/craftcms-garnish/tests/utils.test.ts +++ b/packages/craftcms-garnish/tests/utils.test.ts @@ -2,7 +2,7 @@ import {describe, expect, it} from 'vitest'; import {getDist, within, isString, isTextNode} from '../src/utils/misc'; import {getInputPostVal, getPostData, findInputs} from '../src/utils/forms'; -import {hasAttr} from '../src/utils/dom'; +import {hasAttr, nearestSibling, closestRegistered} from '../src/utils/dom'; describe('misc utils', () => { it('getDist computes Euclidean distance', () => { @@ -30,6 +30,34 @@ describe('dom utils', () => { el.setAttribute('href', '/x'); expect(hasAttr(el, 'href')).toBe(true); }); + + it('nearestSibling skips non-matching siblings in each direction', () => { + const ul = document.createElement('ul'); + ul.innerHTML = + '
  • '; + const b = ul.querySelector('#b')!; + expect(nearestSibling(b, 'li.g', 'previous')?.id).toBe('a'); + expect(nearestSibling(b, 'li.g', 'next')?.id).toBe('c'); + expect( + nearestSibling(ul.querySelector('#a')!, 'li.g', 'previous') + ).toBeNull(); + expect(nearestSibling(ul.querySelector('#c')!, 'li.g', 'next')).toBeNull(); + }); + + it('closestRegistered returns the nearest registered ancestor (self excluded)', () => { + const registry = new WeakMap(); + const outer = document.createElement('div'); + const inner = document.createElement('div'); + const leaf = document.createElement('span'); + outer.appendChild(inner); + inner.appendChild(leaf); + registry.set(outer, 'outer'); + registry.set(inner, 'inner'); + expect(closestRegistered(leaf, registry)).toBe('inner'); + registry.set(leaf, 'leaf'); + expect(closestRegistered(leaf, registry)).toBe('inner'); + expect(closestRegistered(document.createElement('p'), registry)).toBeNull(); + }); }); describe('forms utils', () => { diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index e46532ab329..e145fefc0d9 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -63,15 +63,12 @@ import './js/ElementFieldSettings.js'; import './js/ElementTableSorter.js'; import './js/EntryIndex.js'; import './js/EntrySelectInput.js'; -import './js/EntryTypeSelectInput.js'; import './js/EnvVarGenerator.js'; import './js/EntryMover.js'; import './js/FormObserver.js'; import './js/VolumeFolderSelectorModal.js'; import './js/FieldToggle.js'; import './js/Grid.js'; -import './js/GroupedEntryTypeManager.js'; -import './js/GroupedEntryTypeSelectInput.js'; import './js/HandleGenerator.js'; import './js/IconPicker.js'; import './js/ImageUpload.js'; diff --git a/packages/craftcms-legacy/cp/src/css/_craft-disclosure.scss b/packages/craftcms-legacy/cp/src/css/_craft-disclosure.scss deleted file mode 100644 index 43cd953bf2b..00000000000 --- a/packages/craftcms-legacy/cp/src/css/_craft-disclosure.scss +++ /dev/null @@ -1,17 +0,0 @@ -craft-disclosure { - --disclosure-icon-transform: rotate(0deg); - --disclosure-icon-transform-active: rotate(-90deg); - display: contents; - - [aria-expanded='true'] .cp-icon { - transform: var(--disclosure-icon-transform); - } -} - -craft-disclosure [aria-expanded='false'] .cp-icon { - transform: var(--disclosure-icon-transform-active); -} - -body.rtl craft-disclosure { - --disclosure-icon-transform-active: rotate(90deg); -} diff --git a/packages/craftcms-legacy/cp/src/css/_main.scss b/packages/craftcms-legacy/cp/src/css/_main.scss index c718607b831..fe15bc4070b 100644 --- a/packages/craftcms-legacy/cp/src/css/_main.scss +++ b/packages/craftcms-legacy/cp/src/css/_main.scss @@ -1174,10 +1174,6 @@ i em { } } -input.checkbox + label.smalltext { - padding-block-start: 2px; -} - .required::after { content: 'asterisk'; margin-inline: 5px 0; @@ -1839,10 +1835,6 @@ ul.icons { } } - div.checkbox { - margin-block-start: 2px; - } - &.copyable { color: var(--fg-subtle); @@ -4324,6 +4316,15 @@ table:not(.cp-table) { } } +// Each chip's `` box is `inline-flex`, so in a block list item it +// lays out in a line box and picks up baseline leading — and the flex baseline +// shifts with the prefix, so a chip with no icon/status renders ~11px taller +// than one with. Making the `li` a flex container blockifies the chip (no line +// box, no baseline), keeping every chip the same height. +.componentselect .chips > li { + display: flex; +} + /* element select fields */ .elementselect { position: relative; @@ -7358,114 +7359,6 @@ textarea.text.fullwidth { } } -input.checkbox { - opacity: 0; - position: absolute; - width: var(--checkbox-size); - height: var(--checkbox-size); - - & + label:has(> .text) { - &::before { - inset-block-start: 6px; - } - } -} - -input.checkbox + label, -div.checkbox { - display: inline-block; - clear: none; - position: relative; - padding-inline-start: calc(var(--checkbox-size) + 0.35rem); - line-height: max(1rem, var(--checkbox-size)); - min-height: max(1rem, var(--checkbox-size)); - cursor: pointer; - - &, - &::before { - // set the border radius on the container too, for (some) focus rings - border-radius: var(--input-border-radius); - } - - &::before { - display: block; - position: absolute; - inset-inline-start: 0; - inset-block-start: 0; - width: var(--checkbox-size) !important; - height: var(--checkbox-size); - box-sizing: border-box; - content: ''; - font-size: 0; - background-color: hsl(212deg 50% 99%); - border: var(--input-border); - background-clip: padding-box; - } - - &:empty { - padding-inline-start: var(--checkbox-size); - - &::after { - content: ''; - font-size: 0; - } - } - - .info { - height: 17px; - margin-block-start: -1px; - } -} - -input.checkbox:disabled + label, -.disabled div.checkbox { - cursor: not-allowed; -} - -input.checkbox:checked + label::before, -div.checkbox.checked::before, -.sel div.checkbox::before, -input.checkbox:indeterminate + label::before, -div.checkbox.indeterminate::before, -.elementselectormodal - .body - .content - .main - .elements - .disabled - .checkbox::before { - @include mixins.icon; - line-height: var(--checkbox-size); - color: var(--gray-900); -} - -input.checkbox:checked:not(:indeterminate) + label::before, -div.checkbox.checked:not(.indeterminate)::before, -.sel:not(.matrixblock) div.checkbox:not(.indeterminate)::before, -.sel.matrixblock > .actions div.checkbox:not(.indeterminate)::before, -.elementselectormodal - .body - .content - .main - .elements - .disabled - .checkbox::before { - content: 'check' / ''; - font-size: calc(var(--checkbox-size) * 0.8); -} - -input.checkbox:indeterminate + label::before, -div.checkbox.indeterminate::before { - content: 'minus'; - font-size: 7px; - text-align: center; -} - -input.checkbox:focus-visible + label::before, -div.checkbox:focus-visible > ::before { - @include mixins.input-focused-styles; -} - .checkbox-icon { display: inline-flex; padding: 3px; diff --git a/packages/craftcms-legacy/cp/src/css/craft.scss b/packages/craftcms-legacy/cp/src/css/craft.scss index 4628c523337..28ed3f8603c 100644 --- a/packages/craftcms-legacy/cp/src/css/craft.scss +++ b/packages/craftcms-legacy/cp/src/css/craft.scss @@ -11,7 +11,6 @@ @import 'cp'; @import 'range'; @import 'global-sidebar'; -@import 'craft-disclosure'; @import 'craft-spinner'; @import 'craft-tooltip'; @import 'preview'; diff --git a/packages/craftcms-legacy/cp/src/js/ComponentSelectInput.js b/packages/craftcms-legacy/cp/src/js/ComponentSelectInput.js index b2ac736cb3f..6b423b0ba92 100644 --- a/packages/craftcms-legacy/cp/src/js/ComponentSelectInput.js +++ b/packages/craftcms-legacy/cp/src/js/ComponentSelectInput.js @@ -264,33 +264,43 @@ Craft.ComponentSelectInput = Garnish.Base.extend( Craft.addActionsToChip($component, actions); const disclosureMenu = this.getDisclosureMenu($component); - const moveForwardBtn = disclosureMenu.$container.find( - '[data-move-forward]' - )[0]; - const moveBackwardBtn = disclosureMenu.$container.find( - '[data-move-backward]' - )[0]; - - disclosureMenu.on('show', () => { - const $li = $component.parent(); - const $prev = $li.prev('li:has(.chip)'); - const $next = $li.next('li:has(.chip)'); - - if (moveForwardBtn) { - disclosureMenu.toggleItem(moveForwardBtn, $prev.length); - } - if (moveBackwardBtn) { - disclosureMenu.toggleItem(moveBackwardBtn, $next.length); - } - }); - this.addListener($component, 'dblclick,taphold', (ev) => { - // don't open the edit slideout if we are tapholding to drag - if (ev.type === 'taphold' && ev.target.nodeName === 'BUTTON') { - return; - } - disclosureMenu.$container.find('[data-edit-action]').click(); - }); + if (disclosureMenu) { + const moveForwardBtn = disclosureMenu.$container.find( + '[data-move-forward]' + )[0]; + const moveBackwardBtn = disclosureMenu.$container.find( + '[data-move-backward]' + )[0]; + + // On the modern craft-action-menu shape, `on()`/`toggleItem()` are + // no-ops (see `getDisclosureMenu()`) — there's no real + // `Garnish.DisclosureMenu` "show" event to hook, so the move + // items just stay visible rather than toggling based on + // position. Move actions aren't part of the new self-booting + // `` at all; this only matters for + // legacy-path (`jsClass`) selects. + disclosureMenu.on('show', () => { + const $li = $component.parent(); + const $prev = $li.prev('li:has(.chip)'); + const $next = $li.next('li:has(.chip)'); + + if (moveForwardBtn) { + disclosureMenu.toggleItem(moveForwardBtn, $prev.length); + } + if (moveBackwardBtn) { + disclosureMenu.toggleItem(moveBackwardBtn, $next.length); + } + }); + + this.addListener($component, 'dblclick,taphold', (ev) => { + // don't open the edit slideout if we are tapholding to drag + if (ev.type === 'taphold' && ev.target.nodeName === 'BUTTON') { + return; + } + disclosureMenu.$container.find('[data-edit-action]').click(); + }); + } } if (this.settings.sortable && Craft.hasMousePointerEvents()) { @@ -327,12 +337,17 @@ Craft.ComponentSelectInput = Garnish.Base.extend( axis === 'y' ? Craft.t('app', 'Move up') : Craft.t('app', 'Move forward'), - onActivate: (el) => { - // don't use `this` in case the chip ends up getting assigned to a different component select - $(el) - .closest('.menu') - .data('disclosureMenu') - .$trigger.closest('.componentselect') + onActivate: () => { + // Resolve from `$component` (the chip), not `this` — in case the + // chip ends up getting assigned to a different component select. + // `$component` stays put in the DOM even when the menu it's + // opened from gets relocated (both the legacy + // `Garnish.DisclosureMenu` and Lion's overlay, which backs + // `craft-action-menu`, move their open content elsewhere in the + // document), so traversing from it — rather than from the + // clicked item — works for either action-menu shape. + $component + .closest('.componentselect') .data('componentSelect') .moveComponentForward($component); }, @@ -353,12 +368,10 @@ Craft.ComponentSelectInput = Garnish.Base.extend( axis === 'y' ? Craft.t('app', 'Move down') : Craft.t('app', 'Move backward'), - onActivate: (el) => { - // don't use `this` in case the chip ends up getting assigned to a different component select - $(el) - .closest('.menu') - .data('disclosureMenu') - .$trigger.closest('.componentselect') + onActivate: () => { + // See the Move-forward comment above. + $component + .closest('.componentselect') .data('componentSelect') .moveComponentBackward($component); }, @@ -371,12 +384,10 @@ Craft.ComponentSelectInput = Garnish.Base.extend( actions.push({ icon: async () => await Craft.ui.icon('remove'), label: Craft.t('app', 'Remove'), - onActivate: (el) => { - // don't use `this` in case the chip ends up getting assigned to a different component select - $(el) - .closest('.menu') - .data('disclosureMenu') - .$trigger.closest('.componentselect') + onActivate: () => { + // See the Move-forward comment above. + $component + .closest('.componentselect') .data('componentSelect') .removeComponent($component); }, @@ -398,11 +409,65 @@ Craft.ComponentSelectInput = Garnish.Base.extend( return actions; }, + /** + * Resolves the chip's action menu, in whichever shape it's in. + * + * For the legacy disclosure-menu shape (old `div.chip`, or a + * `[data-disclosure-trigger]` a plugin still renders into the suffix + * slot), this returns the real `Garnish.DisclosureMenu` instance. + * + * For the modern self-booting `` (what + * `ElementHtml::componentActionMenu()` renders now), there's no + * `Garnish.DisclosureMenu` to return — this returns a minimal shim + * instead, covering what this class actually relies on: + * + * - `$container`: the `[slot="content"]` node, for + * `[data-edit-action]`/`[data-move-forward]`/`[data-move-backward]` + * lookups. + * - `hideItem(el)`: functional — sets the modern `hidden` attribute + * (the same consumer-owned-visibility channel used elsewhere), since + * `EntryTypeSelectInput` calls it synchronously to hide the edit + * action. + * - `on()`/`off()`/`toggleItem()`: no-ops. There's no real "show" event + * to hook (`craft-action-menu` doesn't fire a Garnish-style `show`), + * so the move-item visibility toggling in `addComponentInternal()` + * simply never runs — the move items just stay visible. Move actions + * aren't part of the new `` at all; this only + * affects legacy-path (`jsClass`) selects using the modern chip + * markup. + */ getDisclosureMenu: function ($component) { - return $component - .find('> .chip-content > .chip-actions .action-btn') - .disclosureMenu() - .data('disclosureMenu'); + const $trigger = $component + .find( + '> .chip-content > .chip-actions .action-btn, [slot="suffix"] [data-disclosure-trigger]' + ) + .first(); + + if ($trigger.length) { + return $trigger.disclosureMenu().data('disclosureMenu'); + } + + const actionMenu = $component + .find('[slot="suffix"] craft-action-menu') + .get(0); + if (!actionMenu) { + return null; + } + + let $content = $(actionMenu).children('[slot="content"]').first(); + if (!$content.length) { + $content = $(actionMenu); + } + + return { + $container: $content, + on: () => {}, + off: () => {}, + toggleItem: () => {}, + hideItem: (el) => { + $(el).attr('hidden', ''); + }, + }; }, onChange() { @@ -565,7 +630,10 @@ Craft.ComponentSelectInput = Garnish.Base.extend( await Craft.appendHeadHtml(data.headHtml); await Craft.appendBodyHtml(data.bodyHtml); - if (this.settings.showDescription && $item) { + if ($item) { + // Initialize the chip's UI elements (its action menu's + // disclosure trigger, info icons, …). Chips rendered with the + // page get this from the global boot pass; fetched chips don't. Craft.initUiElements($item); } }, diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index 1ff729651d2..451745e31b8 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -2472,6 +2472,23 @@ $.extend(Craft, { /** * Adds actions to a chip or card. * + * Supports both action-menu shapes: + * + * - The modern self-booting ``, as rendered by + * `ElementHtml::componentActionMenu()` (chips' `[slot="suffix"]`, or a + * card's `.card-actions`) — `craft-action-item`s are built from the + * action descriptors and injected into its `[slot="content"]`. These + * self-boot, so no `Craft.initUiElements()` pass is required — which is + * what makes this safe to call on chips fetched over AJAX. + * - The legacy jQuery `disclosureMenu()` shape (old `div.chip`/card + * markup, or a plugin that still renders it that way). + * + * When neither shape is present yet (e.g. the chip's action menu was + * disabled at render time), a new `` is built if a + * recognized container exists to hang it on; otherwise the legacy + * button + disclosure-menu markup is built from scratch, matching the + * pre-existing fallback. + * * @param {jQuery|HTMLElement} chip * @param {Array} actions * @param {boolean} [prepend] @@ -2481,30 +2498,41 @@ $.extend(Craft, { return; } - // Try old-style chip/card containers first - let $actions = $(chip).find( - '> .chip-content > .chip-actions, > .card-titlebar > .card-actions-container > .card-actions' - ); + // Whichever action container already exists: the modern craft-chip's + // `[slot="suffix"]`, or the (chip or card) `*-actions` container from + // either markup generation. + const $container = $(chip) + .find( + '> [slot="suffix"], > .chip-content > .chip-actions, > .card-titlebar > .card-actions-container > .card-actions' + ) + .first(); + + // An existing modern menu — the common case for anything rendered by + // `ElementHtml::componentActionMenu()`. + let $actionMenu = $container.find('> craft-action-menu').first(); + if ($actionMenu.length) { + this._addActionsToActionMenu($actionMenu.get(0), actions, prepend); + return; + } - let $actionMenuBtn; + // An existing legacy trigger — old markup, or a plugin that still + // renders the disclosure-menu shape. + let $actionMenuBtn = $container + .find('.action-btn, [data-disclosure-trigger]') + .first() + .removeClass('hidden'); - if ($actions.length) { - // Old div.chip — look for existing .action-btn - $actionMenuBtn = $actions.find('.action-btn').removeClass('hidden'); - } else { - // New craft-chip — look in [slot="suffix"] for an existing disclosure trigger - const $suffixSlot = $(chip).children('[slot="suffix"]'); - if ($suffixSlot.length) { - $actions = $suffixSlot; - $actionMenuBtn = $actions - .find('[data-disclosure-trigger]') - .first() - .removeClass('hidden'); + if (!$actionMenuBtn.length) { + if ($container.length) { + // A recognized container exists but has no menu yet — build a + // modern one. + $actionMenu = this._buildActionMenu().appendTo($container); + this._addActionsToActionMenu($actionMenu.get(0), actions, prepend); + return; } - } - if (!$actionMenuBtn?.length) { - // No existing action button — create one and wire it up + // No container at all — very old/custom markup. Build the legacy + // button + disclosure menu from scratch. const menuId = `actions-${Math.floor(Math.random() * 1000000)}`; const labelId = `${menuId}-label`; const $label = $('