Skip to content

Commit 6cd6c45

Browse files
committed
feat(registry): add the styled suggestion menu
1 parent b66f0ae commit 6cd6c45

12 files changed

Lines changed: 1333 additions & 1 deletion

File tree

‎apps/web/components/sidebar/data.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export const componentItems: ComponentItem[] = [
158158
{
159159
type: 'single',
160160
title: 'Suggestion Menu',
161-
url: '/docs/primitives/suggestion-menu',
161+
url: '/docs/components/suggestion-menu',
162162
audience: 'private',
163163
},
164164
{

‎apps/web/content/docs/suggestion-menu/index.mdx‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ section: Getting Started
44
summary: Suggests `/` commands, `@` mentions and `:` emoji while the user types in an editor or text field, without taking focus from it.
55
audience: private
66
tiers:
7+
- components
78
- primitives
89
---
910

@@ -17,6 +18,24 @@ The host tells the menu what was typed through a **handle**. The menu handles th
1718

1819
## Installation
1920

21+
<ComponentOnly>
22+
23+
Install the styled component from our registry:
24+
25+
```npm
26+
npx shadcn@latest add {{REGISTRY_URL}}/r/suggestion-menu
27+
```
28+
29+
It wraps the `@bazza-ui/react` primitive with base styles and is added to your components directory as `suggestion-menu-preview.tsx`, so it won't conflict with an existing file.
30+
31+
```tsx
32+
import { SuggestionMenu } from '@/components/ui/suggestion-menu-preview'
33+
```
34+
35+
</ComponentOnly>
36+
37+
<PrimitiveOnly>
38+
2039
Install the unstyled primitive from npm:
2140

2241
```npm
@@ -27,6 +46,8 @@ npm install @bazza-ui/react{{RELEASE_CHANNEL}}
2746
import { SuggestionMenu } from '@bazza-ui/react/suggestion-menu'
2847
```
2948

49+
</PrimitiveOnly>
50+
3051
## Anatomy
3152

3253
```tsx
@@ -115,6 +136,28 @@ onSelect: () => {
115136
}
116137
```
117138

139+
<ComponentOnly>
140+
141+
### Mentions
142+
143+
Rows can be grouped and separated like in any other menu. Give a group a `render` that wraps its rows in `SuggestionMenu.Group` with a `SuggestionMenu.GroupLabel`, and give a separator one that returns `SuggestionMenu.Separator`. Groups with no matching rows are hidden as the query narrows. As in every menu, separators only show while nothing has been typed after the trigger.
144+
145+
<Example name="suggestion-menu-mentions" />
146+
147+
### Slash commands
148+
149+
`SuggestionMenu.Shortcut` shows the editor shortcut for a command as a hint. The menu doesn't bind those keys, so everything typed still reaches the host. Since a `/` that matches nothing is probably a path, this menu uses `noResults="close"` (see [When nothing matches](#when-nothing-matches)).
150+
151+
<Example name="suggestion-menu-slash-commands" />
152+
153+
### Emoji
154+
155+
`minLength: 1` waits for a character after `:`, so a colon at the end of a sentence doesn't open the menu. This one is attached to an `<input>`, which gets the `combobox` role while the menu is open.
156+
157+
<Example name="suggestion-menu-emoji" />
158+
159+
</ComponentOnly>
160+
118161
## Editors
119162

120163
Editors decide what counts as a trigger (a `/` inside a code block usually shouldn't open a menu), so they write their own detection and drive the handle from it. They also own the rule that Escape keeps the menu closed until the trigger is typed again. A ProseMirror plugin:
@@ -188,6 +231,14 @@ Choosing a row usually edits the document, which makes the plugin call `close()`
188231

189232
Pass a loader as `asyncContent`. Suggestion menus default to `asyncContentMode="append"`: local `content` shows at once and loaded rows are added after it, with rows whose Resolved ID is already local dropped. The highlight stays on its row while results arrive.
190233

234+
<ComponentOnly>
235+
236+
Recent people are local, so they show and filter instantly. Everyone else comes from a search that takes a few hundred milliseconds. Someone who is both recent and a search result appears once, because both rows share an `id`. A spinner next to "People" shows while a search is in flight.
237+
238+
<Example name="suggestion-menu-async" />
239+
240+
</ComponentOnly>
241+
191242
This `@` mention search keeps the previous results on screen while the next query loads, and waits 150 ms after typing stops:
192243

193244
```tsx

‎apps/web/public/r/base/registry.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@
2929
}
3030
]
3131
},
32+
{
33+
"name": "suggestion-menu",
34+
"title": "Suggestion menu",
35+
"type": "registry:component",
36+
"dependencies": [
37+
"@bazza-ui/react",
38+
"@base-ui/react",
39+
"class-variance-authority"
40+
],
41+
"files": [
42+
{
43+
"path": "registry/ui/suggestion-menu/index.tsx",
44+
"type": "registry:file",
45+
"target": "components/ui/suggestion-menu-preview.tsx"
46+
}
47+
]
48+
},
3249
{
3350
"name": "filters",
3451
"title": "Filters",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
3+
"name": "suggestion-menu",
4+
"type": "registry:component",
5+
"title": "Suggestion menu",
6+
"dependencies": [
7+
"@bazza-ui/react",
8+
"@base-ui/react",
9+
"class-variance-authority"
10+
],
11+
"files": [
12+
{
13+
"path": "registry/ui/suggestion-menu/index.tsx",
14+
"content": "'use client'\n\nimport { ScrollArea } from '@base-ui/react/scroll-area'\nimport { SuggestionMenu as Primitive } from '@bazza-ui/react/suggestion-menu'\nimport { cva } from 'class-variance-authority'\nimport type * as React from 'react'\nimport { forwardRef, useCallback, useRef } from 'react'\nimport { cn } from '@/lib/utils'\n\nconst itemVariants = cva([\n 'group/row flex items-center gap-2 text-sm select-none cursor-default',\n 'data-[highlighted]:text-accent-foreground',\n 'h-8 px-4 w-full overflow-hidden relative z-[1]',\n // Highlight background, inset from the popup edges.\n 'before:absolute before:top-0 before:left-1 before:right-1 before:h-full before:rounded-md before:z-[-1]',\n 'data-[highlighted]:before:bg-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-disabled:opacity-50',\n])\n\nconst scrollAreaViewportVariants = cva('scroll-py-1 overscroll-none', {\n variants: {\n withScrollFade: {\n true: [\n // Gradient fade at the scroll edges, from Base UI ScrollArea's overflow variables.\n 'before:[--scroll-area-overflow-y-start:inherit] after:[--scroll-area-overflow-y-end:inherit]',\n 'before:block after:block',\n 'before:absolute after:absolute before:left-0 after:left-0 before:top-0 after:bottom-0',\n 'before:w-full after:w-full before:z-10 after:z-10',\n 'before:pointer-events-none after:pointer-events-none',\n 'before:bg-gradient-to-b before:from-popover before:to-transparent',\n 'after:bg-gradient-to-t after:from-popover after:to-transparent',\n 'before:h-[min(24px,var(--scroll-area-overflow-y-start,0px))] after:h-[min(24px,var(--scroll-area-overflow-y-end,24px))]',\n ],\n false: '',\n },\n },\n defaultVariants: {\n withScrollFade: true,\n },\n})\n\nconst Root = Primitive.Root\n\nconst Portal = Primitive.Portal\n\nconst Positioner = forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof Primitive.Positioner>\n>(({ className, sideOffset = 8, ...props }, ref) => (\n <Primitive.Positioner\n ref={ref}\n sideOffset={sideOffset}\n className={cn('z-50 outline-none', className)}\n {...props}\n />\n))\nPositioner.displayName = 'SuggestionMenu.Positioner'\n\nconst Popup = forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof Primitive.Popup>\n>(({ className, ...props }, ref) => (\n <Primitive.Popup\n ref={ref}\n className={(state) =>\n cn(\n 'w-64 rounded-lg border bg-popover text-popover-foreground text-sm',\n 'drop-shadow-xl overflow-hidden outline-none',\n 'origin-(--transform-origin)',\n 'opacity-100 scale-100 transition-[opacity,scale] duration-150 ease-out',\n 'motion-reduce:transition-none',\n 'data-[starting-style]:opacity-0 data-[starting-style]:scale-95',\n 'data-[ending-style]:opacity-0 data-[ending-style]:scale-95',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nPopup.displayName = 'SuggestionMenu.Popup'\n\nconst Surface = Primitive.Surface\n\nexport interface ListProps\n extends Omit<React.ComponentProps<typeof Primitive.List>, 'render'> {\n viewportRef?: React.Ref<HTMLDivElement>\n /** Maximum height of the scrollable area; never taller than the room left on screen. */\n maxHeight?: string | number\n /** Whether to fade the list out at its scroll edges. */\n withScrollFade?: boolean\n}\n\nconst List = forwardRef<HTMLDivElement, ListProps>(\n (\n {\n className,\n viewportRef,\n maxHeight = 320,\n withScrollFade = true,\n children,\n ...props\n },\n ref,\n ) => {\n const scrollContainerRef = useRef<HTMLDivElement | null>(null)\n const mergedViewportRef = useCallback(\n (node: HTMLDivElement | null) => {\n scrollContainerRef.current = node\n if (typeof viewportRef === 'function') viewportRef(node)\n else if (viewportRef) viewportRef.current = node\n },\n [viewportRef],\n )\n const maxHeightValue =\n typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight\n\n return (\n <ScrollArea.Root>\n <ScrollArea.Viewport\n ref={mergedViewportRef}\n className={scrollAreaViewportVariants({ withScrollFade })}\n style={{\n maxHeight: `min(${maxHeightValue}, var(--available-height, ${maxHeightValue}))`,\n }}\n >\n <Primitive.List\n ref={ref}\n className={(state) =>\n cn(\n // ScrollArea.Content sets `min-width: fit-content`, which lets the\n // widest row push past the popup's width; rows truncate instead.\n 'py-1 outline-none !min-w-0',\n typeof className === 'function' ? className(state) : className,\n )\n }\n render={<ScrollArea.Content />}\n scrollContainerRef={scrollContainerRef}\n {...props}\n >\n {children}\n </Primitive.List>\n </ScrollArea.Viewport>\n <ScrollArea.Scrollbar\n orientation=\"vertical\"\n className={cn(\n 'z-10 mx-0.5 my-2 flex w-1 touch-none select-none rounded-full bg-border/50',\n 'opacity-0 data-[hovering]:opacity-100 data-[scrolling]:opacity-100 hover:w-1.5',\n 'transition-[width,opacity] duration-150 ease-out',\n )}\n >\n <ScrollArea.Thumb className=\"relative flex-1 rounded-full bg-muted-foreground/50\" />\n </ScrollArea.Scrollbar>\n </ScrollArea.Root>\n )\n },\n)\nList.displayName = 'SuggestionMenu.List'\n\nconst Item = forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof Primitive.Item>\n>(({ className, ...props }, ref) => (\n <Primitive.Item\n ref={ref}\n className={(state) =>\n cn(\n itemVariants(),\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nItem.displayName = 'SuggestionMenu.Item'\n\nconst LinkItem = forwardRef<\n HTMLAnchorElement,\n React.ComponentProps<typeof Primitive.LinkItem>\n>(({ className, ...props }, ref) => (\n <Primitive.LinkItem\n ref={ref}\n className={(state) =>\n cn(\n itemVariants(),\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nLinkItem.displayName = 'SuggestionMenu.LinkItem'\n\nconst Group = Primitive.Group\n\nconst GroupLabel = forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof Primitive.GroupLabel>\n>(({ className, ...props }, ref) => (\n <Primitive.GroupLabel\n ref={ref}\n className={(state) =>\n cn(\n 'mt-3 mb-1 px-4 text-xs font-medium text-muted-foreground',\n // A label that opens the list, or follows a separator, needs less room above it.\n 'data-[first]:mt-1 [[bazzaui-suggestion-menu-separator]+*>&]:mt-2',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nGroupLabel.displayName = 'SuggestionMenu.GroupLabel'\n\nconst Separator = forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof Primitive.Separator>\n>(({ className, ...props }, ref) => (\n <Primitive.Separator\n ref={ref}\n className={(state) =>\n cn(\n 'my-1 h-px w-full bg-border',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nSeparator.displayName = 'SuggestionMenu.Separator'\n\nconst Icon = forwardRef<\n HTMLSpanElement,\n React.ComponentProps<typeof Primitive.Icon>\n>(({ className, ...props }, ref) => (\n <Primitive.Icon\n ref={ref}\n className={(state) =>\n cn(\n 'flex size-4 min-h-4 min-w-4 shrink-0 items-center justify-center',\n 'text-muted-foreground group-data-[highlighted]/row:text-primary',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n />\n))\nIcon.displayName = 'SuggestionMenu.Icon'\n\nexport interface ShortcutProps extends React.ComponentProps<'span'> {\n /** The keys, shown one `<kbd>` each, e.g. `['⌘', '⌥', '1']`. */\n keys: string[]\n}\n\n/**\n * An editor shortcut shown at the end of a row, as a hint for next time.\n * It's only a label: the menu doesn't bind the keys, so typing stays with the host.\n */\nconst Shortcut = forwardRef<HTMLSpanElement, ShortcutProps>(\n ({ className, keys, ...props }, ref) => (\n <span\n ref={ref}\n className={cn(\n 'ml-auto flex shrink-0 items-center gap-0.5 pl-4',\n 'text-xs font-medium text-muted-foreground',\n className,\n )}\n {...props}\n >\n {keys.map((key, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: keys can repeat and never reorder\n <kbd key={i} className=\"min-w-3 text-center font-sans\">\n {key}\n </kbd>\n ))}\n </span>\n ),\n)\nShortcut.displayName = 'SuggestionMenu.Shortcut'\n\nconst Empty = forwardRef<\n HTMLDivElement,\n Omit<React.ComponentProps<typeof Primitive.Empty>, 'children'> & {\n children?: React.ReactNode\n }\n>(({ className, children, ...props }, ref) => (\n <Primitive.Empty\n ref={ref}\n className={(state) =>\n cn(\n 'flex h-8 items-center justify-center text-sm text-muted-foreground',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n >\n {children ?? 'No results.'}\n </Primitive.Empty>\n))\nEmpty.displayName = 'SuggestionMenu.Empty'\n\nconst Loading = forwardRef<\n HTMLDivElement,\n Omit<React.ComponentProps<typeof Primitive.Loading>, 'children'> & {\n children?: React.ReactNode\n }\n>(({ className, children, ...props }, ref) => (\n <Primitive.Loading\n ref={ref}\n className={(state) =>\n cn(\n 'flex h-8 items-center justify-center text-sm text-muted-foreground',\n typeof className === 'function' ? className(state) : className,\n )\n }\n {...props}\n >\n {children ?? 'Loading…'}\n </Primitive.Loading>\n))\nLoading.displayName = 'SuggestionMenu.Loading'\n\nexport const SuggestionMenu = {\n Root,\n Portal,\n Positioner,\n Popup,\n Surface,\n List,\n Item,\n LinkItem,\n Group,\n GroupLabel,\n Separator,\n Icon,\n Shortcut,\n Empty,\n Loading,\n createHandle: Primitive.createHandle,\n attachTextTrigger: Primitive.attachTextTrigger,\n useTextTrigger: Primitive.useTextTrigger,\n useDataList: Primitive.useDataList,\n useAsyncMenuCoordinator: Primitive.useAsyncMenuCoordinator,\n}\n",
15+
"type": "registry:file",
16+
"target": "components/ui/suggestion-menu-preview.tsx"
17+
}
18+
]
19+
}

‎apps/web/registry.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@
2929
}
3030
]
3131
},
32+
{
33+
"name": "suggestion-menu",
34+
"title": "Suggestion menu",
35+
"type": "registry:component",
36+
"dependencies": [
37+
"@bazza-ui/react",
38+
"@base-ui/react",
39+
"class-variance-authority"
40+
],
41+
"files": [
42+
{
43+
"path": "registry/ui/suggestion-menu/index.tsx",
44+
"type": "registry:file",
45+
"target": "components/ui/suggestion-menu-preview.tsx"
46+
}
47+
]
48+
},
3249
{
3350
"name": "filters",
3451
"title": "Filters",

‎apps/web/registry/__index__.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,24 @@ export const examples = {
331331
{ extraFiles: ['components.tsx', 'icons.tsx'] },
332332
),
333333
},
334+
'suggestion-menu': {
335+
textarea: ex(
336+
() => import('@/registry/examples/styled/suggestion-menu/textarea'),
337+
),
338+
mentions: ex(
339+
() => import('@/registry/examples/styled/suggestion-menu/mentions'),
340+
),
341+
'slash-commands': ex(
342+
() =>
343+
import('@/registry/examples/styled/suggestion-menu/slash-commands'),
344+
),
345+
emoji: ex(
346+
() => import('@/registry/examples/styled/suggestion-menu/emoji'),
347+
),
348+
async: ex(
349+
() => import('@/registry/examples/styled/suggestion-menu/async'),
350+
),
351+
},
334352
}),
335353

336354
// Unstyled example variants live here as primitives docs pages adopt them.
@@ -385,6 +403,12 @@ const createNullComponent = () =>
385403
React.lazy(() => Promise.resolve({ default: () => null }))
386404

387405
export const ui: RegistryIndex = {
406+
'suggestion-menu': {
407+
name: 'suggestion-menu',
408+
type: 'registry:ui',
409+
component: createNullComponent(),
410+
files: ['registry/ui/suggestion-menu/index.tsx'],
411+
},
388412
'command-menu': {
389413
name: 'command-menu',
390414
type: 'registry:ui',

0 commit comments

Comments
 (0)