From 8756312c06ebfbb6117cd2afecef357e568cc469 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Sep 2024 17:00:36 -0400 Subject: [PATCH] init command stuff --- .../src/lib/bits/command/command-score.ts | 166 +++ .../src/lib/bits/command/command.svelte.ts | 1100 +++++++++++++++++ .../command/components/command-empty.svelte | 24 + .../components/command-group-heading.svelte | 27 + .../components/command-group-items.svelte | 22 + .../command/components/command-group.svelte | 33 + .../command/components/command-input.svelte | 33 + .../command/components/command-item.svelte | 41 + .../command/components/command-label.svelte | 28 + .../components/command-list-viewport.svelte | 27 + .../command/components/command-list.svelte | 29 + .../command/components/command-loading.svelte | 29 + .../components/command-separator.svelte | 31 + .../bits/command/components/command.svelte | 50 + .../bits-ui/src/lib/bits/command/index.ts | 15 + .../bits-ui/src/lib/bits/command/types.ts | 172 +++ .../bits-ui/src/lib/bits/command/utils.ts | 17 + .../bits-ui/src/lib/internal/afterSleep.ts | 3 + 18 files changed, 1847 insertions(+) create mode 100644 packages/bits-ui/src/lib/bits/command/command-score.ts create mode 100644 packages/bits-ui/src/lib/bits/command/command.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-empty.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-group-heading.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-group-items.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-group.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-input.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-item.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-label.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-list-viewport.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-list.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-loading.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command-separator.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/components/command.svelte create mode 100644 packages/bits-ui/src/lib/bits/command/index.ts create mode 100644 packages/bits-ui/src/lib/bits/command/types.ts create mode 100644 packages/bits-ui/src/lib/bits/command/utils.ts create mode 100644 packages/bits-ui/src/lib/internal/afterSleep.ts diff --git a/packages/bits-ui/src/lib/bits/command/command-score.ts b/packages/bits-ui/src/lib/bits/command/command-score.ts new file mode 100644 index 000000000..ad50b9ba2 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/command-score.ts @@ -0,0 +1,166 @@ +// eslint-disable-next-line ts/ban-ts-comment +// @ts-nocheck +// The scores are arranged so that a continuous match of characters will +// result in a total score of 1. +// +// The best case, this character is a match, and either this is the start +// of the string, or the previous character was also a match. +const SCORE_CONTINUE_MATCH = 1; +// A new match at the start of a word scores better than a new match +// elsewhere as it's more likely that the user will type the starts +// of fragments. +// NOTE: We score word jumps between spaces slightly higher than slashes, brackets +// hyphens, etc. +const SCORE_SPACE_WORD_JUMP = 0.9; +const SCORE_NON_SPACE_WORD_JUMP = 0.8; +// Any other match isn't ideal, but we include it for completeness. +const SCORE_CHARACTER_JUMP = 0.17; +// If the user transposed two letters, it should be significantly penalized. +// +// i.e. "ouch" is more likely than "curtain" when "uc" is typed. +const SCORE_TRANSPOSITION = 0.1; +// The goodness of a match should decay slightly with each missing +// character. +// +// i.e. "bad" is more likely than "bard" when "bd" is typed. +// +// This will not change the order of suggestions based on SCORE_* until +// 100 characters are inserted between matches. +const PENALTY_SKIPPED = 0.999; +// The goodness of an exact-case match should be higher than a +// case-insensitive match by a small amount. +// +// i.e. "HTML" is more likely than "haml" when "HM" is typed. +// +// This will not change the order of suggestions based on SCORE_* until +// 1000 characters are inserted between matches. +const PENALTY_CASE_MISMATCH = 0.9999; +// Match higher for letters closer to the beginning of the word +const PENALTY_DISTANCE_FROM_START = 0.9; +// If the word has more characters than the user typed, it should +// be penalised slightly. +// +// i.e. "html" is more likely than "html5" if I type "html". +// +// However, it may well be the case that there's a sensible secondary +// ordering (like alphabetical) that it makes sense to rely on when +// there are many prefix matches, so we don't make the penalty increase +// with the number of tokens. +const PENALTY_NOT_COMPLETE = 0.99; + +const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/; +const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g; +const IS_SPACE_REGEXP = /[\s-]/; +const COUNT_SPACE_REGEXP = /[\s-]/g; + +function commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + stringIndex, + abbreviationIndex, + memoizedResults +) { + if (abbreviationIndex === abbreviation.length) { + if (stringIndex === string.length) { + return SCORE_CONTINUE_MATCH; + } + return PENALTY_NOT_COMPLETE; + } + + const memoizeKey = `${stringIndex},${abbreviationIndex}`; + if (memoizedResults[memoizeKey] !== undefined) { + return memoizedResults[memoizeKey]; + } + + const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); + let index = lowerString.indexOf(abbreviationChar, stringIndex); + let highScore = 0; + + let score, transposedScore, wordBreaks, spaceBreaks; + + while (index >= 0) { + score = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 1, + memoizedResults + ); + if (score > highScore) { + if (index === stringIndex) { + score *= SCORE_CONTINUE_MATCH; + } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_NON_SPACE_WORD_JUMP; + wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP); + if (wordBreaks && stringIndex > 0) { + score *= PENALTY_SKIPPED ** wordBreaks.length; + } + } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_SPACE_WORD_JUMP; + spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP); + if (spaceBreaks && stringIndex > 0) { + score *= PENALTY_SKIPPED ** spaceBreaks.length; + } + } else { + score *= SCORE_CHARACTER_JUMP; + if (stringIndex > 0) { + score *= PENALTY_SKIPPED ** (index - stringIndex); + } + } + + if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { + score *= PENALTY_CASE_MISMATCH; + } + } + + if ( + (score < SCORE_TRANSPOSITION && + lowerString.charAt(index - 1) === + lowerAbbreviation.charAt(abbreviationIndex + 1)) || + (lowerAbbreviation.charAt(abbreviationIndex + 1) === + lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 + lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) + ) { + transposedScore = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 2, + memoizedResults + ); + + if (transposedScore * SCORE_TRANSPOSITION > score) { + score = transposedScore * SCORE_TRANSPOSITION; + } + } + + if (score > highScore) { + highScore = score; + } + + index = lowerString.indexOf(abbreviationChar, index + 1); + } + + memoizedResults[memoizeKey] = highScore; + return highScore; +} + +function formatInput(str: string) { + // convert all valid space characters to space so they match each other + return str.toLowerCase().replace(COUNT_SPACE_REGEXP, " "); +} + +export function commandScore(str: string, abbrev: string, aliases?: string[]): number { + /* NOTE: + * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() + * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. + */ + str = aliases && aliases.length > 0 ? `${`${str} ${aliases?.join(" ")}`}` : str; + return commandScoreInner(str, abbrev, formatInput(str), formatInput(abbrev), 0, 0, {}); +} diff --git a/packages/bits-ui/src/lib/bits/command/command.svelte.ts b/packages/bits-ui/src/lib/bits/command/command.svelte.ts new file mode 100644 index 000000000..e7d136d0b --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/command.svelte.ts @@ -0,0 +1,1100 @@ +import { tick, untrack } from "svelte"; +import { findNextSibling, findPreviousSibling } from "./utils.js"; +import { commandScore } from "./command-score.js"; +import type { CommandState } from "./types.js"; +import { useRefById } from "$lib/internal/useRefById.svelte.js"; +import { createContext } from "$lib/internal/createContext.js"; +import type { WithRefProps } from "$lib/internal/types.js"; +import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; +import { afterSleep } from "$lib/internal/afterSleep.js"; +import { kbd } from "$lib/internal/kbd.js"; +import { + getAriaDisabled, + getAriaExpanded, + getAriaSelected, + getDataDisabled, + getDataSelected, +} from "$lib/internal/attrs.js"; + +const ROOT_ATTR = "data-command-root"; +const LIST_ATTR = "data-command-list"; +const INPUT_ATTR = "data-command-input"; +const SEPARATOR_ATTR = "data-command-separator"; +const LOADING_ATTR = "data-command-loading"; +const EMPTY_ATTR = "data-command-empty"; +const GROUP_ATTR = "data-command-group"; +const GROUP_ITEMS_ATTR = "data-command-group-items"; +const GROUP_HEADING_ATTR = "data-command-group-heading"; +const ITEM_ATTR = "data-command-item"; +const VALUE_ATTR = `data-value`; +const LIST_VIEWPORT_ATTR = "data-command-list-viewport"; + +const GROUP_SELECTOR = `[${GROUP_ATTR}]`; +const GROUP_ITEMS_SELECTOR = `[${GROUP_ITEMS_ATTR}]`; +const GROUP_HEADING_SELECTOR = `[${GROUP_HEADING_ATTR}]`; +const ITEM_SELECTOR = `[${ITEM_ATTR}]`; +const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`; + +export function defaultFilter(value: string, search: string, keywords?: string[]): number { + return commandScore(value, search, keywords); +} + +const [setCommandRootContext, getCommandRootContext] = + createContext("Command.Root"); + +const [setCommandListContext, getCommandListContext] = + createContext("Command.List"); + +export const [setCommandGroupContainerContext, getCommandGroupContainerContext] = + createContext("Command.Group"); + +type CommandRootStateProps = WithRefProps< + ReadableBoxedValues<{ + filter: (value: string, search: string, keywords?: string[]) => number; + shouldFilter: boolean; + loop: boolean; + }> & + WritableBoxedValues<{ + value: string; + }> +>; + +// eslint-disable-next-line ts/no-explicit-any +type SetState = (key: K, value: CommandState[K], opts?: any) => void; + +class CommandRootState { + allItems = new Set(); // [...itemIds] + allGroups = new Map>(); // groupId → [...itemIds] + allIds = new Map(); + id: CommandRootStateProps["id"]; + ref: CommandRootStateProps["ref"]; + filter: CommandRootStateProps["filter"]; + shouldFilter: CommandRootStateProps["shouldFilter"]; + loop: CommandRootStateProps["loop"]; + listNode = $state(null); + labelNode = $state(null); + valueProp: CommandRootStateProps["value"]; + // published state that the components and other things can react to + commandState = $state.raw(null!); + // internal state that we mutate in batches and publish to the `state` at once + #commmandState = $state(null!); + snapshot = () => this.#commmandState; + setState: SetState = (key, value, opts) => { + if (Object.is(this.#commmandState[key], value)) return; + this.#commmandState[key] = value; + if (key === "search") { + // Filter synchronously before emitting back to children + this.#filterItems(); + this.#sort(); + this.#selectFirstItem(); + } else if (key === "value") { + // opts is a boolean referring to whether it should NOT be scrolled into view + if (!opts) { + // Scroll the selected item into view + this.#scrollSelectedIntoView(); + } + } + // notify subscribers that the state has changed + this.emit(); + }; + emit = () => { + this.commandState = $state.snapshot(this.#commmandState); + }; + + constructor(props: CommandRootStateProps) { + this.id = props.id; + this.ref = props.ref; + this.filter = props.filter; + this.shouldFilter = props.shouldFilter; + this.loop = props.loop; + this.valueProp = props.value; + const defaultState = { + /** Value of the search query */ + search: "", + /** Currnetly selected item value */ + value: this.valueProp.current ?? "", + 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(), + }, + }; + this.#commmandState = defaultState; + this.commandState = defaultState; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + $effect(() => { + untrack(() => { + this.#scrollSelectedIntoView(); + }); + }); + } + + #score = (value: string, keywords?: string[]) => { + const filter = this.filter.current ?? defaultFilter; + return value ? filter(value, this.#commmandState.search, keywords) : 0; + }; + + #sort = () => { + if (!this.#commmandState.search || this.shouldFilter.current === false) return; + + const scores = this.#commmandState.filtered.items; + + // sort the groups + const groups: [string, number][] = []; + for (const value of this.#commmandState.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.listNode; + + this.#getValidItems() + .sort((a, b) => { + const valueA = a.getAttribute("id"); + const valueB = b.getAttribute("id"); + return (scores.get(valueA ?? "") ?? 0) - (scores.get(valueB ?? "") ?? 0); + }) + .forEach((item) => { + const group = item.closest(GROUP_ITEMS_SELECTOR); + + if (group) { + group.appendChild( + item.parentElement === group + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`)! + ); + } else { + listInsertionElement?.appendChild( + item.parentElement === listInsertionElement + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`)! + ); + } + }); + + groups + .sort((a, b) => b[1] - a[1]) + .forEach((group) => { + const element = listInsertionElement?.querySelector( + `${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]` + ); + element?.parentElement?.appendChild(element); + }); + }; + + setValue = (value: string, opts?: boolean) => { + this.setState("value", value, opts); + this.valueProp.current = value; + }; + + #selectFirstItem = () => { + afterSleep(1, () => { + const item = this.#getValidItems().find( + (item) => item.getAttribute("aria-disabled") !== "true" + ); + const value = item?.getAttribute(VALUE_ATTR); + this.setValue(value || ""); + }); + }; + + #filterItems = () => { + if (!this.#commmandState.search || this.shouldFilter.current === false) { + this.#commmandState.filtered.count = this.allItems.size; + return; + } + + // reset the groups + this.#commmandState.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.#commmandState.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.#commmandState.filtered.items.get(itemId); + + if (currItem && currItem > 0) { + this.#commmandState.filtered.groups.add(groupId); + break; + } + } + } + + this.#commmandState.filtered.count = itemCount; + }; + + #getValidItems = () => { + const node = this.ref.current; + if (!node) return []; + return Array.from(node.querySelectorAll(VALID_ITEM_SELECTOR)).filter( + (el): el is HTMLElement => !!el + ); + }; + + #getSelectedItem = () => { + const node = this.ref.current; + if (!node) return; + const selectedNode = node.querySelector( + `${VALID_ITEM_SELECTOR}[aria-selected="true"]` + ); + if (!selectedNode) return; + return selectedNode; + }; + + #scrollSelectedIntoView = () => { + const item = this.#getSelectedItem(); + if (!item) return; + + if (item.parentElement?.firstChild === item) { + tick().then(() => { + item + ?.closest(GROUP_SELECTOR) + ?.querySelector(GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }); + }); + } + tick().then(() => item.scrollIntoView({ block: "nearest" })); + }; + + #updateSelectedToIndex = (index: number) => { + const items = this.#getValidItems(); + const item = items[index]; + if (item) { + this.setValue(item.getAttribute(VALUE_ATTR) ?? ""); + } + }; + + #updateSelectedByItem = (change: 1 | -1) => { + 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.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(VALUE_ATTR) ?? ""); + } + }; + + #updateSelectedByGroup = (change: 1 | -1) => { + const selected = this.#getSelectedItem(); + let group = selected?.closest(GROUP_SELECTOR); + let item: HTMLElement | null | undefined; + + while (group && !item) { + group = + change > 0 + ? findNextSibling(group, GROUP_SELECTOR) + : findPreviousSibling(group, GROUP_SELECTOR); + item = group?.querySelector(VALID_ITEM_SELECTOR); + } + + if (item) { + this.setValue(item.getAttribute(VALUE_ATTR) ?? ""); + } else { + this.#updateSelectedByItem(change); + } + }; + + // keep id -> { value, keywords } mapping up to date + registerValue = (id: string, value: string, keywords?: string[]) => { + if (value === this.allIds.get(id)?.value) return; + this.allIds.set(id, { value, keywords }); + this.#commmandState.filtered.items.set(id, this.#score(value, keywords)); + this.#sort(); + this.emit(); + }; + + registerItem = (id: string, groupId: string | undefined) => { + 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); + } + } + + // Batch this, multiple items can mount in one pass + // and we should not be filtering/sorting/emitting each time + this.#filterItems(); + this.#sort(); + + // Could be initial mount, select the first item if none already selected + if (!this.#commmandState.value) { + this.#selectFirstItem(); + } + + this.emit(); + + return () => { + this.allIds.delete(id); + this.allItems.delete(id); + this.#commmandState.filtered.items.delete(id); + const selectedItem = this.#getSelectedItem(); + + this.#filterItems(); + + // Batch this, multiple items could be removed in one pass + 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.emit(); + }; + }; + + registerGroup = (id: string) => { + if (!this.allGroups.has(id)) { + this.allGroups.set(id, new Set()); + } + + return () => { + this.allIds.delete(id); + this.allGroups.delete(id); + }; + }; + + #last = () => { + return this.#updateSelectedToIndex(this.#getValidItems().length - 1); + }; + + #next = (e: KeyboardEvent) => { + e.preventDefault(); + + if (e.metaKey) { + this.#last(); + } else if (e.altKey) { + this.#updateSelectedByGroup(1); + } else { + this.#updateSelectedByItem(1); + } + }; + + #prev = (e: KeyboardEvent) => { + 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: KeyboardEvent) => { + switch (e.key) { + case kbd.ARROW_DOWN: + this.#next(e); + break; + case kbd.ARROW_UP: + 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: { + e.preventDefault(); + const item = this.#getSelectedItem() as HTMLElement; + if (item) { + item?.click(); + } + } + } + }; + + props = $derived.by( + () => + ({ + id: this.id.current, + role: "application", + [ROOT_ATTR]: "", + tabindex: -1, + onkeydown: this.#onkeydown, + }) as const + ); + + createEmpty(props: CommandEmptyStateProps) { + return new CommandEmptyState(props, this); + } + + createGroupContainer(props: CommandGroupContainerStateProps) { + return new CommandGroupContainerState(props, this); + } + + createInput(props: CommandInputStateProps) { + return new CommandInputState(props, this); + } + + createItem(props: CommandItemStateProps) { + return new CommandItemState(props, this); + } + + createSeparator(props: CommandSeparatorStateProps) { + return new CommandSeparatorState(props, this); + } + + createList(props: CommandListStateProps) { + return new CommandListState(props, this); + } + + createLabel(props: CommandLabelStateProps) { + return new CommandLabelState(props, this); + } +} + +type CommandEmptyStateProps = WithRefProps; + +class CommandEmptyState { + #ref: CommandEmptyStateProps["ref"]; + #id: CommandEmptyStateProps["id"]; + #root: CommandRootState; + shouldRender = $derived.by(() => this.#root.commandState.filtered.count === 0); + + constructor(props: CommandEmptyStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.#root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + condition: () => this.shouldRender, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "presentation", + [EMPTY_ATTR]: "", + }) as const + ); +} + +type CommandGroupContainerStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + forceMount: boolean; + }> +>; + +class CommandGroupContainerState { + #ref: CommandGroupContainerStateProps["ref"]; + id: CommandGroupContainerStateProps["id"]; + forceMount: CommandGroupContainerStateProps["forceMount"]; + #value: CommandGroupContainerStateProps["value"]; + #root: CommandRootState; + headingNode = $state(null); + + shouldRender = $derived.by(() => { + if (this.forceMount.current) return true; + if (this.#root.shouldFilter.current === false) return true; + if (!this.#root.commandState.search) return true; + return this.#root.commandState.filtered.groups.has(this.id.current); + }); + trueValue = $state(""); + + constructor(props: CommandGroupContainerStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.id = props.id; + this.#root = root; + this.forceMount = props.forceMount; + this.#value = props.value; + this.trueValue = props.value.current; + + useRefById({ + id: this.id, + ref: this.#ref, + condition: () => this.shouldRender, + }); + + $effect(() => { + this.id.current; + let unsub = () => {}; + untrack(() => { + unsub = this.#root.registerGroup(this.id.current); + }); + return unsub; + }); + + $effect(() => { + untrack(() => { + if (this.#value.current) { + this.trueValue = this.#value.current; + this.#root.registerValue(this.id.current, this.#value.current); + } else if (this.#ref.current?.textContent) + // } else if (this.#headingValue.current) { + // this.trueValue = this.#headingValue.current.trim().toLowerCase(); + // } else if (this.#ref.current?.textContent) { + + this.trueValue = this.#ref.current.textContent.trim().toLowerCase(); + }); + }); + } + + props = $derived.by( + () => + ({ + id: this.id.current, + role: "presentation", + hidden: this.shouldRender ? undefined : "true", + "data-value": this.trueValue, + [GROUP_ATTR]: "", + }) as const + ); + + createGroupHeading(props: CommandGroupHeadingStateProps) { + return new CommandGroupHeadingState(props, this); + } + + createGroupItems(props: CommandGroupItemsStateProps) { + return new CommandGroupItemsState(props, this); + } +} + +type CommandGroupHeadingStateProps = WithRefProps; + +class CommandGroupHeadingState { + #ref: CommandGroupHeadingStateProps["ref"]; + #id: CommandGroupHeadingStateProps["id"]; + #group: CommandGroupContainerState; + + constructor(props: CommandGroupHeadingStateProps, group: CommandGroupContainerState) { + this.#ref = props.ref; + this.#id = props.id; + this.#group = group; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.#group.headingNode = node; + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + [GROUP_HEADING_ATTR]: "", + }) as const + ); +} + +type CommandGroupItemsStateProps = WithRefProps; + +class CommandGroupItemsState { + #ref: CommandGroupItemsStateProps["ref"]; + #id: CommandGroupItemsStateProps["id"]; + #group: CommandGroupContainerState; + + constructor(props: CommandGroupItemsStateProps, group: CommandGroupContainerState) { + this.#ref = props.ref; + this.#id = props.id; + this.#group = group; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "group", + [GROUP_ITEMS_ATTR]: "", + "aria-labelledby": this.#group.headingNode?.id ?? undefined, + }) as const + ); +} + +type CommandInputStateProps = WithRefProps< + WritableBoxedValues<{ + value: string; + }> & + ReadableBoxedValues<{ + autofocus: boolean; + }> +>; + +class CommandInputState { + #ref: CommandInputStateProps["ref"]; + #id: CommandInputStateProps["id"]; + #root: CommandRootState; + #value: CommandInputStateProps["value"]; + #autofocus: CommandInputStateProps["autofocus"]; + + #selectedItemId = $derived.by(() => { + const item = this.#root.listNode?.querySelector( + `${ITEM_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(this.#value.current)}"]` + ); + if (!item) return; + return item?.getAttribute("id") ?? undefined; + }); + + constructor(props: CommandInputStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.#root = root; + this.#value = props.value; + this.#autofocus = props.autofocus; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + + $effect(() => { + const node = this.#ref.current; + untrack(() => { + if (node && this.#autofocus.current) { + afterSleep(10, () => node.focus()); + } + }); + }); + + $effect(() => { + this.#value.current; + untrack(() => { + if (this.#root.commandState.search !== this.#value.current) { + this.#root.setState("search", this.#value.current); + } + }); + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + type: "text", + [INPUT_ATTR]: "", + autocomplete: "off", + autocorrect: "off", + spellcheck: false, + "aria-autocomplete": "list", + role: "combobox", + "aria-expanded": getAriaExpanded(true), + "aria-controls": this.#root.listNode?.id ?? undefined, + "aria-labelledby": this.#root.labelNode?.id ?? undefined, + "aria-activedescendant": this.#selectedItemId, // TODO + }) as const + ); +} + +type CommandItemStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + disabled: boolean; + onSelect: () => void; + forceMount: boolean; + }> & { + group: CommandGroupContainerState | null; + } +>; + +class CommandItemState { + #ref: CommandItemStateProps["ref"]; + id: CommandItemStateProps["id"]; + #root: CommandRootState; + #value: CommandItemStateProps["value"]; + #disabled: CommandItemStateProps["disabled"]; + #onSelect: CommandItemStateProps["onSelect"]; + #forceMount: CommandItemStateProps["forceMount"]; + #group: CommandGroupContainerState | null = null; + #trueForceMount = $derived.by(() => { + return this.#forceMount.current || this.#group?.forceMount.current === true; + }); + trueValue = $state(""); + shouldRender = $derived.by(() => { + if ( + this.#trueForceMount || + this.#root.shouldFilter.current === false || + !this.#root.commandState.search + ) { + return true; + } + const currentScore = this.#root.commandState.filtered.items.get(this.id.current); + if (currentScore === undefined) return false; + return currentScore > 0; + }); + + isSelected = $derived.by(() => this.#root.valueProp.current === this.trueValue); + + constructor(props: CommandItemStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.id = props.id; + this.#root = root; + this.#value = props.value; + this.#disabled = props.disabled; + this.#onSelect = props.onSelect; + this.#forceMount = props.forceMount; + this.#group = getCommandGroupContainerContext(null); + this.trueValue = props.value.current; + + useRefById({ + id: this.id, + ref: this.#ref, + condition: () => this.shouldRender, + }); + + $effect(() => { + this.id.current; + this.#group?.id.current; + let unsub = () => {}; + untrack(() => { + unsub = this.#root.registerItem(this.id.current, this.#group?.id.current); + }); + return unsub; + }); + + $effect(() => { + const value = this.#value.current; + const node = this.#ref.current; + if (!node) return; + if (!value && node.textContent) { + this.trueValue = node.textContent.trim().toLowerCase(); + } + + untrack(() => { + this.#root.registerValue(this.id.current, this.trueValue); + node.setAttribute(VALUE_ATTR, this.trueValue); + }); + }); + } + + #select = () => { + if (this.#disabled.current) return; + this.#root.setValue(this.trueValue, true); + this.#onSelect?.current(); + }; + + #onpointermove = () => { + if (this.#disabled.current) return; + this.#select(); + }; + + #onclick = () => { + if (this.#disabled.current) return; + this.#select(); + }; + + props = $derived.by( + () => + ({ + id: this.id.current, + "aria-disabled": getAriaDisabled(this.#disabled.current), + "aria-selected": getAriaSelected(this.isSelected), + "data-disabled": getDataDisabled(this.#disabled.current), + "data-selected": getDataSelected(this.isSelected), + [ITEM_ATTR]: "", + role: "option", + onclick: this.#onclick, + onpointermove: this.#onpointermove, + }) as const + ); +} + +type CommandLoadingStateProps = WithRefProps< + ReadableBoxedValues<{ + progress: number; + }> +>; + +class CommandLoadingState { + #ref: CommandLoadingStateProps["ref"]; + #id: CommandLoadingStateProps["id"]; + #progress: CommandLoadingStateProps["progress"]; + + constructor(props: CommandLoadingStateProps) { + this.#ref = props.ref; + this.#id = props.id; + this.#progress = props.progress; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "progressbar", + "aria-valuenow": this.#progress.current, + "aria-valuemin": 0, + "aria-valuemax": 100, + "aria-label": "Loading...", + [LOADING_ATTR]: "", + }) as const + ); +} + +type CommandSeparatorStateProps = WithRefProps & + ReadableBoxedValues<{ + forceMount: boolean; + }>; + +class CommandSeparatorState { + #ref: CommandSeparatorStateProps["ref"]; + #id: CommandSeparatorStateProps["id"]; + #root: CommandRootState; + #forceMount: CommandSeparatorStateProps["forceMount"]; + shouldRender = $derived.by(() => !this.#root.commandState.search || this.#forceMount.current); + + constructor(props: CommandSeparatorStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.#root = root; + this.#forceMount = props.forceMount; + + useRefById({ + id: this.#id, + ref: this.#ref, + condition: () => this.shouldRender, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "separator", + [SEPARATOR_ATTR]: "", + }) as const + ); +} + +type CommandListStateProps = WithRefProps & + ReadableBoxedValues<{ + ariaLabel: string; + }>; + +class CommandListState { + #ref: CommandListStateProps["ref"]; + #id: CommandListStateProps["id"]; + #ariaLabel: CommandListStateProps["ariaLabel"]; + root: CommandRootState; + + constructor(props: CommandListStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.root = root; + this.#ariaLabel = props.ariaLabel; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.root.listNode = node; + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "listbox", + "aria-label": this.#ariaLabel.current, + [LIST_ATTR]: "", + }) as const + ); + + createListViewport(props: CommandListViewportStateProps) { + return new CommandListViewportState(props, this); + } +} + +type CommandLabelStateProps = WithRefProps>; + +class CommandLabelState { + #ref: CommandLabelStateProps["ref"]; + #id: CommandLabelStateProps["id"]; + #root: CommandRootState; + #for: CommandLabelStateProps["for"]; + + constructor(props: CommandLabelStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.#root = root; + this.#for = props.for; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.#root.labelNode = node; + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + "data-cmdk-input-label": "", + for: this.#for?.current, + }) as const + ); +} + +type CommandListViewportStateProps = WithRefProps; + +class CommandListViewportState { + #ref: CommandListViewportStateProps["ref"]; + #id: CommandListViewportStateProps["id"]; + #list: CommandListState; + + constructor(props: CommandListViewportStateProps, list: CommandListState) { + this.#ref = props.ref; + this.#id = props.id; + this.#list = list; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + + $effect(() => { + const node = this.#ref.current; + const listNode = this.#list.root.listNode; + if (!node || !listNode) return; + let aF: number; + + 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.#id.current, + [LIST_VIEWPORT_ATTR]: "", + }) as const + ); +} + +export function useCommandRoot(props: CommandRootStateProps) { + return setCommandRootContext(new CommandRootState(props)); +} + +export function useCommandEmpty(props: CommandEmptyStateProps) { + return getCommandRootContext().createEmpty(props); +} + +export function useCommandItem(props: CommandItemStateProps) { + return getCommandRootContext().createItem(props); +} + +export function useCommandGroupContainer(props: CommandGroupContainerStateProps) { + return setCommandGroupContainerContext(getCommandRootContext().createGroupContainer(props)); +} + +export function useCommandGroupHeading(props: CommandGroupHeadingStateProps) { + return getCommandGroupContainerContext().createGroupHeading(props); +} + +export function useCommandGroupItems(props: CommandGroupItemsStateProps) { + return getCommandGroupContainerContext().createGroupItems(props); +} + +export function useCommandInput(props: CommandInputStateProps) { + return getCommandRootContext().createInput(props); +} + +export function useCommandLoading(props: CommandLoadingStateProps) { + return new CommandLoadingState(props); +} + +export function useCommandSeparator(props: CommandSeparatorStateProps) { + return getCommandRootContext().createSeparator(props); +} + +export function useCommandList(props: CommandListStateProps) { + return setCommandListContext(getCommandRootContext().createList(props)); +} + +export function useCommandListViewport(props: CommandListViewportStateProps) { + return getCommandListContext().createListViewport(props); +} + +export function useCommandLabel(props: CommandLabelStateProps) { + return getCommandRootContext().createLabel(props); +} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte b/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte new file mode 100644 index 000000000..a02796126 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte @@ -0,0 +1,24 @@ + + +{#if emptyState.shouldRender} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-group-heading.svelte b/packages/bits-ui/src/lib/bits/command/components/command-group-heading.svelte new file mode 100644 index 000000000..1339675af --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group-heading.svelte @@ -0,0 +1,27 @@ + + +
+ {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/components/command-group-items.svelte b/packages/bits-ui/src/lib/bits/command/components/command-group-items.svelte new file mode 100644 index 000000000..43f5f3ed1 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group-items.svelte @@ -0,0 +1,22 @@ + + +
+ {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/components/command-group.svelte b/packages/bits-ui/src/lib/bits/command/components/command-group.svelte new file mode 100644 index 000000000..2df7531b8 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group.svelte @@ -0,0 +1,33 @@ + + +{#if groupState.shouldRender} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-input.svelte b/packages/bits-ui/src/lib/bits/command/components/command-input.svelte new file mode 100644 index 000000000..4ceed2830 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-input.svelte @@ -0,0 +1,33 @@ + + + diff --git a/packages/bits-ui/src/lib/bits/command/components/command-item.svelte b/packages/bits-ui/src/lib/bits/command/components/command-item.svelte new file mode 100644 index 000000000..9274dffc0 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-item.svelte @@ -0,0 +1,41 @@ + + +{#if itemState.shouldRender} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-label.svelte b/packages/bits-ui/src/lib/bits/command/components/command-label.svelte new file mode 100644 index 000000000..d72de26b0 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-label.svelte @@ -0,0 +1,28 @@ + + + diff --git a/packages/bits-ui/src/lib/bits/command/components/command-list-viewport.svelte b/packages/bits-ui/src/lib/bits/command/components/command-list-viewport.svelte new file mode 100644 index 000000000..38a6ee777 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-list-viewport.svelte @@ -0,0 +1,27 @@ + + +
+ {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/components/command-list.svelte b/packages/bits-ui/src/lib/bits/command/components/command-list.svelte new file mode 100644 index 000000000..d919a8e60 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-list.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/components/command-loading.svelte b/packages/bits-ui/src/lib/bits/command/components/command-loading.svelte new file mode 100644 index 000000000..16f5606d1 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-loading.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/components/command-separator.svelte b/packages/bits-ui/src/lib/bits/command/components/command-separator.svelte new file mode 100644 index 000000000..bad925a20 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-separator.svelte @@ -0,0 +1,31 @@ + + +{#if separatorState.shouldRender} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/command/components/command.svelte b/packages/bits-ui/src/lib/bits/command/components/command.svelte new file mode 100644 index 000000000..33ba41ce3 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command.svelte @@ -0,0 +1,50 @@ + + +
+ + {label} + + {@render children?.()} +
diff --git a/packages/bits-ui/src/lib/bits/command/index.ts b/packages/bits-ui/src/lib/bits/command/index.ts new file mode 100644 index 000000000..4df528621 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/index.ts @@ -0,0 +1,15 @@ +export { default as Command } from "./components/command.svelte"; + +export type { + CommandRootProps as RootProps, + CommandEmptyProps as EmptyProps, + CommandGroupProps as GroupProps, + CommandGroupHeadingProps as GroupHeadingProps, + CommandGroupItemsProps as GroupItemsProps, + CommandItemProps as ItemProps, + CommandInputProps as InputProps, + CommandSeparatorProps as SeparatorProps, + CommandListProps as ListProps, + CommandLoadingProps as LoadingProps, + CommandListViewportProps as ListViewportProps, +} from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/command/types.ts b/packages/bits-ui/src/lib/bits/command/types.ts new file mode 100644 index 000000000..0801569f6 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/types.ts @@ -0,0 +1,172 @@ +import type { + PrimitiveDivAttributes, + PrimitiveInputAttributes, + WithChild, + Without, +} from "$lib/shared/index.js"; + +export type CommandState = { + /** The value of the search query */ + search: string; + /** The value of the selected command menu item */ + value: string; + /** The filtered items */ + filtered: { + /** The count of all visible items. */ + count: number; + /** Map from visible item id to its search store. */ + items: Map; + /** Set of groups with at least one visible item. */ + groups: Set; + }; +}; + +export type CommandRootPropsWithoutHTML = WithChild<{ + /** + * Controlled state store for the command menu. + * Initialize state using the `createState` function. + */ + state?: CommandState; + + /** + * An accessible label for the command menu. + * Not visible & only used for screen readers. + */ + label?: string; + + /** + * Optionally set to `false` to turn off the automatic filtering + * and sorting. If `false`, you must conditionally render valid + * items yourself. + */ + shouldFilter?: boolean; + + /** + * A custom filter function for whether each command item should + * match the query. It should return a number between `0` and `1`, + * with `1` being a perfect match, and `0` being no match, resulting + * in the item being hidden entirely. + * + * By default, it will use the `command-score` package to score. + */ + filter?: (value: string, search: string) => number; + + /** + * Optionally provide or bind to the selected command menu item. + */ + value?: string; + + /** + * A function that is called when the selected command menu item + * changes. It receives the new value as an argument. + */ + onValueChange?: (value: string) => void; + + /** + * Optionally set to `true` to enable looping through the items + * when the user reaches the end of the list using the keyboard. + */ + loop?: boolean; +}>; + +export type CommandRootProps = CommandRootPropsWithoutHTML & + Without; + +export type CommandEmptyPropsWithoutHTML = WithChild; + +export type CommandEmptyProps = CommandEmptyPropsWithoutHTML & + Without; + +export type CommandGroupPropsWithoutHTML = WithChild<{ + /** + * A unique value for the group. + */ + value: string; + + /** + * Whether to force mount the group container regardless of + * filtering logic. + */ + forceMount?: boolean; +}>; + +export type CommandGroupProps = CommandGroupPropsWithoutHTML & + Without; + +export type CommandGroupHeadingPropsWithoutHTML = WithChild; + +export type CommandGroupHeadingProps = CommandGroupHeadingPropsWithoutHTML & + Without; + +export type CommandGroupItemsPropsWithoutHTML = WithChild; + +export type CommandGroupItemsProps = CommandGroupItemsPropsWithoutHTML & + Without; + +export type CommandItemPropsWithoutHTML = WithChild<{ + /** + * Whether the item is disabled. + * + * @defaultValue false + */ + disabled?: boolean; + + /** + * A callback that is fired when the item is selected, either via + * click or keyboard selection. + */ + onSelect?: () => void; + + /** + * A unique value for this item that will be used when filtering + * and ranking the items. If not provided, an attempt will be made + * to use the `textContent` of the item. If the `textContent` is dynamic, + * you will need to provide a stable unique value for the item. + */ + value?: string; + + /** + * Whether to always mount the item regardless of filtering logic. + */ + forceMount?: boolean; +}>; + +export type CommandItemProps = CommandItemPropsWithoutHTML & + Without; + +export type CommandInputPropsWithoutHTML = WithChild; + +export type CommandInputProps = CommandInputPropsWithoutHTML & + Without; + +export type CommandListPropsWithoutHTML = WithChild; + +export type CommandListProps = CommandListPropsWithoutHTML & + Without; + +export type CommandSeparatorPropsWithoutHTML = WithChild<{ + /** + * Whether to force mount the separator container regardless of + * filtering logic. + */ + forceMount?: boolean; +}>; + +export type CommandSeparatorProps = CommandSeparatorPropsWithoutHTML & + Without; + +export type CommandLoadingPropsWithoutHTML = WithChild<{ + /** + * The current progress of the loading state. + * This is a number between `0` and `100`. + */ + progress?: number; +}>; + +export type CommandLoadingProps = CommandLoadingPropsWithoutHTML & + Without; + +export type CommandListViewportPropsWithoutHTML = WithChild; + +export type CommandListViewportProps = CommandListViewportPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/bits/command/utils.ts b/packages/bits-ui/src/lib/bits/command/utils.ts new file mode 100644 index 000000000..18ef77cab --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/utils.ts @@ -0,0 +1,17 @@ +export function findNextSibling(el: Element, selector: string) { + let sibling = el.nextElementSibling; + + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.nextElementSibling; + } +} + +export function findPreviousSibling(el: Element, selector: string) { + let sibling = el.previousElementSibling; + + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.previousElementSibling; + } +} diff --git a/packages/bits-ui/src/lib/internal/afterSleep.ts b/packages/bits-ui/src/lib/internal/afterSleep.ts new file mode 100644 index 000000000..af05a4fea --- /dev/null +++ b/packages/bits-ui/src/lib/internal/afterSleep.ts @@ -0,0 +1,3 @@ +export function afterSleep(ms: number, cb: () => void) { + setTimeout(cb, ms); +}