Repository: dip/cmdk Branch: main Commit: dd2250ed6084 Files: 66 Total size: 189.6 KB Directory structure: gitextract_vtq8gdaf/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── test.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── ARCHITECTURE.md ├── LICENSE.md ├── README.md ├── cmdk/ │ ├── package.json │ ├── src/ │ │ ├── command-score.ts │ │ └── index.tsx │ └── tsup.config.ts ├── package.json ├── playwright.config.ts ├── pnpm-workspace.yaml ├── test/ │ ├── basic.test.ts │ ├── dialog.test.ts │ ├── group.test.ts │ ├── item.test.ts │ ├── keybind.test.ts │ ├── next-env.d.ts │ ├── numeric.test.ts │ ├── package.json │ ├── pages/ │ │ ├── _app.tsx │ │ ├── dialog.tsx │ │ ├── group.tsx │ │ ├── huge.tsx │ │ ├── index.tsx │ │ ├── item-advanced.tsx │ │ ├── item.tsx │ │ ├── keybinds.tsx │ │ ├── numeric.tsx │ │ ├── portal.tsx │ │ └── props.tsx │ ├── props.test.ts │ ├── style.css │ └── tsconfig.json ├── tsconfig.json └── website/ ├── .eslintrc.json ├── .gitignore ├── README.md ├── components/ │ ├── cmdk/ │ │ ├── framer.tsx │ │ ├── linear.tsx │ │ ├── raycast.tsx │ │ └── vercel.tsx │ ├── code/ │ │ ├── code.module.scss │ │ └── index.tsx │ ├── icons/ │ │ ├── icons.module.scss │ │ └── index.tsx │ └── index.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── _app.tsx │ ├── _document.tsx │ └── index.tsx ├── public/ │ └── robots.txt ├── styles/ │ ├── cmdk/ │ │ ├── framer.scss │ │ ├── linear.scss │ │ ├── raycast.scss │ │ └── vercel.scss │ ├── globals.scss │ └── index.module.scss ├── tsconfig.json └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: pacocoursey ================================================ FILE: .github/workflows/test.yml ================================================ name: Run E2E tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 # respects packageManager in package.json - uses: actions/setup-node@v4 with: cache: 'pnpm' - run: pnpm install env: CI: true - run: pnpm build - run: pnpm test:format - run: pnpm playwright install --with-deps - run: pnpm test || exit 1 - name: Upload test results uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report.json ================================================ FILE: .gitignore ================================================ .DS_Store .idea .env .env.local .env.development .env.development.local *.log yalc.lock .vercel/ .turbo/ .next/ .yalc/ build/ dist/ node_modules/ .vercel ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" pnpm lint-staged ================================================ FILE: .prettierignore ================================================ .next dist pnpm-lock.yaml .pnpm-store .vercel ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: false, singleQuote: true, tabWidth: 2, trailingComma: 'all', printWidth: 120, } ================================================ FILE: ARCHITECTURE.md ================================================ # Architecture > Document is a work in progress! ⌘K is born from a simple constraint: can you write a combobox with filtering and sorting using the [compound component](https://kentcdodds.com/blog/compound-components-with-react-hooks) approach? We didn't want to render items manually from an array: ```tsx // No <> {items.map((item) => { return
{item}
})} ``` We didn't want to provide a render prop: ```tsx // No onItemRender={({ item }) => { return
{item}
}} ``` Instead, we wanted to render components: ```tsx // Yes My item ``` Especially, we wanted full component composition: ```tsx // YES <> {staticItems} ``` Compound components are natural and easy to write. A few months after exploring this library, we were pleased to see [Radix UI](https://www.radix-ui.com) released using this exact approach of component structure – setting the standard for ease of use and composability. However, for a combobox, it is a terrible, terrible constraint that we've spent 2 years fighting. ## Approach ⌘K always keeps every item and group rendered in the React tree. Each item and group adds or removes itself from the DOM based on the search input. The DOM is authoritative. Item selection order is based on the DOM order, which is based on the React render order, which the consumer provides. ### Discarded approach We did not use `React.Children` iteration because it will not support component composition. There is no way to "peek inside" the items contained within ``, so those items cannot be filtered. We did not use an object-based data array for each item, like `{ name: "Logout", action: () => logout() }` because this is strict and limiting. In reality, the interface of those objects grows with edge-cases, like `image`, `detailedSubTitle`, `hideWhenRootSearch`, etc. We prefer that you have full control of item rendering, including icons, keyboard shortcuts, and styling. Don't want an item shown? Don't render it. Only want to show an item under condition xyz? Render it. We did not use a render prop because they are an inelegant pattern and quickly fall to long, centralised if-else logic chains. For example, if you want a fancy sparkle rainbow item, you need a new if statement to render that item specially. The original approach for tracking which item was selected was to keep an index 0..n. But it's impossible to know which Item is in which position within the React tree when React Strict Mode is enabled, because `useEffect` runs twice and `useRef` cannot be used for stable IDs. This may be possible with `useId`, now. We created [use-descendants](https://github.com/pacocoursey/use-descendants) to track relative component indeces, but abandoned it because it could not work in Strict Mode, and will be incompatible with upcoming concurrent mode. Now, we track the selected item with its value, because it is stable across item mounts and unmounts. ## Example ```tsx A B ``` The "A" item should not be shown! But we cannot remove it from the React tree, because the user controls it. In most cases, this is easy because the rendered items is sourced from a backing data array: ```tsx <> {['A', 'B'].map((item) => { if (matches(item, search)) { return {item} } })} ``` But in our case, the item will remain in the React tree and just be removed from the DOM: ```tsx {/* returns `null`, no DOM created */} A B ``` ## Performance This is more expensive memory wise, because if there are 2,000 items but the list is filtered to only 2 items, we still allocate memory for 2,000 instances of the Item component. But it's our only option! Thankfully we can still keep the DOM size to 2 items. ## Groups Item mount informs both the root and the parent group, which keeps track of items within it. Each group informs the root. ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2022 Paco Coursey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

