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
40 changes: 31 additions & 9 deletions src/components/command/CommandDialog.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { reactiveOmit } from '@vueuse/core'
import { useForwardPropsEmits } from 'reka-ui'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '#/components/dialog'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '#/components/dialog'
import Command from './Command.vue'

const props = withDefaults(defineProps<DialogRootProps & {
/** A compact, titled chooser; palette callers keep their search-first layout. */
variant?: 'palette' | 'picker'
title?: string
description?: string
}>(), {
variant: 'palette',
title: 'Command Palette',
description: 'Search for a command to run...',
})
const emits = defineEmits<DialogRootEmits>()

const forwarded = useForwardPropsEmits(props, emits)
const forwarded = useForwardPropsEmits(reactiveOmit(props, 'variant', 'title', 'description'), emits)
</script>

<template>
Expand All @@ -30,16 +34,19 @@ const forwarded = useForwardPropsEmits(props, emits)
menu-shell, card vs popover, shadow-2xl vs dropdown). The backdrop scrim AND the
open/close motion (100ms fade + 2% zoom) are inherited from DialogContent itself —
we ONLY strip chrome here, so the palette shares the one modal scrim + motion
language instead of re-declaring its own. The close X is dropped
(show-close-button=false): a palette dismisses on Esc / outside-click / selection,
and a corner X both clashed with the search row and skipped the icon-button
contract. Width is held to a palette-friendly max-w-md.
language instead of re-declaring its own. A palette dismisses on Esc,
outside-click or selection. The compact picker instead owns a visible
title and the shared close button, with one narrower width rung.
-->
<DialogContent
:show-close-button="false"
class="gap-0 border-0 bg-transparent p-0 shadow-none rounded-none sm:max-w-md"
class="gap-0 border-0 bg-transparent p-0 shadow-none rounded-none"
:class="variant === 'picker' ? 'sm:max-w-sm' : 'sm:max-w-md'"
>
<DialogHeader class="sr-only">
<DialogHeader
v-if="variant === 'palette'"
class="sr-only"
>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
Expand All @@ -49,7 +56,22 @@ const forwarded = useForwardPropsEmits(props, emits)
hairline in dark mode, and NONE in light mode — a white palette already
separates from the dark scrim by luminance, so a dark hairline there would
just muddy the edge instead of sharpening it. -->
<Command class="border-[color:var(--border-menu-elevated)] shadow-[var(--shadow-modal)]">
<Command
class="border-[color:var(--border-menu-elevated)] shadow-[var(--shadow-modal)]"
:class="variant === 'picker' ? 'gap-3 p-4 [&_[data-slot=command-group]]:p-0' : undefined"
>
<DialogHeader
v-if="variant === 'picker'"
class="relative min-h-8 justify-center px-3 pr-10 text-left"
>
<DialogTitle class="text-control leading-5">
{{ title }}
</DialogTitle>
<DialogDescription v-if="description">
{{ description }}
</DialogDescription>
Comment on lines +70 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep palette-only copy out of picker headers

When variant="picker" is used without explicitly passing description="", the existing truthy default makes this new branch visibly render “Search for a command to run...” even for non-search pickers such as a team chooser. Make the default variant-specific or render the description only when the caller supplied one; otherwise this generic picker exposes misleading command-palette copy.

AGENTS.md reference: AGENTS.md:L55-L55

Useful? React with 👍 / 👎.

<DialogCloseButton class="top-0 right-0" />
</DialogHeader>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
Expand Down
6 changes: 4 additions & 2 deletions src/components/dropdown-menu/DropdownMenuSubContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ import { cn } from '#/lib/utils'

const props = withDefaults(defineProps<DropdownMenuSubContentProps & {
class?: HTMLAttributes['class']
/** Information panels can align their top edge with the parent menu. */
alignment?: 'first-item' | 'parent-start'
/** Searchable/virtualized content owns its own scroll viewport and frame. */
scrollable?: boolean
}>(), { scrollable: true })
const emits = defineEmits<DropdownMenuSubContentEmits>()

const delegatedProps = reactiveOmit(props, 'class', 'scrollable')
const delegatedProps = reactiveOmit(props, 'class', 'scrollable', 'alignment')

const forwarded = useForwardPropsEmits(delegatedProps, emits)
const menuArea = ref<InstanceType<typeof MenuScrollArea>>()
const customBody = ref<HTMLElement>()
const currentElement = computed(() => (menuArea.value?.viewportElement ?? customBody.value)
?.closest<HTMLElement>('[data-slot="dropdown-menu-sub-content"]') ?? undefined)
const { alignOffset, ready } = useSubmenuAlignment(currentElement)
const { alignOffset, ready } = useSubmenuAlignment(currentElement, () => props.alignment ?? 'first-item')
// Start the entrance on a fresh frame after mounting and placement have settled.
// Otherwise a long list can consume the animation before its first visible paint.
const motionReady = ref(false)
Expand Down
31 changes: 31 additions & 0 deletions src/components/dropdown-menu/DropdownMenuSummary.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script setup lang="ts">
// Non-interactive menu information uses the same inset and title size as rows.
// Keep the value beside the title and the description beneath it; callers supply
// content rather than rebuilding this hierarchy with a group label.
defineProps<{
title: string
value?: string
description?: string
}>()
</script>

<template>
<div
data-slot="dropdown-menu-summary"
class="cursor-default select-none px-3 py-2"
>
<div class="flex items-baseline justify-between gap-4 text-control font-normal text-popover-foreground">
<span class="min-w-0">{{ title }}</span>
<span
v-if="value"
class="shrink-0"
>{{ value }}</span>
</div>
<p
v-if="description"
class="mt-0.5 text-body text-muted-foreground"
>
{{ description }}
</p>
</div>
</template>
1 change: 1 addition & 0 deletions src/components/dropdown-menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuSummary } from './DropdownMenuSummary.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
Expand Down
14 changes: 13 additions & 1 deletion src/lib/useSubmenuAlignment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { ref, watchPostEffect, type Ref } from 'vue'

/** Align the first row with its trigger, leaving collision handling to Reka. */
export function useSubmenuAlignment(content: Readonly<Ref<HTMLElement | undefined>>) {
export function useSubmenuAlignment(
content: Readonly<Ref<HTMLElement | undefined>>,
alignment: () => 'first-item' | 'parent-start' = () => 'first-item',
) {
const alignOffset = ref(0)
const ready = ref(false)
watchPostEffect((cleanup) => {
Expand All @@ -10,7 +13,16 @@ export function useSubmenuAlignment(content: Readonly<Ref<HTMLElement | undefine
if (!element) return
const trigger = document.getElementById(element.getAttribute('aria-labelledby') ?? '')
if (!trigger) { ready.value = true; return }
const mode = alignment()
const measure = () => {
if (mode === 'parent-start') {
const parent = trigger.closest<HTMLElement>('[role="menu"]')
alignOffset.value = parent
? parent.getBoundingClientRect().top - trigger.getBoundingClientRect().top
: 0
ready.value = true
return
}
// Virtual listboxes may unmount their first option while scrolling. Only
// anchor to logical row one, never to the first currently mounted row.
const first = element.querySelector<HTMLElement>(
Expand Down
Loading