From 58935981e35aeccc32e97e515a1a5ccb9b0ee668 Mon Sep 17 00:00:00 2001 From: river Date: Sun, 26 May 2024 18:19:31 +0800 Subject: [PATCH] Format code --- README.md | 169 +++--- cmdk/src/components/command.tsx | 924 +++++++++++++++--------------- cmdk/src/components/dialog.tsx | 34 +- cmdk/src/components/group.tsx | 90 ++- cmdk/src/components/input.tsx | 100 ++-- cmdk/src/components/item.tsx | 116 ++-- cmdk/src/components/list.tsx | 138 ++--- cmdk/src/components/separator.tsx | 22 +- cmdk/src/constant.ts | 14 +- cmdk/src/hook.tsx | 9 +- cmdk/src/index.tsx | 33 +- cmdk/src/type.ts | 245 ++++---- cmdk/src/utils.tsx | 224 ++++---- package.json | 2 +- test/pages/init-sort.tsx | 18 +- 15 files changed, 1069 insertions(+), 1069 deletions(-) diff --git a/README.md b/README.md index fc2d900..455737a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ - - # motion-cmdk [![cmdk minzip package size](https://img.shields.io/bundlephobia/minzip/motion-cmdk)](https://www.npmjs.com/package/motion-cmdk?activeTab=code) [![cmdk package version](https://img.shields.io/npm/v/motion-cmdk.svg?colorB=green)](https://www.npmjs.com/package/motion-cmdk) + > Based on a modified version of [cmdk](https://github.com/pacocoursey/cmdk) Motion-cmdk is a command menu React component that can also function as an accessible combobox. You render items, and it automatically filters and sorts them. Motion-cmdk supports a fully composable API, allowing you to wrap items in other components or use static JSX.## Install @@ -15,8 +14,8 @@ pnpm install motion-cmdk import { Command } from 'motion-cmdk' const CommandMenu = () => { - return ( - + return ( + No results found. @@ -31,7 +30,7 @@ const CommandMenu = () => { Apple - ) + ) } ``` @@ -41,23 +40,23 @@ Or in a dialog: import { Command } from 'motion-cmdk' const CommandMenu = () => { - const [open, setOpen] = React.useState(false) - - // Toggle the menu when Motion-cmdk 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 ( - + const [open, setOpen] = React.useState(false) + + // Toggle the menu when Motion-cmdk 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. @@ -72,7 +71,7 @@ const CommandMenu = () => { Apple - ) + ) } ``` @@ -92,7 +91,7 @@ Render this to show the command menu inline, or use [Dialog](#dialog-cmdk-dialog const [value, setValue] = React.useState('apple') return ( - + Orange @@ -106,10 +105,10 @@ You can provide a custom `filter` function that is called to rank each item. Not ```tsx { - if (value.includes(search)) return 1 - return 0 - }} + filter={(value, search) => { + if (value.includes(search)) return 1 + return 0 + }} /> ``` @@ -117,11 +116,11 @@ A third argument, `keywords`, can also be provided to the filter function. Keywo ```tsx { - const extendValue = value + ' ' + keywords.join(' ') - if (extendValue.includes(search)) return 1 - return 0 - }} + filter={(value, search, keywords) => { + const extendValue = value + ' ' + keywords.join(' ') + if (extendValue.includes(search)) return 1 + return 0 + }} /> ``` @@ -131,11 +130,11 @@ Or disable filtering and sorting entirely: {filteredItems.map((item) => { - return ( - + return ( + {item} - ) + ) })} @@ -155,7 +154,7 @@ Props are forwarded to [Command](#command-cmdk-root). Composes Radix UI's Dialog const [open, setOpen] = React.useState(false) return ( - + ... ) @@ -167,7 +166,7 @@ You can provide a `container` prop that accepts an HTML element that is forwarde const containerElement = React.useRef(null) return ( - <> + <>
@@ -190,10 +189,10 @@ Contains items and groups. Animate height using the `--cmdk-list-height` CSS var ```css [cmdk-list] { - min-height: 300px; - height: var(--cmdk-list-height); - max-height: 500px; - transition: height 100ms ease; + min-height: 300px; + height: var(--cmdk-list-height); + max-height: 500px; + transition: height 100ms ease; } ``` @@ -201,8 +200,8 @@ To scroll item into view earlier near the edges of the viewport, use scroll-padd ```css [cmdk-list] { - scroll-padding-block-start: 8px; - scroll-padding-block-end: 8px; + scroll-padding-block-start: 8px; + scroll-padding-block-end: 8px; } ``` @@ -212,8 +211,8 @@ Item that becomes active on pointer enter. You should provide a unique `value` f ```tsx console.log('Selected', value)} - // Value is implicity "apple" because of the provided text content + onSelect={(value) => console.log('Selected', value)} + // Value is implicity "apple" because of the provided text content > Apple @@ -227,8 +226,8 @@ You can also provide a `keywords` prop to help with filtering. Keywords are trim ```tsx console.log('Selected', value)} - // Value is implicity "apple" because of the provided text content + onSelect={(value) => console.log('Selected', value)} + // Value is implicity "apple" because of the provided text content > Apple @@ -295,38 +294,38 @@ 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)) - } - }} - > + { + // 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' && ( - <> + {page === 'projects' && ( + <> Project A Project B - )} + )} - {page === 'teams' && ( - <> + {page === 'teams' && ( + <> Team 1 Team 2 - )} + )} ) @@ -338,13 +337,13 @@ If your items have nested sub-items that you only want to reveal when searching, ```tsx const SubItem = (props) => { - const search = useCommandState((state) => state.search) - if (!search) return null - return + const search = useCommandState((state) => state.search) + if (!search) return null + return } return ( - + Change theme… @@ -364,28 +363,28 @@ 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() + async function getItems() { + setLoading(true) + const res = await api.get('/dictionary') + setItems(res) + setLoading(false) + } + + getItems() }, []) return ( - + {loading && Fetching words…} - {items.map((item) => { - return ( - + {items.map((item) => { + return ( + {item} - ) - })} + ) + })} ) @@ -405,7 +404,7 @@ Render `Command` inside of the popover content: import * as Popover from '@radix-ui/react-popover' return ( - + Toggle popover @@ -452,7 +451,7 @@ You can find global stylesheets to drop in as a starting point for styling. See ## History -This was originally written in 2019 by Paco ([@pacocoursey](https://twitter.com/pacocoursey)) to explore the possibility of a composable combobox API. It was later used for the Vercel command menu and autocomplete by Rauno ([@raunofreiberg](https://twitter.com/raunofreiberg)) in 2020. It was independently rewritten in 2022 with a simpler and more efficient approach, with ideas and assistance from Shu ([@shuding_](https://twitter.com/shuding_)). +This was originally written in 2019 by Paco ([@pacocoursey](https://twitter.com/pacocoursey)) to explore the possibility of a composable combobox API. It was later used for the Vercel command menu and autocomplete by Rauno ([@raunofreiberg](https://twitter.com/raunofreiberg)) in 2020. It was independently rewritten in 2022 with a simpler and more efficient approach, with ideas and assistance from Shu ([@shuding\_](https://twitter.com/shuding_)). The [use-descendants](https://github.com/pacocoursey/use-descendants) library was extracted from the 2019 version. diff --git a/cmdk/src/components/command.tsx b/cmdk/src/components/command.tsx index 73ca693..e9eb98c 100644 --- a/cmdk/src/components/command.tsx +++ b/cmdk/src/components/command.tsx @@ -1,500 +1,500 @@ -import * as React from 'react'; -import { CommandProps, Context, State, Store } from '../type'; +import * as React from 'react' +import { CommandProps, Context, State, Store } from '../type' import { - findNextSibling, - findPreviousSibling, - SlottableWithNestedChildren, - srOnlyStyles, - useAsRef, - useLayoutEffect, - useLazyRef, - useScheduleLayoutEffect, -} from '../utils'; -import { useId } from '@radix-ui/react-id'; + findNextSibling, + findPreviousSibling, + SlottableWithNestedChildren, + srOnlyStyles, + useAsRef, + useLayoutEffect, + useLazyRef, + useScheduleLayoutEffect, +} from '../utils' +import { useId } from '@radix-ui/react-id' import { - GROUP_HEADING_SELECTOR, - GROUP_ITEMS_SELECTOR, - GROUP_SELECTOR, - ITEM_SELECTOR, - SELECT_EVENT, - VALID_ITEM_SELECTOR, - VALUE_ATTR, -} from '../constant'; -import { Primitive } from '@radix-ui/react-primitive'; -import { CommandContext, StoreContext } from '../hook'; -import { common } from '../score-tool/common'; - -const defaultFilter: CommandProps['filter'] = (value, search, keywords) => common(value, search, keywords); + GROUP_HEADING_SELECTOR, + GROUP_ITEMS_SELECTOR, + GROUP_SELECTOR, + ITEM_SELECTOR, + SELECT_EVENT, + VALID_ITEM_SELECTOR, + VALUE_ATTR, +} from '../constant' +import { Primitive } from '@radix-ui/react-primitive' +import { CommandContext, StoreContext } from '../hook' +import { common } from '../score-tool/common' + +const defaultFilter: CommandProps['filter'] = (value, search, keywords) => common(value, search, keywords) export const Command = React.forwardRef((props, forwardedRef) => { - const state = useLazyRef(() => ({ - /** Value of the search query. */ - search: '', - /** Currently selected item value. */ - value: props.value ?? props.defaultValue ?? '', - 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(); + const state = useLazyRef(() => ({ + /** Value of the search query. */ + search: '', + /** Currently selected item value. */ + value: props.value ?? props.defaultValue ?? '', + 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') { + // 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 + } } - }, [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') { - // 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); - } - } + // 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()); - } + // Batch this, multiple items can mount in one pass + // and we should not be filtering/sorting/emitting each time + schedule(3, () => { + filterItems() + sort() - return () => { - ids.current.delete(id); - allGroups.current.delete(id); - }; - }, - filter: () => { - return propsRef.current.shouldFilter; - }, - label: label || props['aria-label'], - 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; - } + // Could be initial mount, select the first item if none already selected + if (!state.current.value) { + selectFirstItem() + } - /** 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; - } + store.emit() + }) - 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); - }); - } + return () => { + ids.current.delete(id) + allItems.current.delete(id) + state.current.filtered.items.delete(id) + const selectedItem = getSelectedItem() - function selectFirstItem() { - const item = getValidItems().find((item) => item.getAttribute('aria-disabled') !== 'true'); - const value = item?.getAttribute(VALUE_ATTR); - store.setState('value', value || undefined); - } + // Batch this, multiple items could be removed in one pass + schedule(4, () => { + filterItems() - /** 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; - } + // The item removed have been the selected one, + // so selection should be moved to the first + if (selectedItem?.getAttribute('id') === id) selectFirstItem() - // 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++; + store.emit() + }) } - - // 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; - } - } + }, + // Track group lifecycle (mount, unmount) + group: (id) => { + if (!allGroups.current.has(id)) { + allGroups.current.set(id, new Set()) } - state.current.filtered.count = itemCount; + return () => { + ids.current.delete(id) + allGroups.current.delete(id) + } + }, + filter: () => { + return propsRef.current.shouldFilter + }, + label: label || props['aria-label'], + 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 } - function scrollSelectedIntoView() { - const item = getSelectedItem(); + 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 + } - 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' }); - } + // 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++ + } - // Ensure the item is always in view - item.scrollIntoView({ block: 'nearest' }); + // 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 } + } } - /** Getters */ + state.current.filtered.count = itemCount + } - function getSelectedItem() { - return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`); - } + 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' }) + } - function getValidItems() { - return Array.from(listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR) || []); + // Ensure the item is always in view + item.scrollIntoView({ block: 'nearest' }) } + } - /** Setters */ + /** Getters */ - function updateSelectedToIndex(index: number) { - const items = getValidItems(); - const item = items[index]; - if (item) store.setState('value', item.getAttribute(VALUE_ATTR)); - } + function getSelectedItem() { + return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`) + } - 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]; - } + function getValidItems() { + return Array.from(listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR) || []) + } - if (newSelected) store.setState('value', newSelected.getAttribute(VALUE_ATTR)); - } + /** Setters */ - function updateSelectedByGroup(change: 1 | -1) { - const selected = getSelectedItem(); - let group = selected?.closest(GROUP_SELECTOR); - let item: HTMLElement; + function updateSelectedToIndex(index: number) { + const items = getValidItems() + const item = items[index] + if (item) store.setState('value', item.getAttribute(VALUE_ATTR)) + } - while (group && !item) { - group = change > 0 ? findNextSibling(group, GROUP_SELECTOR) : findPreviousSibling(group, GROUP_SELECTOR); - item = group?.querySelector(VALID_ITEM_SELECTOR); - } + function updateSelectedByItem(change: 1 | -1) { + const selected = getSelectedItem() + const items = getValidItems() + const index = items.findIndex((item) => item === selected) - if (item) { - store.setState('value', item.getAttribute(VALUE_ATTR)); - } else { - updateSelectedByItem(change); - } - } + // Get item at this index + let newSelected = items[index + change] - const last = () => updateSelectedToIndex(getValidItems().length - 1); + if (propsRef.current?.loop) { + newSelected = + index + change < 0 + ? items[items.length - 1] + : index + change === items.length + ? items[0] + : items[index + change] + } - const next = (e: React.KeyboardEvent) => { - e.preventDefault(); + if (newSelected) store.setState('value', newSelected.getAttribute(VALUE_ATTR)) + } - if (e.metaKey) { - // Last item - last(); - } else if (e.altKey) { - // Next group - updateSelectedByGroup(1); - } else { - // Next item - updateSelectedByItem(1); - } - }; + function updateSelectedByGroup(change: 1 | -1) { + const selected = getSelectedItem() + let group = selected?.closest(GROUP_SELECTOR) + let item: HTMLElement - const prev = (e: React.KeyboardEvent) => { - e.preventDefault(); + while (group && !item) { + group = change > 0 ? findNextSibling(group, GROUP_SELECTOR) : findPreviousSibling(group, GROUP_SELECTOR) + item = group?.querySelector(VALID_ITEM_SELECTOR) + } - if (e.metaKey) { - // First item - updateSelectedToIndex(0); - } else if (e.altKey) { - // Previous group - updateSelectedByGroup(-1); - } else { - // Previous item - updateSelectedByItem(-1); - } - }; - - return ( - { - etc.onKeyDown?.(e); - - if (!e.defaultPrevented) { - 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': { - // Check if IME composition is finished before triggering onSelect - // This prevents unwanted triggering while user is still inputting text with IME - // e.keyCode === 229 is for the Japanese IME and Safari. - // isComposing does not work with Japanese IME and Safari combination. - if (!e.nativeEvent.isComposing && e.keyCode !== 229) { - // Trigger item onSelect - e.preventDefault(); - const item = getSelectedItem(); - if (item) { - const event = new Event(SELECT_EVENT); - item.dispatchEvent(event); - } - } - } - } + 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) + + if (!e.defaultPrevented) { + 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': { + // Check if IME composition is finished before triggering onSelect + // This prevents unwanted triggering while user is still inputting text with IME + // e.keyCode === 229 is for the Japanese IME and Safari. + // isComposing does not work with Japanese IME and Safari combination. + if (!e.nativeEvent.isComposing && e.keyCode !== 229) { + // Trigger item onSelect + e.preventDefault() + const item = getSelectedItem() + if (item) { + const event = new Event(SELECT_EVENT) + item.dispatchEvent(event) } - }} - > - - {SlottableWithNestedChildren(props, (child) => ( - - {child} - - ))} - - ); -}); -export { Command as CommandRoot }; -export { defaultFilter }; + } + } + } + } + }} + > + + {SlottableWithNestedChildren(props, (child) => ( + + {child} + + ))} + + ) +}) +export { Command as CommandRoot } +export { defaultFilter } diff --git a/cmdk/src/components/dialog.tsx b/cmdk/src/components/dialog.tsx index 2ae7101..2df81b2 100644 --- a/cmdk/src/components/dialog.tsx +++ b/cmdk/src/components/dialog.tsx @@ -1,22 +1,22 @@ -import * as React from 'react'; -import {DialogProps} from '../type'; -import * as RadixDialog from '@radix-ui/react-dialog'; -import {Command} from './command'; +import * as React from 'react' +import { DialogProps } from '../type' +import * as RadixDialog from '@radix-ui/react-dialog' +import { Command } from './command' /** * Renders the command menu in a Radix Dialog. */ export const Dialog = React.forwardRef((props, forwardedRef) => { - const { open, onOpenChange, overlayClassName, contentClassName, container, ...etc } = props; - return ( - - - - - - - - - ); -}); -export {Dialog as CommandDialog}; + const { open, onOpenChange, overlayClassName, contentClassName, container, ...etc } = props + return ( + + + + + + + + + ) +}) +export { Dialog as CommandDialog } diff --git a/cmdk/src/components/group.tsx b/cmdk/src/components/group.tsx index 92fe12c..5625dfb 100644 --- a/cmdk/src/components/group.tsx +++ b/cmdk/src/components/group.tsx @@ -1,58 +1,52 @@ -import * as React from 'react'; -import {GroupProps} from '../type'; -import {useId} from '@radix-ui/react-id'; -import {GroupContext, useCommand} from '../hook'; -import { - mergeRefs, - SlottableWithNestedChildren, - useCmdk, - useLayoutEffect, - useValue, -} from '../utils'; -import {Primitive} from '@radix-ui/react-primitive'; +import * as React from 'react' +import { GroupProps } from '../type' +import { useId } from '@radix-ui/react-id' +import { GroupContext, useCommand } from '../hook' +import { mergeRefs, SlottableWithNestedChildren, useCmdk, useLayoutEffect, useValue } from '../utils' +import { Primitive } from '@radix-ui/react-primitive' /** * Group command menu items together with a heading. * Grouped items are always shown together. */ export 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), - ); + 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); - }, []); + useLayoutEffect(() => { + return context.group(id) + }, []) - useValue(id, ref, [props.value, props.heading, headingRef]); + useValue(id, ref, [props.value, props.heading, headingRef]) - const contextValue = React.useMemo(() => ({ id, forceMount }), [forceMount]); + const contextValue = React.useMemo(() => ({ id, forceMount }), [forceMount]) - return ( - - ); -}); -export {Group as CommandGroup}; + return ( + + ) +}) +export { Group as CommandGroup } diff --git a/cmdk/src/components/input.tsx b/cmdk/src/components/input.tsx index 3b7d521..34af15c 100644 --- a/cmdk/src/components/input.tsx +++ b/cmdk/src/components/input.tsx @@ -1,60 +1,60 @@ -import * as React from 'react'; -import {InputProps} from '../type'; -import {useCommand, useStore} from '../hook'; -import {useCmdk} from '../utils'; -import {ITEM_SELECTOR, VALUE_ATTR} from '../constant'; -import {Primitive} from '@radix-ui/react-primitive'; +import * as React from 'react' +import { InputProps } from '../type' +import { useCommand, useStore } from '../hook' +import { useCmdk } from '../utils' +import { ITEM_SELECTOR, VALUE_ATTR } from '../constant' +import { Primitive } from '@radix-ui/react-primitive' /** * Command menu input. * All props are forwarded to the underyling `input` element. */ export 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 value = useCmdk((state) => state.value); - const context = useCommand(); + const { onValueChange, ...etc } = props + const isControlled = props.value != null + const store = useStore() + const search = useCmdk((state) => state.search) + const value = useCmdk((state) => state.value) + const context = useCommand() - const selectedItemId = React.useMemo(() => { - const item = context.listInnerRef.current?.querySelector( - `${ ITEM_SELECTOR }[${ VALUE_ATTR }="${ encodeURIComponent(value) }"]`, - ); - return item?.getAttribute('id'); - }, []); + const selectedItemId = React.useMemo(() => { + const item = context.listInnerRef.current?.querySelector( + `${ITEM_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(value)}"]`, + ) + return item?.getAttribute('id') + }, []) - React.useEffect(() => { - if (props.value != null) { - store.setState('search', props.value); - } - }, [props.value]); + React.useEffect(() => { + if (props.value != null) { + store.setState('search', props.value) + } + }, [props.value]) - return ( - { - if (!isControlled) { - store.setState('search', e.target.value); - } + return ( + { + if (!isControlled) { + store.setState('search', e.target.value) + } - onValueChange?.(e.target.value); - } } - /> - ); -}); -export {Input as CommandInput}; + onValueChange?.(e.target.value) + }} + /> + ) +}) +export { Input as CommandInput } diff --git a/cmdk/src/components/item.tsx b/cmdk/src/components/item.tsx index e788667..cbed441 100644 --- a/cmdk/src/components/item.tsx +++ b/cmdk/src/components/item.tsx @@ -1,10 +1,10 @@ -import * as React from 'react'; -import {ItemProps} from '../type'; -import {useId} from '@radix-ui/react-id'; -import {GroupContext, useCommand, useStore} from '../hook'; -import {mergeRefs, useAsRef, useCmdk, useLayoutEffect, useValue} from '../utils'; -import {SELECT_EVENT} from '../constant'; -import {Primitive} from '@radix-ui/react-primitive'; +import * as React from 'react' +import { ItemProps } from '../type' +import { useId } from '@radix-ui/react-id' +import { GroupContext, useCommand, useStore } from '../hook' +import { mergeRefs, useAsRef, useCmdk, useLayoutEffect, useValue } from '../utils' +import { SELECT_EVENT } from '../constant' +import { Primitive } from '@radix-ui/react-primitive' /** * Command menu item. Becomes active on pointer enter or through keyboard navigation. @@ -12,63 +12,63 @@ import {Primitive} from '@radix-ui/react-primitive'; * the rendered item's `textContent`. */ export 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; + 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]); + useLayoutEffect(() => { + if (!forceMount) { + return context.item(id, groupContext?.id) + } + }, [forceMount]) - const value = useValue(id, ref, [props.value, props.children, ref], props.keywords); + 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, - ); + 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]); + 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 onSelect() { + select() + propsRef.current.onSelect?.(value.current) + } - function select() { - store.setState('value', value.current, true); - } + function select() { + store.setState('value', value.current, true) + } - if (!render) return null; + if (!render) return null - const { disabled, value: _, onSelect: __, forceMount: ___, keywords: ____, ...etc } = props; + const { disabled, value: _, onSelect: __, forceMount: ___, keywords: ____, ...etc } = props - return ( - - { props.children } - - ); -}); -export {Item as CommandItem}; + return ( + + {props.children} + + ) +}) +export { Item as CommandItem } diff --git a/cmdk/src/components/list.tsx b/cmdk/src/components/list.tsx index b5c5fcc..1bf0028 100644 --- a/cmdk/src/components/list.tsx +++ b/cmdk/src/components/list.tsx @@ -1,87 +1,87 @@ -import * as React from 'react'; -import {EmptyProps, ListProps, LoadingProps} from '../type'; -import {useCommand} from '../hook'; -import {Primitive} from '@radix-ui/react-primitive'; -import {mergeRefs, SlottableWithNestedChildren, useCmdk} from '../utils'; +import * as React from 'react' +import { EmptyProps, ListProps, LoadingProps } from '../type' +import { useCommand } from '../hook' +import { Primitive } from '@radix-ui/react-primitive' +import { mergeRefs, SlottableWithNestedChildren, useCmdk } from '../utils' /** * Contains `Item`, `Group`, and `Separator`. * Use the `--cmdk-list-height` CSS variable to animate height based on the number of results. */ export const List = React.forwardRef((props, forwardedRef) => { - const { children, label = 'Suggestions', ...etc } = props; - const ref = React.useRef(null); - const height = React.useRef(null); - const context = useCommand(); + const { children, label = 'Suggestions', ...etc } = props + const ref = React.useRef(null) + const height = React.useRef(null) + 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); - }; - } - }, []); + 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 } -
- )) } -
- ); -}); + return ( + + {SlottableWithNestedChildren(props, (child) => ( +
+ {child} +
+ ))} +
+ ) +}) /** * Automatically renders when there are no results for the search query. */ export const Empty = React.forwardRef((props, forwardedRef) => { - const render = useCmdk((state) => state.filtered.count === 0); + const render = useCmdk((state) => state.filtered.count === 0) - if (!render) return null; - return ; -}); + if (!render) return null + return +}) /** * You should conditionally render this with `progress` while loading asynchronous items. */ export const Loading = React.forwardRef((props, forwardedRef) => { - const { progress, children, label = 'Loading...', ...etc } = props; + const { progress, children, label = 'Loading...', ...etc } = props - return ( - - { SlottableWithNestedChildren(props, (child) => ( -
{ child }
- )) } -
- ); -}); -export {Loading as CommandLoading}; -export {Empty as CommandEmpty}; -export {List as CommandList}; + return ( + + {SlottableWithNestedChildren(props, (child) => ( +
{child}
+ ))} +
+ ) +}) +export { Loading as CommandLoading } +export { Empty as CommandEmpty } +export { List as CommandList } diff --git a/cmdk/src/components/separator.tsx b/cmdk/src/components/separator.tsx index 31aca6f..f3539ef 100644 --- a/cmdk/src/components/separator.tsx +++ b/cmdk/src/components/separator.tsx @@ -1,18 +1,18 @@ -import * as React from 'react'; -import {SeparatorProps} from '../type'; -import {mergeRefs, useCmdk} from '../utils'; -import {Primitive} from '@radix-ui/react-primitive'; +import * as React from 'react' +import { SeparatorProps } from '../type' +import { mergeRefs, useCmdk } from '../utils' +import { Primitive } from '@radix-ui/react-primitive' /** * A visual and semantic separator between items or groups. * Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. */ export const Separator = React.forwardRef((props, forwardedRef) => { - const { alwaysRender, ...etc } = props; - const ref = React.useRef(null); - const render = useCmdk((state) => !state.search); + const { alwaysRender, ...etc } = props + const ref = React.useRef(null) + const render = useCmdk((state) => !state.search) - if (!alwaysRender && !render) return null; - return ; -}); -export {Separator as CommandSeparator}; + if (!alwaysRender && !render) return null + return +}) +export { Separator as CommandSeparator } diff --git a/cmdk/src/constant.ts b/cmdk/src/constant.ts index 743b17d..97cdb35 100644 --- a/cmdk/src/constant.ts +++ b/cmdk/src/constant.ts @@ -1,7 +1,7 @@ -export const GROUP_SELECTOR = `[cmdk-group=""]`; -export const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]`; -export const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]`; -export const ITEM_SELECTOR = `[cmdk-item=""]`; -export const VALID_ITEM_SELECTOR = `${ ITEM_SELECTOR }:not([aria-disabled="true"])`; -export const SELECT_EVENT = `cmdk-item-select`; -export const VALUE_ATTR = `data-value`; +export const GROUP_SELECTOR = `[cmdk-group=""]` +export const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]` +export const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]` +export const ITEM_SELECTOR = `[cmdk-item=""]` +export const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])` +export const SELECT_EVENT = `cmdk-item-select` +export const VALUE_ATTR = `data-value` diff --git a/cmdk/src/hook.tsx b/cmdk/src/hook.tsx index d6e442b..a407894 100644 --- a/cmdk/src/hook.tsx +++ b/cmdk/src/hook.tsx @@ -1,10 +1,9 @@ -import * as React from 'react'; -import {Context, Group, Store} from './type'; - +import * as React from 'react' +import { Context, Group, Store } from './type' export const CommandContext = React.createContext(undefined) export const StoreContext = React.createContext(undefined) export const GroupContext = React.createContext(undefined) -export const useCommand = () => React.useContext(CommandContext); -export const useStore = () => React.useContext(StoreContext); +export const useCommand = () => React.useContext(CommandContext) +export const useStore = () => React.useContext(StoreContext) diff --git a/cmdk/src/index.tsx b/cmdk/src/index.tsx index aa885f0..5d37a8c 100644 --- a/cmdk/src/index.tsx +++ b/cmdk/src/index.tsx @@ -1,23 +1,20 @@ -import {Empty, List, Loading} from './components/list'; -import {Input} from './components/input'; -import {Separator} from './components/separator'; -import {Group} from './components/group'; -import {Item} from './components/item'; -import {Dialog} from './components/dialog'; -import {Command} from './components/command'; - +import { Empty, List, Loading } from './components/list' +import { Input } from './components/input' +import { Separator } from './components/separator' +import { Group } from './components/group' +import { Item } from './components/item' +import { Dialog } from './components/dialog' +import { Command } from './components/command' const pkg = Object.assign(Command, { - List, - Item, - Input, - Group, - Separator, - Dialog, - Empty, - Loading, + List, + Item, + Input, + Group, + Separator, + Dialog, + Empty, + Loading, }) export { pkg as Command } - - diff --git a/cmdk/src/type.ts b/cmdk/src/type.ts index 281c49f..ff96ad9 100644 --- a/cmdk/src/type.ts +++ b/cmdk/src/type.ts @@ -1,148 +1,161 @@ import * as RadixDialog from '@radix-ui/react-dialog' import { Primitive } from '@radix-ui/react-primitive' - 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 - } + 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 + /** 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 - } + 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 - } + 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 - } + 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 - } + 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 CommandProps = Children & + DivProps & { + /** + * Accessible label for this command menu. Not shown visibly. + */ + label?: string /** - * Optional controlled state for the value of the search input. + * 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?: (value: string, search: string, keywords?: string[]) => number + /** + * 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 search value changes. + * Event handler called when the selected item of the menu changes. */ - onValueChange?: (search: string) => void -} -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?: (value: string, search: string, keywords?: string[]) => number - /** - * 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 - } + 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 - disablePointerSelection: boolean - // Ids - listId: string - labelId: string - inputId: string - // Refs - listInnerRef: React.RefObject + value: (id: string, value: string, keywords?: string[]) => void + item: (id: string, groupId: string) => () => void + group: (id: string) => () => void + filter: () => boolean + label: string + disablePointerSelection: boolean + // Ids + listId: string + labelId: string + inputId: string + // Refs + listInnerRef: React.RefObject } type State = { - search: string - value: string - filtered: { count: number; items: Map; groups: Set } + search: string + value: 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 + subscribe: (callback: () => void) => () => void + snapshot: () => State + setState: (key: K, value: State[K], opts?: any) => void + emit: () => void } type Group = { - id: string - forceMount?: boolean + id: string + forceMount?: boolean } -export type { LoadingProps, EmptyProps, SeparatorProps, DialogProps, ListProps, ItemProps, GroupProps, InputProps, CommandProps, Context, State, Store, Group } +export type { + LoadingProps, + EmptyProps, + SeparatorProps, + DialogProps, + ListProps, + ItemProps, + GroupProps, + InputProps, + CommandProps, + Context, + State, + Store, + Group, +} diff --git a/cmdk/src/utils.tsx b/cmdk/src/utils.tsx index 1ab0bcd..ab1f262 100644 --- a/cmdk/src/utils.tsx +++ b/cmdk/src/utils.tsx @@ -5,163 +5,163 @@ * * */ -import {State} from './type'; -import * as React from 'react'; -import {VALUE_ATTR} from './constant'; -import {useCommand, useStore} from './hook'; +import { State } from './type' +import * as React from 'react' +import { VALUE_ATTR } from './constant' +import { useCommand, useStore } from './hook' export function findNextSibling(el: Element, selector: string) { - let sibling = el.nextElementSibling; + let sibling = el.nextElementSibling - while (sibling) { - if (sibling.matches(selector)) return sibling; - sibling = sibling.nextElementSibling; - } + while (sibling) { + if (sibling.matches(selector)) return sibling + sibling = sibling.nextElementSibling + } } export function findPreviousSibling(el: Element, selector: string) { - let sibling = el.previousElementSibling; + let sibling = el.previousElementSibling - while (sibling) { - if (sibling.matches(selector)) return sibling; - sibling = sibling.previousElementSibling; - } + while (sibling) { + if (sibling.matches(selector)) return sibling + sibling = sibling.previousElementSibling + } } export function useAsRef(data: T) { - const ref = React.useRef(data); + const ref = React.useRef(data) - useLayoutEffect(() => { - ref.current = data; - }); + useLayoutEffect(() => { + ref.current = data + }) - return ref; + return ref } -export const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; +export const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect export function useLazyRef(fn: () => T) { - const ref = React.useRef(); + const ref = React.useRef() - if (ref.current === undefined) { - ref.current = fn(); - } + if (ref.current === undefined) { + ref.current = fn() + } - return ref as React.MutableRefObject; + return ref as React.MutableRefObject } // ESM is still a nightmare with Next.js so I'm just gonna copy the package code in // https://github.com/gregberge/react-merge-refs // Copyright (c) 2020 Greg Bergé export function mergeRefs(refs: Array | React.LegacyRef>): React.RefCallback { - return (value) => { - refs.forEach((ref) => { - if (typeof ref === 'function') { - ref(value); - } else if (ref != null) { - ;(ref as React.MutableRefObject).current = value; - } - }); - }; + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as React.MutableRefObject).current = value + } + }) + } } /** Run a selector against the store state. */ export function useCmdk(selector: (state: State) => T) { - const store = useStore(); - const cb = () => selector(store.snapshot()); - return React.useSyncExternalStore(store.subscribe, cb, cb); + const store = useStore() + const cb = () => selector(store.snapshot()) + return React.useSyncExternalStore(store.subscribe, cb, cb) } export function useValue( - id: string, - ref: React.RefObject, - deps: (string | React.ReactNode | React.RefObject)[], - aliases: string[] = [], + 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; + 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. */ export 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({}); - }; -}; + 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; + 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 } export function SlottableWithNestedChildren( - { asChild, children }: { asChild?: boolean; children?: React.ReactNode }, - render: (child: React.ReactNode) => JSX.Element, + { 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); + if (asChild && React.isValidElement(children)) { + return React.cloneElement(renderChildren(children), { ref: (children as any).ref }, render(children.props.children)) + } + return render(children) } export 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; -export {useCmdk as useCommandState}; + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: '0', +} as const +export { useCmdk as useCommandState } const getId = (() => { - let i = 0; - return () => `${ i++ }`; -})(); + let i = 0 + return () => `${i++}` +})() export const useIdCompatibility = () => { - React.useState(getId); - const [id] = React.useState(getId); - return 'cmdk' + id; -}; + React.useState(getId) + const [id] = React.useState(getId) + return 'cmdk' + id +} diff --git a/package.json b/package.json index 6c1d86f..c7160e9 100644 --- a/package.json +++ b/package.json @@ -25,4 +25,4 @@ "prettier --write" ] } -} \ No newline at end of file +} diff --git a/test/pages/init-sort.tsx b/test/pages/init-sort.tsx index 1967770..4100442 100644 --- a/test/pages/init-sort.tsx +++ b/test/pages/init-sort.tsx @@ -3,20 +3,18 @@ import { Command } from 'motion-cmdk' const Page = () => { return (
- { - console.log("", item) - if (item === 'c') return 0.2; + { + console.log('', item) + if (item === 'c') return 0.2 if (item.includes(query)) return 1 - - } - - - }> + }} + > No results. - + A