|
|
@@ -1,55 +1,195 @@
|
|
|
<script setup lang="ts">
|
|
|
-import type { CommandPaletteGroup } from "@nuxt/ui"
|
|
|
-import { ref } from "vue"
|
|
|
+/**
|
|
|
+ * EditorSuggestionMenu
|
|
|
+ *
|
|
|
+ * Self-contained suggestion listbox for slash commands (`/`) and mention
|
|
|
+ * completions (`[[`, `((`, `#`). Driven entirely by props — no internal
|
|
|
+ * state machine, no external UI library dependency for navigation.
|
|
|
+ *
|
|
|
+ * Keyboard navigation is exposed via `forwardKey(key)` so that the
|
|
|
+ * capture-phase handler in `useKeyboard.ts` can route ArrowDown/ArrowUp/
|
|
|
+ * Enter/Escape without dispatching synthetic DOM events.
|
|
|
+ *
|
|
|
+ * Items are grouped by `groups` (matching Nuxt UI's `CommandPaletteGroup`
|
|
|
+ * shape). Each item can have an icon, label, suffix, kbd hints, and an
|
|
|
+ * `onSelect` callback. The actively highlighted item is tracked by
|
|
|
+ * `activeIndex` (flat across all groups) and rendered via
|
|
|
+ * `data-highlighted` attribute — styled with CSS tokens matching the
|
|
|
+ * existing editor theme.
|
|
|
+ */
|
|
|
+import type { CommandPaletteGroup, CommandPaletteItem } from "@nuxt/ui"
|
|
|
+import { computed, ref, watch } from "vue"
|
|
|
|
|
|
const props = defineProps<{
|
|
|
+ /** Grouped items following Nuxt UI's CommandPaletteGroup shape. */
|
|
|
groups: CommandPaletteGroup[]
|
|
|
+ /** Screen-space position (top-left of the trigger character). */
|
|
|
position: { x: number; y: number }
|
|
|
+ /** Current search/filter text (shown in the empty-state message). */
|
|
|
query: string
|
|
|
+ /** Whether the menu is visible. Resets activeIndex to 0 on transition. */
|
|
|
active: boolean
|
|
|
}>()
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
+ /**
|
|
|
+ * Fired when an item is selected via click or Enter, or when
|
|
|
+ * Escape is pressed. The parent should clear the pattern session.
|
|
|
+ */
|
|
|
close: []
|
|
|
}>()
|
|
|
|
|
|
-const paletteRef = ref<any>(null)
|
|
|
+/** Filter groups by query (case-insensitive match against item label). */
|
|
|
+const filteredGroups = computed(() => {
|
|
|
+ const q = props.query.toLowerCase()
|
|
|
+ if (!q) return props.groups
|
|
|
+ return props.groups
|
|
|
+ .map((g) => ({
|
|
|
+ ...g,
|
|
|
+ items: g.items?.filter((item) => item.label?.toLowerCase().includes(q)),
|
|
|
+ }))
|
|
|
+ .filter((g) => g.items && g.items.length > 0)
|
|
|
+})
|
|
|
|
|
|
+/** Index of the currently highlighted item (flat across all filtered groups). */
|
|
|
+const activeIndex = ref(0)
|
|
|
+const total = computed(() =>
|
|
|
+ filteredGroups.value.reduce((sum, g) => sum + (g.items?.length ?? 0), 0),
|
|
|
+)
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.active,
|
|
|
+ (v) => {
|
|
|
+ if (v) activeIndex.value = 0
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.query,
|
|
|
+ () => {
|
|
|
+ activeIndex.value = 0
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+/** Convert (group index, item-within-group index) to a flat global index. */
|
|
|
+function getGlobalIndex(groupIdx: number, itemIdx: number): number {
|
|
|
+ let offset = 0
|
|
|
+ for (let g = 0; g < groupIdx; g++) {
|
|
|
+ offset += filteredGroups.value[g]?.items?.length ?? 0
|
|
|
+ }
|
|
|
+ return offset + itemIdx
|
|
|
+}
|
|
|
+
|
|
|
+/** Select an item by group + item index. Calls onSelect and emits close. */
|
|
|
+function selectItem(groupIdx: number, itemIdx: number) {
|
|
|
+ const group = filteredGroups.value[groupIdx]
|
|
|
+ const item = group?.items?.[itemIdx]
|
|
|
+ if (item && !item.disabled) {
|
|
|
+ item.onSelect?.(new Event("select"))
|
|
|
+ emit("close")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Drive keyboard navigation externally.
|
|
|
+ *
|
|
|
+ * Called by the capture-phase handler in `useKeyboard.ts` when a pattern
|
|
|
+ * session is active. Updates `activeIndex` for ArrowUp/Down, selects
|
|
|
+ * the highlighted item on Enter, and closes on Escape.
|
|
|
+ */
|
|
|
function forwardKey(key: string) {
|
|
|
- const input = paletteRef.value?.$el?.querySelector("input")
|
|
|
- input?.dispatchEvent(
|
|
|
- new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
|
- )
|
|
|
+ if (key === "Escape") {
|
|
|
+ emit("close")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (total.value === 0) return
|
|
|
+ if (key === "ArrowDown") {
|
|
|
+ activeIndex.value = (activeIndex.value + 1) % total.value
|
|
|
+ } else if (key === "ArrowUp") {
|
|
|
+ activeIndex.value = (activeIndex.value - 1 + total.value) % total.value
|
|
|
+ } else if (key === "Enter") {
|
|
|
+ const item = findItemByGlobalIndex(activeIndex.value)
|
|
|
+ if (item && !item.disabled) {
|
|
|
+ item.onSelect?.(new Event("select"))
|
|
|
+ }
|
|
|
+ emit("close")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Resolve a flat global index (on filtered items) back to the original item. */
|
|
|
+function findItemByGlobalIndex(index: number): CommandPaletteItem | null {
|
|
|
+ let offset = 0
|
|
|
+ for (const g of filteredGroups.value) {
|
|
|
+ if (!g.items) continue
|
|
|
+ if (index < offset + g.items.length) {
|
|
|
+ return g.items[index - offset] ?? null
|
|
|
+ }
|
|
|
+ offset += g.items.length
|
|
|
+ }
|
|
|
+ return null
|
|
|
}
|
|
|
|
|
|
defineExpose({ forwardKey })
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
- <Teleport to="body">
|
|
|
- <Transition
|
|
|
- enter-active-class="transition ease-out duration-100"
|
|
|
- enter-from-class="opacity-0 scale-95"
|
|
|
- enter-to-class="opacity-100 scale-100"
|
|
|
- leave-active-class="transition ease-in duration-75"
|
|
|
- leave-from-class="opacity-100 scale-100"
|
|
|
- leave-to-class="opacity-0 scale-95"
|
|
|
- >
|
|
|
- <div
|
|
|
- v-if="active"
|
|
|
- data-command-palette
|
|
|
- class="fixed z-50 w-80 shadow-lg rounded-lg overflow-hidden ring ring-default bg-(--ui-bg)"
|
|
|
- :style="{ top: `${props.position.y + 24}px`, left: `${props.position.x}px` }"
|
|
|
- >
|
|
|
- <UCommandPalette
|
|
|
- ref="paletteRef"
|
|
|
- :groups="props.groups"
|
|
|
- :autofocus="false"
|
|
|
- :ui="{ input: 'sr-only' }"
|
|
|
- :search-term="props.query"
|
|
|
- @update:model-value="emit('close')"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </Transition>
|
|
|
- </Teleport>
|
|
|
+ <div
|
|
|
+ v-if="active"
|
|
|
+ data-command-palette
|
|
|
+ class="flex flex-col absolute z-50 w-80 shadow-lg rounded-lg overflow-hidden ring ring-default bg-(--ui-bg)"
|
|
|
+ :style="{ top: `${props.position.y + 24}px`, left: `${props.position.x}px` }"
|
|
|
+ >
|
|
|
+ <template v-for="(group, gIdx) in filteredGroups" :key="group.id">
|
|
|
+ <div
|
|
|
+ v-if="group.label && group.items?.length"
|
|
|
+ class="px-3 py-1.5 text-xs font-semibold text-(--ui-text-muted) uppercase tracking-wider select-none"
|
|
|
+ >
|
|
|
+ {{ group.label }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="(item, iIdx) in group.items"
|
|
|
+ :key="`${group.id}-${iIdx}`"
|
|
|
+ :data-highlighted="getGlobalIndex(gIdx, iIdx) === activeIndex ? '' : undefined"
|
|
|
+ :class="[
|
|
|
+ 'flex items-center gap-2.5 px-3 py-2 cursor-pointer text-sm transition-colors',
|
|
|
+ item.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-(--ui-bg-elevated)/60',
|
|
|
+ getGlobalIndex(gIdx, iIdx) === activeIndex ? 'bg-(--ui-bg-elevated)' : '',
|
|
|
+ ]"
|
|
|
+ @click="selectItem(gIdx, iIdx)"
|
|
|
+ @mouseenter="activeIndex = getGlobalIndex(gIdx, iIdx)"
|
|
|
+ >
|
|
|
+ <UIcon
|
|
|
+ v-if="item.icon"
|
|
|
+ :name="item.icon"
|
|
|
+ class="w-5 h-5 shrink-0 text-(--ui-text-muted)"
|
|
|
+ />
|
|
|
+
|
|
|
+ <span class="flex-1 truncate text-(--ui-text-main)">{{ item.label }}</span>
|
|
|
+
|
|
|
+ <span
|
|
|
+ v-if="item.suffix"
|
|
|
+ class="text-xs text-(--ui-text-muted) font-mono ml-auto"
|
|
|
+ >{{ item.suffix }}</span>
|
|
|
+
|
|
|
+ <span
|
|
|
+ v-else-if="item.kbds?.length"
|
|
|
+ class="flex items-center gap-1 ml-auto"
|
|
|
+ >
|
|
|
+ <kbd
|
|
|
+ v-for="(k, kIdx) in item.kbds"
|
|
|
+ :key="kIdx"
|
|
|
+ class="px-1 py-0.5 text-[10px] font-mono rounded bg-(--ui-bg-elevated)/50 text-(--ui-text-muted) border border-(--ui-border)"
|
|
|
+ >{{ k }}</kbd>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="!total"
|
|
|
+ class="px-3 py-4 text-sm text-(--ui-text-muted) text-center select-none"
|
|
|
+ >
|
|
|
+ {{ query ? `No results for "${query}"` : "No items" }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</template>
|