UNPKG

bits-ui

Version:

The headless components for Svelte.

1,302 lines (1,301 loc) 45.4 kB
import { afterSleep, afterTick, srOnlyStyles, attachRef, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { findNextSibling, findPreviousSibling } from "./utils.js"; import { kbd } from "../../internal/kbd.js"; import { createBitsAttrs, getAriaDisabled, getAriaExpanded, getAriaSelected, getDataDisabled, getDataSelected, } from "../../internal/attrs.js"; import { getFirstNonCommentChild } from "../../internal/dom.js"; import { computeCommandScore } from "./index.js"; import { cssEscape } from "../../internal/css-escape.js"; const COMMAND_VALUE_ATTR = "data-value"; const commandAttrs = createBitsAttrs({ component: "command", parts: [ "root", "list", "input", "separator", "loading", "empty", "group", "group-items", "group-heading", "item", "viewport", "input-label", ], }); // selectors const COMMAND_GROUP_SELECTOR = commandAttrs.selector("group"); const COMMAND_GROUP_ITEMS_SELECTOR = commandAttrs.selector("group-items"); const COMMAND_GROUP_HEADING_SELECTOR = commandAttrs.selector("group-heading"); const COMMAND_ITEM_SELECTOR = commandAttrs.selector("item"); const COMMAND_VALID_ITEM_SELECTOR = `${commandAttrs.selector("item")}:not([aria-disabled="true"])`; const CommandRootContext = new Context("Command.Root"); const CommandListContext = new Context("Command.List"); const CommandGroupContainerContext = new Context("Command.Group"); const defaultState = { /** Value of the search query */ search: "", /** Currently selected item value */ value: "", filtered: { /** The count of all visible items. */ count: 0, /** Map from visible item id to its search store. */ items: new Map(), /** Set of groups with at least one visible item. */ groups: new Set(), }, }; export class CommandRootState { static create(opts) { return CommandRootContext.set(new CommandRootState(opts)); } opts; attachment; #updateScheduled = false; #isInitialMount = true; sortAfterTick = false; sortAndFilterAfterTick = false; allItems = new Set(); allGroups = new Map(); allIds = new Map(); // attempt to prevent the harsh delay when user is typing fast key = $state(0); viewportNode = $state(null); inputNode = $state(null); labelNode = $state(null); // published state that the components and other things can react to commandState = $state.raw(defaultState); // internal state that we mutate in batches and publish to the `state` at once _commandState = $state(defaultState); #snapshot() { return $state.snapshot(this._commandState); } #scheduleUpdate() { if (this.#updateScheduled) return; this.#updateScheduled = true; afterTick(() => { this.#updateScheduled = false; const currentState = this.#snapshot(); const hasStateChanged = !Object.is(this.commandState, currentState); if (hasStateChanged) { this.commandState = currentState; this.opts.onStateChange?.current?.(currentState); } }); } setState(key, value, preventScroll) { if (Object.is(this._commandState[key], value)) return; this._commandState[key] = value; if (key === "search") { // Filter synchronously before emitting back to children this.#filterItems(); this.#sort(); } else if (key === "value") { if (!preventScroll) this.#scrollSelectedIntoView(); } this.#scheduleUpdate(); } constructor(opts) { this.opts = opts; this.attachment = attachRef(this.opts.ref); const defaults = { ...this._commandState, value: this.opts.value.current ?? "" }; this._commandState = defaults; this.commandState = defaults; this.onkeydown = this.onkeydown.bind(this); } /** * Calculates score for an item based on search text and keywords. * Higher score = better match. * * @param value - Item's display text * @param keywords - Optional keywords to boost scoring * @returns Score from 0-1, where 0 = no match */ #score(value, keywords) { const filter = this.opts.filter.current ?? computeCommandScore; const score = value ? filter(value, this._commandState.search, keywords) : 0; return score; } /** * Sorts items and groups based on search scores. * Groups are sorted by their highest scoring item. * When no search active, selects first item. */ #sort() { if (!this._commandState.search || this.opts.shouldFilter.current === false) { // If no search and no selection yet, select first item this.#selectFirstItem(); return; } const scores = this._commandState.filtered.items; // sort the groups const groups = []; for (const value of this._commandState.filtered.groups) { const items = this.allGroups.get(value); let max = 0; if (!items) { groups.push([value, max]); continue; } // get the max score of the group's items for (const item of items) { const score = scores.get(item); max = Math.max(score ?? 0, max); } groups.push([value, max]); } // Sort items within groups to bottom // Sort items outside of groups // Sort groups to bottom (pushes all non-grouped items to the top) const listInsertionElement = this.viewportNode; const sorted = this.getValidItems().sort((a, b) => { const valueA = a.getAttribute("data-value"); const valueB = b.getAttribute("data-value"); const scoresA = scores.get(valueA) ?? 0; const scoresB = scores.get(valueB) ?? 0; return scoresB - scoresA; }); for (const item of sorted) { const group = item.closest(COMMAND_GROUP_ITEMS_SELECTOR); if (group) { const itemToAppend = item.parentElement === group ? item : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`); if (itemToAppend) { group.appendChild(itemToAppend); } } else { const itemToAppend = item.parentElement === listInsertionElement ? item : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`); if (itemToAppend) { listInsertionElement?.appendChild(itemToAppend); } } } const sortedGroups = groups.sort((a, b) => b[1] - a[1]); for (const group of sortedGroups) { const element = listInsertionElement?.querySelector(`${COMMAND_GROUP_SELECTOR}[${COMMAND_VALUE_ATTR}="${cssEscape(group[0])}"]`); element?.parentElement?.appendChild(element); } this.#selectFirstItem(); } /** * Sets current value and triggers re-render if cleared. * * @param value - New value to set */ setValue(value, opts) { if (value !== this.opts.value.current && value === "") { afterTick(() => { this.key++; }); } this.setState("value", value, opts); this.opts.value.current = value; } /** * Selects first non-disabled item on next tick. */ #selectFirstItem() { afterTick(() => { const item = this.getValidItems().find((item) => item.getAttribute("aria-disabled") !== "true"); const value = item?.getAttribute(COMMAND_VALUE_ATTR); const shouldPreventScroll = this.#isInitialMount && this.opts.disableInitialScroll.current; this.setValue(value ?? "", shouldPreventScroll); this.#isInitialMount = false; }); } /** * Updates filtered items/groups based on search. * Recalculates scores and filtered count. */ #filterItems() { if (!this._commandState.search || this.opts.shouldFilter.current === false) { this._commandState.filtered.count = this.allItems.size; return; } // reset the groups this._commandState.filtered.groups = new Set(); let itemCount = 0; // Check which items should be included for (const id of this.allItems) { const value = this.allIds.get(id)?.value ?? ""; const keywords = this.allIds.get(id)?.keywords ?? []; const rank = this.#score(value, keywords); this._commandState.filtered.items.set(id, rank); if (rank > 0) itemCount++; } // Check which groups have at least 1 item shown for (const [groupId, group] of this.allGroups) { for (const itemId of group) { const currItem = this._commandState.filtered.items.get(itemId); if (currItem && currItem > 0) { this._commandState.filtered.groups.add(groupId); break; } } } this._commandState.filtered.count = itemCount; } /** * Gets all non-disabled, visible command items. * * @returns Array of valid item elements * @remarks Exposed for direct item access and bound checking */ getValidItems() { const node = this.opts.ref.current; if (!node) return []; const validItems = Array.from(node.querySelectorAll(COMMAND_VALID_ITEM_SELECTOR)).filter((el) => !!el); return validItems; } /** * Gets all visible command items. * * @returns Array of valid item elements * @remarks Exposed for direct item access and bound checking */ getVisibleItems() { const node = this.opts.ref.current; if (!node) return []; const visibleItems = Array.from(node.querySelectorAll(COMMAND_ITEM_SELECTOR)).filter((el) => !!el); return visibleItems; } /** Returns all visible items in a matrix structure * * @remarks Returns empty if the command isn't configured as a grid * * @returns */ get itemsGrid() { if (!this.isGrid) return []; const columns = this.opts.columns.current ?? 1; const items = this.getVisibleItems(); const grid = [[]]; let currentGroup = items[0]?.getAttribute("data-group"); let column = 0; let row = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; const itemGroup = item?.getAttribute("data-group"); if (currentGroup !== itemGroup) { currentGroup = itemGroup; column = 1; row++; grid.push([{ index: i, firstRowOfGroup: true, ref: item }]); } else { column++; if (column > columns) { row++; column = 1; grid.push([]); } grid[row]?.push({ index: i, firstRowOfGroup: grid[row]?.[0]?.firstRowOfGroup ?? i === 0, ref: item, }); } } return grid; } /** * Gets currently selected command item. * * @returns Selected element or undefined */ #getSelectedItem() { const node = this.opts.ref.current; if (!node) return; const selectedNode = node.querySelector(`${COMMAND_VALID_ITEM_SELECTOR}[data-selected]`); if (!selectedNode) return; return selectedNode; } /** * Scrolls selected item into view. * Special handling for first items in groups. */ #scrollSelectedIntoView() { afterTick(() => { const item = this.#getSelectedItem(); if (!item) return; const grandparent = item.parentElement?.parentElement; if (!grandparent) return; if (this.isGrid) { const isFirstRowOfGroup = this.#itemIsFirstRowOfGroup(item); // ensure item is visible item.scrollIntoView({ block: "nearest" }); if (isFirstRowOfGroup) { const closestGroupHeader = item ?.closest(COMMAND_GROUP_SELECTOR) ?.querySelector(COMMAND_GROUP_HEADING_SELECTOR); closestGroupHeader?.scrollIntoView({ block: "nearest" }); return; } } else { const firstChildOfParent = getFirstNonCommentChild(grandparent); if (firstChildOfParent && firstChildOfParent.dataset?.value === item.dataset?.value) { const closestGroupHeader = item ?.closest(COMMAND_GROUP_SELECTOR) ?.querySelector(COMMAND_GROUP_HEADING_SELECTOR); closestGroupHeader?.scrollIntoView({ block: "nearest" }); return; } } item.scrollIntoView({ block: "nearest" }); }); } #itemIsFirstRowOfGroup(item) { const grid = this.itemsGrid; if (grid.length === 0) return false; for (let r = 0; r < grid.length; r++) { const row = grid[r]; if (row === undefined) continue; for (let c = 0; c < row.length; c++) { const column = row[c]; if (column === undefined || column.ref !== item) continue; return column.firstRowOfGroup; } } return false; } /** * Sets selection to item at specified index in valid items array. * If index is out of bounds, does nothing. * * @param index - Zero-based index of item to select * @remarks * Uses `getValidItems()` to get selectable items, filtering out disabled/hidden ones. * Access valid items directly via `getValidItems()` to check bounds before calling. * * @example * // get valid items length for bounds check * const items = getValidItems() * if (index < items.length) { * updateSelectedToIndex(index) * } */ updateSelectedToIndex(index) { const item = this.getValidItems()[index]; if (!item) return; this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? ""); } /** * Updates selected item by moving up/down relative to current selection. * Handles wrapping when loop option is enabled. * * @param change - Direction to move: 1 for next item, -1 for previous item * @remarks * The loop behavior wraps: * - From last item to first when moving next * - From first item to last when moving previous * * Uses `getValidItems()` to get all selectable items, which filters out disabled/hidden items. * You can call `getValidItems()` directly to get the current valid items array. * * @example * // select next item * updateSelectedByItem(1) * * // get all valid items * const items = getValidItems() */ updateSelectedByItem(change) { const selected = this.#getSelectedItem(); const items = this.getValidItems(); const index = items.findIndex((item) => item === selected); // Get item at this index let newSelected = items[index + change]; if (this.opts.loop.current) { newSelected = index + change < 0 ? items[items.length - 1] : index + change === items.length ? items[0] : items[index + change]; } if (newSelected) { this.setValue(newSelected.getAttribute(COMMAND_VALUE_ATTR) ?? ""); } } /** * Moves selection to the first valid item in the next/previous group. * If no group is found, falls back to selecting the next/previous item globally. * * @param change - Direction to move: 1 for next group, -1 for previous group * @example * // move to first item in next group * updateSelectedByGroup(1) * * // move to first item in previous group * updateSelectedByGroup(-1) */ updateSelectedByGroup(change) { const selected = this.#getSelectedItem(); let group = selected?.closest(COMMAND_GROUP_SELECTOR); let item; while (group && !item) { group = change > 0 ? findNextSibling(group, COMMAND_GROUP_SELECTOR) : findPreviousSibling(group, COMMAND_GROUP_SELECTOR); item = group?.querySelector(COMMAND_VALID_ITEM_SELECTOR); } if (item) { this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? ""); } else { this.updateSelectedByItem(change); } } /** * Maps item id to display value and search keywords. * Returns cleanup function to remove mapping. * * @param id - Unique item identifier * @param value - Display text * @param keywords - Optional search boost terms * @returns Cleanup function */ registerValue(value, keywords) { if (!(value && value === this.allIds.get(value)?.value)) { this.allIds.set(value, { value, keywords }); } this._commandState.filtered.items.set(value, this.#score(value, keywords)); // Schedule sorting to run after this tick when all items are added not each time an item is added if (!this.sortAfterTick) { this.sortAfterTick = true; afterTick(() => { this.#sort(); this.sortAfterTick = false; }); } return () => { this.allIds.delete(value); }; } /** * Registers item in command list and its group. * Handles filtering, sorting and selection updates. * * @param id - Item identifier * @param groupId - Optional group to add item to * @returns Cleanup function that handles selection */ registerItem(id, groupId) { this.allItems.add(id); // Track this item within the group if (groupId) { if (!this.allGroups.has(groupId)) { this.allGroups.set(groupId, new Set([id])); } else { this.allGroups.get(groupId).add(id); } } // Schedule sorting and filtering to run after this tick when all items are added not each time an item is added if (!this.sortAndFilterAfterTick) { this.sortAndFilterAfterTick = true; afterTick(() => { this.#filterItems(); this.#sort(); this.sortAndFilterAfterTick = false; }); } this.#scheduleUpdate(); return () => { const selectedItem = this.#getSelectedItem(); this.allIds.delete(id); this.allItems.delete(id); this.commandState.filtered.items.delete(id); this.#filterItems(); // The item removed have been the selected one, // so selection should be moved to the first if (selectedItem?.getAttribute("id") === id) { this.#selectFirstItem(); } this.#scheduleUpdate(); }; } /** * Creates empty group if not exists. * * @param id - Group identifier * @returns Cleanup function */ registerGroup(id) { if (!this.allGroups.has(id)) { this.allGroups.set(id, new Set()); } return () => { this.allIds.delete(id); this.allGroups.delete(id); }; } get isGrid() { return this.opts.columns.current !== null; } /** * Selects last valid item. */ #last() { return this.updateSelectedToIndex(this.getValidItems().length - 1); } /** * Handles next item selection: * - Meta: Jump to last * - Alt: Next group * - Default: Next item * * @param e - Keyboard event */ #next(e) { e.preventDefault(); if (e.metaKey) { this.#last(); } else if (e.altKey) { this.updateSelectedByGroup(1); } else { this.updateSelectedByItem(1); } } #down(e) { if (this.opts.columns.current === null) return; e.preventDefault(); if (e.metaKey) { this.updateSelectedByGroup(1); } else { this.updateSelectedByItem(this.#nextRowColumnOffset(e)); } } #getColumn(item, grid) { if (grid.length === 0) return null; for (let r = 0; r < grid.length; r++) { const row = grid[r]; if (row === undefined) continue; for (let c = 0; c < row.length; c++) { const column = row[c]; if (column === undefined || column.ref !== item) continue; return { columnIndex: c, rowIndex: r }; } } return null; } #nextRowColumnOffset(e) { const grid = this.itemsGrid; const selected = this.#getSelectedItem(); if (!selected) return 0; const column = this.#getColumn(selected, grid); if (!column) return 0; let newItem = null; const skipRows = e.altKey ? 1 : 0; // if this is the second to last row then we need to go to the last row when skipping and not in loop mode if (e.altKey && column.rowIndex === grid.length - 2 && !this.opts.loop.current) { newItem = this.#findNextNonDisabledItem({ start: grid.length - 1, end: grid.length, expectedColumnIndex: column.columnIndex, grid, }); } else if (column.rowIndex === grid.length - 1) { // if this is the last row we apply the loop logic if (!this.opts.loop.current) return 0; newItem = this.#findNextNonDisabledItem({ start: 0 + skipRows, end: column.rowIndex, expectedColumnIndex: column.columnIndex, grid, }); } else { newItem = this.#findNextNonDisabledItem({ start: column.rowIndex + 1 + skipRows, end: grid.length, expectedColumnIndex: column.columnIndex, grid, }); // this happens if there were no non-disabled columns below the current column // we can now try starting from the beginning to find the right column if (newItem === null && this.opts.loop.current) { newItem = this.#findNextNonDisabledItem({ start: 0, end: column.rowIndex, expectedColumnIndex: column.columnIndex, grid, }); } } return this.#calculateOffset(selected, newItem); } /** Attempts to find the next non-disabled column that matches the expected column. * * @remarks * - Skips over disabled columns * - When a row is shorter than the expected column it defaults to the last item in the row * * @param param0 * @returns */ #findNextNonDisabledItem({ start, end, grid, expectedColumnIndex, }) { let newItem = null; for (let r = start; r < end; r++) { const row = grid[r]; // try to get the next column newItem = row[expectedColumnIndex]?.ref ?? null; // skip over disabled items if (newItem !== null && itemIsDisabled(newItem)) { newItem = null; continue; } // if that column doesn't exist default to the next highest column if (newItem === null) { // try and find the next highest non-disabled item in the row // if there aren't any non-disabled items we just give up and return null for (let i = row.length - 1; i >= 0; i--) { const item = row[row.length - 1]; if (item === undefined || itemIsDisabled(item.ref)) continue; newItem = item.ref; break; } } break; } return newItem; } #calculateOffset(selected, newSelected) { if (newSelected === null) return 0; const items = this.getValidItems(); const ogIndex = items.findIndex((item) => item === selected); const newIndex = items.findIndex((item) => item === newSelected); return newIndex - ogIndex; } #up(e) { if (this.opts.columns.current === null) return; e.preventDefault(); if (e.metaKey) { this.updateSelectedByGroup(-1); } else { this.updateSelectedByItem(this.#previousRowColumnOffset(e)); } } #previousRowColumnOffset(e) { const grid = this.itemsGrid; const selected = this.#getSelectedItem(); if (selected === undefined) return 0; const column = this.#getColumn(selected, grid); if (column === null) return 0; let newItem = null; const skipRows = e.altKey ? 1 : 0; // if this is the second row then we need to go to the top when skipping and not in loop mode if (e.altKey && column.rowIndex === 1 && this.opts.loop.current === false) { newItem = this.#findNextNonDisabledItemDesc({ start: 0, end: 0, expectedColumnIndex: column.columnIndex, grid, }); } else if (column.rowIndex === 0) { // if this is the last row we apply the loop logic if (this.opts.loop.current === false) return 0; newItem = this.#findNextNonDisabledItemDesc({ start: grid.length - 1 - skipRows, end: column.rowIndex + 1, expectedColumnIndex: column.columnIndex, grid, }); } else { newItem = this.#findNextNonDisabledItemDesc({ start: column.rowIndex - 1 - skipRows, end: 0, expectedColumnIndex: column.columnIndex, grid, }); // this happens if there were no non-disabled columns below the current column // we can now try starting from the beginning to find the right column if (newItem === null && this.opts.loop.current) { newItem = this.#findNextNonDisabledItemDesc({ start: grid.length - 1, end: column.rowIndex + 1, expectedColumnIndex: column.columnIndex, grid, }); } } return this.#calculateOffset(selected, newItem); } /** * Attempts to find the next non-disabled column that matches the expected column. * * @remarks * - Skips over disabled columns * - When a row is shorter than the expected column it defaults to the last item in the row */ #findNextNonDisabledItemDesc({ start, end, grid, expectedColumnIndex, }) { let newItem = null; for (let r = start; r >= end; r--) { const row = grid[r]; if (row === undefined) continue; // try to get the next column newItem = row[expectedColumnIndex]?.ref ?? null; // skip over disabled items if (newItem !== null && itemIsDisabled(newItem)) { newItem = null; continue; } // if that column doesn't exist default to the next highest column if (newItem === null) { // try and find the next highest non-disabled item in the row // if there aren't any non-disabled items we just give up and return null for (let i = row.length - 1; i >= 0; i--) { const item = row[row.length - 1]; if (item === undefined || itemIsDisabled(item.ref)) continue; newItem = item.ref; break; } } break; } return newItem; } /** * Handles previous item selection: * - Meta: Jump to first * - Alt: Previous group * - Default: Previous item * * @param e - Keyboard event */ #prev(e) { e.preventDefault(); if (e.metaKey) { // First item this.updateSelectedToIndex(0); } else if (e.altKey) { // Previous group this.updateSelectedByGroup(-1); } else { // Previous item this.updateSelectedByItem(-1); } } onkeydown(e) { const isVim = this.opts.vimBindings.current && e.ctrlKey; switch (e.key) { case kbd.n: case kbd.j: { // vim down if (isVim) { if (this.isGrid) { this.#down(e); } else { this.#next(e); } } break; } case kbd.l: { // vim right if (isVim) { if (this.isGrid) { this.#next(e); } } break; } case kbd.ARROW_DOWN: if (this.isGrid) { this.#down(e); } else { this.#next(e); } break; case kbd.ARROW_RIGHT: if (!this.isGrid) break; this.#next(e); break; case kbd.p: case kbd.k: { // vim up if (isVim) { if (this.isGrid) { this.#up(e); } else { this.#prev(e); } } break; } case kbd.h: { // vim left if (isVim && this.isGrid) { this.#prev(e); } break; } case kbd.ARROW_UP: if (this.isGrid) { this.#up(e); } else { this.#prev(e); } break; case kbd.ARROW_LEFT: if (!this.isGrid) break; this.#prev(e); break; case kbd.HOME: // first item e.preventDefault(); this.updateSelectedToIndex(0); break; case kbd.END: // last item e.preventDefault(); this.#last(); break; case kbd.ENTER: { /** * Check if IME composition is finished before triggering the select event. * This prevents unwanted triggering while user is still inputting text with IME. * e.keyCode === 229 is for the Japanese IME && Safari as `isComposing` does not * work with Japanese IME and Safari in combination. */ if (!e.isComposing && e.keyCode !== 229) { e.preventDefault(); const item = this.#getSelectedItem(); if (item) { item?.click(); } } } } } props = $derived.by(() => ({ id: this.opts.id.current, role: "application", [commandAttrs.root]: "", tabindex: -1, onkeydown: this.onkeydown, ...this.attachment, })); } function itemIsDisabled(item) { return item.getAttribute("aria-disabled") === "true"; } export class CommandEmptyState { static create(opts) { return new CommandEmptyState(opts, CommandRootContext.get()); } opts; root; attachment; shouldRender = $derived.by(() => { return ((this.root._commandState.filtered.count === 0 && this.#isInitialRender === false) || this.opts.forceMount.current); }); #isInitialRender = true; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); $effect.pre(() => { this.#isInitialRender = false; }); } props = $derived.by(() => ({ id: this.opts.id.current, role: "presentation", [commandAttrs.empty]: "", ...this.attachment, })); } export class CommandGroupContainerState { static create(opts) { return CommandGroupContainerContext.set(new CommandGroupContainerState(opts, CommandRootContext.get())); } opts; root; attachment; shouldRender = $derived.by(() => { if (this.opts.forceMount.current) return true; if (this.root.opts.shouldFilter.current === false) return true; if (!this.root.commandState.search) return true; return this.root._commandState.filtered.groups.has(this.trueValue); }); headingNode = $state(null); trueValue = $state(""); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); this.trueValue = opts.value.current ?? opts.id.current; watch(() => this.trueValue, () => { return this.root.registerGroup(this.trueValue); }); $effect(() => { if (this.opts.value.current) { this.trueValue = this.opts.value.current; return this.root.registerValue(this.opts.value.current); } else if (this.headingNode && this.headingNode.textContent) { this.trueValue = this.headingNode.textContent.trim().toLowerCase(); return this.root.registerValue(this.trueValue); } else { this.trueValue = `-----${this.opts.id.current}`; return this.root.registerValue(this.trueValue); } }); } props = $derived.by(() => ({ id: this.opts.id.current, role: "presentation", hidden: this.shouldRender ? undefined : true, "data-value": this.trueValue, [commandAttrs.group]: "", ...this.attachment, })); } export class CommandGroupHeadingState { static create(opts) { return new CommandGroupHeadingState(opts, CommandGroupContainerContext.get()); } opts; group; attachment; constructor(opts, group) { this.opts = opts; this.group = group; this.attachment = attachRef(this.opts.ref, (v) => (this.group.headingNode = v)); } props = $derived.by(() => ({ id: this.opts.id.current, [commandAttrs["group-heading"]]: "", ...this.attachment, })); } export class CommandGroupItemsState { static create(opts) { return new CommandGroupItemsState(opts, CommandGroupContainerContext.get()); } opts; group; attachment; constructor(opts, group) { this.opts = opts; this.group = group; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "group", [commandAttrs["group-items"]]: "", "aria-labelledby": this.group.headingNode?.id ?? undefined, ...this.attachment, })); } export class CommandInputState { static create(opts) { return new CommandInputState(opts, CommandRootContext.get()); } opts; root; attachment; #selectedItemId = $derived.by(() => { const item = this.root.viewportNode?.querySelector(`${COMMAND_ITEM_SELECTOR}[${COMMAND_VALUE_ATTR}="${cssEscape(this.root.opts.value.current)}"]`); if (item === undefined || item === null) return; return item.getAttribute("id") ?? undefined; }); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => (this.root.inputNode = v)); watch(() => this.opts.ref.current, () => { const node = this.opts.ref.current; if (node && this.opts.autofocus.current) { afterSleep(10, () => node.focus()); } }); watch(() => this.opts.value.current, () => { if (this.root.commandState.search !== this.opts.value.current) { this.root.setState("search", this.opts.value.current); } }); } props = $derived.by(() => ({ id: this.opts.id.current, type: "text", [commandAttrs.input]: "", autocomplete: "off", autocorrect: "off", spellcheck: false, "aria-autocomplete": "list", role: "combobox", "aria-expanded": getAriaExpanded(true), "aria-controls": this.root.viewportNode?.id ?? undefined, "aria-labelledby": this.root.labelNode?.id ?? undefined, "aria-activedescendant": this.#selectedItemId, ...this.attachment, })); } export class CommandItemState { static create(opts) { const group = CommandGroupContainerContext.getOr(null); return new CommandItemState({ ...opts, group }, CommandRootContext.get()); } opts; root; attachment; #group = null; #trueForceMount = $derived.by(() => { return this.opts.forceMount.current || this.#group?.opts.forceMount.current === true; }); shouldRender = $derived.by(() => { this.opts.ref.current; if (this.#trueForceMount || this.root.opts.shouldFilter.current === false || !this.root.commandState.search) { return true; } const currentScore = this.root.commandState.filtered.items.get(this.trueValue); if (currentScore === undefined) return false; return currentScore > 0; }); isSelected = $derived.by(() => this.root.opts.value.current === this.trueValue && this.trueValue !== ""); trueValue = $state(""); constructor(opts, root) { this.opts = opts; this.root = root; this.#group = CommandGroupContainerContext.getOr(null); this.trueValue = opts.value.current; this.attachment = attachRef(this.opts.ref); watch([ () => this.trueValue, () => this.#group?.trueValue, () => this.opts.forceMount.current, ], () => { if (this.opts.forceMount.current) return; return this.root.registerItem(this.trueValue, this.#group?.trueValue); }); watch([() => this.opts.value.current, () => this.opts.ref.current], () => { if (!this.opts.value.current && this.opts.ref.current?.textContent) { this.trueValue = this.opts.ref.current.textContent.trim(); } this.root.registerValue(this.trueValue, opts.keywords.current.map((kw) => kw.trim())); this.opts.ref.current?.setAttribute(COMMAND_VALUE_ATTR, this.trueValue); }); // bindings this.onclick = this.onclick.bind(this); this.onpointermove = this.onpointermove.bind(this); } #onSelect() { if (this.opts.disabled.current) return; this.#select(); this.opts.onSelect?.current(); } #select() { if (this.opts.disabled.current) return; this.root.setValue(this.trueValue, true); } onpointermove(_) { if (this.opts.disabled.current || this.root.opts.disablePointerSelection.current) return; this.#select(); } onclick(_) { if (this.opts.disabled.current) return; this.#onSelect(); } props = $derived.by(() => ({ id: this.opts.id.current, "aria-disabled": getAriaDisabled(this.opts.disabled.current), "aria-selected": getAriaSelected(this.isSelected), "data-disabled": getDataDisabled(this.opts.disabled.current), "data-selected": getDataSelected(this.isSelected), "data-value": this.trueValue, "data-group": this.#group?.trueValue, [commandAttrs.item]: "", role: "option", onpointermove: this.onpointermove, onclick: this.onclick, ...this.attachment, })); } export class CommandLoadingState { static create(opts) { return new CommandLoadingState(opts); } opts; attachment; constructor(opts) { this.opts = opts; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "progressbar", "aria-valuenow": this.opts.progress.current, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": "Loading...", [commandAttrs.loading]: "", ...this.attachment, })); } export class CommandSeparatorState { static create(opts) { return new CommandSeparatorState(opts, CommandRootContext.get()); } opts; root; attachment; shouldRender = $derived.by(() => !this.root._commandState.search || this.opts.forceMount.current); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, // role="separator" cannot belong to a role="listbox" "aria-hidden": "true", [commandAttrs.separator]: "", ...this.attachment, })); } export class CommandListState { static create(opts) { return CommandListContext.set(new CommandListState(opts, CommandRootContext.get())); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "listbox", "aria-label": this.opts.ariaLabel.current, [commandAttrs.list]: "", ...this.attachment, })); } export class CommandLabelState { static create(opts) { return new CommandLabelState(opts, CommandRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => (this.root.labelNode = v)); } props = $derived.by(() => ({ id: this.opts.id.current, [commandAttrs["input-label"]]: "", for: this.opts.for?.current, style: srOnlyStyles, ...this.attachment, })); } export class CommandViewportState { static create(opts) { return new CommandViewportState(opts, CommandListContext.get()); } opts; list; attachment; constructor(opts, list) { this.opts = opts; this.list = list; this.attachment = attachRef(this.opts.ref, (v) => (this.list.root.viewportNode = v)); watch([() => this.opts.ref.current, () => this.list.opts.ref.current], ([node, listNode]) => { if (node === null || listNode === null) return; let aF; const observer = new ResizeObserver(() => { aF = requestAnimationFrame(() => { const height = node.offsetHeight; listNode.style.setProperty("--bits-command-list-height", `${height.toFixed(1)}px`); }); }); observer.observe(node); return () => { cancelAnimationFrame(aF); observer.unobserve(node); }; }); } props = $derived.by(() => ({ id: this.opts.id.current, [commandAttrs.viewport]: "", ...this.attachment, })); }