diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 86c6f68..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["@wojtekolek/eslint-config"], - settings: { - "import/resolver": { - typescript: { - project: ["apps/*/tsconfig.json", "packages/*/tsconfig.json"], - }, - node: { - project: ["apps/*/tsconfig.json", "packages/*/tsconfig.json"], - }, - }, - }, -}; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cba70fe --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @wojtekolek diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..58f4cdb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + + + +## Changes + +- + +## Test plan + +- [ ] Tests pass (`pnpm test`) +- [ ] Lint passes (`pnpm lint`) +- [ ] Build succeeds (`pnpm build`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..820222b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2eb1d00..fbda179 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,7 @@ name: Release on: push: - branches: - - master + branches: [master] concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -12,21 +11,16 @@ jobs: name: Release runs-on: ubuntu-latest steps: - - name: Checkout Repo - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 16.x - uses: actions/setup-node@v2 - with: - node-version: 16.x + - uses: pnpm/action-setup@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + - uses: actions/setup-node@v4 with: - version: 8.2.0 + node-version-file: package.json + cache: pnpm - - name: Install Dependencies - run: pnpm install + - run: pnpm install --frozen-lockfile - name: Create Release Pull Request or Publish to npm id: changesets diff --git a/.gitignore b/.gitignore index 849425f..06d9eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,15 +2,13 @@ # dependencies node_modules -.pnp -.pnp.js +dist # testing coverage -# next.js -.next/ -out/ +# astro +.astro build # misc diff --git a/.prettierrc.cjs b/.prettierrc.cjs deleted file mode 100644 index 3114a6f..0000000 --- a/.prettierrc.cjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import("prettier").Config} */ -module.exports = { - ...require("@wojtekolek/eslint-config/prettier.config"), - plugins: [ - ...require("@wojtekolek/eslint-config/prettier.config").plugins, - require("prettier-plugin-tailwindcss"), - ], -}; diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..583549a --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,30 @@ +{ + "format_on_save": "on", + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true, + }, + "languages": { + "JavaScript": { "formatter": { "language_server": { "name": "biome" } } }, + "TypeScript": { "formatter": { "language_server": { "name": "biome" } } }, + "TSX": { "formatter": { "language_server": { "name": "biome" } } }, + "JSON": { "formatter": { "language_server": { "name": "biome" } } }, + "JSONC": { "formatter": { "language_server": { "name": "biome" } } }, + "Astro": { "formatter": { "language_server": { "name": "biome" } } }, + "CSS": { + "formatter": { "language_server": { "name": "biome" } }, + "language_servers": [ + "tailwindcss-intellisense-css", + "!vscode-css-language-server", + "..." + ] + }, + }, + "lsp": { + "tailwindcss-language-server": { + "settings": { + "classFunctions": ["cva", "cn"], + } + } + } +} diff --git a/README.md b/README.md index d41c37b..7eebfae 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,265 @@ # CommandMenu -This package offers a hook for creating a customized command menu. -It returns an object that includes properties for every element in the menu, such as the menu itself, the search input, and a list of all the necessary properties for each menu item which enables you to easily build a command menu tailored to your specific needs. +A headless React hook for building command menus. It handles search, keyboard navigation, shortcuts, and selection — you bring your own UI. Demo: [commandmenu.wojtekolek.com](https://commandmenu.wojtekolek.com/) -### Installation +## Installation ```bash -# npm npm i commandmenu - -# yarn +# or yarn add commandmenu - -# pnpm +# or pnpm add commandmenu ``` -### Get started +## Quick start -In order to fully utilize the functionality of this package, you must pass a configuration array that includes all the items you wish to display in the menu. A basic example of this configuration array might look something like the following: +Define your config and pass it to the hook. Spread the returned props onto your elements. -```typescript -// config.ts -import type { ConfigData } from "commandmenu"; -import type { IconName } from "components/Icon"; +```tsx +import { type Config, useCommandMenu } from "commandmenu"; -const config: ConfigData = [ +const config = [ { - id: 'github', - label: 'Github', - icon: 'Github', - description: 'Check github', - onSelect: () => console.log('github selected') + id: "docs", + label: "Documentation", + description: "Read the docs", + onSelect: () => console.log("docs"), }, { - id: 'spotifyPlay', - label: 'Spotify play', - icon: 'Play', - description: 'Play songs on Spotify', - onSelect: () => console.log('spotify play selected') + id: "search", + label: "Search", + shortcut: "F", + onSelect: () => console.log("search"), }, - { - id: 'spotifyNext', - label: 'Spotify next', - icon: 'Next', - description: 'Next song on Spotify', - onSelect: () => console.log('spotify next selected') - }, -] -``` +] as const satisfies Config[]; -```typescript -// CommandMenu.tsx -import { useCommandMenu } from "commandmenu"; -import { config } from "./config"; - -const { selectedItem, selectedItemRef, menuProps, searchProps, list } = - useCommandMenu({ config }) +const CommandMenu = () => { + const { menuProps, searchProps, list, selection } = useCommandMenu({ config }); + + return ( +
+ + +
+ ); +} ``` -Utilizing the props data returned by the hook is a straightforward process. Simply spread the `menuProps` and `searchProps`, then map through the list in order to render all the necessary menu items. +## Grouping -```typescript -// CommandMenu.tsx -return ( - - - - {list.map(({ id, label, icon, description }) => { - const isSelected = id === selectedItem - return ( - - {icon && } - {label} - {description && ( - - {description} - - )} - - ) - })} - - -) +Pass a `groups` array to organize items into sections. Each group references item IDs from your config. + +```tsx +import { type Config, type Group, isGroupList, useCommandMenu } from "commandmenu"; + +const config = [ + { id: "home", label: "Home", onSelect: () => {} }, + { id: "about", label: "About", onSelect: () => {} }, + { id: "new-file", label: "New File", shortcut: "N", onSelect: () => {} }, + { id: "settings", label: "Settings", onSelect: () => {} }, +] as const satisfies Config[]; + +type MyConfig = typeof config; + +const groups: Group[] = [ + { id: "nav", label: "Navigation", items: ["home", "about"] }, + { id: "actions", label: "Actions", items: ["new-file", "settings"] }, +]; + +const CommandMenu = () => { + const { menuProps, searchProps, list, selection } = useCommandMenu({ + config, + groups, + }); + + return ( +
+ +
    + {isGroupList(list) && + list.map((group) => ( +
  • +
    {group.label}
    +
      + {group.items.map((item) => { + const isSelected = item.id === selection.id; + return ( +
    • + {item.label} +
    • + ); + })} +
    +
  • + ))} +
