Skip to content

Commit

Permalink
support rendering Command.List in a portal, fixes #95
Browse files Browse the repository at this point in the history
pacocoursey committed Jan 30, 2024
1 parent 3dae25d commit 54aa261
Showing 4 changed files with 95 additions and 38 deletions.
43 changes: 23 additions & 20 deletions cmdk/src/index.tsx
Original file line number Diff line number Diff line change
@@ -122,11 +122,12 @@ type Context = {
filter: () => boolean
label: string
disablePointerSelection: boolean
commandRef: React.RefObject<HTMLDivElement | null>
// Ids
listId: string
labelId: string
inputId: string
// Refs
listInnerRef: React.RefObject<HTMLDivElement | null>
}
type State = {
search: string
@@ -164,7 +165,6 @@ const useStore = () => React.useContext(StoreContext)
const GroupContext = React.createContext<Group>(undefined)

const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwardedRef) => {
const ref = React.useRef<HTMLDivElement>(null)
const state = useLazyRef<State>(() => ({
/** Value of the search query. */
search: '',
@@ -201,6 +201,8 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
const labelId = React.useId()
const inputId = React.useId()

const listInnerRef = React.useRef<HTMLDivElement>(null)

const schedule = useScheduleLayoutEffect()

/** Controlled mode `value` handling. */
@@ -331,10 +333,10 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
},
label: label || props['aria-label'],
disablePointerSelection,
commandRef: ref,
listId,
inputId,
labelId,
listInnerRef,
}),
[],
)
@@ -347,7 +349,6 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
/** Sorts items by score, and groups by highest item score. */
function sort() {
if (
!ref.current ||
!state.current.search ||
// Explicitly false, because true | undefined is the default
propsRef.current.shouldFilter === false
@@ -375,7 +376,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
// Sort items within groups to bottom
// Sort items outside of groups
// Sort groups to bottom (pushes all non-grouped items to the top)
const list = ref.current.querySelector(LIST_SELECTOR)
const listInsertionElement = listInnerRef.current.querySelector(LIST_SELECTOR)

// Sort the items
getValidItems()
@@ -390,14 +391,16 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
if (group) {
group.appendChild(item.parentElement === group ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`))
} else {
list.appendChild(item.parentElement === list ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`))
listInsertionElement.appendChild(
item.parentElement === listInsertionElement ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`),
)
}
})

groups
.sort((a, b) => b[1] - a[1])
.forEach((group) => {
const element = ref.current.querySelector(`${GROUP_SELECTOR}[${VALUE_ATTR}="${group[0]}"]`)
const element = listInnerRef.current.querySelector(`${GROUP_SELECTOR}[${VALUE_ATTR}="${group[0]}"]`)
element?.parentElement.appendChild(element)
})
}
@@ -463,11 +466,11 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
/** Getters */

function getSelectedItem() {
return ref.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`)
return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`)
}

function getValidItems() {
return Array.from(ref.current.querySelectorAll(VALID_ITEM_SELECTOR))
return Array.from(listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR))
}

/** Setters */
@@ -478,7 +481,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
if (item) store.setState('value', item.getAttribute(VALUE_ATTR))
}

function updateSelectedByChange(change: 1 | -1) {
function updateSelectedByItem(change: 1 | -1) {
const selected = getSelectedItem()
const items = getValidItems()
const index = items.findIndex((item) => item === selected)
@@ -498,7 +501,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
if (newSelected) store.setState('value', newSelected.getAttribute(VALUE_ATTR))
}

function updateSelectedToGroup(change: 1 | -1) {
function updateSelectedByGroup(change: 1 | -1) {
const selected = getSelectedItem()
let group = selected?.closest(GROUP_SELECTOR)
let item: HTMLElement
@@ -511,7 +514,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
if (item) {
store.setState('value', item.getAttribute(VALUE_ATTR))
} else {
updateSelectedByChange(change)
updateSelectedByItem(change)
}
}

@@ -525,10 +528,10 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
last()
} else if (e.altKey) {
// Next group
updateSelectedToGroup(1)
updateSelectedByGroup(1)
} else {
// Next item
updateSelectedByChange(1)
updateSelectedByItem(1)
}
}

@@ -540,16 +543,16 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
updateSelectedToIndex(0)
} else if (e.altKey) {
// Previous group
updateSelectedToGroup(-1)
updateSelectedByGroup(-1)
} else {
// Previous item
updateSelectedByChange(-1)
updateSelectedByItem(-1)
}
}

return (
<Primitive.div
ref={mergeRefs([ref, forwardedRef])}
ref={forwardedRef}
tabIndex={-1}
{...etc}
cmdk-root=""
@@ -766,11 +769,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, forwardedRe
const context = useCommand()

const selectedItemId = React.useMemo(() => {
const item = context.commandRef.current?.querySelector(
const item = context.listInnerRef.current?.querySelector(
`${ITEM_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(value)}"]`,
)
return item?.getAttribute('id')
}, [value, context.commandRef])
}, [])

React.useEffect(() => {
if (props.value != null) {
@@ -837,7 +840,7 @@ const List = React.forwardRef<HTMLDivElement, ListProps>((props, forwardedRef) =

return (
<Primitive.div
ref={mergeRefs([ref, forwardedRef])}
ref={mergeRefs([ref, forwardedRef, context.listInnerRef])}
{...etc}
cmdk-list=""
role="listbox"
41 changes: 23 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/package.json
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
"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",
48 changes: 48 additions & 0 deletions test/pages/portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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)
React.useEffect(() => setRender(true), [])
if (!render) return null

return (
<div>
<Command className="root">
<Command.Input placeholder="Search…" className="input" />

<Portal.Root data-portal="true">
<Command.List className="list">
<Command.Item className="item">Apple</Command.Item>
<Command.Item className="item">Banana</Command.Item>
<Command.Item className="item">Cherry</Command.Item>
<Command.Item className="item">Dragonfruit</Command.Item>
<Command.Item className="item">Elderberry</Command.Item>
<Command.Item className="item">Fig</Command.Item>
<Command.Item className="item">Grape</Command.Item>
<Command.Item className="item">Honeydew</Command.Item>
<Command.Item className="item">Jackfruit</Command.Item>
<Command.Item className="item">Kiwi</Command.Item>
<Command.Item className="item">Lemon</Command.Item>
<Command.Item className="item">Mango</Command.Item>
<Command.Item className="item">Nectarine</Command.Item>
<Command.Item className="item">Orange</Command.Item>
<Command.Item className="item">Papaya</Command.Item>
<Command.Item className="item">Quince</Command.Item>
<Command.Item className="item">Raspberry</Command.Item>
<Command.Item className="item">Strawberry</Command.Item>
<Command.Item className="item">Tangerine</Command.Item>
<Command.Item className="item">Ugli</Command.Item>
<Command.Item className="item">Watermelon</Command.Item>
<Command.Item className="item">Xigua</Command.Item>
<Command.Item className="item">Yuzu</Command.Item>
<Command.Item className="item">Zucchini</Command.Item>
</Command.List>
</Portal.Root>
</Command>
</div>
)
}

export default Page

0 comments on commit 54aa261

Please sign in to comment.