Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/web/content/docs/dropdown-menu/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,17 @@ Then, compose with the same parts for the root menu to render the popup.
</Example.PreviewCode>
</Example>

### Subpage navigation
### Subpage

Use subpages to model deeper menu flows without relying on deep-search/data-first APIs.
Use subpages as a natural replacement to modals, great for workflows such as item creation.

<Example name="dropdown-menu-subpage-linear" />
Use the `<DropdownMenu.Subpage>` part, give it a `pageId`, and wrap it around a new `<DropdownMenu.Surface>` with the subpage content.

Render a `<DropdownMenu.SubpageTrigger>` and wire it up using the `targetPageId` prop.



<Example name="dropdown-menu-subpage" />

### Virtualized

Expand Down
21 changes: 21 additions & 0 deletions apps/web/registry/__index__.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ export const examples: RegistryIndex = {
'registry/examples/dropdown-menu/subpage-linear/icons.tsx',
],
},
'dropdown-menu-subpage': {
name: 'dropdown-menu-subpage',
type: 'registry:example',
component: React.lazy(
() => import('@/registry/examples/dropdown-menu/subpage'),
),
files: ['registry/examples/dropdown-menu/subpage/index.tsx'],
},
'dropdown-menu-linear-subpage-label-creation': {
name: 'dropdown-menu-linear-subpage-label-creation',
type: 'registry:example',
component: React.lazy(
() =>
import(
'@/registry/examples/dropdown-menu/linear-subpage-label-creation'
),
),
files: [
'registry/examples/dropdown-menu/linear-subpage-label-creation/index.tsx',
],
},
'dropdown-menu-search': {
name: 'dropdown-menu-search',
type: 'registry:example',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,16 @@ function buildMenuContent(): NodeDef[] {
)

return [
aiFilterSubpage,
{
kind: 'separator',
id: 'ai-filter-separator',
render: ({ props }) => <DropdownMenu.Separator {...props} />,
},
statusMenu,
assigneeMenu,
priorityMenu,
labelsMenu,
aiFilterSubpage,
projectPropertiesMenu,
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ function createLabelsSubmenu(params: {
</div>
</DropdownMenu.SubmenuTrigger>
<DropdownMenu.Portal>
<DropdownMenu.Positioner align="list-start" className="relative">
<DropdownMenu.Positioner className="relative" sideOffset={12}>
<DropdownMenu.Popup
render={({ className, children, ...props }, state) => (
<>
Expand Down Expand Up @@ -810,9 +810,9 @@ export default function DropdownMenuDeepSearchSubpagesLinear() {
debug={{
showSafeTriangleArea: {
enabled: true,
showMissState: true,
showMissState: false,
missColor: '#ff4d4f',
missFreezeDuration: 180,
missFreezeDuration: 2000,
},
}}
>
Expand Down
14 changes: 2 additions & 12 deletions apps/web/registry/examples/dropdown-menu/subpage-linear/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ function SubmenuSurface({
export default function DropdownMenuSubpageLinear() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger render={<Button variant="ghost" size="sm" />}>
<DropdownMenu.Trigger render={<Button variant="outline" size="sm" />}>
<FilterIcon />
Filter
</DropdownMenu.Trigger>
Expand All @@ -242,13 +242,6 @@ export default function DropdownMenuSubpageLinear() {
<DropdownMenu.List>
<DropdownMenu.Empty />

<DropdownMenu.SubpageTrigger targetPageId="ai-filter">
<AIFilterIcon />
AI Filter
</DropdownMenu.SubpageTrigger>

<DropdownMenu.Separator />

<DropdownMenu.Submenu>
<DropdownMenu.SubmenuTrigger>
<MenuLabel icon={<StatusIcon />} label="Status" />
Expand Down Expand Up @@ -326,10 +319,7 @@ export default function DropdownMenuSubpageLinear() {
label="Project properties"
/>
</DropdownMenu.SubmenuTrigger>
<SubmenuSurface
placeholder="Project properties..."
hideUntilActive
>
<SubmenuSurface placeholder="Project properties...">
<DropdownMenu.Submenu>
<DropdownMenu.SubmenuTrigger>
<MenuLabel
Expand Down
213 changes: 213 additions & 0 deletions apps/web/registry/examples/dropdown-menu/subpage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { PlusIcon } from 'lucide-react'
import React from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { DropdownMenu } from '@/registry/ui/dropdown-menu'

const CREATABLE_LABEL_COLORS = [
{ id: 'red', label: 'Red' },
{ id: 'orange', label: 'Orange' },
{ id: 'green', label: 'Green' },
{ id: 'blue', label: 'Blue' },
{ id: 'violet', label: 'Violet' },
{ id: 'pink', label: 'Pink' },
] as const

export const LABEL_STYLES_BG = {
red: 'bg-red-500',
orange: 'bg-orange-500',
amber: 'bg-amber-500',
yellow: 'bg-yellow-500',
lime: 'bg-lime-500',
green: 'bg-green-500',
emerald: 'bg-emerald-500',
teal: 'bg-teal-500',
cyan: 'bg-cyan-500',
sky: 'bg-sky-500',
blue: 'bg-blue-500',
indigo: 'bg-indigo-500',
violet: 'bg-violet-500',
purple: 'bg-purple-500',
fuchsia: 'bg-fuchsia-500',
pink: 'bg-pink-500',
rose: 'bg-rose-500',
neutral: 'bg-neutral-500',
} as const

export type TW_COLOR = keyof typeof LABEL_STYLES_BG

type LabelRecord = {
id: string
name: string
color: TW_COLOR
checked?: boolean
}

const labelsData: LabelRecord[] = [
{ id: 'frontend', name: 'Frontend', color: 'orange' },
{ id: 'backend', name: 'Backend', color: 'teal' },
{ id: 'api', name: 'API', color: 'red' },
{ id: 'security', name: 'Security', color: 'sky' },
{ id: 'testing', name: 'Testing', color: 'yellow' },
{ id: 'documentation', name: 'Documentation', color: 'rose' },
]

export default function SubpageDemo() {
const [labels, setLabels] = React.useState<LabelRecord[]>(labelsData)
const [search, setSearch] = React.useState<string>('')

const selectedLabels = React.useMemo(
() => labels.filter((label) => label.checked),
[labels],
)

const hasSelectedLabels = React.useMemo(
() => selectedLabels.length > 0,
[selectedLabels.length],
)

const normalizedSearch = React.useMemo(
() => search.trim().toLowerCase(),
[search],
)

const foundExactMatch = React.useMemo(
() => labels.find((label) => normalizedSearch === label.name.toLowerCase()),
[labels, normalizedSearch],
)

function createLabel(value: LabelRecord) {
setLabels((prev) => [...prev, { ...value, checked: true }])
toast(`Created label: ${value.name}`)
}

function handleLabelCheckedChange(id: string, checked: boolean) {
setLabels((prev) =>
prev.map((label) => (label.id === id ? { ...label, checked } : label)),
)
}

return (
<div className="flex flex-col items-center gap-y-8">
<div className="flex flex-col items-center gap-y-2 text-sm text-muted-foreground">
<span>Select labels to add them to the list.</span>
<span>
Then, search for a label that doesn't exist (e.g. "Bug") and create
it.
</span>
</div>
<DropdownMenu.Root>
<div className="flex items-center gap-2">
{selectedLabels.map((label) => (
<div
key={label.id}
className="rounded-2xl border px-2.5 h-7.5 flex items-center gap-2"
>
<div
className={cn(
LABEL_STYLES_BG[label.color],
'size-2.5 rounded-full',
)}
/>
<span className="text-sm">{label.name}</span>
</div>
))}
<DropdownMenu.Trigger
render={
<Button
variant="ghost"
className={cn(
'h-7.5',
hasSelectedLabels && 'size-7.5 rounded-full',
)}
size={hasSelectedLabels ? 'icon' : 'sm'}
/>
}
>
<PlusIcon />
{selectedLabels.length === 0 && 'Add label'}
</DropdownMenu.Trigger>
</div>
<DropdownMenu.Portal>
<DropdownMenu.Positioner disableAnchorTracking>
<DropdownMenu.Popup>
<DropdownMenu.Surface search={search} onSearchChange={setSearch}>
<DropdownMenu.Input placeholder="Add or change labels..." />
<DropdownMenu.List>
{labels.map((label) => (
<DropdownMenu.CheckboxItem
key={label.id}
id={label.name}
checked={label.checked}
onCheckedChange={(checked) =>
handleLabelCheckedChange(label.id, checked)
}
>
<DropdownMenu.CheckboxItemIndicator />
<DropdownMenu.Icon>
<div
className={cn(
LABEL_STYLES_BG[label.color],
'size-2.5 rounded-full',
)}
/>
</DropdownMenu.Icon>
{label.name}
</DropdownMenu.CheckboxItem>
))}
{normalizedSearch.length > 0 && !foundExactMatch && (
<DropdownMenu.SubpageTrigger
targetPageId="create-label"
forceMount
>
<DropdownMenu.Icon render={<PlusIcon />} />
<span className="whitespace-nowrap truncate">
Create new label:{' '}
<span className="text-muted-foreground">
"{search}"
</span>
</span>
</DropdownMenu.SubpageTrigger>
)}
<DropdownMenu.Empty />
</DropdownMenu.List>
</DropdownMenu.Surface>
<DropdownMenu.Subpage pageId="create-label">
<DropdownMenu.Surface>
<DropdownMenu.Input placeholder="Choose color for label" />
<DropdownMenu.List>
{CREATABLE_LABEL_COLORS.map((color) => (
<DropdownMenu.SubpageBackItem
key={color.id}
value={color.label}
onSelect={() =>
createLabel({
id: search,
name: search,
color: color.id,
})
}
>
<DropdownMenu.Icon>
<div
className={cn(
LABEL_STYLES_BG[color.id],
'size-2.5 rounded-full',
)}
/>
</DropdownMenu.Icon>

{color.label}
</DropdownMenu.SubpageBackItem>
))}
</DropdownMenu.List>
</DropdownMenu.Surface>
</DropdownMenu.Subpage>
</DropdownMenu.Popup>
</DropdownMenu.Positioner>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)
}
23 changes: 1 addition & 22 deletions apps/web/registry/ui/dropdown-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,7 @@ function Root({
[onOpenChange],
)

return (
<Primitive.Root
onOpenChange={handleOpenChange}
onHighlightChange={(id, index) => {
console.log('highlight changed to', id, 'at index', index)
}}
{...props}
/>
)
return <Primitive.Root onOpenChange={handleOpenChange} {...props} />
}

const Trigger = Primitive.Trigger
Expand Down Expand Up @@ -892,13 +884,6 @@ function Submenu({
>
>[1],
) => {
console.log('[DropdownMenu.Submenu] onOpenChange:', {
open,
reason: eventDetails.reason,
hasEvent: !!eventDetails.event,
hasCancel: typeof eventDetails.cancel === 'function',
})

// Prevent closing when clicking on feedback toolbar elements
if (
!open &&
Expand All @@ -910,13 +895,7 @@ function Submenu({
const feedbackToolbar = target?.closest(
'[data-feedback-toolbar="true"]',
)
console.log('[DropdownMenu.Submenu] outside-press check:', {
target: target?.tagName,
targetClasses: target?.className,
feedbackToolbar: !!feedbackToolbar,
})
if (feedbackToolbar) {
console.log('[DropdownMenu.Submenu] Cancelling close!')
eventDetails.cancel()
return
}
Expand Down
Loading