+
+ ); +} ``` -#### Grouping +## Nested menus -If you wish to group items in your menu, it's easy to do so by wrapping them in a group object configuration. Once you've done this, you're ready to go! +The hook doesn't impose a nesting model — you control it by swapping the `config` (and optionally `groups`) when an item is selected. Use Backspace on an empty search to go back. -```typescript -// config.ts -{ - id: 'favs', - label: 'Favorites', - groupItems: [ - { - id: 'github', - label: 'Github', - icon: 'Github', - description: 'Check our Github', - onSelect: () => console.log('open Github') - }, - { - id: 'twitter', - label: 'Twitter', - icon: 'Twitter', - description: 'Check our Twitter', - onSelect: () => console.log('open Twitter') - }, - { - id: 'instagram', - label: 'Instagram', - icon: 'Instagram', - description: 'Check our Instagram', - onSelect: () => console.log('open Instagram') +```tsx +import { type Config, useCommandMenu } from "commandmenu"; +import { useCallback, useMemo, useState } from "react"; + +type MenuLevel = { label: string; config: Config[] }; + +const CommandMenu = () => { + const [menuStack, setMenuStack] = useState([]); + + const openSubmenu = useCallback((level: MenuLevel) => { + setMenuStack((s) => [...s, level]); + }, []); + + const goBack = useCallback(() => { + setMenuStack((s) => s.slice(0, -1)); + }, []); + + const rootConfig = useMemo( + (): Config[] => [ + { id: "home", label: "Home", onSelect: () => console.log("home") }, + { + id: "settings", + label: "Settings", + onSelect: () => + openSubmenu({ + label: "Settings", + config: [ + { id: "theme", label: "Theme", onSelect: () => console.log("theme") }, + { id: "language", label: "Language", onSelect: () => console.log("language") }, + ], + }), + }, + ], + [openSubmenu], + ); + + const currentLevel = menuStack[menuStack.length - 1]; + const activeConfig = currentLevel?.config ?? rootConfig; + + const { menuProps, searchProps, list, selection } = useCommandMenu({ + config: activeConfig, + onKeyDown: (e) => { + if (e.key === "Backspace" && (e.target as HTMLInputElement).value === "" && menuStack.length > 0) { + e.preventDefault(); + goBack(); + } }, - ] -}, + }); + + return ( +
+ +
    + {list.map((item) => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +} ``` -Once you've updated the command menu configuration, the final step is to simply render the group elements inside another list. This can be accomplished using code similar to the following: +## API -```typescript -// CommandMenu.tsx -return ( - - - - {list.map((item) => { - if (isGroupItem(item)) { - return ( - - - {item.label} - - - {item.groupItems.map((groupItem) => ( - - ))} - - - ) - } - return ( - - ) - })} - - -) -``` +### `useCommandMenu(args)` + +#### Arguments + +| Prop | Type | Description | +|------|------|-------------| +| `config` | `Config[]` | Menu items (required) | +| `groups` | `Group[]` | Optional grouping of items by ID | +| `asyncResultsGroup` | `AsyncResultsGroup` | Optional async-loaded items | +| `onKeyDown` | `KeyboardEventHandler` | Custom keydown handler | +| `onKeyUp` | `KeyboardEventHandler` | Custom keyup handler | +| `onSearchChange` | `(query: string) => void` | Called when search query changes | + +#### Return value + +| Prop | Type | Description | +|------|------|-------------| +| `list` | `PreparedItem[] \| PreparedGroup[]` | Items to render (flat or grouped) | +| `selection` | `Selection` | Current selection (`id` and `ref`) | +| `menuProps` | `{ onKeyDown, onKeyUp }` | Spread on the menu container | +| `searchProps` | `{ value, onChange }` | Spread on the search input | +| `searchQuery` | `string` | Current search query | +| `isAsyncLoading` | `boolean` | Whether async results are loading | + +### `isGroupList(list)` -#### Nested menus +Type guard that returns `true` when the list contains `PreparedGroup[]` (i.e., groups were provided). -If you'd like to include multiple options related to a specific item, you can utilize nested menus. You can even add nested menus to each level, as needed. Once you've updated the configuration accordingly, this feature should work seamlessly, allowing you to take your menu functionality to the next level! +### Types ```typescript -// config.ts -{ - id: 'spotify', - label: 'Spotify', - icon: 'Music', - description: 'Control Spotify', - items: [ - { - id: 'spotifyPlay', - label: 'Play', - icon: 'Play', - onSelect: () => console.log('spotify play selected') - }, - { - id: 'spotifyPause', - label: 'Pause', - icon: 'Pause', - onSelect: () => console.log('spotify pasue selected') - }, - { - id: 'spotifyNext', - label: 'Next', - icon: 'ArrowRight', - onSelect: () => console.log('spotify next selected') - }, - { - id: 'spotifyPrevious', - label: 'Previous', - icon: 'ArrowLeft', - onSelect: () => console.log('spotify prev selected') - }, - ] -}, -``` \ No newline at end of file +type Config = { + id: string; + label: string; + icon?: ElementType; + shortcut?: string; + description?: string; + disabled?: boolean; + onSelect: () => void; +}; + +type Group = { + id: string; + label: string; + items: T[number]["id"][]; +}; + +type Selection = { + id: string | undefined; + ref: RefObject; +}; + +type PreparedItem = { + id: string; + label: string; + icon?: ElementType; + shortcut?: string; + description?: string; + onClick: (() => void) | undefined; + onPointerMove: () => void; +}; + +type PreparedGroup = { + id: string; + label: string; + items: PreparedItem[]; +}; +``` + +## License + +MIT diff --git a/apps/example/.eslintrc.cjs b/apps/example/.eslintrc.cjs deleted file mode 100644 index 292b7e1..0000000 --- a/apps/example/.eslintrc.cjs +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["../../.eslintrc.cjs", "@wojtekolek/eslint-config/nextjs"], -}; diff --git a/apps/example/.vscode/settings.json b/apps/example/.vscode/settings.json deleted file mode 100644 index 41db953..0000000 --- a/apps/example/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "typescript.tsdk": "../../node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} \ No newline at end of file diff --git a/apps/example/README.md b/apps/example/README.md deleted file mode 100644 index 520dbbb..0000000 --- a/apps/example/README.md +++ /dev/null @@ -1 +0,0 @@ -## CommandPalette Example diff --git a/apps/example/app/layout.tsx b/apps/example/app/layout.tsx deleted file mode 100644 index 09a4608..0000000 --- a/apps/example/app/layout.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { FunctionComponent, ReactNode } from "react"; - -import { Analytics } from "@vercel/analytics/react"; -import type { Metadata } from "next"; -import { Montserrat } from "next/font/google"; - -import { Footer } from "components/Footer"; -import { TopMenu } from "components/TopMenu"; -import { colors } from "utils/styles/colors.cjs"; -import "utils/styles/globals.css"; - -const font = Montserrat({ - display: "swap", - subsets: ["latin"], - style: ["normal"], - weight: ["200", "400", "500"], - variable: "--font-montserrat", -}); - -const Title = "Command Menu — Headless UI for building command menus in React."; -const Description = "Headless UI for building command menus in React."; -const URL = "https://commandmenu.wojtekolek.com"; - -export const metadata: Metadata = { - title: Title, - description: Description, - keywords: ["Next.js", "React", "JavaScript", "Typescript", "CommandMenu"], - icons: { - icon: "/favicon.ico", - shortcut: "/favicon-32x32.png", - apple: "/apple-touch-icon.png", - }, - manifest: `${URL}/site.webmanifest`, - authors: [ - { - name: "Wojtek Olek", - url: "https://wojtekolek.com", - }, - ], - themeColor: [ - { media: "(prefers-color-scheme: light)", color: colors.primary[950] }, - { media: "(prefers-color-scheme: dark)", color: colors.primary[950] }, - ], - openGraph: { - title: "Command Menu", - description: Description, - url: URL, - siteName: "Command Menu", - images: [ - { - url: `${URL}/og.png`, - width: 1200, - height: 630, - }, - ], - locale: "en-US", - type: "website", - }, -}; - -type RootLayoutProps = { - children: ReactNode; -}; - -const RootLayout: FunctionComponent = ({ children }) => ( - - - -
{children}
-