# ⌘K [![cmdk minzip package size](https://img.shields.io/bundlephobia/minzip/cmdk)](https://www.npmjs.com/package/cmdk?activeTab=code) [![cmdk package version](https://img.shields.io/npm/v/cmdk.svg?colorB=green)](https://www.npmjs.com/package/cmdk) ⌘K is a command menu React component that can also be used as an accessible combobox. You render items, it filters and sorts them automatically. ⌘K supports a fully composable API [How?](/ARCHITECTURE.md), so you can wrap items in other components or even as static JSX. ## Install ```bash pnpm install cmdk ``` ## Use ```tsx import { Command } from 'cmdk' const CommandMenu = () => { return ( No results found. a b c Apple ) } ``` Or in a dialog: ```tsx import { Command } from 'cmdk' const CommandMenu = () => { const [open, setOpen] = React.useState(false) // Toggle the menu when ⌘K is pressed React.useEffect(() => { const down = (e) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen((open) => !open) } } document.addEventListener('keydown', down) return () => document.removeEventListener('keydown', down) }, []) return ( No results found. a b c Apple ) } ``` ## Parts and styling All parts forward props, including `ref`, to an appropriate element. Each part has a specific data-attribute (starting with `cmdk-`) that can be used for styling. ### Command `[cmdk-root]` Render this to show the command menu inline, or use [Dialog](#dialog-cmdk-dialog-cmdk-overlay) to render in a elevated context. Can be controlled with the `value` and `onValueChange` props. > **Note** > > Values are always trimmed with the [trim()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim) method. ```tsx const [value, setValue] = React.useState('apple') return ( Orange Apple ) ``` You can provide a custom `filter` function that is called to rank each item. Note that the value will be trimmed. ```tsx { if (value.includes(search)) return 1 return 0 }} /> ``` A third argument, `keywords`, can also be provided to the filter function. Keywords act as aliases for the item value, and can also affect the rank of the item. Keywords are trimmed. ```tsx { const extendValue = value + ' ' + keywords.join(' ') if (extendValue.includes(search)) return 1 return 0 }} /> ``` Or disable filtering and sorting entirely: ```tsx {filteredItems.map((item) => { return ( {item} ) })} ``` You can make the arrow keys wrap around the list (when you reach the end, it goes back to the first item) by setting the `loop` prop: ```tsx ``` ### Dialog `[cmdk-dialog]` `[cmdk-overlay]` Props are forwarded to [Command](#command-cmdk-root). Composes Radix UI's Dialog component. The overlay is always rendered. See the [Radix Documentation](https://www.radix-ui.com/docs/primitives/components/dialog) for more information. Can be controlled with the `open` and `onOpenChange` props. ```tsx const [open, setOpen] = React.useState(false) return ( ... ) ``` You can provide a `container` prop that accepts an HTML element that is forwarded to Radix UI's Dialog Portal component to specify which element the Dialog should portal into (defaults to `body`). See the [Radix Documentation](https://www.radix-ui.com/docs/primitives/components/dialog#portal) for more information. ```tsx const containerElement = React.useRef(null) return ( <>
) ``` ### Input `[cmdk-input]` All props are forwarded to the underlying `input` element. Can be controlled with the `value` and `onValueChange` props. ```tsx const [search, setSearch] = React.useState('') return ``` ### List `[cmdk-list]` Contains items and groups. Animate height using the `--cmdk-list-height` CSS variable. ```css [cmdk-list] { min-height: 300px; height: var(--cmdk-list-height); max-height: 500px; transition: height 100ms ease; } ``` To scroll item into view earlier near the edges of the viewport, use scroll-padding: ```css [cmdk-list] { scroll-padding-block-start: 8px; scroll-padding-block-end: 8px; } ``` ### Item `[cmdk-item]` `[data-disabled?]` `[data-selected?]` Item that becomes active on pointer enter. You should provide a unique `value` for each item, but it will be automatically inferred from the `.textContent`. ```tsx console.log('Selected', value)} // Value is implicity "apple" because of the provided text content > Apple ``` You can also provide a `keywords` prop to help with filtering. Keywords are trimmed. ```tsx Apple ``` ```tsx console.log('Selected', value)} // Value is implicity "apple" because of the provided text content > Apple ``` You can force an item to always render, regardless of filtering, by passing the `forceMount` prop. ### Group `[cmdk-group]` `[hidden?]` Groups items together with the given `heading` (`[cmdk-group-heading]`). ```tsx Apple ``` Groups will not unmount from the DOM, rather the `hidden` attribute is applied to hide it from view. This may be relevant in your styling. You can force a group to always render, regardless of filtering, by passing the `forceMount` prop. ### Separator `[cmdk-separator]` Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. ### Empty `[cmdk-empty]` Automatically renders when there are no results for the search query. ### Loading `[cmdk-loading]` You should conditionally render this with `progress` while loading asynchronous items. ```tsx const [loading, setLoading] = React.useState(false) return {loading && Hang on…} ``` ### `useCommandState(state => state.selectedField)` Hook that composes [`useSyncExternalStore`](https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore). Pass a function that returns a slice of the command menu state to re-render when that slice changes. This hook is provided for advanced use cases and should not be commonly used. A good use case would be to render a more detailed empty state, like so: ```tsx const search = useCommandState((state) => state.search) return No results found for "{search}". ``` ## Examples Code snippets for common use cases. ### Nested items Often selecting one item should navigate deeper, with a more refined set of items. For example selecting "Change theme…" should show new items "Dark theme" and "Light theme". We call these sets of items "pages", and they can be implemented with simple state: ```tsx const ref = React.useRef(null) const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState('') const [pages, setPages] = React.useState([]) const page = pages[pages.length - 1] return ( { // Escape goes to previous page // Backspace goes to previous page when search is empty if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { e.preventDefault() setPages((pages) => pages.slice(0, -1)) } }} > {!page && ( <> setPages([...pages, 'projects'])}>Search projects… setPages([...pages, 'teams'])}>Join a team… )} {page === 'projects' && ( <> Project A Project B )} {page === 'teams' && ( <> Team 1 Team 2 )} ) ``` ### Show sub-items when searching If your items have nested sub-items that you only want to reveal when searching, render based on the search state: ```tsx const SubItem = (props) => { const search = useCommandState((state) => state.search) if (!search) return null return } return ( Change theme… Change theme to dark Change theme to light ) ``` ### Asynchronous results Render the items as they become available. Filtering and sorting will happen automatically. ```tsx const [loading, setLoading] = React.useState(false) const [items, setItems] = React.useState([]) React.useEffect(() => { async function getItems() { setLoading(true) const res = await api.get('/dictionary') setItems(res) setLoading(false) } getItems() }, []) return ( {loading && Fetching words…} {items.map((item) => { return ( {item} ) })} ) ``` ### Use inside Popover We recommend using the [Radix UI popover](https://www.radix-ui.com/docs/primitives/components/popover) component. ⌘K relies on the Radix UI Dialog component, so this will reduce your bundle size a bit due to shared dependencies. ```bash $ pnpm install @radix-ui/react-popover ``` Render `Command` inside of the popover content: ```tsx import * as Popover from '@radix-ui/react-popover' return ( Toggle popover Apple ) ``` ### Drop in stylesheets You can find global stylesheets to drop in as a starting point for styling. See [website/styles/cmdk](website/styles/cmdk) for examples. ## FAQ **Accessible?** Yes. Labeling, aria attributes, and DOM ordering tested with Voice Over and Chrome DevTools. [Dialog](#dialog-cmdk-dialog-cmdk-overlay) composes an accessible Dialog implementation. **Virtualization?** No. Good performance up to 2,000-3,000 items, though. Read below to bring your own. **Filter/sort items manually?** Yes. Pass `shouldFilter={false}` to [Command](#command-cmdk-root). Better memory usage and performance. Bring your own virtualization this way. **React 18 safe?** Yes, required. Uses React 18 hooks like `useId` and `useSyncExternalStore`. **Unstyled?** Yes, use the listed CSS selectors. **Hydration mismatch?** No, likely a bug in your code. Ensure the `open` prop to `Command.Dialog` is `false` on the server. **React strict mode safe?** Yes. Open an issue if you notice an issue. **Weird/wrong behavior?** Make sure your `Command.Item` has a `key` and unique `value`. **Concurrent mode safe?** Maybe, but concurrent mode is not yet real. Uses risky approaches like manual DOM ordering. **React server component?** No, it's a client component. **Listen for ⌘K automatically?** No, do it yourself to have full control over keybind context. **React Native?** No, and no plans to support it. If you build a React Native version, let us know and we'll link your repository here. ## History Written in 2019 by Paco ([@pacocoursey](https://twitter.com/pacocoursey)) to see if a composable combobox API was possible. Used for the Vercel command menu and autocomplete by Rauno ([@raunofreiberg](https://twitter.com/raunofreiberg)) in 2020. Re-written independently in 2022 with a simpler and more performant approach. Ideas and help from Shu ([@shuding\_](https://twitter.com/shuding_)). [use-descendants](https://github.com/pacocoursey/use-descendants) was extracted from the 2019 version. ## Testing First, install dependencies and Playwright browsers: ```bash pnpm install pnpm playwright install ``` Then ensure you've built the library: ```bash pnpm build ``` Then run the tests using your local build against real browser engines: ```bash pnpm test ``` ================================================ FILE: cmdk/package.json ================================================ { "name": "cmdk", "version": "1.1.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "files": [ "dist" ], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "scripts": { "prepublishOnly": "cp ../README.md . && pnpm build", "postpublish": "rm README.md", "build": "tsup src", "dev": "tsup src --watch" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "devDependencies": { "@types/react": "18.0.15" }, "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/pacocoursey/cmdk.git", "directory": "cmdk" }, "bugs": { "url": "https://github.com/pacocoursey/cmdk/issues" }, "homepage": "https://github.com/pacocoursey/cmdk#readme", "author": { "name": "Paco", "url": "https://github.com/pacocoursey" } } ================================================ FILE: cmdk/src/command-score.ts ================================================ // 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. var 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. SCORE_SPACE_WORD_JUMP = 0.9, SCORE_NON_SPACE_WORD_JUMP = 0.8, // Any other match isn't ideal, but we include it for completeness. 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. 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. 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. PENALTY_CASE_MISMATCH = 0.9999, // Match higher for letters closer to the beginning of the word 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. PENALTY_NOT_COMPLETE = 0.99 var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, IS_SPACE_REGEXP = /[\s-]/, 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 } var memoizeKey = `${stringIndex},${abbreviationIndex}` if (memoizedResults[memoizeKey] !== undefined) { return memoizedResults[memoizeKey] } var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) var index = lowerString.indexOf(abbreviationChar, stringIndex) var highScore = 0 var 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 *= Math.pow(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 *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length) } } else { score *= SCORE_CHARACTER_JUMP if (stringIndex > 0) { score *= Math.pow(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, {}) } ================================================ FILE: cmdk/src/index.tsx ================================================ 'use client' import * as RadixDialog from '@radix-ui/react-dialog' import * as React from 'react' import { commandScore } from './command-score' import { Primitive } from '@radix-ui/react-primitive' import { useId } from '@radix-ui/react-id' import { composeRefs } from '@radix-ui/react-compose-refs' type Children = { children?: React.ReactNode } type DivProps = React.ComponentPropsWithoutRef type LoadingProps = Children & DivProps & { /** Estimated progress of loading asynchronous options. */ progress?: number /** * Accessible label for this loading progressbar. Not shown visibly. */ label?: string } type EmptyProps = Children & DivProps & {} type SeparatorProps = DivProps & { /** Whether this separator should always be rendered. Useful if you disable automatic filtering. */ alwaysRender?: boolean } type DialogProps = RadixDialog.DialogProps & CommandProps & { /** Provide a className to the Dialog overlay. */ overlayClassName?: string /** Provide a className to the Dialog content. */ contentClassName?: string /** Provide a custom element the Dialog should portal into. */ container?: HTMLElement } type ListProps = Children & DivProps & { /** * Accessible label for this List of suggestions. Not shown visibly. */ label?: string } type ItemProps = Children & Omit & { /** Whether this item is currently disabled. */ disabled?: boolean /** Event handler for when this item is selected, either via click or keyboard selection. */ onSelect?: (value: string) => void /** * A unique value for this item. * If no value is provided, it will be inferred from `children` or the rendered `textContent`. If your `textContent` changes between renders, you _must_ provide a stable, unique `value`. */ value?: string /** Optional keywords to match against when filtering. */ keywords?: string[] /** Whether this item is forcibly rendered regardless of filtering. */ forceMount?: boolean } type GroupProps = Children & Omit & { /** Optional heading to render for this group. */ heading?: React.ReactNode /** If no heading is provided, you must provide a value that is unique for this group. */ value?: string /** Whether this group is forcibly rendered regardless of filtering. */ forceMount?: boolean } type InputProps = Omit, 'value' | 'onChange' | 'type'> & { /** * Optional controlled state for the value of the search input. */ value?: string /** * Event handler called when the search value changes. */ onValueChange?: (search: string) => void } type CommandFilter = (value: string, search: string, keywords?: string[]) => number type CommandProps = Children & DivProps & { /** * Accessible label for this command menu. Not shown visibly. */ label?: string /** * Optionally set to `false` to turn off the automatic filtering and sorting. * If `false`, you must conditionally render valid items based on the search query yourself. */ shouldFilter?: boolean /** * Custom filter function for whether each command menu item should matches the given search query. * It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely. * By default, uses the `command-score` library. */ filter?: CommandFilter /** * Optional default item value when it is initially rendered. */ defaultValue?: string /** * Optional controlled state of the selected command menu item. */ value?: string /** * Event handler called when the selected item of the menu changes. */ onValueChange?: (value: string) => void /** * Optionally set to `true` to turn on looping around when using the arrow keys. */ loop?: boolean /** * Optionally set to `true` to disable selection via pointer events. */ disablePointerSelection?: boolean /** * Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`. */ vimBindings?: boolean } type Context = { value: (id: string, value: string, keywords?: string[]) => void item: (id: string, groupId: string) => () => void group: (id: string) => () => void filter: () => boolean label: string getDisablePointerSelection: () => boolean // Ids listId: string labelId: string inputId: string // Refs listInnerRef: React.RefObject } type State = { search: string value: string selectedItemId?: string filtered: { count: number; items: Map; groups: Set } } type Store = { subscribe: (callback: () => void) => () => void snapshot: () => State setState: (key: K, value: State[K], opts?: any) => void emit: () => void } type Group = { id: string forceMount?: boolean } const GROUP_SELECTOR = `[cmdk-group=""]` const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]` const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]` const ITEM_SELECTOR = `[cmdk-item=""]` const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])` const SELECT_EVENT = `cmdk-item-select` const VALUE_ATTR = `data-value` const defaultFilter: CommandFilter = (value, search, keywords) => commandScore(value, search, keywords) const CommandContext = React.createContext(undefined) const useCommand = () => React.useContext(CommandContext) const StoreContext = React.createContext(undefined) const useStore = () => React.useContext(StoreContext) const GroupContext = React.createContext(undefined) const Command = React.forwardRef((props, forwardedRef) => { const state = useLazyRef(() => ({ /** Value of the search query. */ search: '', /** Currently selected item value. */ value: props.value ?? props.defaultValue ?? '', /** Currently selected item id. */ selectedItemId: undefined, filtered: { /** The count of all visible items. */ count: 0, /** Map from visible item id to its search score. */ items: new Map(), /** Set of groups with at least one visible item. */ groups: new Set(), }, })) const allItems = useLazyRef>(() => new Set()) // [...itemIds] const allGroups = useLazyRef>>(() => new Map()) // groupId → [...itemIds] const ids = useLazyRef>(() => new Map()) // id → { value, keywords } const listeners = useLazyRef void>>(() => new Set()) // [...rerenders] const propsRef = useAsRef(props) const { label, children, value, onValueChange, filter, shouldFilter, loop, disablePointerSelection = false, vimBindings = true, ...etc } = props const listId = useId() const labelId = useId() const inputId = useId() const listInnerRef = React.useRef(null) const schedule = useScheduleLayoutEffect() /** Controlled mode `value` handling. */ useLayoutEffect(() => { if (value !== undefined) { const v = value.trim() state.current.value = v store.emit() } }, [value]) useLayoutEffect(() => { schedule(6, scrollSelectedIntoView) }, []) const store: Store = React.useMemo(() => { return { subscribe: (cb) => { listeners.current.add(cb) return () => listeners.current.delete(cb) }, snapshot: () => { return state.current }, setState: (key, value, opts) => { if (Object.is(state.current[key], value)) return state.current[key] = value if (key === 'search') { // Filter synchronously before emitting back to children filterItems() sort() schedule(1, selectFirstItem) } else if (key === 'value') { // Force focus input or root so accessibility works if (document.activeElement.hasAttribute('cmdk-input') || document.activeElement.hasAttribute('cmdk-root')) { const input = document.getElementById(inputId) if (input) input.focus() else document.getElementById(listId)?.focus() } schedule(7, () => { state.current.selectedItemId = getSelectedItem()?.id store.emit() }) // opts is a boolean referring to whether it should NOT be scrolled into view if (!opts) { // Scroll the selected item into view schedule(5, scrollSelectedIntoView) } if (propsRef.current?.value !== undefined) { // If controlled, just call the callback instead of updating state internally const newValue = (value ?? '') as string propsRef.current.onValueChange?.(newValue) return } } // Notify subscribers that state has changed store.emit() }, emit: () => { listeners.current.forEach((l) => l()) }, } }, []) const context: Context = React.useMemo( () => ({ // Keep id → {value, keywords} mapping up-to-date value: (id, value, keywords) => { if (value !== ids.current.get(id)?.value) { ids.current.set(id, { value, keywords }) state.current.filtered.items.set(id, score(value, keywords)) schedule(2, () => { sort() store.emit() }) } }, // Track item lifecycle (mount, unmount) item: (id, groupId) => { allItems.current.add(id) // Track this item within the group if (groupId) { if (!allGroups.current.has(groupId)) { allGroups.current.set(groupId, new Set([id])) } else { allGroups.current.get(groupId).add(id) } } // Batch this, multiple items can mount in one pass // and we should not be filtering/sorting/emitting each time schedule(3, () => { filterItems() sort() // Could be initial mount, select the first item if none already selected if (!state.current.value) { selectFirstItem() } store.emit() }) return () => { ids.current.delete(id) allItems.current.delete(id) state.current.filtered.items.delete(id) const selectedItem = getSelectedItem() // Batch this, multiple items could be removed in one pass schedule(4, () => { filterItems() // The item removed have been the selected one, // so selection should be moved to the first if (selectedItem?.getAttribute('id') === id) selectFirstItem() store.emit() }) } }, // Track group lifecycle (mount, unmount) group: (id) => { if (!allGroups.current.has(id)) { allGroups.current.set(id, new Set()) } return () => { ids.current.delete(id) allGroups.current.delete(id) } }, filter: () => { return propsRef.current.shouldFilter }, label: label || props['aria-label'], getDisablePointerSelection: () => { return propsRef.current.disablePointerSelection }, listId, inputId, labelId, listInnerRef, }), [], ) function score(value: string, keywords?: string[]) { const filter = propsRef.current?.filter ?? defaultFilter return value ? filter(value, state.current.search, keywords) : 0 } /** Sorts items by score, and groups by highest item score. */ function sort() { if ( !state.current.search || // Explicitly false, because true | undefined is the default propsRef.current.shouldFilter === false ) { return } const scores = state.current.filtered.items // Sort the groups const groups: [string, number][] = [] state.current.filtered.groups.forEach((value) => { const items = allGroups.current.get(value) // Get the maximum score of the group's items let max = 0 items.forEach((item) => { const score = scores.get(item) max = Math.max(score, 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 = listInnerRef.current // Sort the items getValidItems() .sort((a, b) => { const valueA = a.getAttribute('id') const valueB = b.getAttribute('id') return (scores.get(valueB) ?? 0) - (scores.get(valueA) ?? 0) }) .forEach((item) => { const group = item.closest(GROUP_ITEMS_SELECTOR) if (group) { group.appendChild(item.parentElement === group ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`)) } else { listInsertionElement.appendChild( item.parentElement === listInsertionElement ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), ) } }) groups .sort((a, b) => b[1] - a[1]) .forEach((group) => { const element = listInnerRef.current?.querySelector( `${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]`, ) element?.parentElement.appendChild(element) }) } function selectFirstItem() { const item = getValidItems().find((item) => item.getAttribute('aria-disabled') !== 'true') const value = item?.getAttribute(VALUE_ATTR) store.setState('value', value || undefined) } /** Filters the current items. */ function filterItems() { if ( !state.current.search || // Explicitly false, because true | undefined is the default propsRef.current.shouldFilter === false ) { state.current.filtered.count = allItems.current.size // Do nothing, each item will know to show itself because search is empty return } // Reset the groups state.current.filtered.groups = new Set() let itemCount = 0 // Check which items should be included for (const id of allItems.current) { const value = ids.current.get(id)?.value ?? '' const keywords = ids.current.get(id)?.keywords ?? [] const rank = score(value, keywords) state.current.filtered.items.set(id, rank) if (rank > 0) itemCount++ } // Check which groups have at least 1 item shown for (const [groupId, group] of allGroups.current) { for (const itemId of group) { if (state.current.filtered.items.get(itemId) > 0) { state.current.filtered.groups.add(groupId) break } } } state.current.filtered.count = itemCount } function scrollSelectedIntoView() { const item = getSelectedItem() if (item) { if (item.parentElement?.firstChild === item) { // First item in Group, ensure heading is in view item.closest(GROUP_SELECTOR)?.querySelector(GROUP_HEADING_SELECTOR)?.scrollIntoView({ block: 'nearest' }) } // Ensure the item is always in view item.scrollIntoView({ block: 'nearest' }) } } /** Getters */ function getSelectedItem() { return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`) } function getValidItems() { return Array.from(listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR) || []) } /** Setters */ function updateSelectedToIndex(index: number) { const items = getValidItems() const item = items[index] if (item) store.setState('value', item.getAttribute(VALUE_ATTR)) } function updateSelectedByItem(change: 1 | -1) { const selected = getSelectedItem() const items = getValidItems() const index = items.findIndex((item) => item === selected) // Get item at this index let newSelected = items[index + change] if (propsRef.current?.loop) { newSelected = index + change < 0 ? items[items.length - 1] : index + change === items.length ? items[0] : items[index + change] } if (newSelected) store.setState('value', newSelected.getAttribute(VALUE_ATTR)) } function updateSelectedByGroup(change: 1 | -1) { const selected = getSelectedItem() let group = selected?.closest(GROUP_SELECTOR) let item: HTMLElement while (group && !item) { group = change > 0 ? findNextSibling(group, GROUP_SELECTOR) : findPreviousSibling(group, GROUP_SELECTOR) item = group?.querySelector(VALID_ITEM_SELECTOR) } if (item) { store.setState('value', item.getAttribute(VALUE_ATTR)) } else { updateSelectedByItem(change) } } const last = () => updateSelectedToIndex(getValidItems().length - 1) const next = (e: React.KeyboardEvent) => { e.preventDefault() if (e.metaKey) { // Last item last() } else if (e.altKey) { // Next group updateSelectedByGroup(1) } else { // Next item updateSelectedByItem(1) } } const prev = (e: React.KeyboardEvent) => { e.preventDefault() if (e.metaKey) { // First item updateSelectedToIndex(0) } else if (e.altKey) { // Previous group updateSelectedByGroup(-1) } else { // Previous item updateSelectedByItem(-1) } } return ( { etc.onKeyDown?.(e) // Check if IME composition is finished before triggering key binds // This prevents unwanted triggering while user is still inputting text with IME // e.keyCode === 229 is for the CJK IME with Legacy Browser [https://w3c.github.io/uievents/#determine-keydown-keyup-keyCode] // isComposing is for the CJK IME with Modern Browser [https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent/isComposing] const isComposing = e.nativeEvent.isComposing || e.keyCode === 229 if (e.defaultPrevented || isComposing) { return } switch (e.key) { case 'n': case 'j': { // vim keybind down if (vimBindings && e.ctrlKey) { next(e) } break } case 'ArrowDown': { next(e) break } case 'p': case 'k': { // vim keybind up if (vimBindings && e.ctrlKey) { prev(e) } break } case 'ArrowUp': { prev(e) break } case 'Home': { // First item e.preventDefault() updateSelectedToIndex(0) break } case 'End': { // Last item e.preventDefault() last() break } case 'Enter': { // Trigger item onSelect e.preventDefault() const item = getSelectedItem() if (item) { const event = new Event(SELECT_EVENT) item.dispatchEvent(event) } } } }} > {SlottableWithNestedChildren(props, (child) => ( {child} ))} ) }) /** * Command menu item. Becomes active on pointer enter or through keyboard navigation. * Preferably pass a `value`, otherwise the value will be inferred from `children` or * the rendered item's `textContent`. */ const Item = React.forwardRef((props, forwardedRef) => { const id = useId() const ref = React.useRef(null) const groupContext = React.useContext(GroupContext) const context = useCommand() const propsRef = useAsRef(props) const forceMount = propsRef.current?.forceMount ?? groupContext?.forceMount useLayoutEffect(() => { if (!forceMount) { return context.item(id, groupContext?.id) } }, [forceMount]) const value = useValue(id, ref, [props.value, props.children, ref], props.keywords) const store = useStore() const selected = useCmdk((state) => state.value && state.value === value.current) const render = useCmdk((state) => forceMount ? true : context.filter() === false ? true : !state.search ? true : state.filtered.items.get(id) > 0, ) React.useEffect(() => { const element = ref.current if (!element || props.disabled) return element.addEventListener(SELECT_EVENT, onSelect) return () => element.removeEventListener(SELECT_EVENT, onSelect) }, [render, props.onSelect, props.disabled]) function onSelect() { select() propsRef.current.onSelect?.(value.current) } function select() { store.setState('value', value.current, true) } if (!render) return null const { disabled, value: _, onSelect: __, forceMount: ___, keywords: ____, ...etc } = props return ( {props.children} ) }) /** * Group command menu items together with a heading. * Grouped items are always shown together. */ const Group = React.forwardRef((props, forwardedRef) => { const { heading, children, forceMount, ...etc } = props const id = useId() const ref = React.useRef(null) const headingRef = React.useRef(null) const headingId = useId() const context = useCommand() const render = useCmdk((state) => forceMount ? true : context.filter() === false ? true : !state.search ? true : state.filtered.groups.has(id), ) useLayoutEffect(() => { return context.group(id) }, []) useValue(id, ref, [props.value, props.heading, headingRef]) const contextValue = React.useMemo(() => ({ id, forceMount }), [forceMount]) return ( ) }) /** * A visual and semantic separator between items or groups. * Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. */ const Separator = React.forwardRef((props, forwardedRef) => { const { alwaysRender, ...etc } = props const ref = React.useRef(null) const render = useCmdk((state) => !state.search) if (!alwaysRender && !render) return null return }) /** * Command menu input. * All props are forwarded to the underyling `input` element. */ const Input = React.forwardRef((props, forwardedRef) => { const { onValueChange, ...etc } = props const isControlled = props.value != null const store = useStore() const search = useCmdk((state) => state.search) const selectedItemId = useCmdk((state) => state.selectedItemId) const context = useCommand() React.useEffect(() => { if (props.value != null) { store.setState('search', props.value) } }, [props.value]) return ( { if (!isControlled) { store.setState('search', e.target.value) } onValueChange?.(e.target.value) }} /> ) }) /** * Contains `Item`, `Group`, and `Separator`. * Use the `--cmdk-list-height` CSS variable to animate height based on the number of results. */ const List = React.forwardRef((props, forwardedRef) => { const { children, label = 'Suggestions', ...etc } = props const ref = React.useRef(null) const height = React.useRef(null) const selectedItemId = useCmdk((state) => state.selectedItemId) const context = useCommand() React.useEffect(() => { if (height.current && ref.current) { const el = height.current const wrapper = ref.current let animationFrame const observer = new ResizeObserver(() => { animationFrame = requestAnimationFrame(() => { const height = el.offsetHeight wrapper.style.setProperty(`--cmdk-list-height`, height.toFixed(1) + 'px') }) }) observer.observe(el) return () => { cancelAnimationFrame(animationFrame) observer.unobserve(el) } } }, []) return ( {SlottableWithNestedChildren(props, (child) => (
{child}
))}
) }) /** * Renders the command menu in a Radix Dialog. */ const Dialog = React.forwardRef((props, forwardedRef) => { const { open, onOpenChange, overlayClassName, contentClassName, container, ...etc } = props return ( ) }) /** * Automatically renders when there are no results for the search query. */ const Empty = React.forwardRef((props, forwardedRef) => { const render = useCmdk((state) => state.filtered.count === 0) if (!render) return null return }) /** * You should conditionally render this with `progress` while loading asynchronous items. */ const Loading = React.forwardRef((props, forwardedRef) => { const { progress, children, label = 'Loading...', ...etc } = props return ( {SlottableWithNestedChildren(props, (child) => (
{child}
))}
) }) const pkg = Object.assign(Command, { List, Item, Input, Group, Separator, Dialog, Empty, Loading, }) export { useCmdk as useCommandState } export { pkg as Command } export { defaultFilter } export { Command as CommandRoot } export { List as CommandList } export { Item as CommandItem } export { Input as CommandInput } export { Group as CommandGroup } export { Separator as CommandSeparator } export { Dialog as CommandDialog } export { Empty as CommandEmpty } export { Loading as CommandLoading } /** * * * Helpers * * */ function findNextSibling(el: Element, selector: string) { let sibling = el.nextElementSibling while (sibling) { if (sibling.matches(selector)) return sibling sibling = sibling.nextElementSibling } } function findPreviousSibling(el: Element, selector: string) { let sibling = el.previousElementSibling while (sibling) { if (sibling.matches(selector)) return sibling sibling = sibling.previousElementSibling } } function useAsRef(data: T) { const ref = React.useRef(data) useLayoutEffect(() => { ref.current = data }) return ref } const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect function useLazyRef(fn: () => T) { const ref = React.useRef() if (ref.current === undefined) { ref.current = fn() } return ref as React.MutableRefObject } /** Run a selector against the store state. */ function useCmdk(selector: (state: State) => T): T { const store = useStore() const cb = () => selector(store.snapshot()) return React.useSyncExternalStore(store.subscribe, cb, cb) } function useValue( id: string, ref: React.RefObject, deps: (string | React.ReactNode | React.RefObject)[], aliases: string[] = [], ) { const valueRef = React.useRef() const context = useCommand() useLayoutEffect(() => { const value = (() => { for (const part of deps) { if (typeof part === 'string') { return part.trim() } if (typeof part === 'object' && 'current' in part) { if (part.current) { return part.current.textContent?.trim() } return valueRef.current } } })() const keywords = aliases.map((alias) => alias.trim()) context.value(id, value, keywords) ref.current?.setAttribute(VALUE_ATTR, value) valueRef.current = value }) return valueRef } /** Imperatively run a function on the next layout effect cycle. */ const useScheduleLayoutEffect = () => { const [s, ss] = React.useState() const fns = useLazyRef(() => new Map void>()) useLayoutEffect(() => { fns.current.forEach((f) => f()) fns.current = new Map() }, [s]) return (id: string | number, cb: () => void) => { fns.current.set(id, cb) ss({}) } } function renderChildren(children: React.ReactElement) { const childrenType = children.type as any // The children is a component if (typeof childrenType === 'function') return childrenType(children.props) // The children is a component with `forwardRef` else if ('render' in childrenType) return childrenType.render(children.props) // It's a string, boolean, etc. else return children } function SlottableWithNestedChildren( { asChild, children }: { asChild?: boolean; children?: React.ReactNode }, render: (child: React.ReactNode) => JSX.Element, ) { if (asChild && React.isValidElement(children)) { return React.cloneElement(renderChildren(children), { ref: (children as any).ref }, render(children.props.children)) } return render(children) } const srOnlyStyles = { position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: '0', } as const ================================================ FILE: cmdk/tsup.config.ts ================================================ import { defineConfig } from 'tsup' export default defineConfig({ sourcemap: false, minify: true, dts: true, format: ['esm', 'cjs'], loader: { '.js': 'jsx', }, }) ================================================ FILE: package.json ================================================ { "name": "cmdk-root", "private": true, "scripts": { "build": "pnpm -F cmdk build", "dev": "pnpm -F cmdk build --watch", "website": "pnpm -F cmdk-website dev", "testsite": "pnpm -F cmdk-tests dev", "format": "prettier '**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}' --write", "preinstall": "npx only-allow pnpm", "test:format": "prettier '**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}' --check", "test": "playwright test" }, "devDependencies": { "@playwright/test": "1.51.0", "husky": "^8.0.1", "lint-staged": "15.2.0", "prettier": "2.7.1", "tsup": "8.0.1", "typescript": "4.6.4" }, "packageManager": "pnpm@8.8.0", "lint-staged": { "**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}": [ "prettier --write" ] } } ================================================ FILE: playwright.config.ts ================================================ import { PlaywrightTestConfig, devices } from '@playwright/test' const config: PlaywrightTestConfig = { forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['github'], ['json', { outputFile: 'playwright-report.json' }]] : 'list', testDir: './test', use: { trace: 'on-first-retry', baseURL: 'http://localhost:3000', }, timeout: 5000, webServer: { command: 'npm run dev', url: 'http://localhost:3000', cwd: './test', reuseExistingServer: !process.env.CI, }, projects: [ { name: 'webkit', use: { ...devices['Desktop Safari'], headless: true }, }, ], } export default config ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - 'website' - 'test' - 'cmdk' ================================================ FILE: test/basic.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('basic behavior', async () => { test.beforeEach(async ({ page }) => { await page.goto('/') }) test('input props are forwarded', async ({ page }) => { const input = page.locator(`input[placeholder="Search…"]`) await expect(input).toHaveCount(1) }) test('item value is derived from textContent', async ({ page }) => { const item = page.locator(`[cmdk-item][data-value="Item"]`) await expect(item).toHaveText('Item') }) test('item value prop is preferred over textContent', async ({ page }) => { const item = page.locator(`[cmdk-item][data-value="xxx"]`) await expect(item).toHaveText('Value') }) test('item onSelect is called on click', async ({ page }) => { const item = page.locator(`[cmdk-item][data-value="Item"]`) await item.click() expect(await page.evaluate(() => (window as any).onSelect)).toEqual('Item selected') }) test('first item is selected by default', async ({ page }) => { const item = page.locator(`[cmdk-item][aria-selected="true"]`) await expect(item).toHaveText('Item') }) test('first item is selected when search changes', async ({ page }) => { const input = page.locator(`[cmdk-input]`) await input.type('x') const selected = page.locator(`[cmdk-item][aria-selected="true"]`) await expect(selected).toHaveText('Value') }) test('items filter when searching', async ({ page }) => { const input = page.locator(`[cmdk-input]`) await input.type('x') const removed = page.locator(`[cmdk-item][data-value="Item"]`) const remains = page.locator(`[cmdk-item][data-value="xxx"]`) await expect(removed).toHaveCount(0) await expect(remains).toHaveCount(1) }) test('items filter when searching by keywords', async ({ page }) => { const input = page.locator(`[cmdk-input]`) await input.type('key') const removed = page.locator(`[cmdk-item][data-value="xxx"]`) const remains = page.locator(`[cmdk-item][data-value="Item"]`) await expect(removed).toHaveCount(0) await expect(remains).toHaveCount(1) }) test('empty component renders when there are no results', async ({ page }) => { const input = page.locator('[cmdk-input]') await input.type('z') await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) await expect(page.locator(`[cmdk-empty]`)).toHaveText('No results.') }) test('className is applied to each part', async ({ page }) => { await expect(page.locator(`.root`)).toHaveCount(1) await expect(page.locator(`.input`)).toHaveCount(1) await expect(page.locator(`.list`)).toHaveCount(1) await expect(page.locator(`.item`)).toHaveCount(2) await page.locator('[cmdk-input]').type('zzzz') await expect(page.locator(`.item`)).toHaveCount(0) await expect(page.locator(`.empty`)).toHaveCount(1) }) }) ================================================ FILE: test/dialog.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('dialog', async () => { test.beforeEach(async ({ page }) => { await page.goto('/dialog') }) test('dialog renders in portal', async ({ page }) => { await expect(page.locator(`[cmdk-dialog]`)).toHaveCount(1) await expect(page.locator(`[cmdk-overlay]`)).toHaveCount(1) }) }) ================================================ FILE: test/group.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('group', async () => { test.beforeEach(async ({ page }) => { await page.goto('/group') }) test('groups are shown/hidden based on item matches', async ({ page }) => { await page.locator(`[cmdk-input]`).type('z') await expect(page.locator(`[cmdk-group][data-value="Animals"]`)).not.toBeVisible() await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).toBeVisible() }) test('group can be progressively rendered', async ({ page }) => { await expect(page.locator(`[cmdk-group][data-value="Numbers"]`)).not.toBeVisible() await page.locator(`[cmdk-input]`).type('t') await expect(page.locator(`[cmdk-group][data-value="Animals"]`)).not.toBeVisible() await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).not.toBeVisible() await expect(page.locator(`[cmdk-group][data-value="Numbers"]`)).toBeVisible() }) test('mounted group still rendered with filter using forceMount', async ({ page }) => { await page.locator(`data-testid=forceMount`).click() await page.locator(`[cmdk-input]`).type('Giraffe') await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).toBeVisible() }) }) ================================================ FILE: test/item.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('item', async () => { test.beforeEach(async ({ page }) => { await page.goto('/item') }) test('mounted item matches search', async ({ page }) => { await page.locator(`[cmdk-input]`).type('b') await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) await page.locator(`data-testid=mount`).click() await expect(page.locator(`[cmdk-item]`)).toHaveText('B') }) test('mounted item does not match search', async ({ page }) => { await page.locator(`[cmdk-input]`).type('z') await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) await page.locator(`data-testid=mount`).click() await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) }) test('unmount item that is selected', async ({ page }) => { await page.locator(`data-testid=mount`).click() await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('A') await page.locator(`data-testid=unmount`).click() await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') }) test('unmount item that is the only result', async ({ page }) => { await page.locator(`data-testid=unmount`).click() await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) }) test('mount item that is the only result', async ({ page }) => { await page.locator(`data-testid=unmount`).click() await expect(page.locator(`[cmdk-empty]`)).toHaveCount(1) await page.locator(`data-testid=mount`).click() await expect(page.locator(`[cmdk-empty]`)).toHaveCount(0) await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) }) test('selected does not change when mounting new items', async ({ page }) => { await page.locator(`data-testid=mount`).click() await page.locator(`[cmdk-item][data-value="B"]`).click() await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') await page.locator(`data-testid=many`).click() await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') }) test('mounted item still rendered with filter usingForceMount', async ({ page }) => { await page.locator(`data-testid=forceMount`).click() await page.locator(`[cmdk-input]`).type('z') await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) }) }) test.describe('item advanced', async () => { test.beforeEach(async ({ page }) => { await page.goto('/item-advanced') }) test('re-rendering re-matches implicit textContent value', async ({ page }) => { await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) await page.locator(`[cmdk-input]`).type('2') const button = page.locator(`data-testid=increment`) await button.click() await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) await button.click() await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) }) }) ================================================ FILE: test/keybind.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('arrow keybinds', async () => { test.beforeEach(async ({ page }) => { await page.goto('/keybinds') }) test('arrow up/down changes selected item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('ArrowDown') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('ArrowUp') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('meta arrow up/down goes to first and last item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Meta+ArrowDown') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') await page.locator(`[cmdk-input]`).press('Meta+ArrowUp') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('alt arrow up/down goes to next and prev item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Alt+ArrowDown') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+ArrowDown') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') await page.locator(`[cmdk-input]`).press('Alt+ArrowUp') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+ArrowUp') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) }) test.describe('vim jk keybinds', async () => { test.beforeEach(async ({ page }) => { await page.goto('/keybinds') }) test('ctrl j/k changes selected item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+j') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Control+k') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('meta ctrl j/k goes to first and last item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Meta+Control+j') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') await page.locator(`[cmdk-input]`).press('Meta+Control+k') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('alt ctrl j/k goes to next and prev item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Alt+Control+j') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+Control+j') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') await page.locator(`[cmdk-input]`).press('Alt+Control+k') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+Control+k') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) }) test.describe('vim np keybinds', async () => { test.beforeEach(async ({ page }) => { await page.goto('/keybinds') }) test('ctrl n/p changes selected item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+n') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Control+p') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('meta ctrl n/p goes to first and last item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Meta+Control+n') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') await page.locator(`[cmdk-input]`).press('Meta+Control+p') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('alt ctrl n/p goes to next and prev item', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Alt+Control+n') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+Control+n') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') await page.locator(`[cmdk-input]`).press('Alt+Control+p') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') await page.locator(`[cmdk-input]`).press('Alt+Control+p') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) }) test.describe('no-vim keybinds', async () => { test.beforeEach(async ({ page }) => { await page.goto('/keybinds?noVim=true') }) test('ctrl j/k does nothing', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+j') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+k') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) test('ctrl n/p does nothing', async ({ page }) => { await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+n') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') await page.locator(`[cmdk-input]`).press('Control+p') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') }) }) ================================================ FILE: test/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: test/numeric.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('behavior for numeric values', async () => { test.beforeEach(async ({ page }) => { await page.goto('/numeric') }) test('items filter correctly on numeric inputs', async ({ page }) => { const input = page.locator(`[cmdk-input]`) await input.type('112') const removed = page.locator(`[cmdk-item][data-value="removed"]`) const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) await expect(removed).toHaveCount(0) await expect(remains).toHaveCount(1) }) test('items filter correctly on non-numeric inputs', async ({ page }) => { const input = page.locator(`[cmdk-input]`) await input.type('bar') const removed = page.locator(`[cmdk-item][data-value="removed"]`) const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) await expect(removed).toHaveCount(0) await expect(remains).toHaveCount(1) }) }) ================================================ FILE: test/package.json ================================================ { "name": "cmdk-tests", "version": "0.0.0", "scripts": { "dev": "next" }, "dependencies": { "@radix-ui/react-portal": "^1.0.4", "@types/node": "18.0.4", "@types/react": "18.0.15", "@types/react-dom": "18.0.6", "cmdk": "workspace:*", "next": "13.5.1", "react": "18.2.0", "react-dom": "18.2.0", "typescript": "4.7.4" } } ================================================ FILE: test/pages/_app.tsx ================================================ import '../style.css' export default function App({ Component, pageProps }) { return } ================================================ FILE: test/pages/dialog.tsx ================================================ import { Command } from 'cmdk' import * as React from 'react' const Page = () => { const [open, setOpen] = React.useState(false) React.useEffect(() => { setOpen(true) }, []) return (
No results. console.log('Item selected')}>Item Value
) } export default Page ================================================ FILE: test/pages/group.tsx ================================================ import { Command } from 'cmdk' import * as React from 'react' const Page = () => { const [search, setSearch] = React.useState('') const [forceMount, setForceMount] = React.useState(false) return (
No results. Giraffe Chicken A B Z {!!search && ( One Two Three )}
) } export default Page ================================================ FILE: test/pages/huge.tsx ================================================ import { Command } from 'cmdk' import * as React from 'react' const items = new Array(1000).fill(0) const Page = () => { return (
{ console.log({ phase, actualDuration, baseDuration }) }} > {items.map((_, i) => { return })}
) } const Item = () => { const id = React.useId() return Item {id} } export default Page ================================================ FILE: test/pages/index.tsx ================================================ import { Command } from 'cmdk' const Page = () => { return (
No results. { ;(window as any).onSelect = 'Item selected' }} className="item" > Item Value
) } export default Page ================================================ FILE: test/pages/item-advanced.tsx ================================================ import { Command } from 'cmdk' import * as React from 'react' const Page = () => { const [count, setCount] = React.useState(0) return (
No results. Item A {count} Item B {count}
) } export default Page ================================================ FILE: test/pages/item.tsx ================================================ import { Command } from 'cmdk' import * as React from 'react' const Page = () => { const [unmount, setUnmount] = React.useState(false) const [mount, setMount] = React.useState(false) const [many, setMany] = React.useState(false) const [forceMount, setForceMount] = React.useState(false) return (
No results. {!unmount && A} {many && ( <> 1 2 3 )} {mount && B}
) } export default Page ================================================ FILE: test/pages/keybinds.tsx ================================================ import { Command } from 'cmdk' import { useRouter } from 'next/router' import * as React from 'react' const Page = () => { const { query: { noVim }, } = useRouter() return (
No results. Disabled First A B Z Apple Banana Orange Dragon Fruit Pear Last Disabled 3
) } export default Page ================================================ FILE: test/pages/numeric.tsx ================================================ import { Command } from 'cmdk' const Page = () => { return (
No results. To be removed Not to be removed
) } export default Page ================================================ FILE: test/pages/portal.tsx ================================================ import * as React from 'react' import { Command } from 'cmdk' import * as Portal from '@radix-ui/react-portal' const Page = () => { const [render, setRender] = React.useState(false) const [search, setSearch] = React.useState('') const [open, setOpen] = React.useState(true) React.useEffect(() => setRender(true), []) if (!render) return null return (
{open && ( Apple Banana Cherry Dragonfruit Elderberry Fig Grape Honeydew Jackfruit Kiwi Lemon Mango Nectarine Orange Papaya Quince Raspberry Strawberry Tangerine Ugli Watermelon Xigua Yuzu Zucchini )}
) } export default Page ================================================ FILE: test/pages/props.tsx ================================================ import { Command } from 'cmdk' import { useRouter } from 'next/router' import * as React from 'react' const Page = () => { const [value, setValue] = React.useState('ant') const [search, setSearch] = React.useState('') const [shouldFilter, setShouldFilter] = React.useState(true) const [customFilter, setCustomFilter] = React.useState(false) const router = useRouter() React.useEffect(() => { if (router.isReady) { setShouldFilter(router.query.shouldFilter === 'false' ? false : true) setCustomFilter(router.query.customFilter === 'true' ? true : false) setValue((router.query.initialValue as string) ?? 'ant') } }, [router.isReady]) return (
{value}
{search}
{ console.log(item, search) if (!search || !item) return 1 return item.endsWith(search) ? 1 : 0 } : undefined } > ant anteater
) } export default Page ================================================ FILE: test/props.test.ts ================================================ import { expect, test } from '@playwright/test' test.describe('props', async () => { test('results do not change when filtering is disabled', async ({ page }) => { await page.goto('/props?shouldFilter=false') await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) await page.locator(`[cmdk-input]`).type('z') await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) }) test('results match against custom filter', async ({ page }) => { await page.goto('/props?customFilter=true') await page.locator(`[cmdk-input]`).type(`ant`) await expect(page.locator(`[cmdk-item]`)).toHaveAttribute('data-value', 'ant') }) test('controlled value', async ({ page }) => { await page.goto('/props') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'ant') await page.locator(`data-testid=controlledValue`).click() await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') }) test('keep controlled value if empty results', async ({ page }) => { await page.goto('/props') await expect(page.locator(`[data-testid=value]`)).toHaveText('ant') await page.locator(`[cmdk-input]`).fill('d') await expect(page.locator(`[data-testid=value]`)).toHaveText('') await page.locator(`[cmdk-input]`).fill('ant') await expect(page.locator(`[data-testid=value]`)).toHaveText('ant') }) test('controlled search', async ({ page }) => { await page.goto('/props') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'ant') await page.locator(`data-testid=controlledSearch`).click() await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') }) test('keep focus on the provided initial value', async ({ page }) => { await page.goto('/props?initialValue=anteater') await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') }) }) ================================================ FILE: test/style.css ================================================ [cmdk-item][aria-selected='true'] { color: red; } ================================================ FILE: test/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../dialog.test.ts"], "exclude": ["node_modules"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2018", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react", "noEmit": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], "exclude": ["node_modules", "build", "dist", ".next"] } ================================================ FILE: website/.eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: website/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo ================================================ FILE: website/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash pnpm run dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: website/components/cmdk/framer.tsx ================================================ import { Command } from 'cmdk' import React from 'react' export function FramerCMDK() { const [value, setValue] = React.useState('Button') return (
setValue(v)}>

{value === 'Button' &&
) } function Button() { return } function Input() { return } function Badge() { return
Badge
} function Radio() { return ( ) } function Slider() { return (
) } function Avatar() { return Avatar of Rauno } function Container() { return
} function Item({ children, value, subtitle }: { children: React.ReactNode; value: string; subtitle: string }) { return ( {}}>
{children}
{value} {subtitle}
) } function ButtonIcon() { return ( ) } function InputIcon() { return ( ) } function RadioIcon() { return ( ) } function BadgeIcon() { return ( ) } function ToggleIcon() { return ( ) } function AvatarIcon() { return ( ) } function ContainerIcon() { return ( ) } function SearchIcon() { return ( ) } function SliderIcon() { return ( ) } ================================================ FILE: website/components/cmdk/linear.tsx ================================================ import { Command } from 'cmdk' export function LinearCMDK() { return (
Issue - FUN-343
No results found. {items.map(({ icon, label, shortcut }) => { return ( {icon} {label}
{shortcut.map((key) => { return {key} })}
) })}
) } const items = [ { icon: , label: 'Assign to...', shortcut: ['A'], }, { icon: , label: 'Assign to me', shortcut: ['I'], }, { icon: , label: 'Change status...', shortcut: ['S'], }, { icon: , label: 'Change priority...', shortcut: ['P'], }, { icon: , label: 'Change labels...', shortcut: ['L'], }, { icon: , label: 'Remove label...', shortcut: ['⇧', 'L'], }, { icon: , label: 'Set due date...', shortcut: ['⇧', 'D'], }, ] function AssignToIcon() { return ( ) } function AssignToMeIcon() { return ( ) } function ChangeStatusIcon() { return ( ) } function ChangePriorityIcon() { return ( ) } function ChangeLabelsIcon() { return ( ) } function RemoveLabelIcon() { return ( ) } function SetDueDateIcon() { return ( ) } ================================================ FILE: website/components/cmdk/raycast.tsx ================================================ import React from 'react' import { useTheme } from 'next-themes' import * as Popover from '@radix-ui/react-popover' import { Command } from 'cmdk' import { Logo, LinearIcon, FigmaIcon, SlackIcon, YouTubeIcon, RaycastIcon } from 'components' export function RaycastCMDK() { const { resolvedTheme: theme } = useTheme() const [value, setValue] = React.useState('linear') const inputRef = React.useRef(null) const listRef = React.useRef(null) React.useEffect(() => { inputRef?.current?.focus() }, []) return (
setValue(v)}>

No results found. Linear Figma Slack YouTube Raycast Clipboard History Import Extension Manage Extensions
{theme === 'dark' ? : }
) } function Item({ children, value, keywords, isCommand = false, }: { children: React.ReactNode value: string keywords?: string[] isCommand?: boolean }) { return ( {}}> {children} {isCommand ? 'Command' : 'Application'} ) } function SubCommand({ inputRef, listRef, selectedValue, }: { inputRef: React.RefObject listRef: React.RefObject selectedValue: string }) { const [open, setOpen] = React.useState(false) React.useEffect(() => { function listener(e: KeyboardEvent) { if (e.key === 'k' && e.metaKey) { e.preventDefault() setOpen((o) => !o) } } document.addEventListener('keydown', listener) return () => { document.removeEventListener('keydown', listener) } }, []) React.useEffect(() => { const el = listRef.current if (!el) return if (open) { el.style.overflow = 'hidden' } else { el.style.overflow = '' } }, [open, listRef]) return ( setOpen(true)} aria-expanded={open}> Actions K { e.preventDefault() inputRef?.current?.focus() }} > Open Application Show in Finder Show Info in Finder Add to Favorites ) } function SubItem({ children, shortcut }: { children: React.ReactNode; shortcut: string }) { return ( {children}
{shortcut.split(' ').map((key) => { return {key} })}
) } function TerminalIcon() { return ( ) } function RaycastLightIcon() { return ( ) } function RaycastDarkIcon() { return ( ) } function WindowIcon() { return ( ) } function FinderIcon() { return ( ) } function StarIcon() { return ( ) } function ClipboardIcon() { return (
) } function HammerIcon() { return (
) } ================================================ FILE: website/components/cmdk/vercel.tsx ================================================ import React from 'react' import { Command } from 'cmdk' export function VercelCMDK() { const ref = React.useRef(null) const [inputValue, setInputValue] = React.useState('') const [pages, setPages] = React.useState(['home']) const activePage = pages[pages.length - 1] const isHome = activePage === 'home' const popPage = React.useCallback(() => { setPages((pages) => { const x = [...pages] x.splice(-1, 1) return x }) }, []) const onKeyDown = React.useCallback( (e: KeyboardEvent) => { if (isHome || inputValue.length) { return } if (e.key === 'Backspace') { e.preventDefault() popPage() } }, [inputValue.length, isHome, popPage], ) function bounce() { if (ref.current) { ref.current.style.transform = 'scale(0.96)' setTimeout(() => { if (ref.current) { ref.current.style.transform = '' } }, 100) setInputValue('') } } return (
{ if (e.key === 'Enter') { bounce() } if (isHome || inputValue.length) { return } if (e.key === 'Backspace') { e.preventDefault() popPage() bounce() } }} >
{pages.map((p) => (
{p}
))}
{ setInputValue(value) }} /> No results found. {activePage === 'home' && setPages([...pages, 'projects'])} />} {activePage === 'projects' && }
) } function Home({ searchProjects }: { searchProjects: Function }) { return ( <> { searchProjects() }} > Search Projects... Create New Project... Search Teams... Create New Team... Search Docs... Send Feedback... Contact Support ) } function Projects() { return ( <> Project 1 Project 2 Project 3 Project 4 Project 5 Project 6 ) } function Item({ children, shortcut, onSelect = () => {}, }: { children: React.ReactNode shortcut?: string onSelect?: (value: string) => void }) { return ( {children} {shortcut && (
{shortcut.split(' ').map((key) => { return {key} })}
)}
) } function ProjectsIcon() { return ( ) } function PlusIcon() { return ( ) } function TeamsIcon() { return ( ) } function CopyIcon() { return ( ) } function DocsIcon() { return ( ) } function FeedbackIcon() { return ( ) } function ContactIcon() { return ( ) } ================================================ FILE: website/components/code/code.module.scss ================================================ .root { border-radius: 12px; padding: 16px; backdrop-filter: blur(10px); border: 1px solid var(--gray6); position: relative; line-height: 16px; background: var(--lowContrast); white-space: pre-wrap; box-shadow: rgb(0 0 0 / 10%) 0px 5px 30px -5px; @media (prefers-color-scheme: dark) { background: var(--grayA2); } button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: var(--grayA3); border-radius: 8px; position: absolute; top: 12px; right: 12px; color: var(--gray11); cursor: copy; transition: color 150ms ease, background 150ms ease, transform 150ms ease; &:hover { color: var(--gray12); background: var(--grayA4); } &:active { color: var(--gray12); background: var(--grayA5); transform: scale(0.96); } } } .shine { @media (prefers-color-scheme: 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: 97%; z-index: -1; } } @media (max-width: 640px) { .root { :global(.token-line) { font-size: 11px !important; } } } ================================================ FILE: website/components/code/index.tsx ================================================ import React from 'react' import copy from 'copy-to-clipboard' import Highlight, { defaultProps } from 'prism-react-renderer' import styles from './code.module.scss' import { CopyIcon } from 'components/icons' const theme = { plain: { color: 'var(--gray12)', fontSize: 12, fontFamily: 'Menlo, monospace', }, styles: [ { types: ['comment'], style: { color: 'var(--gray9)', }, }, { types: ['atrule', 'keyword', 'attr-name', 'selector'], style: { color: 'var(--gray10)', }, }, { types: ['punctuation', 'operator'], style: { color: 'var(--gray9)', }, }, { types: ['class-name', 'function', 'tag'], style: { color: 'var(--gray12)', }, }, ], } export function Code({ children }: { children: string }) { return ( {({ className, style, tokens, getLineProps, getTokenProps }) => (
          
          
{tokens.map((line, i) => (
{line.map((token, key) => ( ))}
))}
)}
) } ================================================ FILE: website/components/icons/icons.module.scss ================================================ .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; } } } ================================================ FILE: website/components/icons/index.tsx ================================================ import styles from './icons.module.scss' export function FigmaIcon() { return ( ) } export function RaycastIcon() { return ( ) } export function YouTubeIcon() { return ( ) } export function SlackIcon() { return ( ) } export function VercelIcon() { return ( ) } export function LinearIcon({ style }: { style?: Object }) { return ( ) } export function Logo({ children, size = '20px' }: { children: React.ReactNode; size?: string }) { return (
{children}
{children}
) } export function CopyIcon() { return ( ) } export function CopiedIcon() { return ( ) } export function GitHubIcon() { return ( ) } export function FramerIcon() { return ( ) } ================================================ FILE: website/components/index.ts ================================================ export * from './cmdk/framer' export * from './cmdk/linear' export * from './cmdk/vercel' export * from './cmdk/raycast' export * from './icons' export * from './code' ================================================ FILE: website/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: website/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, swcMinify: true, } module.exports = nextConfig ================================================ FILE: website/package.json ================================================ { "name": "cmdk-website", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "pnpm -F 'cmdk-website^...' build && next build", "start": "next start", "lint": "eslint --ext .tsx" }, "dependencies": { "@radix-ui/react-popover": "^0.1.6", "cmdk": "workspace:*", "copy-to-clipboard": "^3.3.1", "framer-motion": "^6.5.1", "next": "13.5.1", "next-seo": "^5.5.0", "next-themes": "^0.2.0", "prism-react-renderer": "^1.3.5", "react": "18.2.0", "react-dom": "18.2.0", "sass": "^1.53.0" }, "devDependencies": { "@types/node": "18.0.4", "@types/react": "18.0.15", "@types/react-dom": "18.0.6", "babel-eslint": "^10.1.0", "eslint": "^8.19.0", "eslint-config-next": "12.2.2", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", "husky": "^8.0.1", "lint-staged": "^13.0.3", "typescript": "4.7.4" }, "lint-staged": { "*.{tsx},*.{ts},*.{mdx}": [ "eslint --fix" ] }, "husky": { "hooks": { "pre-commit": "lint-staged" } } } ================================================ FILE: website/pages/_app.tsx ================================================ import 'styles/globals.scss' import 'styles/cmdk/vercel.scss' import 'styles/cmdk/linear.scss' import 'styles/cmdk/raycast.scss' import 'styles/cmdk/framer.scss' import type { AppProps } from 'next/app' import { ThemeProvider } from 'next-themes' import { NextSeo } from 'next-seo' import Head from 'next/head' const title = '⌘K' const description = 'Fast, composable, unstyled command menu for React' const siteUrl = 'https://cmdk.paco.me' export default function App({ Component, pageProps }: AppProps) { return ( <> ) } ================================================ FILE: website/pages/_document.tsx ================================================ /* eslint-disable @next/next/no-sync-scripts */ import React from 'react' import NextDocument, { Html, Head, Main, NextScript } from 'next/document' export default class Document extends NextDocument { render() { return (
) } } ================================================ FILE: website/pages/index.tsx ================================================ import styles from 'styles/index.module.scss' import React from 'react' import { AnimatePresence, AnimateSharedLayout, motion, MotionProps, useInView } from 'framer-motion' import { FramerCMDK, LinearCMDK, LinearIcon, VercelCMDK, VercelIcon, RaycastCMDK, RaycastIcon, CopyIcon, FramerIcon, GitHubIcon, Code, CopiedIcon, } from 'components' import packageJSON from '../../cmdk/package.json' type TTheme = { theme: Themes setTheme: Function } type Themes = 'linear' | 'raycast' | 'vercel' | 'framer' const ThemeContext = React.createContext({} as TTheme) export default function Index() { const [theme, setTheme] = React.useState('raycast') return (

⌘K

Fast, composable, unstyled command menu for React.

{theme === 'framer' && ( )} {theme === 'vercel' && ( )} {theme === 'linear' && ( )} {theme === 'raycast' && ( )}
) } function CMDKWrapper(props: MotionProps & { children: React.ReactNode }) { return ( ) } ////////////////////////////////////////////////////////////////// function InstallButton() { const [copied, setCopied] = React.useState(false) return ( ) } function GitHubButton() { return ( pacocoursey/cmdk ) } ////////////////////////////////////////////////////////////////// const themes = [ { icon: , key: 'raycast', }, { icon: , key: 'linear', }, { icon: , key: 'vercel', }, { icon: , key: 'framer', }, ] function ThemeSwitcher() { const { theme, setTheme } = React.useContext(ThemeContext) const ref = React.useRef(null) const [showArrowKeyHint, setShowArrowKeyHint] = React.useState(false) React.useEffect(() => { function listener(e: KeyboardEvent) { const themeNames = themes.map((t) => t.key) if (e.key === 'ArrowRight') { const currentIndex = themeNames.indexOf(theme) const nextIndex = currentIndex + 1 const nextItem = themeNames[nextIndex] if (nextItem) { setTheme(nextItem) } } if (e.key === 'ArrowLeft') { const currentIndex = themeNames.indexOf(theme) const prevIndex = currentIndex - 1 const prevItem = themeNames[prevIndex] if (prevItem) { setTheme(prevItem) } } } document.addEventListener('keydown', listener) return () => { document.removeEventListener('keydown', listener) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [theme]) return (
{themes.map(({ key, icon }) => { const isActive = theme === key return ( ) })}
) } ////////////////////////////////////////////////////////////////// function Codeblock() { const code = `import { Command } from 'cmdk'; {loading && Hang on…} No results found. Apple Orange Pear Blueberry Fish ` return (
{code}
) } ////////////////////////////////////////////////////////////////// function VersionBadge() { return v{packageJSON.version} } function Footer() { const ref = React.useRef(null) const isInView = useInView(ref, { once: true, margin: '100px', }) return ( ) } function RaunoSignature() { return ( ) } function PacoSignature() { return ( ) } ================================================ FILE: website/public/robots.txt ================================================ User-agent: * Disallow: ================================================ FILE: website/styles/cmdk/framer.scss ================================================ .framer { [cmdk-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(--cmdk-shadow); outline: none; .dark & { background: var(--gray2); } } [cmdk-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); } } [cmdk-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); } } [cmdk-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='true'] { background: var(--blue9); color: #ffffff; [cmdk-framer-item-subtitle] { color: #ffffff; } } &[data-disabled='true'] { color: var(--gray8); cursor: not-allowed; } & + [cmdk-item] { margin-top: 4px; } svg { width: 16px; height: 16px; color: #ffffff; } } [cmdk-framer-icon-wrapper] { display: flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; background: orange; border-radius: 8px; } [cmdk-framer-item-meta] { display: flex; flex-direction: column; gap: 4px; } [cmdk-framer-item-subtitle] { font-size: 12px; font-weight: 400; color: var(--gray11); } [cmdk-framer-items] { min-height: 308px; display: flex; } [cmdk-framer-left] { width: 40%; } [cmdk-framer-separator] { width: 1px; border: 0; margin-right: 8px; background: var(--gray6); } [cmdk-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); } @media (prefers-color-scheme: dark) { background: var(--gray3); } } [cmdk-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); } [cmdk-framer-container] { width: 100px; height: 100px; background: var(--blue9); border-radius: 16px; } [cmdk-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; } [cmdk-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); } } } [cmdk-list] { overflow: auto; } [cmdk-separator] { height: 1px; width: 100%; background: var(--gray5); margin: 4px 0; } [cmdk-group-heading] { user-select: none; font-size: 12px; color: var(--gray11); padding: 0 8px; display: flex; align-items: center; margin-bottom: 8px; } [cmdk-empty] { font-size: 14px; padding: 32px; white-space: pre-wrap; color: var(--gray11); } } @media (max-width: 640px) { .framer { [cmdk-framer-icon-wrapper] { } [cmdk-framer-item-subtitle] { display: none; } } } ================================================ FILE: website/styles/cmdk/linear.scss ================================================ .linear { [cmdk-root] { max-width: 640px; width: 100%; background: #ffffff; border-radius: 8px; overflow: hidden; padding: 0; font-family: var(--font-sans); box-shadow: var(--cmdk-shadow); outline: none; .dark & { background: linear-gradient(136.61deg, rgb(39, 40, 43) 13.72%, rgb(45, 46, 49) 74.3%); } } [cmdk-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; } [cmdk-linear-shortcuts] { display: flex; margin-left: auto; gap: 8px; kbd { font-family: var(--font-sans); font-size: 13px; color: var(--gray11); } } [cmdk-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); } } [cmdk-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='true'] { background: var(--gray3); svg { color: var(--gray12); } &:after { content: ''; position: absolute; left: 0; z-index: 123; width: 3px; height: 100%; background: #5f6ad2; } } &[data-disabled='true'] { color: var(--gray8); cursor: not-allowed; } &:active { transition-property: background; background: var(--gray4); } & + [cmdk-item] { margin-top: 4px; } svg { width: 16px; height: 16px; color: var(--gray10); } } [cmdk-list] { height: min(300px, var(--cmdk-list-height)); max-height: 400px; overflow: auto; overscroll-behavior: contain; transition: 100ms ease; transition-property: height; } [cmdk-group-heading] { user-select: none; font-size: 12px; color: var(--gray11); padding: 0 8px; display: flex; align-items: center; } [cmdk-empty] { font-size: 14px; display: flex; align-items: center; justify-content: center; height: 64px; white-space: pre-wrap; color: var(--gray11); } } ================================================ FILE: website/styles/cmdk/raycast.scss ================================================ .raycast { [cmdk-root] { max-width: 640px; width: 100%; background: var(--gray1); border-radius: 12px; padding: 8px 0; font-family: var(--font-sans); box-shadow: var(--cmdk-shadow); border: 1px solid var(--gray6); position: relative; outline: none; .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; } } } [cmdk-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); } } [cmdk-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; } } [cmdk-raycast-loader] { --loader-color: var(--gray9); border: 0; 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; } } [cmdk-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; &[data-selected='true'] { background: var(--gray4); color: var(--gray12); } &[data-disabled='true'] { color: var(--gray8); cursor: not-allowed; } &:active { transition-property: background; background: var(--gray4); } &:first-child { margin-top: 8px; } & + [cmdk-item] { margin-top: 4px; } svg { width: 18px; height: 18px; } } [cmdk-raycast-meta] { margin-left: auto; color: var(--gray11); font-size: 13px; } [cmdk-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; } [cmdk-raycast-open-trigger], [cmdk-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; } [cmdk-raycast-clipboard-icon], [cmdk-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; } } [cmdk-raycast-clipboard-icon] { background: linear-gradient(to bottom, #f55354, #eb4646); } [cmdk-raycast-hammer-icon] { background: linear-gradient(to bottom, #6cb9a3, #2c6459); } [cmdk-raycast-open-trigger] { display: flex; align-items: center; color: var(--gray12); } [cmdk-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); } } } [cmdk-separator] { height: 1px; width: 100%; background: var(--gray5); margin: 4px 0; } *:not([hidden]) + [cmdk-group] { margin-top: 8px; } [cmdk-group-heading] { user-select: none; font-size: 12px; color: var(--gray11); padding: 0 8px; display: flex; align-items: center; } [cmdk-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; 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; } @media (prefers-color-scheme: dark) { background: var(--gray2); } } [cmdk-dialog] { z-index: var(--layer-portal); position: fixed; left: 50%; top: var(--page-top); transform: translateX(-50%); [cmdk] { width: 640px; transform-origin: center center; animation: dialogIn var(--transition-fast) forwards; } &[data-state='closed'] [cmdk] { animation: dialogOut var(--transition-fast) forwards; } } [cmdk-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 { [cmdk-root] { display: flex; flex-direction: column; width: 320px; border: 1px solid var(--gray6); background: var(--gray2); border-radius: 8px; } [cmdk-list] { padding: 8px; overflow: auto; overscroll-behavior: contain; transition: 100ms ease; transition-property: height; } [cmdk-item] { 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; &[aria-selected='true'] { background: var(--gray5); color: var(--gray12); [cmdk-raycast-submenu-shortcuts] kbd { background: var(--gray7); } } &[aria-disabled='true'] { color: var(--gray8); cursor: not-allowed; } svg { width: 16px; height: 16px; } [cmdk-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; } } } } [cmdk-group-heading] { text-transform: capitalize; font-size: 12px; color: var(--gray11); font-weight: 500; margin-bottom: 8px; margin-top: 8px; margin-left: 4px; } [cmdk-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; } [cmdk-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 { [cmdk-input] { font-size: 16px; } } } ================================================ FILE: website/styles/cmdk/vercel.scss ================================================ .vercel { [cmdk-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(--cmdk-shadow); transition: transform 100ms ease; outline: none; .dark & { background: rgba(22, 22, 22, 0.7); } } [cmdk-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); } } [cmdk-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; } [cmdk-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='true'] { background: var(--grayA3); color: var(--gray12); } &[data-disabled='true'] { color: var(--gray8); cursor: not-allowed; } &:active { transition-property: background; background: var(--gray4); } & + [cmdk-item] { margin-top: 4px; } svg { width: 18px; height: 18px; } } [cmdk-list] { height: min(330px, calc(var(--cmdk-list-height))); max-height: 400px; overflow: auto; overscroll-behavior: contain; transition: 100ms ease; transition-property: height; } [cmdk-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; } } [cmdk-separator] { height: 1px; width: 100%; background: var(--gray5); margin: 4px 0; } *:not([hidden]) + [cmdk-group] { margin-top: 8px; } [cmdk-group-heading] { user-select: none; font-size: 12px; color: var(--gray11); padding: 0 8px; display: flex; align-items: center; margin-bottom: 8px; } [cmdk-empty] { font-size: 14px; display: flex; align-items: center; justify-content: center; height: 48px; white-space: pre-wrap; color: var(--gray11); } } ================================================ FILE: website/styles/globals.scss ================================================ @font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; // Range of weights supported 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: hotpink; 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); --cmdk-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%); } ================================================ FILE: website/styles/index.module.scss ================================================ .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'); z-index: -1; position: absolute; width: 100%; height: 100%; top: 0; opacity: 0.2; filter: invert(1); @media (prefers-color-scheme: dark) { 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) { 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: 177px; 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='true'] { 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='true'] { 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; align-items: center; gap: 4px; width: fit-content; margin: 32px auto; bottom: 16px; color: var(--gray11); font-size: 13px; z-index: 3; 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) { 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; opacity: 0; } .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; } } } ================================================ FILE: website/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "baseUrl": "." }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } ================================================ FILE: website/vercel.json ================================================ { "headers": [ { "source": "/inter-var-latin.woff2", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ] }