bits-ui
Version:
The headless components for Svelte.
1,302 lines (1,301 loc) • 45.4 kB
JavaScript
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,
}));
}