From 5819cf117a0a3ba233de2f3e7149b09dbeecadc7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:38:50 -0400 Subject: [PATCH] next: Command (#670) --- .../src/lib/bits/command/command-score.ts | 174 +++ .../src/lib/bits/command/command.svelte.ts | 1169 +++++++++++++++++ .../command/components/_command-label.svelte | 32 + .../command/components/command-empty.svelte | 35 + .../components/command-group-heading.svelte | 33 + .../components/command-group-items.svelte | 35 + .../command/components/command-group.svelte | 37 + .../command/components/command-input.svelte | 39 + .../command/components/command-item.svelte | 53 + .../components/command-link-item.svelte | 53 + .../command/components/command-list.svelte | 35 + .../command/components/command-loading.svelte | 35 + .../components/command-separator.svelte | 37 + .../components/command-viewport.svelte | 32 + .../bits/command/components/command.svelte | 65 + .../bits-ui/src/lib/bits/command/index.ts | 27 + .../bits-ui/src/lib/bits/command/types.ts | 194 +++ .../bits-ui/src/lib/bits/command/utils.ts | 30 + packages/bits-ui/src/lib/bits/index.ts | 1 + .../link-preview-content-static.svelte | 2 +- .../components/link-preview-content.svelte | 2 +- .../menu/components/menu-radio-item.svelte | 2 +- .../menubar/components/menubar-trigger.svelte | 2 +- .../bits/menubar/components/menubar.svelte | 2 +- .../components/navigation-menu-item.svelte | 2 +- .../components/navigation-menu-link.svelte | 2 +- .../select/components/select-value.svelte | 2 +- .../lib/bits/switch/components/switch.svelte | 2 +- .../components/toggle-group-item.svelte | 2 +- .../components/tooltip-content-static.svelte | 2 +- .../tooltip/components/tooltip-content.svelte | 2 +- .../bits-ui/src/lib/internal/afterSleep.ts | 3 + packages/bits-ui/src/lib/internal/dom.ts | 9 + packages/bits-ui/src/lib/internal/kbd.ts | 3 + packages/bits-ui/src/lib/types.ts | 1 + sites/docs/content/components/command.md | 61 + .../lib/components/demos/combobox-demo.svelte | 2 +- .../demos/command-demo-dialog.svelte | 122 ++ .../lib/components/demos/command-demo.svelte | 84 ++ .../lib/components/demos/dialog-demo.svelte | 1 - sites/docs/src/lib/components/demos/index.ts | 2 + .../examples/command/command-wrapper.svelte | 17 + .../command/framer/framer-command.svelte | 120 ++ .../examples/command/framer/framer.pcss | 268 ++++ .../command/framer/icons/avatar.svelte | 8 + .../command/framer/icons/badge.svelte | 8 + .../command/framer/icons/button.svelte | 8 + .../command/framer/icons/container.svelte | 8 + .../examples/command/framer/icons/index.ts | 8 + .../command/framer/icons/input.svelte | 8 + .../command/framer/icons/radio.svelte | 8 + .../command/framer/icons/search.svelte | 14 + .../command/framer/icons/slider.svelte | 8 + .../examples/command/icons/copied.svelte | 15 + .../examples/command/icons/copy.svelte | 14 + .../examples/command/icons/figma.svelte | 10 + .../examples/command/icons/framer.svelte | 6 + .../examples/command/icons/github.svelte | 6 + .../examples/command/icons/index.ts | 10 + .../examples/command/icons/linear.svelte | 22 + .../examples/command/icons/raycast.svelte | 8 + .../examples/command/icons/slack.svelte | 34 + .../examples/command/icons/vercel.svelte | 3 + .../examples/command/icons/youtube.svelte | 7 + .../command/linear/icons/assign-to-me.svelte | 13 + .../command/linear/icons/assign-to.svelte | 5 + .../command/linear/icons/change-labels.svelte | 7 + .../linear/icons/change-priority.svelte | 5 + .../command/linear/icons/change-status.svelte | 10 + .../examples/command/linear/icons/index.ts | 7 + .../command/linear/icons/remove-label.svelte | 7 + .../command/linear/icons/set-due-date.svelte | 7 + .../command/linear/linear-command.svelte | 82 ++ .../examples/command/linear/linear.pcss | 144 ++ .../components/examples/command/logo.svelte | 13 + .../command/raycast/icons/clipboard.svelte | 11 + .../command/raycast/icons/finder.svelte | 9 + .../command/raycast/icons/hammer.svelte | 11 + .../examples/command/raycast/icons/index.ts | 7 + .../command/raycast/icons/raycast-dark.svelte | 14 + .../raycast/icons/raycast-light.svelte | 14 + .../command/raycast/icons/star.svelte | 9 + .../command/raycast/icons/window.svelte | 9 + .../examples/command/raycast/item.svelte | 29 + .../command/raycast/raycast-command.svelte | 109 ++ .../examples/command/raycast/raycast.postcss | 552 ++++++++ .../command/raycast/sub-command.svelte | 82 ++ .../examples/command/raycast/sub-item.svelte | 15 + .../examples/command/theme-switcher.svelte | 103 ++ .../examples/command/vercel/home.svelte | 58 + .../command/vercel/icons/contact.svelte | 14 + .../examples/command/vercel/icons/docs.svelte | 17 + .../command/vercel/icons/feedback.svelte | 15 + .../examples/command/vercel/icons/index.ts | 6 + .../examples/command/vercel/icons/plus.svelte | 14 + .../command/vercel/icons/projects.svelte | 16 + .../command/vercel/icons/teams.svelte | 16 + .../examples/command/vercel/item.svelte | 27 + .../examples/command/vercel/projects.svelte | 9 + .../command/vercel/vercel-command.svelte | 77 ++ .../examples/command/vercel/vercel.pcss | 154 +++ .../src/lib/content/api-reference/command.ts | 309 +++++ .../command/command-filter-prop.md | 3 + .../extended-types/command/index.ts | 1 + .../src/lib/content/api-reference/index.ts | 3 + .../src/lib/content/api-reference/tabs.ts | 3 +- .../docs/src/lib/styles/command/command.pcss | 497 +++++++ .../docs/src/lib/styles/command/globals.pcss | 151 +++ sites/docs/src/lib/styles/command/icons.pcss | 46 + .../examples/command/+layout.svelte | 9 + .../(examples)/examples/command/+page.svelte | 37 + .../examples/command/sink/+page.svelte | 43 + .../src/routes/{ => (main)}/+error.svelte | 0 .../src/routes/{ => (main)}/+layout.svelte | 0 .../docs/src/routes/{ => (main)}/+page.svelte | 0 sites/docs/src/routes/(main)/+page.ts | 5 + sites/docs/src/routes/(main)/docs/+page.ts | 5 + .../{ => (main)}/docs/[...slug]/+page.svelte | 0 .../{ => (main)}/docs/[...slug]/+page.ts | 5 +- .../docs/components/[name]/+page.svelte | 0 .../docs/components/[name]/+page.ts | 5 +- .../src/routes/{ => (main)}/sink/+page.svelte | 0 sites/docs/src/routes/+page.ts | 6 - sites/docs/src/routes/docs/+page.ts | 6 - 124 files changed, 5844 insertions(+), 33 deletions(-) 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-label.svelte 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-link-item.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-viewport.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 create mode 100644 packages/bits-ui/src/lib/internal/dom.ts create mode 100644 sites/docs/content/components/command.md create mode 100644 sites/docs/src/lib/components/demos/command-demo-dialog.svelte create mode 100644 sites/docs/src/lib/components/demos/command-demo.svelte create mode 100644 sites/docs/src/lib/components/examples/command/command-wrapper.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/framer-command.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/framer.pcss create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/avatar.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/badge.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/button.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/container.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/index.ts create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/input.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/radio.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/search.svelte create mode 100644 sites/docs/src/lib/components/examples/command/framer/icons/slider.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/copied.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/copy.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/figma.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/framer.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/github.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/index.ts create mode 100644 sites/docs/src/lib/components/examples/command/icons/linear.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/raycast.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/slack.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/vercel.svelte create mode 100644 sites/docs/src/lib/components/examples/command/icons/youtube.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/assign-to-me.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/assign-to.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/change-labels.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/change-priority.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/change-status.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/index.ts create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/remove-label.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/icons/set-due-date.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/linear-command.svelte create mode 100644 sites/docs/src/lib/components/examples/command/linear/linear.pcss create mode 100644 sites/docs/src/lib/components/examples/command/logo.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/clipboard.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/finder.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/hammer.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/index.ts create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/raycast-dark.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/raycast-light.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/star.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/icons/window.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/item.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/raycast-command.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/raycast.postcss create mode 100644 sites/docs/src/lib/components/examples/command/raycast/sub-command.svelte create mode 100644 sites/docs/src/lib/components/examples/command/raycast/sub-item.svelte create mode 100644 sites/docs/src/lib/components/examples/command/theme-switcher.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/home.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/contact.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/docs.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/feedback.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/index.ts create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/plus.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/projects.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/icons/teams.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/item.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/projects.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/vercel-command.svelte create mode 100644 sites/docs/src/lib/components/examples/command/vercel/vercel.pcss create mode 100644 sites/docs/src/lib/content/api-reference/command.ts create mode 100644 sites/docs/src/lib/content/api-reference/extended-types/command/command-filter-prop.md create mode 100644 sites/docs/src/lib/content/api-reference/extended-types/command/index.ts create mode 100644 sites/docs/src/lib/styles/command/command.pcss create mode 100644 sites/docs/src/lib/styles/command/globals.pcss create mode 100644 sites/docs/src/lib/styles/command/icons.pcss create mode 100644 sites/docs/src/routes/(examples)/examples/command/+layout.svelte create mode 100644 sites/docs/src/routes/(examples)/examples/command/+page.svelte create mode 100644 sites/docs/src/routes/(examples)/examples/command/sink/+page.svelte rename sites/docs/src/routes/{ => (main)}/+error.svelte (100%) rename sites/docs/src/routes/{ => (main)}/+layout.svelte (100%) rename sites/docs/src/routes/{ => (main)}/+page.svelte (100%) create mode 100644 sites/docs/src/routes/(main)/+page.ts create mode 100644 sites/docs/src/routes/(main)/docs/+page.ts rename sites/docs/src/routes/{ => (main)}/docs/[...slug]/+page.svelte (100%) rename sites/docs/src/routes/{ => (main)}/docs/[...slug]/+page.ts (63%) rename sites/docs/src/routes/{ => (main)}/docs/components/[name]/+page.svelte (100%) rename sites/docs/src/routes/{ => (main)}/docs/components/[name]/+page.ts (67%) rename sites/docs/src/routes/{ => (main)}/sink/+page.svelte (100%) delete mode 100644 sites/docs/src/routes/+page.ts delete mode 100644 sites/docs/src/routes/docs/+page.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..c20459b74 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/command-score.ts @@ -0,0 +1,174 @@ +// 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(string) { + // convert all valid space characters to space so they match each other + return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " "); +} + +export function commandScore(string: string, abbreviation: 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. + */ + string = aliases && aliases.length > 0 ? `${`${string} ${aliases?.join(" ")}`}` : string; + return commandScoreInner( + string, + abbreviation, + formatInput(string), + formatInput(abbreviation), + 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..e8e611cf7 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/command.svelte.ts @@ -0,0 +1,1169 @@ +import { 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"; +import { getFirstNonCommentChild } from "$lib/internal/dom.js"; +import { srOnlyStyles } from "$lib/internal/style.js"; +import { afterTick } from "$lib/internal/afterTick.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 VIEWPORT_ATTR = "data-command-viewport"; +const INPUT_LABEL_ATTR = "data-command-input-label"; + +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; + vimBindings: boolean; + disablePointerSelection: 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"]; + // attempt to prevent the harsh delay when user is typing fast + key = $state(0); + viewportNode = $state(null); + inputNode = $state(null); + labelNode = $state(null); + valueProp: CommandRootStateProps["value"]; + #vimBindings: CommandRootStateProps["vimBindings"]; + disablePointerSelection: CommandRootStateProps["disablePointerSelection"]; + // 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 + _commandState = $state(null!); + snapshot = () => this._commandState; + setState: SetState = (key, value, opts) => { + 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(); + 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._commandState); + }; + + 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; + this.#vimBindings = props.vimBindings; + this.disablePointerSelection = props.disablePointerSelection; + + 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._commandState = defaultState; + this.commandState = defaultState; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + $effect(() => { + this._commandState.value; + this.#scrollSelectedIntoView(); + }); + } + + #score = (value: string, keywords?: string[]) => { + const filter = this.filter.current ?? defaultFilter; + const score = value ? filter(value, this._commandState.search, keywords) : 0; + return score; + }; + + #sort = () => { + if (!this._commandState.search || this.shouldFilter.current === false) return; + + const scores = this._commandState.filtered.items; + + // sort the groups + const groups: [string, number][] = []; + 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("id"); + const valueB = b.getAttribute("id"); + const scoresA = scores.get(valueA!) ?? 0; + const scoresB = scores.get(valueB!) ?? 0; + return scoresB - scoresA; + }); + + for (const item of sorted) { + const group = item.closest(GROUP_ITEMS_SELECTOR); + + if (group) { + const itemToAppend = + item.parentElement === group + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`); + + if (itemToAppend) { + group.appendChild(itemToAppend); + } + } else { + const itemToAppend = + item.parentElement === listInsertionElement + ? item + : item.closest(`${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( + `${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]` + ); + element?.parentElement?.appendChild(element); + } + }; + + setValue = (value: string, opts?: boolean) => { + if (value !== this.valueProp.current && value === "") { + afterTick(() => { + this.key++; + }); + } + this.setState("value", value, opts); + this.valueProp.current = value; + }; + + #selectFirstItem = () => { + afterTick(() => { + const item = this.#getValidItems().find( + (item) => item.getAttribute("aria-disabled") !== "true" + ); + const value = item?.getAttribute(VALUE_ATTR); + this.setValue(value || ""); + }); + }; + + #filterItems = () => { + if (!this._commandState.search || this.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; + }; + + #getValidItems = () => { + const node = this.ref.current; + if (!node) return []; + const validItems = Array.from( + node.querySelectorAll(VALID_ITEM_SELECTOR) + ).filter((el): el is HTMLElement => !!el); + return validItems; + }; + + #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 = () => { + afterSleep(1, () => { + const item = this.#getSelectedItem(); + if (!item) return; + const firstChildOfParent = getFirstNonCommentChild(item.parentElement); + if (firstChildOfParent === item) { + item + ?.closest(GROUP_SELECTOR) + ?.querySelector(GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }); + } + 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._commandState.filtered.items.set(id, this.#score(value, keywords)); + + this.#sort(); + this.emit(); + + return () => { + this.allIds.delete(id); + }; + }; + + 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); + } + } + + this.#filterItems(); + this.#sort(); + + // Could be initial mount, select the first item if none already selected + if (!this.commandState.value) { + this.#selectFirstItem(); + } + + this.emit(); + return () => { + this.allIds.delete(id); + this.allItems.delete(id); + this.commandState.filtered.items.delete(id); + const selectedItem = this.#getSelectedItem(); + + 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.n: + case kbd.j: { + // vim down + if (this.#vimBindings.current && e.ctrlKey) { + this.#next(e); + } + break; + } + case kbd.ARROW_DOWN: + this.#next(e); + break; + case kbd.p: + case kbd.k: { + // vim up + if (this.#vimBindings.current && e.ctrlKey) { + this.#prev(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: { + /** + * 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.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; + #isInitialRender = true; + shouldRender = $derived.by( + () => this.#root._commandState.filtered.count === 0 && this.#isInitialRender === false + ); + + constructor(props: CommandEmptyStateProps, root: CommandRootState) { + this.#ref = props.ref; + this.#id = props.id; + this.#root = root; + + $effect(() => { + this.#isInitialRender = false; + }); + + 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(() => { + return this.#root.registerGroup(this.id.current); + }); + + $effect(() => { + if (this.#value.current) { + this.trueValue = this.#value.current; + return this.#root.registerValue(this.id.current, this.#value.current); + } else if (this.headingNode && this.headingNode.textContent) { + this.trueValue = this.headingNode.textContent.trim().toLowerCase(); + return this.#root.registerValue(this.id.current, this.trueValue); + } else if (this.#ref.current?.textContent) { + this.trueValue = this.#ref.current.textContent.trim().toLowerCase(); + return this.#root.registerValue(this.id.current, this.trueValue); + } + }); + } + + 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.viewportNode?.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, + onRefChange: (node) => { + this.#root.inputNode = node; + }, + }); + + $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.viewportNode?.id ?? undefined, + "aria-labelledby": this.#root.labelNode?.id ?? undefined, + "aria-activedescendant": this.#selectedItemId, + }) as const + ); +} + +type CommandItemStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + disabled: boolean; + onSelect: () => void; + forceMount: boolean; + keywords: string[]; + }> & { + group: CommandGroupContainerState | null; + } +>; + +class CommandItemState { + #ref: CommandItemStateProps["ref"]; + id: CommandItemStateProps["id"]; + root: CommandRootState; + + #value: CommandItemStateProps["value"]; + #disabled: CommandItemStateProps["disabled"]; + #onSelectProp: 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 && 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.#onSelectProp = props.onSelect; + this.#forceMount = props.forceMount; + this.#group = getCommandGroupContainerContext(null); + this.trueValue = props.value.current; + + useRefById({ + id: this.id, + ref: this.#ref, + condition: () => Boolean(this.root.commandState.search), + }); + + $effect(() => { + this.id.current; + this.#group?.id.current; + if (!this.#forceMount.current) { + untrack(() => { + return this.root.registerItem(this.id.current, this.#group?.id.current); + }); + } + }); + + $effect(() => { + const value = this.#value.current; + const node = this.#ref.current; + if (!node) return; + if (!value && node.textContent) { + this.trueValue = node.textContent.trim(); + } + + untrack(() => { + this.root.registerValue( + this.id.current, + this.trueValue, + props.keywords.current.map((keyword) => keyword.trim()) + ); + node.setAttribute(VALUE_ATTR, this.trueValue); + }); + }); + } + + #onSelect = () => { + if (this.#disabled.current) return; + this.#select(); + this.#onSelectProp?.current(); + }; + + #select = () => { + if (this.#disabled.current) return; + this.root.setValue(this.trueValue, true); + }; + + #onpointermove = () => { + if (this.#disabled.current || this.root.disablePointerSelection.current) return; + this.#select(); + }; + + #onclick = () => { + if (this.#disabled.current) return; + this.#onSelect(); + }; + + 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, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "listbox", + "aria-label": this.#ariaLabel.current, + [LIST_ATTR]: "", + }) as const + ); + + createViewport(props: CommandViewportStateProps) { + return new CommandViewportState(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, + [INPUT_LABEL_ATTR]: "", + for: this.#for?.current, + style: srOnlyStyles, + }) as const + ); +} + +type CommandViewportStateProps = WithRefProps; + +class CommandViewportState { + #ref: CommandViewportStateProps["ref"]; + #id: CommandViewportStateProps["id"]; + #list: CommandListState; + + constructor(props: CommandViewportStateProps, list: CommandListState) { + this.#ref = props.ref; + this.#id = props.id; + this.#list = list; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.#list.root.viewportNode = node; + }, + }); + + $effect(() => { + const node = this.#ref.current; + const listNode = this.#list.ref.current; + 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, + [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 useCommandViewport(props: CommandViewportStateProps) { + return getCommandListContext().createViewport(props); +} + +export function useCommandLabel(props: CommandLabelStateProps) { + return getCommandRootContext().createLabel(props); +} 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..38db03747 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/_command-label.svelte @@ -0,0 +1,32 @@ + + + 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..5e60188f9 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte @@ -0,0 +1,35 @@ + + +{#if emptyState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/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..852dd2b72 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group-heading.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} 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..5072fae7f --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group-items.svelte @@ -0,0 +1,35 @@ + + +
+ {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
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..a2cfe83b2 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-group.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@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..cd7ac0da2 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-input.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} 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..f4314da4e --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-item.svelte @@ -0,0 +1,53 @@ + + +{#key itemState.root.key} +
+ {#if itemState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/if} +
+{/key} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-link-item.svelte b/packages/bits-ui/src/lib/bits/command/components/command-link-item.svelte new file mode 100644 index 000000000..e761487c6 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-link-item.svelte @@ -0,0 +1,53 @@ + + +{#key itemState.root.key} +
+ {#if itemState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {@render children?.()} + + {/if} + {/if} +
+{/key} 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..ad28469e1 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-list.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} 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..742a7217e --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-loading.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} 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..14290140e --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-separator.svelte @@ -0,0 +1,37 @@ + + +{#if separatorState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/if} diff --git a/packages/bits-ui/src/lib/bits/command/components/command-viewport.svelte b/packages/bits-ui/src/lib/bits/command/components/command-viewport.svelte new file mode 100644 index 000000000..2256b38f6 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command-viewport.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@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..fd68941ff --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/components/command.svelte @@ -0,0 +1,65 @@ + + +{#snippet Label()} + + {label} + +{/snippet} + +{#if child} + {@render Label()} + {@render child({ props: mergedProps })} +{:else} +
+ {@render Label()} + {@render children?.()} +
+{/if} 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..f4e4e37fa --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/index.ts @@ -0,0 +1,27 @@ +export { default as Root } from "./components/command.svelte"; +export { default as Empty } from "./components/command-empty.svelte"; +export { default as Group } from "./components/command-group.svelte"; +export { default as GroupHeading } from "./components/command-group-heading.svelte"; +export { default as GroupItems } from "./components/command-group-items.svelte"; +export { default as Input } from "./components/command-input.svelte"; +export { default as Item } from "./components/command-item.svelte"; +export { default as LinkItem } from "./components/command-link-item.svelte"; +export { default as List } from "./components/command-list.svelte"; +export { default as Viewport } from "./components/command-viewport.svelte"; +export { default as Loading } from "./components/command-loading.svelte"; +export { default as Separator } from "./components/command-separator.svelte"; + +export type { + CommandRootProps as RootProps, + CommandEmptyProps as EmptyProps, + CommandGroupProps as GroupProps, + CommandGroupHeadingProps as GroupHeadingProps, + CommandGroupItemsProps as GroupItemsProps, + CommandItemProps as ItemProps, + CommandLinkItemProps as LinkItemProps, + CommandInputProps as InputProps, + CommandSeparatorProps as SeparatorProps, + CommandListProps as ListProps, + CommandLoadingProps as LoadingProps, + CommandViewportProps as ViewportProps, +} 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..307b11554 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/types.ts @@ -0,0 +1,194 @@ +import type { + PrimitiveAnchorAttributes, + 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<{ + /** + * 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, keywords?: 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; + + /** + * Optionally set to `true` to disable selection via pointer events. + */ + disablePointerSelection?: boolean; + + /** + * Set the `false` to disable the option to use ctrl+n/j/p/k (vim style) navigation. + * + * @defaultValue true + */ + vimBindings?: 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; + + /** + * A list of keywords that will be used to filter the item. + */ + keywords?: string[]; + + /** + * Whether to always mount the item regardless of filtering logic. + */ + forceMount?: boolean; +}>; + +export type CommandItemProps = CommandItemPropsWithoutHTML & + Without; + +export type CommandLinkItemPropsWithoutHTML = CommandItemPropsWithoutHTML; + +export type CommandLinkItemProps = CommandLinkItemPropsWithoutHTML & + Without; + +export type CommandInputPropsWithoutHTML = WithChild<{ + /** + * The value of the input element, used to search/filter items. + */ + value?: string; +}>; + +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 CommandViewportPropsWithoutHTML = WithChild; + +export type CommandViewportProps = CommandViewportPropsWithoutHTML & + 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..e910d5ea8 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/command/utils.ts @@ -0,0 +1,30 @@ +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; + } +} + +export function findFirstStartMarkerWithImmediateSiblingAsEnd(el: Element, type: "item" | "group") { + const startMarkers = el.querySelectorAll(`[data-bits-command-${type}-start]`); + + for (const startMarker of startMarkers) { + const endMarker = startMarker.nextElementSibling; + if (endMarker && endMarker.hasAttribute(`data-bits-command-${type}-end`)) { + return startMarker; + } + } + + return null; +} diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 81fc69516..c078f6475 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -7,6 +7,7 @@ export * as Calendar from "./calendar/index.js"; export * as Checkbox from "./checkbox/index.js"; export * as Collapsible from "./collapsible/index.js"; export * as Combobox from "./combobox/index.js"; +export * as Command from "./command/index.js"; export * as ContextMenu from "./context-menu/index.js"; export * as DateField from "./date-field/index.js"; export * as DatePicker from "./date-picker/index.js"; diff --git a/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content-static.svelte b/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content-static.svelte index aa01f8824..4ab9e543a 100644 --- a/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content-static.svelte +++ b/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content-static.svelte @@ -53,7 +53,7 @@ {#if child} {@render child({ props })} {:else} -
+
{@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content.svelte b/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content.svelte index 6136bd8b4..735a74aea 100644 --- a/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content.svelte +++ b/packages/bits-ui/src/lib/bits/link-preview/components/link-preview-content.svelte @@ -83,7 +83,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -
+
{@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/menu/components/menu-radio-item.svelte b/packages/bits-ui/src/lib/bits/menu/components/menu-radio-item.svelte index 080849e9c..39880872c 100644 --- a/packages/bits-ui/src/lib/bits/menu/components/menu-radio-item.svelte +++ b/packages/bits-ui/src/lib/bits/menu/components/menu-radio-item.svelte @@ -40,7 +40,7 @@ {#if child} {@render child({ props: mergedProps, checked: radioItemState.isChecked })} {:else} -
+
{@render children?.({ checked: radioItemState.isChecked })}
{/if} diff --git a/packages/bits-ui/src/lib/bits/menubar/components/menubar-trigger.svelte b/packages/bits-ui/src/lib/bits/menubar/components/menubar-trigger.svelte index 0652fd0bb..03665abb7 100644 --- a/packages/bits-ui/src/lib/bits/menubar/components/menubar-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/menubar/components/menubar-trigger.svelte @@ -41,7 +41,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/menubar/components/menubar.svelte b/packages/bits-ui/src/lib/bits/menubar/components/menubar.svelte index b3a6e0286..e65db77bd 100644 --- a/packages/bits-ui/src/lib/bits/menubar/components/menubar.svelte +++ b/packages/bits-ui/src/lib/bits/menubar/components/menubar.svelte @@ -42,7 +42,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -
+
{@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte index 6663763ff..09c8f6643 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte @@ -25,7 +25,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -
  • +
  • {@render children?.()}
  • {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte index 9911c2dc4..195e4ef93 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte @@ -28,7 +28,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} - + {@render children?.()} {/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-value.svelte b/packages/bits-ui/src/lib/bits/select/components/select-value.svelte index 351f83d8b..d1dc2f48a 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-value.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-value.svelte @@ -25,7 +25,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} - + {#if valueState.showPlaceholder} {placeholder} {:else} diff --git a/packages/bits-ui/src/lib/bits/switch/components/switch.svelte b/packages/bits-ui/src/lib/bits/switch/components/switch.svelte index 760731fd6..e9bef6d74 100644 --- a/packages/bits-ui/src/lib/bits/switch/components/switch.svelte +++ b/packages/bits-ui/src/lib/bits/switch/components/switch.svelte @@ -46,7 +46,7 @@ {#if child} {@render child({ props: mergedProps, checked: rootState.checked.current })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/toggle-group/components/toggle-group-item.svelte b/packages/bits-ui/src/lib/bits/toggle-group/components/toggle-group-item.svelte index 9db6c2c7a..820c10915 100644 --- a/packages/bits-ui/src/lib/bits/toggle-group/components/toggle-group-item.svelte +++ b/packages/bits-ui/src/lib/bits/toggle-group/components/toggle-group-item.svelte @@ -32,7 +32,7 @@ {#if child} {@render child({ props: mergedProps, pressed: itemState.isPressed })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content-static.svelte b/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content-static.svelte index e1861d89f..5992f184a 100644 --- a/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content-static.svelte +++ b/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content-static.svelte @@ -54,7 +54,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -
    +
    {@render children?.()}
    {/if} diff --git a/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content.svelte b/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content.svelte index c016989e2..5e767571c 100644 --- a/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content.svelte +++ b/packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content.svelte @@ -80,7 +80,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -
    +
    {@render children?.()}
    {/if} 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); +} diff --git a/packages/bits-ui/src/lib/internal/dom.ts b/packages/bits-ui/src/lib/internal/dom.ts new file mode 100644 index 000000000..c5905eb72 --- /dev/null +++ b/packages/bits-ui/src/lib/internal/dom.ts @@ -0,0 +1,9 @@ +export function getFirstNonCommentChild(element: HTMLElement | null) { + if (!element) return null; + for (const child of element.childNodes) { + if (child.nodeType !== Node.COMMENT_NODE) { + return child; + } + } + return null; +} diff --git a/packages/bits-ui/src/lib/internal/kbd.ts b/packages/bits-ui/src/lib/internal/kbd.ts index cb62084e4..ace1d9bf6 100644 --- a/packages/bits-ui/src/lib/internal/kbd.ts +++ b/packages/bits-ui/src/lib/internal/kbd.ts @@ -74,4 +74,7 @@ export const kbd = { P: "P", A: "A", p: "p", + n: "n", + j: "j", + k: "k", }; diff --git a/packages/bits-ui/src/lib/types.ts b/packages/bits-ui/src/lib/types.ts index 9e378f8a6..da8832c74 100644 --- a/packages/bits-ui/src/lib/types.ts +++ b/packages/bits-ui/src/lib/types.ts @@ -7,6 +7,7 @@ export type * from "$lib/bits/calendar/types.js"; export type * from "$lib/bits/checkbox/types.js"; export type * from "$lib/bits/collapsible/types.js"; export type * from "$lib/bits/combobox/types.js"; +export type * from "$lib/bits/command/types.js"; export type * from "$lib/bits/context-menu/types.js"; export type * from "$lib/bits/date-field/types.js"; export type * from "$lib/bits/date-picker/types.js"; diff --git a/sites/docs/content/components/command.md b/sites/docs/content/components/command.md new file mode 100644 index 000000000..d04692924 --- /dev/null +++ b/sites/docs/content/components/command.md @@ -0,0 +1,61 @@ +--- +title: Command +description: A command menu component that can be used to search, filter, and select items. +--- + + + + + +{#snippet preview()} + +{/snippet} + + + +## Structure + +```svelte + + + + + + + + + + + + + + + + + + + + + +``` + +## Within a Dialog + +You can combine the `Command` component with the `Dialog` component to display the command menu within a modal. + +
    + + + +{#snippet preview()} + +{/snippet} + + + + diff --git a/sites/docs/src/lib/components/demos/combobox-demo.svelte b/sites/docs/src/lib/components/demos/combobox-demo.svelte index c7d0f1568..13b616ce5 100644 --- a/sites/docs/src/lib/components/demos/combobox-demo.svelte +++ b/sites/docs/src/lib/components/demos/combobox-demo.svelte @@ -70,7 +70,7 @@ {#each filteredFruits as fruit, i (i + fruit.value)} diff --git a/sites/docs/src/lib/components/demos/command-demo-dialog.svelte b/sites/docs/src/lib/components/demos/command-demo-dialog.svelte new file mode 100644 index 000000000..120dc6eec --- /dev/null +++ b/sites/docs/src/lib/components/demos/command-demo-dialog.svelte @@ -0,0 +1,122 @@ + + + + + + + Open Command Menu ⌘J + + + + + Command Menu + + This is the command menu. Use the arrow keys to navigate and press ⌘K to open the + search bar. + + + + + + + No results found. + + + + Suggestions + + + + + Introduction + + + + Delegation + + + + Styling + + + + + + + Components + + + + + Calendar + + + + Radio Group + + + + Combobox + + + + + + + + + diff --git a/sites/docs/src/lib/components/demos/command-demo.svelte b/sites/docs/src/lib/components/demos/command-demo.svelte new file mode 100644 index 000000000..d4f1978ca --- /dev/null +++ b/sites/docs/src/lib/components/demos/command-demo.svelte @@ -0,0 +1,84 @@ + + + + + + + + No results found. + + + + Suggestions + + + + + Introduction + + + + Delegation + + + + Styling + + + + + + + Components + + + + + Calendar + + + + Radio Group + + + + Combobox + + + + + + diff --git a/sites/docs/src/lib/components/demos/dialog-demo.svelte b/sites/docs/src/lib/components/demos/dialog-demo.svelte index 537018a7d..7f087519f 100644 --- a/sites/docs/src/lib/components/demos/dialog-demo.svelte +++ b/sites/docs/src/lib/components/demos/dialog-demo.svelte @@ -17,7 +17,6 @@ class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> + import type { Snippet } from "svelte"; + + let { children }: { children?: Snippet } = $props(); + + +
    +
    + {@render children?.()} +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/framer/framer-command.svelte b/sites/docs/src/lib/components/examples/command/framer/framer-command.svelte new file mode 100644 index 000000000..8a5de8eb6 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/framer-command.svelte @@ -0,0 +1,120 @@ + + +
    + +
    + + +
    + + +
    +
    + + Components + + {#each components as { value, subtitle, icon }} + {@const Icon = icon} + +
    + +
    +
    + {value} + + {subtitle} + +
    +
    + {/each} +
    +
    +
    +
    +
    + {#if value === "Button"} + + {:else if value === "Input"} + + {:else if value === "Badge"} +
    Badge
    + {:else if value === "Radio"} + + {:else if value === "Avatar"} + Avatar of Rauno + {:else if value === "Slider"} +
    +
    +
    + {:else if value === "Container"} +
    + {/if} +
    +
    +
    +
    +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/framer/framer.pcss b/sites/docs/src/lib/components/examples/command/framer/framer.pcss new file mode 100644 index 000000000..b82a6ef1a --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/framer.pcss @@ -0,0 +1,268 @@ +.framer { + [data-command-root] { + max-width: 640px; + width: 100%; + padding: 8px; + background: #ffffff; + border-radius: 16px; + overflow: hidden; + font-family: var(--font-sans); + border: 1px solid var(--gray6); + box-shadow: var(--command-shadow); + + .dark & { + background: var(--gray2); + } + } + + [data-command-framer-header] { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + padding: 0 8px; + border-bottom: 1px solid var(--gray5); + margin-bottom: 12px; + padding-bottom: 8px; + + & svg { + width: 20px; + height: 20px; + color: var(--gray9); + transform: translateY(1px); + } + } + + [data-command-input] { + font-family: var(--font-sans); + border: none; + width: 100%; + font-size: 16px; + outline: none; + background: var(--bg); + color: var(--gray12); + + &::placeholder { + color: var(--gray9); + } + } + + [data-command-item] { + content-visibility: auto; + + cursor: pointer; + border-radius: 12px; + font-size: 14px; + display: flex; + align-items: center; + gap: 12px; + color: var(--gray12); + padding: 8px 8px; + margin-right: 8px; + font-weight: 500; + transition: all 150ms ease; + transition-property: none; + + &[data-selected] { + background: var(--blue9); + color: #ffffff; + + [data-command-framer-item-subtitle] { + color: #ffffff; + } + } + + &[data-disabled] { + color: var(--gray8); + cursor: not-allowed; + } + + & + [data-command-item] { + margin-top: 4px; + } + + & svg { + width: 16px; + height: 16px; + color: #ffffff; + } + } + + [data-command-framer-icon-wrapper] { + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + background: orange; + border-radius: 8px; + } + + [data-command-framer-item-meta] { + display: flex; + flex-direction: column; + gap: 4px; + } + + [data-command-framer-item-subtitle] { + font-size: 12px; + font-weight: 400; + color: var(--gray11); + } + + [data-command-framer-items] { + min-height: 308px; + display: flex; + } + + [data-command-framer-left] { + width: 40%; + } + + [data-command-framer-separator] { + width: 1px; + border: 0; + margin-right: 8px; + background: var(--gray6); + } + + [data-command-framer-right] { + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + margin-left: 8px; + width: 60%; + + & button { + width: 120px; + height: 40px; + background: var(--blue9); + border-radius: 6px; + font-weight: 500; + color: white; + font-size: 14px; + } + + & input[type="text"] { + height: 40px; + width: 160px; + border: 1px solid var(--gray6); + background: #ffffff; + border-radius: 6px; + padding: 0 8px; + font-size: 14px; + font-family: var(--font-sans); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.08); + + &::placeholder { + color: var(--gray9); + } + } + + [data-command-framer-radio] { + display: flex; + align-items: center; + gap: 4px; + color: var(--gray12); + font-weight: 500; + font-size: 14px; + accent-color: var(--blue9); + + & input { + width: 20px; + height: 20px; + } + } + + & img { + width: 40px; + height: 40px; + border-radius: 9999px; + border: 1px solid var(--gray6); + } + + [data-command-framer-container] { + width: 100px; + height: 100px; + background: var(--blue9); + border-radius: 16px; + } + + [data-command-framer-badge] { + background: var(--blue3); + padding: 0 8px; + height: 28px; + font-size: 14px; + line-height: 28px; + color: var(--blue11); + border-radius: 9999px; + font-weight: 500; + } + + [data-command-framer-slider] { + height: 20px; + width: 200px; + background: linear-gradient(90deg, var(--blue9) 40%, var(--gray3) 0%); + border-radius: 9999px; + + & div { + width: 20px; + height: 20px; + background: #ffffff; + border-radius: 9999px; + box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.32); + transform: translateX(70px); + } + } + } + + [data-command-list] { + overflow: auto; + } + + [data-command-separator] { + height: 1px; + width: 100%; + background: var(--gray5); + margin: 4px 0; + } + + [data-command-group-heading] { + user-select: none; + font-size: 12px; + color: var(--gray11); + padding: 0 8px; + display: flex; + align-items: center; + margin-bottom: 8px; + } + + [data-command-empty] { + font-size: 14px; + padding: 32px; + white-space: pre-wrap; + color: var(--gray11); + } +} + +@media (max-width: 640px) { + .framer { + [data-command-framer-icon-wrapper] { + } + + [data-command-framer-item-subtitle] { + display: none; + } + } +} + +@media (prefers-color-scheme: dark) { + .framer { + [data-command-framer-right] { + & input[type="text"] { + background: var(--gray3); + } + } + } +} diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/avatar.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/avatar.svelte new file mode 100644 index 000000000..3f9e1924b --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/avatar.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/badge.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/badge.svelte new file mode 100644 index 000000000..04d66d8f9 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/badge.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/button.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/button.svelte new file mode 100644 index 000000000..1121dc104 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/button.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/container.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/container.svelte new file mode 100644 index 000000000..faedf43f8 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/container.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/index.ts b/sites/docs/src/lib/components/examples/command/framer/icons/index.ts new file mode 100644 index 000000000..9082a4f47 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/index.ts @@ -0,0 +1,8 @@ +export { default as AvatarIcon } from "./avatar.svelte"; +export { default as BadgeIcon } from "./badge.svelte"; +export { default as ButtonIcon } from "./button.svelte"; +export { default as ContainerIcon } from "./container.svelte"; +export { default as InputIcon } from "./input.svelte"; +export { default as RadioIcon } from "./radio.svelte"; +export { default as SearchIcon } from "./search.svelte"; +export { default as SliderIcon } from "./slider.svelte"; diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/input.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/input.svelte new file mode 100644 index 000000000..91491de8c --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/input.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/radio.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/radio.svelte new file mode 100644 index 000000000..44e950cc8 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/radio.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/search.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/search.svelte new file mode 100644 index 000000000..1c577a2a0 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/search.svelte @@ -0,0 +1,14 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/framer/icons/slider.svelte b/sites/docs/src/lib/components/examples/command/framer/icons/slider.svelte new file mode 100644 index 000000000..06f110174 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/framer/icons/slider.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/copied.svelte b/sites/docs/src/lib/components/examples/command/icons/copied.svelte new file mode 100644 index 000000000..6727b7f24 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/copied.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/copy.svelte b/sites/docs/src/lib/components/examples/command/icons/copy.svelte new file mode 100644 index 000000000..69d07e348 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/copy.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/figma.svelte b/sites/docs/src/lib/components/examples/command/icons/figma.svelte new file mode 100644 index 000000000..82b185ce5 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/figma.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/framer.svelte b/sites/docs/src/lib/components/examples/command/icons/framer.svelte new file mode 100644 index 000000000..f87831ff1 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/framer.svelte @@ -0,0 +1,6 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/github.svelte b/sites/docs/src/lib/components/examples/command/icons/github.svelte new file mode 100644 index 000000000..25b567fb4 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/github.svelte @@ -0,0 +1,6 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/index.ts b/sites/docs/src/lib/components/examples/command/icons/index.ts new file mode 100644 index 000000000..bc4205cd0 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/index.ts @@ -0,0 +1,10 @@ +export { default as CopiedIcon } from "./copied.svelte"; +export { default as CopyIcon } from "./copy.svelte"; +export { default as FigmaIcon } from "./figma.svelte"; +export { default as FramerIcon } from "./framer.svelte"; +export { default as GitHubIcon } from "./github.svelte"; +export { default as LinearIcon } from "./linear.svelte"; +export { default as RaycastIcon } from "./raycast.svelte"; +export { default as SlackIcon } from "./slack.svelte"; +export { default as VercelIcon } from "./vercel.svelte"; +export { default as YouTubeIcon } from "./youtube.svelte"; diff --git a/sites/docs/src/lib/components/examples/command/icons/linear.svelte b/sites/docs/src/lib/components/examples/command/icons/linear.svelte new file mode 100644 index 000000000..b1b61cc89 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/linear.svelte @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/raycast.svelte b/sites/docs/src/lib/components/examples/command/icons/raycast.svelte new file mode 100644 index 000000000..9b0c8c17a --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/raycast.svelte @@ -0,0 +1,8 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/slack.svelte b/sites/docs/src/lib/components/examples/command/icons/slack.svelte new file mode 100644 index 000000000..056732412 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/slack.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/vercel.svelte b/sites/docs/src/lib/components/examples/command/icons/vercel.svelte new file mode 100644 index 000000000..fbfbd3d94 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/vercel.svelte @@ -0,0 +1,3 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/icons/youtube.svelte b/sites/docs/src/lib/components/examples/command/icons/youtube.svelte new file mode 100644 index 000000000..6a3a43414 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/icons/youtube.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/assign-to-me.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/assign-to-me.svelte new file mode 100644 index 000000000..e24c0ddc6 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/assign-to-me.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/assign-to.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/assign-to.svelte new file mode 100644 index 000000000..b0c04b307 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/assign-to.svelte @@ -0,0 +1,5 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/change-labels.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/change-labels.svelte new file mode 100644 index 000000000..4b5f0bab3 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/change-labels.svelte @@ -0,0 +1,7 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/change-priority.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/change-priority.svelte new file mode 100644 index 000000000..e8d29a29f --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/change-priority.svelte @@ -0,0 +1,5 @@ + + + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/change-status.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/change-status.svelte new file mode 100644 index 000000000..a5062a13b --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/change-status.svelte @@ -0,0 +1,10 @@ + + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/index.ts b/sites/docs/src/lib/components/examples/command/linear/icons/index.ts new file mode 100644 index 000000000..6ba8a7ea3 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/index.ts @@ -0,0 +1,7 @@ +export { default as AssignToIcon } from "./assign-to.svelte"; +export { default as AssignToMeIcon } from "./assign-to-me.svelte"; +export { default as ChangeLabelsIcon } from "./change-labels.svelte"; +export { default as ChangePriorityIcon } from "./change-priority.svelte"; +export { default as ChangeStatusIcon } from "./change-status.svelte"; +export { default as RemoveLabelIcon } from "./remove-label.svelte"; +export { default as SetDueDateIcon } from "./set-due-date.svelte"; diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/remove-label.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/remove-label.svelte new file mode 100644 index 000000000..4b5f0bab3 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/remove-label.svelte @@ -0,0 +1,7 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/icons/set-due-date.svelte b/sites/docs/src/lib/components/examples/command/linear/icons/set-due-date.svelte new file mode 100644 index 000000000..4ed378bf3 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/icons/set-due-date.svelte @@ -0,0 +1,7 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/linear/linear-command.svelte b/sites/docs/src/lib/components/examples/command/linear/linear-command.svelte new file mode 100644 index 000000000..40bbce68f --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/linear-command.svelte @@ -0,0 +1,82 @@ + + +
    + +
    Issue - FUN-343
    + + + + No results found. + {#each items as { label, shortcut, icon }} + {@const Icon = icon} + + + {label} +
    + {#each shortcut as key} + {key} + {/each} +
    +
    + {/each} +
    +
    +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/linear/linear.pcss b/sites/docs/src/lib/components/examples/command/linear/linear.pcss new file mode 100644 index 000000000..864c14d79 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/linear/linear.pcss @@ -0,0 +1,144 @@ +.linear { + [data-command-root] { + max-width: 640px; + width: 100%; + background: #ffffff; + border-radius: 8px; + overflow: hidden; + padding: 0; + font-family: var(--font-sans); + box-shadow: var(--command-shadow); + + .dark & { + background: linear-gradient(136.61deg, rgb(39, 40, 43) 13.72%, rgb(45, 46, 49) 74.3%); + } + } + + [data-command-linear-badge] { + height: 24px; + padding: 0 8px; + font-size: 12px; + color: var(--gray11); + background: var(--gray3); + border-radius: 4px; + width: fit-content; + display: flex; + align-items: center; + margin: 16px 16px 0; + } + + [data-command-linear-shortcuts] { + display: flex; + margin-left: auto; + gap: 8px; + + & kbd { + font-family: var(--font-sans); + font-size: 13px; + color: var(--gray11); + } + } + + [data-command-input] { + font-family: var(--font-sans); + border: none; + width: 100%; + font-size: 18px; + padding: 20px; + outline: none; + background: var(--bg); + color: var(--gray12); + border-bottom: 1px solid var(--gray6); + border-radius: 0; + caret-color: #6e5ed2; + margin: 0; + + &::placeholder { + color: var(--gray9); + } + } + + [data-command-item] { + content-visibility: auto; + + cursor: pointer; + height: 48px; + font-size: 14px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + color: var(--gray12); + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + position: relative; + + &[data-selected] { + background: var(--gray3); + + svg { + color: var(--gray12); + } + + &:after { + content: ""; + position: absolute; + left: 0; + z-index: 123; + width: 3px; + height: 100%; + background: #5f6ad2; + } + } + + &[data-disabled] { + color: var(--gray8); + cursor: not-allowed; + } + + &:active { + transition-property: background; + background: var(--gray4); + } + + & + [data-command-item] { + margin-top: 4px; + } + + & svg { + width: 16px; + height: 16px; + color: var(--gray10); + } + } + + [data-command-list] { + height: min(300px, var(--command-list-height)); + max-height: 400px; + overflow: auto; + overscroll-behavior: contain; + transition: 100ms ease; + transition-property: height; + } + + [data-command-group-heading] { + user-select: none; + font-size: 12px; + color: var(--gray11); + padding: 0 8px; + display: flex; + align-items: center; + } + + [data-command-empty] { + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + height: 64px; + white-space: pre-wrap; + color: var(--gray11); + } +} diff --git a/sites/docs/src/lib/components/examples/command/logo.svelte b/sites/docs/src/lib/components/examples/command/logo.svelte new file mode 100644 index 000000000..330cf263d --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/logo.svelte @@ -0,0 +1,13 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/clipboard.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/clipboard.svelte new file mode 100644 index 000000000..0c672d632 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/clipboard.svelte @@ -0,0 +1,11 @@ +
    + + + +
    diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/finder.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/finder.svelte new file mode 100644 index 000000000..751dc67b3 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/finder.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/hammer.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/hammer.svelte new file mode 100644 index 000000000..7d84901a7 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/hammer.svelte @@ -0,0 +1,11 @@ +
    + + + +
    diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/index.ts b/sites/docs/src/lib/components/examples/command/raycast/icons/index.ts new file mode 100644 index 000000000..d99502ed2 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/index.ts @@ -0,0 +1,7 @@ +export { default as ClipboardIcon } from "./clipboard.svelte"; +export { default as FinderIcon } from "./finder.svelte"; +export { default as HammerIcon } from "./hammer.svelte"; +export { default as RaycastDarkIcon } from "./raycast-dark.svelte"; +export { default as RaycastLightIcon } from "./raycast-light.svelte"; +export { default as StarIcon } from "./star.svelte"; +export { default as WindowIcon } from "./window.svelte"; diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-dark.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-dark.svelte new file mode 100644 index 000000000..b7545fffe --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-dark.svelte @@ -0,0 +1,14 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-light.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-light.svelte new file mode 100644 index 000000000..ffa4744d5 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/raycast-light.svelte @@ -0,0 +1,14 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/star.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/star.svelte new file mode 100644 index 000000000..69f3faf84 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/star.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/icons/window.svelte b/sites/docs/src/lib/components/examples/command/raycast/icons/window.svelte new file mode 100644 index 000000000..0d4d9b85c --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/icons/window.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/item.svelte b/sites/docs/src/lib/components/examples/command/raycast/item.svelte new file mode 100644 index 000000000..a1ca58252 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/item.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + {#if isCommand} + Command + {:else} + Application + {/if} + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/raycast-command.svelte b/sites/docs/src/lib/components/examples/command/raycast/raycast-command.svelte new file mode 100644 index 000000000..6f4e1207b --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/raycast-command.svelte @@ -0,0 +1,109 @@ + + +
    + +
    + +
    + + + No results found. + + Suggestions + + + + + + Linear + + + + + + Figma + + + + + + Slack + + + + + + YouTube + + + + + + Raycast + + + + + Commands + + + + + + Clipboard History + + + + + + Import Extension + + + + + + Manage Extensions + + + + + + +
    + {#if $mode === "dark"} + + {:else} + + {/if} + + +
    + + +
    +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/raycast/raycast.postcss b/sites/docs/src/lib/components/examples/command/raycast/raycast.postcss new file mode 100644 index 000000000..2a0611c32 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/raycast.postcss @@ -0,0 +1,552 @@ +.raycast { + [data-command-root] { + max-width: 640px; + width: 100%; + background: var(--gray1); + border-radius: 12px; + padding: 8px 0; + font-family: var(--font-sans); + box-shadow: var(--command-shadow); + border: 1px solid var(--gray6); + position: relative; + + .dark & { + background: var(--gray2); + border: 0; + + &:after { + content: ""; + background: linear-gradient( + to right, + var(--gray6) 20%, + var(--gray6) 40%, + var(--gray10) 50%, + var(--gray10) 55%, + var(--gray6) 70%, + var(--gray6) 100% + ); + z-index: -1; + position: absolute; + border-radius: 12px; + top: -1px; + left: -1px; + width: calc(100% + 2px); + height: calc(100% + 2px); + animation: shine 3s ease forwards 0.1s; + background-size: 200% auto; + } + + &:before { + content: ""; + z-index: -1; + position: absolute; + border-radius: 12px; + top: -1px; + left: -1px; + width: calc(100% + 2px); + height: calc(100% + 2px); + box-shadow: 0 0 0 1px transparent; + animation: border 1s linear forwards 0.5s; + } + } + + & kbd { + font-family: var(--font-sans); + background: var(--gray3); + color: var(--gray11); + height: 20px; + width: 20px; + border-radius: 4px; + padding: 0 4px; + display: flex; + align-items: center; + justify-content: center; + + &:first-of-type { + margin-left: 8px; + } + } + } + + [data-command-input] { + font-family: var(--font-sans); + border: none; + width: 100%; + font-size: 15px; + padding: 8px 16px; + outline: none; + background: var(--bg); + color: var(--gray12); + + &::placeholder { + color: var(--gray9); + } + } + + [data-command-raycast-top-shine] { + .dark & { + background: linear-gradient( + 90deg, + rgba(56, 189, 248, 0), + var(--gray5) 20%, + var(--gray9) 67.19%, + rgba(236, 72, 153, 0) + ); + height: 1px; + position: absolute; + top: -1px; + width: 100%; + z-index: -1; + opacity: 0; + animation: showTopShine 0.1s ease forwards 0.2s; + } + } + + [data-command-raycast-loader] { + --loader-color: var(--gray9); + border: 0; + width: 100%; + width: 100%; + left: 0; + height: 1px; + background: var(--gray6); + position: relative; + overflow: visible; + display: block; + margin-top: 12px; + margin-bottom: 12px; + + &:after { + content: ""; + width: 50%; + height: 1px; + position: absolute; + background: linear-gradient( + 90deg, + transparent 0%, + var(--loader-color) 50%, + transparent 100% + ); + top: -1px; + opacity: 0; + animation-duration: 1.5s; + animation-delay: 1s; + animation-timing-function: ease; + animation-name: loading; + } + } + + [data-command-item] { + content-visibility: auto; + + cursor: pointer; + height: 40px; + border-radius: 8px; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + color: var(--gray12); + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + + &[data-selected] { + background: var(--gray4); + color: var(--gray12); + } + + &[data-disabled] { + color: var(--gray8); + cursor: not-allowed; + } + + &:first-child { + margin-top: 8px; + } + + &:active { + transition-property: background; + background: var(--gray4); + } + + & + [data-command-item] { + margin-top: 4px; + } + + & svg { + width: 18px; + height: 18px; + } + } + + [data-command-raycast-meta] { + margin-left: auto; + color: var(--gray11); + font-size: 13px; + } + + [data-command-list] { + padding: 0 8px; + height: 393px; + overflow: auto; + overscroll-behavior: contain; + scroll-padding-block-end: 40px; + transition: 100ms ease; + transition-property: height; + padding-bottom: 40px; + } + + [data-command-raycast-open-trigger], + [data-command-raycast-subcommand-trigger] { + color: var(--gray11); + padding: 0px 4px 0px 8px; + border-radius: 6px; + font-weight: 500; + font-size: 12px; + height: 28px; + letter-spacing: -0.25px; + } + + [data-command-raycast-clipboard-icon], + [data-command-raycast-hammer-icon] { + width: 20px; + height: 20px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + + & svg { + width: 14px; + height: 14px; + } + } + + [data-command-raycast-clipboard-icon] { + background: linear-gradient(to bottom, #f55354, #eb4646); + } + + [data-command-raycast-hammer-icon] { + background: linear-gradient(to bottom, #6cb9a3, #2c6459); + } + + [data-command-raycast-open-trigger] { + display: flex; + align-items: center; + color: var(--gray12); + } + + [data-command-raycast-subcommand-trigger] { + display: flex; + align-items: center; + gap: 4px; + right: 8px; + bottom: 8px; + + & svg { + width: 14px; + height: 14px; + } + + & hr { + height: 100%; + background: var(--gray6); + border: 0; + width: 1px; + } + + &[aria-expanded="true"], + &:hover { + background: var(--gray4); + + & kbd { + background: var(--gray7); + } + } + } + + [data-command-separator] { + height: 1px; + width: 100%; + background: var(--gray5); + margin: 4px 0; + } + + & *:not([hidden]) + [data-command-group] { + margin-top: 8px; + } + + [data-command-group-heading] { + user-select: none; + font-size: 12px; + color: var(--gray11); + padding: 0 8px; + display: flex; + align-items: center; + } + + [data-command-raycast-footer] { + display: flex; + height: 40px; + align-items: center; + width: 100%; + position: absolute; + background: var(--gray1); + bottom: 0; + padding: 8px; + border-top: 1px solid var(--gray6); + border-radius: 0 0 12px 12px; + z-index: 2; + + & svg { + width: 20px; + height: 20px; + filter: grayscale(1); + margin-right: auto; + } + + & hr { + height: 12px; + width: 1px; + border: 0; + background: var(--gray6); + margin: 0 4px 0px 12px; + } + } + + [data-command-dialog] { + z-index: var(--layer-portal); + position: fixed; + left: 50%; + top: var(--page-top); + transform: translateX(-50%); + + [data-command-root] { + width: 640px; + transform-origin: center center; + animation: dialogIn var(--transition-fast) forwards; + } + + &[data-state="closed"] [data-command-root] { + animation: dialogOut var(--transition-fast) forwards; + } + } + + [data-command-empty] { + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + height: 64px; + white-space: pre-wrap; + color: var(--gray11); + } +} + +@keyframes loading { + 0% { + opacity: 0; + transform: translateX(0); + } + + 50% { + opacity: 1; + transform: translateX(100%); + } + + 100% { + opacity: 0; + transform: translateX(0); + } +} + +@keyframes shine { + to { + background-position: 200% center; + opacity: 0; + } +} + +@keyframes border { + to { + box-shadow: 0 0 0 1px var(--gray6); + } +} + +@keyframes showTopShine { + to { + opacity: 1; + } +} + +.raycast-submenu { + z-index: 50; + + [data-command-root] { + display: flex; + flex-direction: column; + width: 320px; + border: 1px solid var(--gray6); + background: var(--gray2); + border-radius: 8px; + } + + [data-command-list] { + padding: 8px; + overflow: auto; + overscroll-behavior: contain; + transition: 100ms ease; + transition-property: height; + } + + [data-command-item] { + height: 40px; + + cursor: pointer; + height: 40px; + border-radius: 8px; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + color: var(--gray12); + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + + &[aria-selected="true"] { + background: var(--gray5); + color: var(--gray12); + + [data-command-raycast-submenu-shortcuts] kbd { + background: var(--gray7); + } + } + + &[aria-disabled="true"] { + color: var(--gray8); + cursor: not-allowed; + } + + & svg { + width: 16px; + height: 16px; + } + + [data-command-raycast-submenu-shortcuts] { + display: flex; + margin-left: auto; + gap: 2px; + + & kbd { + font-family: var(--font-sans); + background: var(--gray5); + color: var(--gray11); + height: 20px; + width: 20px; + border-radius: 4px; + padding: 0 4px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + + &:first-of-type { + margin-left: 8px; + } + } + } + } + + [data-command-group-heading] { + text-transform: capitalize; + font-size: 12px; + color: var(--gray11); + font-weight: 500; + margin-bottom: 8px; + margin-top: 8px; + margin-left: 4px; + } + + [data-command-input] { + padding: 12px; + font-family: var(--font-sans); + border: 0; + border-top: 1px solid var(--gray6); + font-size: 13px; + background: transparent; + margin-top: auto; + width: 100%; + outline: 0; + border-radius: 0; + } + + animation-duration: 0.2s; + animation-timing-function: ease; + animation-fill-mode: forwards; + transform-origin: var(--radix-popover-content-transform-origin); + + &[data-state="open"] { + animation-name: slideIn; + } + + &[data-state="closed"] { + animation-name: slideOut; + } + + [data-command-empty] { + display: flex; + align-items: center; + justify-content: center; + height: 64px; + white-space: pre-wrap; + font-size: 14px; + color: var(--gray11); + } +} + +@keyframes slideIn { + 0% { + opacity: 0; + transform: scale(0.96); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideOut { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.96); + } +} + +@media (max-width: 640px) { + .raycast { + [data-command-input] { + font-size: 16px; + } + } +} + +@media (prefers-color-scheme: dark) { + .raycast { + [data-command-raycast-footer] { + background: var(--gray2); + } + } +} diff --git a/sites/docs/src/lib/components/examples/command/raycast/sub-command.svelte b/sites/docs/src/lib/components/examples/command/raycast/sub-command.svelte new file mode 100644 index 000000000..95a59ce20 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/sub-command.svelte @@ -0,0 +1,82 @@ + + + + + + + {#snippet child({ props })} + + {/snippet} + + + { + e.preventDefault(); + inputEl?.focus(); + }} + preventScroll={true} + class="raycast-submenu" + side="top" + align="end" + > + + + + {selectedValue} + + + Open Application + + + + Show in Finder + + + + Show Info in Finder + + + + Add to Favorites + + + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/raycast/sub-item.svelte b/sites/docs/src/lib/components/examples/command/raycast/sub-item.svelte new file mode 100644 index 000000000..3f57711e5 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/raycast/sub-item.svelte @@ -0,0 +1,15 @@ + + + + {@render children?.()} +
    + {#each shortcut.split(" ") as key, i (i)} + {key} + {/each} +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/theme-switcher.svelte b/sites/docs/src/lib/components/examples/command/theme-switcher.svelte new file mode 100644 index 000000000..30e4b7fa1 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/theme-switcher.svelte @@ -0,0 +1,103 @@ + + + + +
    + + ← + + {#each themes as { key, icon }} + {@const isActive = theme === key} + {@const Icon = icon} + + {/each} + + → + +
    diff --git a/sites/docs/src/lib/components/examples/command/vercel/home.svelte b/sites/docs/src/lib/components/examples/command/vercel/home.svelte new file mode 100644 index 000000000..78cf10aa7 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/home.svelte @@ -0,0 +1,58 @@ + + + + Projects + + + + Search Projects... + + + + Create New Project... + + + + + Teams + + + + Search Teams... + + + + Create New Team... + + + + + Help + + + + Search Docs... + + + + Send Feedback... + + + + Contact Support + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/contact.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/contact.svelte new file mode 100644 index 000000000..b51599da7 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/contact.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/docs.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/docs.svelte new file mode 100644 index 000000000..2657f2002 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/docs.svelte @@ -0,0 +1,17 @@ + + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/feedback.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/feedback.svelte new file mode 100644 index 000000000..d56e60978 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/feedback.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/index.ts b/sites/docs/src/lib/components/examples/command/vercel/icons/index.ts new file mode 100644 index 000000000..f1c265847 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/index.ts @@ -0,0 +1,6 @@ +export { default as ContactIcon } from "./contact.svelte"; +export { default as DocsIcon } from "./docs.svelte"; +export { default as FeedbackIcon } from "./feedback.svelte"; +export { default as PlusIcon } from "./plus.svelte"; +export { default as ProjectsIcon } from "./projects.svelte"; +export { default as TeamsIcon } from "./teams.svelte"; diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/plus.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/plus.svelte new file mode 100644 index 000000000..1869cca84 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/plus.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/projects.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/projects.svelte new file mode 100644 index 000000000..ebaf96360 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/projects.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/icons/teams.svelte b/sites/docs/src/lib/components/examples/command/vercel/icons/teams.svelte new file mode 100644 index 000000000..c10e8c36f --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/icons/teams.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/sites/docs/src/lib/components/examples/command/vercel/item.svelte b/sites/docs/src/lib/components/examples/command/vercel/item.svelte new file mode 100644 index 000000000..f9763530f --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/item.svelte @@ -0,0 +1,27 @@ + + + + {@render children?.()} + {#if shortcut} +
    + {#each shortcut.split(" ") as key} + {key} + {/each} +
    + {/if} +
    diff --git a/sites/docs/src/lib/components/examples/command/vercel/projects.svelte b/sites/docs/src/lib/components/examples/command/vercel/projects.svelte new file mode 100644 index 000000000..5236e0a08 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/projects.svelte @@ -0,0 +1,9 @@ + + +{#each { length: 6 } as _, i} + + Project {i + 1} + +{/each} diff --git a/sites/docs/src/lib/components/examples/command/vercel/vercel-command.svelte b/sites/docs/src/lib/components/examples/command/vercel/vercel-command.svelte new file mode 100644 index 000000000..ee4ad63b1 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/vercel-command.svelte @@ -0,0 +1,77 @@ + + +
    + +
    + {#each pages as page} +
    + {page} +
    + {/each} +
    + + + + No results found. + {#key activePage} + {#if activePage === "home"} + { + pages = [...pages, "projects"]; + }} + /> + {/if} + {#if activePage === "projects"} + + {/if} + {/key} + + +
    +
    diff --git a/sites/docs/src/lib/components/examples/command/vercel/vercel.pcss b/sites/docs/src/lib/components/examples/command/vercel/vercel.pcss new file mode 100644 index 000000000..e693d59e0 --- /dev/null +++ b/sites/docs/src/lib/components/examples/command/vercel/vercel.pcss @@ -0,0 +1,154 @@ +.vercel { + [data-command-root] { + max-width: 640px; + width: 100%; + padding: 8px; + background: #ffffff; + border-radius: 12px; + overflow: hidden; + font-family: var(--font-sans); + border: 1px solid var(--gray6); + box-shadow: var(--command-shadow); + transition: transform 100ms ease; + + .dark & { + background: rgba(22, 22, 22, 0.7); + } + } + + [data-command-input] { + font-family: var(--font-sans); + border: none; + width: 100%; + font-size: 17px; + padding: 8px 8px 16px 8px; + outline: none; + background: var(--bg); + color: var(--gray12); + border-bottom: 1px solid var(--gray6); + margin-bottom: 16px; + border-radius: 0; + + &::placeholder { + color: var(--gray9); + } + } + + [data-command-vercel-badge] { + height: 20px; + background: var(--grayA3); + display: inline-flex; + align-items: center; + padding: 0 8px; + font-size: 12px; + color: var(--grayA11); + border-radius: 4px; + margin: 4px 0 4px 4px; + user-select: none; + text-transform: capitalize; + font-weight: 500; + } + + [data-command-item] { + content-visibility: auto; + + cursor: pointer; + height: 48px; + border-radius: 8px; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + color: var(--gray11); + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + + &[data-selected] { + background: var(--grayA3); + color: var(--gray12); + } + + &[data-disabled] { + color: var(--gray8); + cursor: not-allowed; + } + + &:active { + transition-property: background; + background: var(--gray4); + } + + & + [data-command-item] { + margin-top: 4px; + } + + svg { + width: 18px; + height: 18px; + } + } + + [data-command-list] { + height: min(330px, calc(var(--bits-command-list-height))); + max-height: 400px; + overflow: auto; + overscroll-behavior: contain; + transition: 100ms ease; + transition-property: height; + } + + [data-command-vercel-shortcuts] { + display: flex; + margin-left: auto; + gap: 8px; + + & kbd { + font-family: var(--font-sans); + font-size: 12px; + min-width: 20px; + padding: 4px; + height: 20px; + border-radius: 4px; + color: var(--gray11); + background: var(--gray4); + display: inline-flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + } + } + + [data-command-separator] { + height: 1px; + width: 100%; + background: var(--gray5); + margin: 4px 0; + } + + *:not([hidden]) + [data-command-group] { + margin-top: 8px; + } + + [data-command-group-heading] { + user-select: none; + font-size: 12px; + color: var(--gray11); + padding: 0 8px; + display: flex; + align-items: center; + margin-bottom: 8px; + } + + [data-command-empty] { + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + height: 48px; + white-space: pre-wrap; + color: var(--gray11); + } +} diff --git a/sites/docs/src/lib/content/api-reference/command.ts b/sites/docs/src/lib/content/api-reference/command.ts new file mode 100644 index 000000000..d721ec65e --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/command.ts @@ -0,0 +1,309 @@ +import type { + CommandEmptyPropsWithoutHTML, + CommandGroupHeadingPropsWithoutHTML, + CommandGroupItemsPropsWithoutHTML, + CommandGroupPropsWithoutHTML, + CommandInputPropsWithoutHTML, + CommandItemPropsWithoutHTML, + CommandLinkItemPropsWithoutHTML, + CommandListPropsWithoutHTML, + CommandLoadingPropsWithoutHTML, + CommandRootPropsWithoutHTML, + CommandSeparatorPropsWithoutHTML, + CommandViewportPropsWithoutHTML, +} from "bits-ui"; +import { NoopProp, OnStringValueChangeProp } from "./extended-types/shared/index.js"; +import { CommandFilterProp } from "./extended-types/command/index.js"; +import { + createApiSchema, + createBooleanProp, + createCSSVarSchema, + createDataAttrSchema, + createFunctionProp, + createNumberProp, + createPropSchema, + createStringProp, + withChildProps, +} from "$lib/content/api-reference/helpers.js"; +import * as C from "$lib/content/constants.js"; + +const root = createApiSchema({ + title: "Root", + description: "The root command component which manages & scopes the state of the command.", + props: { + value: createStringProp({ + default: "", + description: "The value of the command.", + bindable: true, + }), + onValueChange: createFunctionProp({ + definition: OnStringValueChangeProp, + description: "A callback that is fired when the command value changes.", + }), + label: createStringProp({ + description: + "An accessible label for the command menu. This is not visible and is only used for screen readers.", + }), + filter: createFunctionProp({ + definition: CommandFilterProp, + description: + "A custom filter function used to filter items. This function 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. The items are sorted/filtered based on this score.", + }), + shouldFilter: createBooleanProp({ + default: C.TRUE, + description: + "Whether or not the command menu should filter items. This is useful when you want to apply custom filtering logic outside of the Command component.", + }), + loop: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the command menu should loop through items when navigating with the keyboard.", + }), + disablePointerSelection: createBooleanProp({ + default: C.FALSE, + description: + "Set this to true to prevent items from being selected when the users pointer moves over them.", + }), + vimBindings: createBooleanProp({ + default: C.TRUE, + description: + "Whether VIM bindings should be enabled or not, which allow the user to navigate using ctrl+n/j/p/k", + }), + ...withChildProps({ + elType: "HTMLDivElement", + }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-root", + description: "Present on the root element.", + }), + ], +}); + +const input = createApiSchema({ + title: "Input", + description: + "A representation of the combobox input element, which is typically displayed in the content.", + props: { + value: createStringProp({ + description: + "The value of the search query. This is used to filter items and to search for items.", + bindable: true, + }), + ...withChildProps({ elType: "HTMLInputElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-input", + description: "Present on the input element.", + }), + ], +}); + +const list = createApiSchema({ + title: "List", + description: "The container for the viewport and its items of the command menu.", + props: { + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-list", + description: "Present on the list element.", + }), + ], + cssVars: [ + createCSSVarSchema({ + name: "--bits-command-list-height", + description: + "The height of the command list element, which is computed by the `Command.Viewport` component.", + }), + ], +}); + +const viewport = createApiSchema({ + title: "Viewport", + description: + "The viewport component which contains the items of the command menu. This component tracks the height of the viewport and updates the `--bits-command-list-height` CSS variable on the `Command.List` component.", + props: { + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-viewport", + description: "Present on the viewport element.", + }), + ], +}); + +const group = createApiSchema({ + title: "Group", + description: "A group of items within the command menu.", + props: { + value: createStringProp({ + description: + "If a `Command.GroupHeading` is used within this group, the contents of the heading will be used as the value. If the content is dynamic or you wish to have a more specific value, you can provide a unique value for the group here.", + }), + forceMount: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the group should always be mounted to the DOM, regardless of the internal filtering logic", + }), + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-group", + description: "Present on the group element.", + }), + ], +}); + +const groupHeading = createApiSchema({ + title: "GroupHeading", + description: "A heading for a group of items within the command menu.", + props: { + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-group-heading", + description: "Present on the group heading element.", + }), + ], +}); + +const groupItems = createApiSchema({ + title: "GroupItems", + description: "The container for the items within a group.", + props: { + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-group-items", + description: "Present on the group items element.", + }), + ], +}); + +const item = createApiSchema({ + title: "Item", + description: + "Represents a single item within the command menu. If you wish to render an anchor element to link to a page, use the `Command.LinkItem` component.", + props: { + value: createStringProp({ + description: "The value of the item.", + required: true, + }), + keywords: createPropSchema({ + type: "string[]", + description: + "An array of additional keywords or aliases that will be used to filter the item.", + }), + forceMount: createBooleanProp({ + description: + "Whether or not the item should always be mounted to the DOM, regardless of the internal filtering logic", + default: C.FALSE, + }), + onSelect: createFunctionProp({ + definition: NoopProp, + description: "A callback that is fired when the item is selected.", + }), + disabled: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the combobox item is disabled. This will prevent interaction/selection.", + }), + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "disabled", + description: "Present when the item is disabled.", + }), + createDataAttrSchema({ + name: "selected", + description: "Present when the item is selected.", + }), + createDataAttrSchema({ + name: "command-item", + description: "Present on the item element.", + }), + ], +}); + +const linkItem = createApiSchema({ + ...item, + title: "LinkItem", + description: + "Similar to the `Command.Item` component, but renders an anchor element to take advantage of preloading before navigation.", +}); + +const empty = createApiSchema({ + title: "Empty", + description: "The empty state of the command menu. Shown when there are no items to display.", + props: { + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-empty", + description: "Present on the empty element.", + }), + ], +}); + +const loading = createApiSchema({ + title: "Loading", + description: "The loading state of the command menu. Shown when the menu is loading items.", + props: { + progress: createNumberProp({ + default: "0", + description: "The progress of the loading state.", + }), + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-loading", + description: "Present on the loading element.", + }), + ], +}); + +const separator = createApiSchema({ + title: "Separator", + description: + "A visual separator for use between items and groups. Visible when the search query is empty or the `forceMount` prop is `true`.", + props: { + forceMount: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the separator should always be mounted to the DOM, regardless of the internal filtering logic", + }), + ...withChildProps({ elType: "HTMLDivElement" }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "command-separator", + description: "Present on the separator element.", + }), + ], +}); + +export const command = [ + root, + input, + list, + viewport, + group, + groupHeading, + groupItems, + item, + linkItem, + empty, + loading, + separator, +]; diff --git a/sites/docs/src/lib/content/api-reference/extended-types/command/command-filter-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/command/command-filter-prop.md new file mode 100644 index 000000000..e7cfe5d7d --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/command/command-filter-prop.md @@ -0,0 +1,3 @@ +```ts +(value: string, search: string, keywords?: string[]) => number; +``` diff --git a/sites/docs/src/lib/content/api-reference/extended-types/command/index.ts b/sites/docs/src/lib/content/api-reference/extended-types/command/index.ts new file mode 100644 index 000000000..f8f1ad8e3 --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/command/index.ts @@ -0,0 +1 @@ +export { default as CommandFilterProp } from "./command-filter-prop.md"; diff --git a/sites/docs/src/lib/content/api-reference/index.ts b/sites/docs/src/lib/content/api-reference/index.ts index b8008a077..e573460d3 100644 --- a/sites/docs/src/lib/content/api-reference/index.ts +++ b/sites/docs/src/lib/content/api-reference/index.ts @@ -7,6 +7,7 @@ import { calendar } from "./calendar.js"; import { checkbox } from "./checkbox.js"; import { collapsible } from "./collapsible.js"; import { combobox } from "./combobox.js"; +import { command } from "./command.js"; import { contextMenu } from "./context-menu.js"; import { dateField } from "./date-field.js"; import { datePicker } from "./date-picker.js"; @@ -47,6 +48,7 @@ export const bits = [ "checkbox", "collapsible", "combobox", + "command", "context-menu", "date-field", "date-picker", @@ -95,6 +97,7 @@ export const apiSchemas: Record = { checkbox, collapsible, combobox, + command, "context-menu": contextMenu, "date-field": dateField, "date-picker": datePicker, diff --git a/sites/docs/src/lib/content/api-reference/tabs.ts b/sites/docs/src/lib/content/api-reference/tabs.ts index edc3e0a3d..8334cf1f1 100644 --- a/sites/docs/src/lib/content/api-reference/tabs.ts +++ b/sites/docs/src/lib/content/api-reference/tabs.ts @@ -4,6 +4,7 @@ import type { TabsRootPropsWithoutHTML, TabsTriggerPropsWithoutHTML, } from "bits-ui"; +import { OnStringValueChangeProp } from "./extended-types/shared/index.js"; import { createApiSchema, createBooleanProp, @@ -24,7 +25,7 @@ const root = createApiSchema({ bindable: true, }), onValueChange: createFunctionProp({ - definition: "(value: string) => void", + definition: OnStringValueChangeProp, description: "A callback function called when the active tab value changes.", }), activationMode: createEnumProp({ diff --git a/sites/docs/src/lib/styles/command/command.pcss b/sites/docs/src/lib/styles/command/command.pcss new file mode 100644 index 000000000..e76472c9e --- /dev/null +++ b/sites/docs/src/lib/styles/command/command.pcss @@ -0,0 +1,497 @@ +.main { + width: 100vw; + min-height: 100vh; + position: relative; + display: flex; + justify-content: center; + padding: 120px 24px 160px 24px; + + &:before { + background: radial-gradient(circle, rgba(2, 0, 36, 0) 0, var(--gray1) 100%); + position: absolute; + content: ""; + z-index: 2; + width: 100%; + height: 100%; + top: 0; + } + + &:after { + content: ""; + background-image: url("/grid.svg"); + position: absolute; + z-index: -1; + top: 0; + width: 100%; + height: 100%; + opacity: 0.2; + filter: invert(1); + } + + @media (prefers-color-scheme: dark) { + &:after { + filter: unset; + } + } + + & h1 { + font-size: 32px; + color: var(--gray12); + font-weight: 600; + letter-spacing: -2px; + line-height: 40px; + } + + & p { + color: var(--gray11); + margin-top: 8px; + font-size: 16px; + } +} + +.content { + height: fit-content; + position: relative; + z-index: 3; + width: 100%; + max-width: 640px; + + &:after { + background-image: radial-gradient(at 27% 37%, hsla(215, 98%, 61%, 1) 0px, transparent 50%), + radial-gradient(at 97% 21%, hsla(256, 98%, 72%, 1) 0px, transparent 50%), + radial-gradient(at 52% 99%, hsla(354, 98%, 61%, 1) 0px, transparent 50%), + radial-gradient(at 10% 29%, hsla(133, 96%, 67%, 1) 0px, transparent 50%), + radial-gradient(at 97% 96%, hsla(38, 60%, 74%, 1) 0px, transparent 50%), + radial-gradient(at 33% 50%, hsla(222, 67%, 73%, 1) 0px, transparent 50%), + radial-gradient(at 79% 53%, hsla(343, 68%, 79%, 1) 0px, transparent 50%); + position: absolute; + content: ""; + z-index: 2; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 80px; + opacity: 0.2; + transform: translateZ(0); + } + + @media (prefers-color-scheme: dark) { + &:after { + opacity: 0.1; + } + } +} + +.meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 48px; + flex-wrap: wrap; + gap: 16px; +} + +.buttons { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + transform: translateY(12px); +} + +.githubButton, +.installButton, +.switcher button { + height: 40px; + color: var(--gray12); + border-radius: 9999px; + font-size: 14px; + transition-duration: 150ms; + transition-property: background, color, transform; + transition-timing-function: ease-in; + will-change: transform; +} + +.githubButton { + width: 200px; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 500; + + &:hover { + background: var(--grayA3); + } + + &:active { + background: var(--grayA5); + transform: scale(0.97); + } + + &:focus-visible { + outline: 0; + outline: 2px solid var(--gray7); + } +} + +.installButton { + background: var(--grayA3); + display: flex; + align-items: center; + gap: 16px; + padding: 0px 8px 0 16px; + cursor: copy; + font-weight: 500; + + &:hover { + background: var(--grayA4); + + & span { + background: var(--grayA5); + + & svg { + color: var(--gray12); + } + } + } + + &:focus-visible { + outline: 0; + outline: 2px solid var(--gray7); + outline-offset: 2px; + } + + &:active { + background: var(--gray5); + transform: scale(0.97); + } + + & span { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + background: var(--grayA3); + border-radius: 9999px; + transition: background 150ms ease; + + & svg { + size: 16px; + color: var(--gray11); + transition: color 150ms ease; + } + } +} + +.switcher { + display: grid; + grid-template-columns: repeat(4, 100px); + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 48px; + position: relative; + + & button { + height: 32px; + line-height: 32px; + display: flex; + align-items: center; + margin: auto; + gap: 8px; + padding: 0 16px; + border-radius: 9999px; + color: var(--gray11); + font-size: 14px; + cursor: pointer; + user-select: none; + position: relative; + text-transform: capitalize; + + &:hover { + color: var(--gray12); + } + + &:active { + transform: scale(0.96); + } + + &:focus-visible { + outline: 0; + outline: 2px solid var(--gray7); + } + + & svg { + width: 14px; + height: 14px; + } + + &[data-selected] { + color: var(--gray12); + + &:hover .activeTheme { + background: var(--grayA6); + } + + &:active { + transform: scale(0.96); + + .activeTheme { + background: var(--grayA7); + } + } + } + } + + .activeTheme { + background: var(--grayA5); + border-radius: 9999px; + height: 32px; + width: 100%; + top: 0; + position: absolute; + left: 0; + } + + .arrow { + color: var(--gray11); + user-select: none; + position: absolute; + } +} + +.header { + position: absolute; + left: 0; + top: -64px; + gap: 8px; + background: var(--gray3); + padding: 4px; + display: flex; + align-items: center; + border-radius: 9999px; + + & button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 4px; + border-radius: 9999px; + color: var(--gray11); + + & svg { + width: 16px; + height: 16px; + } + + &[aria-selected] { + background: #ffffff; + color: var(--gray12); + box-shadow: + 0px 2px 5px -2px rgb(0 0 0 / 15%), + 0 1px 3px -1px rgb(0 0 0 / 20%); + } + } +} + +.versionBadge { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--grayA11); + background: var(--grayA3); + padding: 4px 8px; + border-radius: 4px; + font-weight: 500; + font-size: 14px; + margin-bottom: 8px; + + @media (prefers-color-scheme: dark) { + background: var(--grayA2); + } +} + +.codeBlock { + margin-top: 72px; + position: relative; +} + +.footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: fit-content; + margin: 32px auto; + bottom: 16px; + color: var(--gray11); + font-size: 13px; + z-index: 10; + position: absolute; + bottom: 0; + + & a { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--gray12); + font-weight: 500; + border-radius: 9999px; + padding: 4px; + margin: 0 -2px; + transition: background 150ms ease; + + &:hover, + &:focus-visible { + background: var(--grayA4); + outline: 0; + } + } + + & img { + width: 20px; + height: 20px; + border: 1px solid var(--gray5); + border-radius: 9999px; + } +} + +.line { + height: 20px; + width: 180px; + margin: 64px auto; + background-image: url("/line.svg"); + filter: invert(1); + mask-image: linear-gradient(90deg, transparent, #fff 4rem, #fff calc(100% - 4rem), transparent); + + @media (prefers-color-scheme: dark) { + filter: unset; + } +} + +.line2 { + height: 1px; + width: 300px; + background: var(--gray7); + position: absolute; + top: 0; + mask-image: linear-gradient(90deg, transparent, #fff 4rem, #fff calc(100% - 4rem), transparent); +} + +.line3 { + height: 300px; + width: calc(100% + 32px); + position: absolute; + top: -16px; + left: -16px; + + border-radius: 16px 16px 0 0; + --size: 1px; + --gradient: linear-gradient(to top, var(--gray1), var(--gray7)); + + &:before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: var(--size); + background: linear-gradient(to top, var(--gray1), var(--gray7)); + mask: + linear-gradient(black, black) content-box, + linear-gradient(black, black); + mask-composite: exclude; + transform: translateZ(0); + } + + @media (prefers-color-scheme: dark) { + &:before { + mask: none; + mask-composite: none; + opacity: 0.2; + backdrop-filter: blur(20px); + } + } +} + +.raunoSignature, +.pacoSignature { + position: absolute; + height: fit-content; + color: var(--gray11); + pointer-events: none; +} + +.raunoSignature { + width: 120px; + stroke-dashoffset: 1; + stroke-dasharray: 1; + right: -48px; +} + +.pacoSignature { + width: 120px; + stroke-dashoffset: 1; + stroke-dasharray: 1; + left: -8px; +} + +.footerText { + display: flex; + display: flex; + align-items: center; + gap: 4px; +} + +.footer[data-animate="true"] { + .raunoSignature path { + animation: drawRaunoSignature 1.5s ease forwards 0.5s; + } + + .pacoSignature path { + animation: drawPacoSignature 0.8s linear forwards 0.5s; + } + + .footerText { + animation: showFooter 1s linear forwards 3s; + } +} + +@keyframes drawPacoSignature { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes drawRaunoSignature { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes showFooter { + 100% { + opacity: 1; + } +} + +@media (max-width: 640px) { + .main { + padding-top: 24px; + padding-bottom: 120px; + } + + .switcher { + grid-template-columns: repeat(2, 100px); + gap: 16px; + + .arrow { + display: none; + } + } +} diff --git a/sites/docs/src/lib/styles/command/globals.pcss b/sites/docs/src/lib/styles/command/globals.pcss new file mode 100644 index 000000000..a3e50cc9f --- /dev/null +++ b/sites/docs/src/lib/styles/command/globals.pcss @@ -0,0 +1,151 @@ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + font-display: optional; + src: url(/inter-var-latin.woff2) format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +::selection { + background: #eb5027; + color: white; +} + +html, +body { + padding: 0; + margin: 0; + font-family: var(--font-sans); +} + +body { + background: var(--app-bg); + overflow-x: hidden; +} + +button { + background: none; + font-family: var(--font-sans); + padding: 0; + border: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +*, +*::after, +*::before { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:root { + --font-sans: "Inter", --apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, + Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + --app-bg: var(--gray1); + --command-shadow: 0 16px 70px rgb(0 0 0 / 20%); + + --lowContrast: #ffffff; + --highContrast: #000000; + + --gray1: hsl(0, 0%, 99%); + --gray2: hsl(0, 0%, 97.3%); + --gray3: hsl(0, 0%, 95.1%); + --gray4: hsl(0, 0%, 93%); + --gray5: hsl(0, 0%, 90.9%); + --gray6: hsl(0, 0%, 88.7%); + --gray7: hsl(0, 0%, 85.8%); + --gray8: hsl(0, 0%, 78%); + --gray9: hsl(0, 0%, 56.1%); + --gray10: hsl(0, 0%, 52.3%); + --gray11: hsl(0, 0%, 43.5%); + --gray12: hsl(0, 0%, 9%); + + --grayA1: hsla(0, 0%, 0%, 0.012); + --grayA2: hsla(0, 0%, 0%, 0.027); + --grayA3: hsla(0, 0%, 0%, 0.047); + --grayA4: hsla(0, 0%, 0%, 0.071); + --grayA5: hsla(0, 0%, 0%, 0.09); + --grayA6: hsla(0, 0%, 0%, 0.114); + --grayA7: hsla(0, 0%, 0%, 0.141); + --grayA8: hsla(0, 0%, 0%, 0.22); + --grayA9: hsla(0, 0%, 0%, 0.439); + --grayA10: hsla(0, 0%, 0%, 0.478); + --grayA11: hsla(0, 0%, 0%, 0.565); + --grayA12: hsla(0, 0%, 0%, 0.91); + + --blue1: hsl(206, 100%, 99.2%); + --blue2: hsl(210, 100%, 98%); + --blue3: hsl(209, 100%, 96.5%); + --blue4: hsl(210, 98.8%, 94%); + --blue5: hsl(209, 95%, 90.1%); + --blue6: hsl(209, 81.2%, 84.5%); + --blue7: hsl(208, 77.5%, 76.9%); + --blue8: hsl(206, 81.9%, 65.3%); + --blue9: hsl(206, 100%, 50%); + --blue10: hsl(208, 100%, 47.3%); + --blue11: hsl(211, 100%, 43.2%); + --blue12: hsl(211, 100%, 15%); +} + +.dark { + --app-bg: var(--gray1); + + --lowContrast: #000000; + --highContrast: #ffffff; + + --gray1: hsl(0, 0%, 8.5%); + --gray2: hsl(0, 0%, 11%); + --gray3: hsl(0, 0%, 13.6%); + --gray4: hsl(0, 0%, 15.8%); + --gray5: hsl(0, 0%, 17.9%); + --gray6: hsl(0, 0%, 20.5%); + --gray7: hsl(0, 0%, 24.3%); + --gray8: hsl(0, 0%, 31.2%); + --gray9: hsl(0, 0%, 43.9%); + --gray10: hsl(0, 0%, 49.4%); + --gray11: hsl(0, 0%, 62.8%); + --gray12: hsl(0, 0%, 93%); + + --grayA1: hsla(0, 0%, 100%, 0); + --grayA2: hsla(0, 0%, 100%, 0.026); + --grayA3: hsla(0, 0%, 100%, 0.056); + --grayA4: hsla(0, 0%, 100%, 0.077); + --grayA5: hsla(0, 0%, 100%, 0.103); + --grayA6: hsla(0, 0%, 100%, 0.129); + --grayA7: hsla(0, 0%, 100%, 0.172); + --grayA8: hsla(0, 0%, 100%, 0.249); + --grayA9: hsla(0, 0%, 100%, 0.386); + --grayA10: hsla(0, 0%, 100%, 0.446); + --grayA11: hsla(0, 0%, 100%, 0.592); + --grayA12: hsla(0, 0%, 100%, 0.923); + + --blue1: hsl(212, 35%, 9.2%); + --blue2: hsl(216, 50%, 11.8%); + --blue3: hsl(214, 59.4%, 15.3%); + --blue4: hsl(214, 65.8%, 17.9%); + --blue5: hsl(213, 71.2%, 20.2%); + --blue6: hsl(212, 77.4%, 23.1%); + --blue7: hsl(211, 85.1%, 27.4%); + --blue8: hsl(211, 89.7%, 34.1%); + --blue9: hsl(206, 100%, 50%); + --blue10: hsl(209, 100%, 60.6%); + --blue11: hsl(210, 100%, 66.1%); + --blue12: hsl(206, 98%, 95.8%); +} diff --git a/sites/docs/src/lib/styles/command/icons.pcss b/sites/docs/src/lib/styles/command/icons.pcss new file mode 100644 index 000000000..85c4b7e90 --- /dev/null +++ b/sites/docs/src/lib/styles/command/icons.pcss @@ -0,0 +1,46 @@ +.blurLogo { + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-radius: 4px; + overflow: hidden; + box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.015); + + .bg { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: 1; + pointer-events: none; + user-select: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: scale(1.5) translateZ(0); + filter: blur(12px) opacity(0.4) saturate(100%); + transition: filter 150ms ease; + } + + .inner { + display: flex; + align-items: center; + justify-content: center; + object-fit: cover; + width: 100%; + height: 100%; + user-select: none; + pointer-events: none; + border-radius: inherit; + z-index: 2; + + & svg { + width: 14px; + height: 14px; + filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.16)); + transition: filter 150ms ease; + } + } +} diff --git a/sites/docs/src/routes/(examples)/examples/command/+layout.svelte b/sites/docs/src/routes/(examples)/examples/command/+layout.svelte new file mode 100644 index 000000000..7ea07340c --- /dev/null +++ b/sites/docs/src/routes/(examples)/examples/command/+layout.svelte @@ -0,0 +1,9 @@ + + +{@render children?.()} diff --git a/sites/docs/src/routes/(examples)/examples/command/+page.svelte b/sites/docs/src/routes/(examples)/examples/command/+page.svelte new file mode 100644 index 000000000..47076acda --- /dev/null +++ b/sites/docs/src/routes/(examples)/examples/command/+page.svelte @@ -0,0 +1,37 @@ + + +
    +
    +
    + {#if theme === "raycast"} + + + + {:else if theme === "linear"} + + + + {:else if theme === "vercel"} + + + + {:else if theme === "framer"} + + + + {/if} +
    + + + +
    +
    diff --git a/sites/docs/src/routes/(examples)/examples/command/sink/+page.svelte b/sites/docs/src/routes/(examples)/examples/command/sink/+page.svelte new file mode 100644 index 000000000..0037efb74 --- /dev/null +++ b/sites/docs/src/routes/(examples)/examples/command/sink/+page.svelte @@ -0,0 +1,43 @@ + + +
    + Total items: {names.length} +
    +
    + + + + No item found. + {#each names as txt (txt)} + {txt} + {/each} + + +
    diff --git a/sites/docs/src/routes/+error.svelte b/sites/docs/src/routes/(main)/+error.svelte similarity index 100% rename from sites/docs/src/routes/+error.svelte rename to sites/docs/src/routes/(main)/+error.svelte diff --git a/sites/docs/src/routes/+layout.svelte b/sites/docs/src/routes/(main)/+layout.svelte similarity index 100% rename from sites/docs/src/routes/+layout.svelte rename to sites/docs/src/routes/(main)/+layout.svelte diff --git a/sites/docs/src/routes/+page.svelte b/sites/docs/src/routes/(main)/+page.svelte similarity index 100% rename from sites/docs/src/routes/+page.svelte rename to sites/docs/src/routes/(main)/+page.svelte diff --git a/sites/docs/src/routes/(main)/+page.ts b/sites/docs/src/routes/(main)/+page.ts new file mode 100644 index 000000000..ac3ee4cba --- /dev/null +++ b/sites/docs/src/routes/(main)/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export async function load() { + redirect(303, "/docs/introduction"); +} diff --git a/sites/docs/src/routes/(main)/docs/+page.ts b/sites/docs/src/routes/(main)/docs/+page.ts new file mode 100644 index 000000000..ac3ee4cba --- /dev/null +++ b/sites/docs/src/routes/(main)/docs/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export async function load() { + redirect(303, "/docs/introduction"); +} diff --git a/sites/docs/src/routes/docs/[...slug]/+page.svelte b/sites/docs/src/routes/(main)/docs/[...slug]/+page.svelte similarity index 100% rename from sites/docs/src/routes/docs/[...slug]/+page.svelte rename to sites/docs/src/routes/(main)/docs/[...slug]/+page.svelte diff --git a/sites/docs/src/routes/docs/[...slug]/+page.ts b/sites/docs/src/routes/(main)/docs/[...slug]/+page.ts similarity index 63% rename from sites/docs/src/routes/docs/[...slug]/+page.ts rename to sites/docs/src/routes/(main)/docs/[...slug]/+page.ts index c5031c3b0..345a7c547 100644 --- a/sites/docs/src/routes/docs/[...slug]/+page.ts +++ b/sites/docs/src/routes/(main)/docs/[...slug]/+page.ts @@ -1,11 +1,10 @@ -import type { PageLoad } from "./$types.js"; import { getDoc } from "$lib/utils/docs.js"; -export const load: PageLoad = async (event) => { +export async function load(event) { const { component, title, metadata } = await getDoc(event.params.slug); return { component, title, metadata, }; -}; +} diff --git a/sites/docs/src/routes/docs/components/[name]/+page.svelte b/sites/docs/src/routes/(main)/docs/components/[name]/+page.svelte similarity index 100% rename from sites/docs/src/routes/docs/components/[name]/+page.svelte rename to sites/docs/src/routes/(main)/docs/components/[name]/+page.svelte diff --git a/sites/docs/src/routes/docs/components/[name]/+page.ts b/sites/docs/src/routes/(main)/docs/components/[name]/+page.ts similarity index 67% rename from sites/docs/src/routes/docs/components/[name]/+page.ts rename to sites/docs/src/routes/(main)/docs/components/[name]/+page.ts index d44fccc7c..e7903212f 100644 --- a/sites/docs/src/routes/docs/components/[name]/+page.ts +++ b/sites/docs/src/routes/(main)/docs/components/[name]/+page.ts @@ -1,7 +1,6 @@ -import type { PageLoad } from "./$types.js"; import { getComponentDoc } from "$lib/utils/docs.js"; -export const load: PageLoad = async (event) => { +export async function load(event) { const { component, title, metadata, schemas } = await getComponentDoc(event.params.name); return { component, @@ -9,4 +8,4 @@ export const load: PageLoad = async (event) => { metadata, schemas, }; -}; +} diff --git a/sites/docs/src/routes/sink/+page.svelte b/sites/docs/src/routes/(main)/sink/+page.svelte similarity index 100% rename from sites/docs/src/routes/sink/+page.svelte rename to sites/docs/src/routes/(main)/sink/+page.svelte diff --git a/sites/docs/src/routes/+page.ts b/sites/docs/src/routes/+page.ts deleted file mode 100644 index 17bd9dc82..000000000 --- a/sites/docs/src/routes/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from "@sveltejs/kit"; -import type { PageLoad } from "./$types.js"; - -export const load: PageLoad = async () => { - redirect(303, "/docs/introduction"); -}; diff --git a/sites/docs/src/routes/docs/+page.ts b/sites/docs/src/routes/docs/+page.ts deleted file mode 100644 index 3c3ce1bad..000000000 --- a/sites/docs/src/routes/docs/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from "@sveltejs/kit"; -import type { PageLoad } from "../$types.js"; - -export const load: PageLoad = async () => { - redirect(303, "/docs/introduction"); -};