feat(ui): add shared dialog shells

- Build typed Reka-based dialog and alert-dialog anatomy with shared theme slots

- Migrate variables, asset details, and ACP permissions to the common shell

- Add Storybook coverage for reusable dialog states
This commit is contained in:
Danila Poyarkov 2026-07-26 14:07:15 +03:00
parent 1436b9cb23
commit 0a46ff64eb
14 changed files with 755 additions and 511 deletions

View file

@ -8,10 +8,6 @@ import {
ContextMenuRoot,
ContextMenuTrigger,
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle
} from 'reka-ui'
@ -25,7 +21,7 @@ import AssetThumbnail from '@/components/assets-panel/AssetThumbnail.vue'
import { findAssetPage } from '@/components/assets-panel/page'
import AppInput from '@/components/ui/AppInput.vue'
import { useButtonUI } from '@/components/ui/button'
import { useDialogUI } from '@/components/ui/dialog'
import { AppDialogRoot, useDialogUI } from '@/components/ui/dialog'
import { useMenuUI } from '@/components/ui/menu'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tip from '@/components/ui/Tip.vue'
@ -66,7 +62,7 @@ const previewLoading = ref(false)
let previewRequestId = 0
const insertButton = useButtonUI({ tone: 'ghost', size: 'iconSm' })
const primaryButton = useButtonUI({ tone: 'accent', size: 'md' })
const dialog = useDialogUI({ content: 'flex w-[720px] max-w-[92vw] flex-col overflow-hidden' })
const dialog = useDialogUI()
const contextMenu = useMenuUI({ content: 'min-w-44' })
const viewOptions = computed(() => [
{ value: 'grid', label: panels.value.gridView },
@ -409,140 +405,126 @@ function insertSelectedAsset() {
</div>
</div>
<DialogRoot v-model:open="detailsOpen">
<DialogPortal>
<DialogOverlay :class="dialog.overlay" />
<DialogContent
v-if="selectedAsset"
data-test-id="asset-details-dialog"
:class="dialog.content"
<AppDialogRoot
v-if="selectedAsset"
v-model:open="detailsOpen"
size="lg"
data-test-id="asset-details-dialog"
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<component :is="nodeIcon(selectedAsset.node)" class="size-4 shrink-0 text-component" />
<div class="min-w-0">
<DialogTitle :class="dialog.title" class="truncate">{{
selectedAsset.name
}}</DialogTitle>
<p class="mt-0.5 text-[11px] text-muted">
{{
selectedAsset.node.type === 'COMPONENT_SET' ? panels.componentSet : panels.component
}}
<span v-if="selectedAsset.variantCount > 0">
· {{ selectedAsset.variantCount }} variants</span
>
</p>
</div>
</div>
<DialogClose
data-test-id="asset-details-close"
class="flex size-7 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<component
:is="nodeIcon(selectedAsset.node)"
class="size-4 shrink-0 text-component"
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<div class="grid min-h-0 grid-cols-[260px_1fr] gap-0">
<div class="border-r border-border p-4">
<div
data-test-id="asset-details-preview"
class="flex h-36 items-center justify-center overflow-hidden rounded-lg border border-border bg-canvas/60"
>
<img
v-if="previewUrl"
data-test-id="asset-details-preview-image"
:src="previewUrl"
:alt="`${selectedAsset.name} preview`"
class="max-h-[120px] max-w-[210px] object-contain"
/>
<div v-else class="text-center">
<icon-lucide-loader-2
v-if="previewLoading"
class="mx-auto size-5 animate-spin text-muted"
/>
<div class="min-w-0">
<DialogTitle :class="dialog.title" class="truncate">{{
selectedAsset.name
}}</DialogTitle>
<p class="mt-0.5 text-[11px] text-muted">
{{
selectedAsset.node.type === 'COMPONENT_SET'
? panels.componentSet
: panels.component
}}
<span v-if="selectedAsset.variantCount > 0">
· {{ selectedAsset.variantCount }} variants</span
>
</p>
</div>
<component
v-else
:is="nodeIcon(selectedAsset.node)"
class="mx-auto size-8 text-component"
/>
<p class="mt-2 max-w-44 truncate text-xs font-medium text-surface">
{{ selectedAsset.name }}
</p>
</div>
<DialogClose
data-test-id="asset-details-close"
class="flex size-7 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
</div>
<button
data-test-id="asset-details-insert"
:class="primaryButton.base"
class="mt-3 w-full"
@click="insertSelectedAsset"
>
{{ panels.insertInstance }}
</button>
</div>
<div class="min-w-0 p-4">
<section v-if="selectedAsset.description" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.description }}
</h3>
<p data-test-id="asset-details-description" class="mt-1 text-xs leading-5 text-surface">
{{ selectedAsset.description }}
</p>
</section>
<section v-if="selectedAsset.sourceLibraryKey" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.assetLibraryBadge }}
</h3>
<p data-test-id="asset-details-library" class="mt-1 break-all text-xs text-muted">
{{ selectedAsset.sourceLibraryKey }}
</p>
</section>
<section v-if="selectedAsset.docsUrl" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.documentation }}
</h3>
<button
data-test-id="asset-details-docs"
class="mt-1 inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs text-component hover:bg-component/10"
@click="selectedAsset.docsUrl ? openExternalLink(selectedAsset.docsUrl) : undefined"
>
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<icon-lucide-book-open class="size-3" />
{{ panels.openDocs }}
</button>
</section>
<div class="grid min-h-0 grid-cols-[260px_1fr] gap-0">
<div class="border-r border-border p-4">
<section v-if="selectedAsset.variants.length > 0">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.properties }}
</h3>
<div class="mt-2 flex flex-col gap-2">
<div
data-test-id="asset-details-preview"
class="flex h-36 items-center justify-center overflow-hidden rounded-lg border border-border bg-canvas/60"
v-for="variant in selectedAsset.variants"
:key="variant.name"
data-test-id="asset-details-property"
class="rounded border border-border bg-input/40 px-2 py-1.5"
>
<img
v-if="previewUrl"
data-test-id="asset-details-preview-image"
:src="previewUrl"
:alt="`${selectedAsset.name} preview`"
class="max-h-[120px] max-w-[210px] object-contain"
/>
<div v-else class="text-center">
<icon-lucide-loader-2
v-if="previewLoading"
class="mx-auto size-5 animate-spin text-muted"
/>
<component
v-else
:is="nodeIcon(selectedAsset.node)"
class="mx-auto size-8 text-component"
/>
<p class="mt-2 max-w-44 truncate text-xs font-medium text-surface">
{{ selectedAsset.name }}
</p>
</div>
<div class="text-xs font-medium text-surface">{{ variant.name }}</div>
<div class="mt-1 text-[11px] text-muted">{{ variant.values.join(', ') }}</div>
</div>
<button
data-test-id="asset-details-insert"
:class="primaryButton.base"
class="mt-3 w-full"
@click="insertSelectedAsset"
>
{{ panels.insertInstance }}
</button>
</div>
<div class="min-w-0 p-4">
<section v-if="selectedAsset.description" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.description }}
</h3>
<p
data-test-id="asset-details-description"
class="mt-1 text-xs leading-5 text-surface"
>
{{ selectedAsset.description }}
</p>
</section>
<section v-if="selectedAsset.sourceLibraryKey" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.assetLibraryBadge }}
</h3>
<p data-test-id="asset-details-library" class="mt-1 break-all text-xs text-muted">
{{ selectedAsset.sourceLibraryKey }}
</p>
</section>
<section v-if="selectedAsset.docsUrl" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.documentation }}
</h3>
<button
data-test-id="asset-details-docs"
class="mt-1 inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs text-component hover:bg-component/10"
@click="
selectedAsset.docsUrl ? openExternalLink(selectedAsset.docsUrl) : undefined
"
>
<icon-lucide-book-open class="size-3" />
{{ panels.openDocs }}
</button>
</section>
<section v-if="selectedAsset.variants.length > 0">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.properties }}
</h3>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="variant in selectedAsset.variants"
:key="variant.name"
data-test-id="asset-details-property"
class="rounded border border-border bg-input/40 px-2 py-1.5"
>
<div class="text-xs font-medium text-surface">{{ variant.name }}</div>
<div class="mt-1 text-[11px] text-muted">{{ variant.values.join(', ') }}</div>
</div>
</div>
</section>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</section>
</div>
</div>
</AppDialogRoot>
</section>
</template>

View file

@ -2,11 +2,7 @@
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
import { computed } from 'vue'
@ -17,14 +13,9 @@ import {
rejectCurrentPermission,
respondToPermission
} from '@/app/ai/acp/permission'
import { useDialogUI } from '@/components/ui/dialog'
import { AppAlertDialogRoot } from '@/components/ui/dialog'
const open = computed(() => currentPermission.value !== null)
const cls = useDialogUI({
overlay: 'z-50',
content: 'w-80 rounded-lg p-4 shadow-xl'
})
interface ToolCallInfo {
title?: string
rawInput?: unknown
@ -60,50 +51,47 @@ function handleDismiss() {
</script>
<template>
<AlertDialogRoot :open="open">
<AlertDialogPortal>
<AlertDialogOverlay :class="cls.overlay" @click="handleDismiss" />
<AlertDialogContent
data-test-id="acp-permission-dialog"
:class="cls.content"
@escape-key-down="handleDismiss"
<AppAlertDialogRoot
:open="open"
:ui="{ overlay: 'z-50', content: 'w-80 rounded-lg p-4 shadow-xl' }"
data-test-id="acp-permission-dialog"
@overlay-click="handleDismiss"
@escape-key-down="handleDismiss"
>
<AlertDialogTitle class="text-sm font-semibold text-surface">
Permission Request
</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-xs text-muted">
<span class="font-medium text-surface">{{ toolName }}</span> is requesting permission.
</AlertDialogDescription>
<pre
v-if="toolInput"
class="mt-2 max-h-32 overflow-auto rounded bg-input p-2 text-[10px] text-muted"
>{{ toolInput }}</pre
>
<div class="mt-4 flex flex-col gap-2">
<AlertDialogAction
v-for="opt in allowOptions"
:key="opt.optionId"
v-test-id="acpPermissionOptionTestId(opt.kind)"
class="w-full rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="respondToPermission(opt.optionId)"
>
<AlertDialogTitle class="text-sm font-semibold text-surface">
Permission Request
</AlertDialogTitle>
{{ opt.name }}
</AlertDialogAction>
<AlertDialogDescription class="mt-2 text-xs text-muted">
<span class="font-medium text-surface">{{ toolName }}</span> is requesting permission.
</AlertDialogDescription>
<pre
v-if="toolInput"
class="mt-2 max-h-32 overflow-auto rounded bg-input p-2 text-[10px] text-muted"
>{{ toolInput }}</pre
>
<div class="mt-4 flex flex-col gap-2">
<AlertDialogAction
v-for="opt in allowOptions"
:key="opt.optionId"
v-test-id="acpPermissionOptionTestId(opt.kind)"
class="w-full rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="respondToPermission(opt.optionId)"
>
{{ opt.name }}
</AlertDialogAction>
<AlertDialogCancel
v-for="opt in rejectOptions"
:key="opt.optionId"
v-test-id="acpPermissionOptionTestId(opt.kind)"
class="w-full rounded border border-border bg-canvas px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
@click="respondToPermission(opt.optionId)"
>
{{ opt.name }}
</AlertDialogCancel>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogCancel
v-for="opt in rejectOptions"
:key="opt.optionId"
v-test-id="acpPermissionOptionTestId(opt.kind)"
class="w-full rounded border border-border bg-canvas px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
@click="respondToPermission(opt.optionId)"
>
{{ opt.name }}
</AlertDialogCancel>
</div>
</AppAlertDialogRoot>
</template>

View file

@ -1,28 +0,0 @@
import { tv } from 'tailwind-variants'
export const dialog = tv({
slots: {
overlay: 'fixed inset-0 z-40 bg-black/50',
content:
'fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-panel shadow-[0_8px_30px_rgb(0_0_0/0.4)] outline-none',
title: 'text-sm font-semibold text-surface',
description: 'text-xs text-muted'
}
})
interface DialogUI {
overlay?: string
content?: string
title?: string
description?: string
}
export function useDialogUI(ui?: DialogUI) {
const cls = dialog()
return {
overlay: cls.overlay({ class: ui?.overlay }),
content: cls.content({ class: ui?.content }),
title: cls.title({ class: ui?.title }),
description: cls.description({ class: ui?.description })
}
}

View file

@ -0,0 +1,47 @@
<script setup lang="ts">
import { AlertDialogContent, AlertDialogOverlay, AlertDialogPortal, AlertDialogRoot } from 'reka-ui'
import { computed, useAttrs } from 'vue'
import { useDialogUI, type DialogUI, type DialogVariants } from '@/components/ui/dialog/ui'
defineOptions({ inheritAttrs: false })
const {
size = 'sm',
height = 'auto',
ui
} = defineProps<{
size?: DialogVariants['size']
height?: DialogVariants['height']
ui?: DialogUI
}>()
const emit = defineEmits<{
escapeKeyDown: [event: KeyboardEvent]
overlayClick: [event: MouseEvent]
}>()
const open = defineModel<boolean>('open', { default: false })
const attrs = useAttrs()
const cls = computed(() => useDialogUI(ui, { size, height }))
</script>
<template>
<AlertDialogRoot :open="open">
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
:class="cls.overlay"
@click="emit('overlayClick', $event)"
/>
<AlertDialogContent
v-bind="attrs"
data-slot="alert-dialog-content"
:class="cls.content"
@escape-key-down="emit('escapeKeyDown', $event)"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import { useDialogUI, type DialogUI } from '@/components/ui/dialog/ui'
const { ui } = defineProps<{ ui?: DialogUI }>()
const cls = useDialogUI(ui)
</script>
<template>
<div data-slot="dialog-body" :class="cls.body">
<slot />
</div>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { DialogClose } from 'reka-ui'
import { useDialogUI } from '@/components/ui/dialog/ui'
const { ariaLabel, class: className } = defineProps<{
ariaLabel: string
class?: string
}>()
const cls = useDialogUI({ close: className })
</script>
<template>
<DialogClose as-child>
<button type="button" data-slot="dialog-close" :class="cls.close" :aria-label="ariaLabel">
<slot><icon-lucide-x class="size-4" /></slot>
</button>
</DialogClose>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import { useDialogUI, type DialogUI } from '@/components/ui/dialog/ui'
const { ui } = defineProps<{ ui?: DialogUI }>()
const cls = useDialogUI(ui)
</script>
<template>
<footer data-slot="dialog-footer" :class="cls.footer">
<slot />
</footer>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { DialogClose, DialogDescription, DialogTitle } from 'reka-ui'
import { useDialogUI, type DialogUI } from '@/components/ui/dialog/ui'
const {
heading,
description,
closeLabel,
showClose = true,
titleVisuallyHidden = false,
ui
} = defineProps<{
heading: string
description?: string
closeLabel?: string
showClose?: boolean
titleVisuallyHidden?: boolean
ui?: DialogUI
}>()
const cls = useDialogUI(ui)
</script>
<template>
<header data-slot="dialog-header" :class="cls.header">
<div :class="cls.heading">
<DialogTitle
data-slot="dialog-title"
:data-visually-hidden="titleVisuallyHidden"
:class="cls.title"
>
<slot name="title">{{ heading }}</slot>
</DialogTitle>
<DialogDescription
v-if="description || $slots.description"
data-slot="dialog-description"
:class="cls.description"
class="mt-0.5"
>
<slot name="description">{{ description }}</slot>
</DialogDescription>
</div>
<slot name="actions" />
<DialogClose v-if="showClose" as-child>
<button type="button" data-slot="dialog-close" :class="cls.close" :aria-label="closeLabel">
<slot name="close-icon"><icon-lucide-x class="size-4" /></slot>
</button>
</DialogClose>
</header>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { computed, useAttrs } from 'vue'
import { useDialogUI, type DialogUI, type DialogVariants } from '@/components/ui/dialog/ui'
defineOptions({ inheritAttrs: false })
const {
size = 'md',
height = 'auto',
ui
} = defineProps<{
size?: DialogVariants['size']
height?: DialogVariants['height']
ui?: DialogUI
}>()
const emit = defineEmits<{
escapeKeyDown: [event: KeyboardEvent]
pointerDownOutside: [event: Event]
interactOutside: [event: Event]
}>()
const open = defineModel<boolean>('open', { default: false })
const attrs = useAttrs()
const cls = computed(() => useDialogUI(ui, { size, height }))
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay data-slot="dialog-overlay" :class="cls.overlay" />
<DialogContent
v-bind="attrs"
data-slot="dialog-content"
:class="cls.content"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
>
<slot />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View file

@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { DialogClose } from 'reka-ui'
import { expect, userEvent, within } from 'storybook/test'
import { ref } from 'vue'
import AppDialogBody from './AppDialogBody.vue'
import AppDialogFooter from './AppDialogFooter.vue'
import AppDialogHeader from './AppDialogHeader.vue'
import AppDialogRoot from './AppDialogRoot.vue'
const meta = {
title: 'Design System/Dialog',
component: AppDialogRoot,
tags: ['autodocs']
} satisfies Meta<typeof AppDialogRoot>
export default meta
type Story = StoryObj<typeof meta>
export const Standard: Story = {
render: () => ({
components: {
AppDialogBody,
AppDialogFooter,
AppDialogHeader,
AppDialogRoot,
DialogClose
},
setup() {
const open = ref(false)
return { open }
},
template: `
<button type="button" @click="open = true">Open dialog</button>
<AppDialogRoot v-model:open="open" size="sm">
<AppDialogHeader
heading="Integration settings"
description="Configure a reusable provider."
close-label="Close"
/>
<AppDialogBody>
<p class="text-xs text-surface">Dialog bodies scroll independently of their header and footer.</p>
</AppDialogBody>
<AppDialogFooter>
<DialogClose as-child>
<button type="button" class="rounded bg-accent px-3 py-1.5 text-xs text-white">Done</button>
</DialogClose>
</AppDialogFooter>
</AppDialogRoot>
`
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }))
const body = within(document.body)
await expect(body.getByRole('dialog')).toBeVisible()
await userEvent.click(body.getByRole('button', { name: 'Done' }))
await expect(body.queryByRole('dialog')).not.toBeInTheDocument()
}
}

View file

@ -0,0 +1,8 @@
export { default as AppAlertDialogRoot } from './AppAlertDialogRoot.vue'
export { default as AppDialogBody } from './AppDialogBody.vue'
export { default as AppDialogClose } from './AppDialogClose.vue'
export { default as AppDialogFooter } from './AppDialogFooter.vue'
export { default as AppDialogHeader } from './AppDialogHeader.vue'
export { default as AppDialogRoot } from './AppDialogRoot.vue'
export { dialog, useDialogUI } from './ui'
export type { DialogUI, DialogVariants } from './ui'

View file

@ -0,0 +1,34 @@
import { tv, type VariantProps } from 'tailwind-variants'
import dialogTheme from '@/theme/dialog'
export const dialog = tv(dialogTheme)
export type DialogVariants = VariantProps<typeof dialog>
export interface DialogUI {
overlay?: string
content?: string
header?: string
heading?: string
title?: string
description?: string
close?: string
body?: string
footer?: string
}
export function useDialogUI(ui?: DialogUI, variants?: DialogVariants) {
const cls = dialog(variants)
return {
overlay: cls.overlay({ class: ui?.overlay }),
content: cls.content({ class: ui?.content }),
header: cls.header({ class: ui?.header }),
heading: cls.heading({ class: ui?.heading }),
title: cls.title({ class: ui?.title }),
description: cls.description({ class: ui?.description }),
close: cls.close({ class: ui?.close }),
body: cls.body({ class: ui?.body }),
footer: cls.footer({ class: ui?.footer })
}
}

View file

@ -10,10 +10,6 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DropdownMenuContent,
DropdownMenuItem,
@ -37,14 +33,13 @@ import IconType from '~icons/lucide/type'
import IconX from '~icons/lucide/x'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useDialogUI } from '@/components/ui/dialog'
import { AppDialogRoot } from '@/components/ui/dialog'
import { useMenuUI } from '@/components/ui/menu'
import variableTableTheme from '@/theme/variable-table'
import type { VariableType } from '@open-pencil/scene-graph'
const open = defineModel<boolean>('open', { default: false })
const cls = useDialogUI({ content: 'flex h-[75vh] w-[800px] max-w-[90vw] flex-col' })
const menuCls = useMenuUI({ content: 'w-40', item: 'justify-start gap-2' })
const addVariableMenuCls = useMenuUI({ content: 'w-48' })
const variableTable = tv(variableTableTheme)
@ -120,18 +115,118 @@ function resizeHandleClass(resizing: boolean) {
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay :class="cls.overlay" />
<DialogContent
data-test-id="variables-dialog"
:aria-describedby="undefined"
:class="cls.content"
>
<DialogTitle class="sr-only">{{ dialogs.localVariables }}</DialogTitle>
<div v-if="!ctx.hasCollections.value" class="flex flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
<h2 class="text-sm font-semibold text-surface">{{ dialogs.localVariables }}</h2>
<AppDialogRoot
v-model:open="open"
height="tall"
:ui="{ content: 'w-[800px] max-w-[90vw]' }"
data-test-id="variables-dialog"
:aria-describedby="undefined"
>
<DialogTitle class="sr-only">{{ dialogs.localVariables }}</DialogTitle>
<div v-if="!ctx.hasCollections.value" class="flex flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
<h2 class="text-sm font-semibold text-surface">{{ dialogs.localVariables }}</h2>
<DialogClose
:aria-label="dialogs.close"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<p class="text-sm text-muted">{{ dialogs.noVariableCollections }}</p>
<button
data-test-id="variables-create-collection"
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
@click="ctx.addCollection"
>
{{ dialogs.createCollection }}
</button>
</div>
</div>
</div>
<template v-else>
<TabsRoot v-model="ctx.activeCollectionId.value" class="flex flex-1 flex-col overflow-hidden">
<div class="flex shrink-0 items-center border-b border-border">
<TabsList class="flex flex-1 gap-0.5 overflow-x-auto px-3 py-1">
<template v-for="col in ctx.collections.value" :key="col.id">
<input
v-if="ctx.collectionRename.editingId.value === col.id"
ref="collectionInput"
class="w-24 rounded border border-accent bg-input px-2 py-0.5 text-xs text-surface outline-none"
:value="col.name"
@blur="ctx.collectionRename.commit(col.id, $event)"
@keydown="ctx.collectionRename.onKeydown"
/>
<TabsTrigger
v-else
:value="col.id"
data-test-id="variables-collection-tab"
class="cursor-pointer rounded border-none px-2.5 py-1 text-xs whitespace-nowrap text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
@dblclick="ctx.startRenameCollection(col.id)"
>
{{ col.name }}
</TabsTrigger>
</template>
</TabsList>
<div class="flex items-center gap-1.5 px-3">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-collection-menu"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-ellipsis class="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
:side-offset="4"
align="start"
:class="menuCls.content"
>
<DropdownMenuItem
:class="menuCls.item"
@select="ctx.startRenameCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameCollection }}
</DropdownMenuItem>
<DropdownMenuSeparator class="mx-1.5 my-1 h-px bg-border" />
<DropdownMenuItem
:class="menuCls.item"
class="text-red-500"
data-test-id="variables-delete-collection"
@select="ctx.removeCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteCollection }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<div class="flex items-center gap-1 rounded border border-border px-2 py-0.5">
<icon-lucide-search class="size-3 text-muted" />
<input
v-model="ctx.searchTerm.value"
data-test-id="variables-search-input"
class="w-24 border-none bg-transparent text-xs text-surface outline-none placeholder:text-muted"
:placeholder="dialogs.search"
/>
</div>
<Tip :label="dialogs.createCollection">
<button
data-test-id="variables-add-collection"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@click="ctx.addCollection"
>
<icon-lucide-folder-plus class="size-3.5" />
</button>
</Tip>
<DialogClose
:aria-label="dialogs.close"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@ -139,290 +234,179 @@ function resizeHandleClass(resizing: boolean) {
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<p class="text-sm text-muted">{{ dialogs.noVariableCollections }}</p>
<button
data-test-id="variables-create-collection"
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
@click="ctx.addCollection"
>
{{ dialogs.createCollection }}
</button>
</div>
</div>
</div>
<template v-else>
<TabsRoot
v-model="ctx.activeCollectionId.value"
class="flex flex-1 flex-col overflow-hidden"
>
<div class="flex shrink-0 items-center border-b border-border">
<TabsList class="flex flex-1 gap-0.5 overflow-x-auto px-3 py-1">
<template v-for="col in ctx.collections.value" :key="col.id">
<input
v-if="ctx.collectionRename.editingId.value === col.id"
ref="collectionInput"
class="w-24 rounded border border-accent bg-input px-2 py-0.5 text-xs text-surface outline-none"
:value="col.name"
@blur="ctx.collectionRename.commit(col.id, $event)"
@keydown="ctx.collectionRename.onKeydown"
/>
<TabsTrigger
v-else
:value="col.id"
data-test-id="variables-collection-tab"
class="cursor-pointer rounded border-none px-2.5 py-1 text-xs whitespace-nowrap text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
@dblclick="ctx.startRenameCollection(col.id)"
>
{{ col.name }}
</TabsTrigger>
</template>
</TabsList>
<div class="flex items-center gap-1.5 px-3">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-collection-menu"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-ellipsis class="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
:side-offset="4"
align="start"
:class="menuCls.content"
>
<DropdownMenuItem
:class="menuCls.item"
@select="ctx.startRenameCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameCollection }}
</DropdownMenuItem>
<DropdownMenuSeparator class="mx-1.5 my-1 h-px bg-border" />
<DropdownMenuItem
:class="menuCls.item"
class="text-red-500"
data-test-id="variables-delete-collection"
@select="ctx.removeCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteCollection }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<div class="flex items-center gap-1 rounded border border-border px-2 py-0.5">
<icon-lucide-search class="size-3 text-muted" />
<input
v-model="ctx.searchTerm.value"
data-test-id="variables-search-input"
class="w-24 border-none bg-transparent text-xs text-surface outline-none placeholder:text-muted"
:placeholder="dialogs.search"
/>
</div>
<Tip :label="dialogs.createCollection">
<button
data-test-id="variables-add-collection"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@click="ctx.addCollection"
>
<icon-lucide-folder-plus class="size-3.5" />
</button>
</Tip>
<DialogClose
:aria-label="dialogs.close"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
</div>
<TabsContent
v-for="col in ctx.collections.value"
:key="col.id"
:value="col.id"
class="flex flex-1 flex-col overflow-hidden outline-none"
<TabsContent
v-for="col in ctx.collections.value"
:key="col.id"
:value="col.id"
class="flex flex-1 flex-col overflow-hidden outline-none"
>
<div class="flex-1 overflow-auto">
<table
class="w-full min-w-full border-collapse"
:style="{ width: `${ctx.table.getCenterTotalSize()}px` }"
>
<div class="flex-1 overflow-auto">
<table
class="w-full min-w-full border-collapse"
:style="{ width: `${ctx.table.getCenterTotalSize()}px` }"
<thead class="sticky top-0 z-10 bg-panel">
<tr
v-for="headerGroup in ctx.table.getHeaderGroups()"
:key="headerGroup.id"
class="border-b border-border"
>
<thead class="sticky top-0 z-10 bg-panel">
<tr
v-for="headerGroup in ctx.table.getHeaderGroups()"
:key="headerGroup.id"
class="border-b border-border"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="relative px-4 py-2 text-left text-[11px] font-medium text-muted"
:style="{ width: `${header.getSize()}px` }"
>
<template v-if="getModeId(header.column.id)">
<input
v-if="ctx.modeRename.editingId.value === getModeId(header.column.id)"
ref="modeInput"
class="-mx-1 w-full rounded border border-accent bg-input px-1 py-0 text-[11px] font-medium text-surface outline-none"
:value="header.column.columnDef.header"
@blur="ctx.modeRename.commit(modeId(header.column.id), $event)"
@keydown="ctx.modeRename.onKeydown"
/>
<ContextMenuRoot v-else>
<ContextMenuTrigger as-child>
<span
:data-default="
getModeId(header.column.id) === col.defaultModeId || undefined
"
:class="
modeLabelClass(getModeId(header.column.id) === col.defaultModeId)
"
@dblclick="ctx.startRenameMode(modeId(header.column.id))"
>
{{ header.column.columnDef.header }}
</span>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="menuCls.content">
<ContextMenuItem
:class="menuCls.item"
@select="ctx.startRenameMode(modeId(header.column.id))"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameMode }}
</ContextMenuItem>
<ContextMenuItem
:class="menuCls.item"
@select="ctx.duplicateMode(modeId(header.column.id))"
>
<icon-lucide-copy :class="menuCls.icon" />
{{ dialogs.duplicateMode }}
</ContextMenuItem>
<ContextMenuItem
v-if="getModeId(header.column.id) !== col.defaultModeId"
:class="menuCls.item"
@select="ctx.setDefaultMode(modeId(header.column.id))"
>
<icon-lucide-pin :class="menuCls.icon" />
{{ dialogs.setDefaultMode }}
</ContextMenuItem>
<ContextMenuSeparator :class="menuCls.separator" />
<ContextMenuItem
:class="[menuCls.item, 'text-red-500']"
:disabled="col.modes.length <= 1"
@select="ctx.removeMode(modeId(header.column.id))"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteMode }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<FlexRender
v-else-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<div
v-if="header.column.getCanResize()"
:data-resizing="header.column.getIsResizing() || undefined"
:class="resizeHandleClass(header.column.getIsResizing())"
@mousedown="header.getResizeHandler()?.($event)"
@touchstart="header.getResizeHandler()?.($event)"
@dblclick="header.column.resetSize()"
/>
</th>
<th class="w-8 px-1 py-2">
<Tip :label="dialogs.addMode">
<button
data-test-id="variables-add-mode"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@click="ctx.addMode"
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="relative px-4 py-2 text-left text-[11px] font-medium text-muted"
:style="{ width: `${header.getSize()}px` }"
>
<template v-if="getModeId(header.column.id)">
<input
v-if="ctx.modeRename.editingId.value === getModeId(header.column.id)"
ref="modeInput"
class="-mx-1 w-full rounded border border-accent bg-input px-1 py-0 text-[11px] font-medium text-surface outline-none"
:value="header.column.columnDef.header"
@blur="ctx.modeRename.commit(modeId(header.column.id), $event)"
@keydown="ctx.modeRename.onKeydown"
/>
<ContextMenuRoot v-else>
<ContextMenuTrigger as-child>
<span
:data-default="
getModeId(header.column.id) === col.defaultModeId || undefined
"
:class="
modeLabelClass(getModeId(header.column.id) === col.defaultModeId)
"
@dblclick="ctx.startRenameMode(modeId(header.column.id))"
>
<icon-lucide-plus class="size-3" />
</button>
</Tip>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in ctx.table.getRowModel().rows"
:key="row.id"
data-test-id="variable-row"
:class="tableStyles.row()"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="px-4 py-1.5"
:style="{ width: `${cell.column.getSize()}px` }"
{{ header.column.columnDef.header }}
</span>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="menuCls.content">
<ContextMenuItem
:class="menuCls.item"
@select="ctx.startRenameMode(modeId(header.column.id))"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameMode }}
</ContextMenuItem>
<ContextMenuItem
:class="menuCls.item"
@select="ctx.duplicateMode(modeId(header.column.id))"
>
<icon-lucide-copy :class="menuCls.icon" />
{{ dialogs.duplicateMode }}
</ContextMenuItem>
<ContextMenuItem
v-if="getModeId(header.column.id) !== col.defaultModeId"
:class="menuCls.item"
@select="ctx.setDefaultMode(modeId(header.column.id))"
>
<icon-lucide-pin :class="menuCls.icon" />
{{ dialogs.setDefaultMode }}
</ContextMenuItem>
<ContextMenuSeparator :class="menuCls.separator" />
<ContextMenuItem
:class="[menuCls.item, 'text-red-500']"
:disabled="col.modes.length <= 1"
@select="ctx.removeMode(modeId(header.column.id))"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteMode }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<FlexRender
v-else-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<div
v-if="header.column.getCanResize()"
:data-resizing="header.column.getIsResizing() || undefined"
:class="resizeHandleClass(header.column.getIsResizing())"
@mousedown="header.getResizeHandler()?.($event)"
@touchstart="header.getResizeHandler()?.($event)"
@dblclick="header.column.resetSize()"
/>
</th>
<th class="w-8 px-1 py-2">
<Tip :label="dialogs.addMode">
<button
data-test-id="variables-add-mode"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@click="ctx.addMode"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
<icon-lucide-plus class="size-3" />
</button>
</Tip>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in ctx.table.getRowModel().rows"
:key="row.id"
data-test-id="variable-row"
:class="tableStyles.row()"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="px-4 py-1.5"
:style="{ width: `${cell.column.getSize()}px` }"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
</tbody>
</table>
</div>
<div
class="flex w-full shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-2"
>
<span class="text-xs text-muted">{{ panels.createVariable }}</span>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-add-variable"
class="flex cursor-pointer items-center gap-1.5 rounded bg-hover px-2.5 py-1.5 text-xs text-surface hover:bg-border"
>
<icon-lucide-plus class="size-3.5" />
{{ panels.add }}
<icon-lucide-chevron-down class="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
:side-offset="8"
align="end"
:class="addVariableMenuCls.content"
>
<DropdownMenuItem
v-for="item in variableTypes"
:key="item.type"
:class="menuCls.item"
v-test-id="variablesAddTestId(item.type)"
@select="ctx.addVariable(item.type)"
>
<component :is="variableTypeIcons[item.type]" :class="menuCls.icon" />
<span class="flex min-w-0 flex-1 flex-col">
<span>{{ item.label() }}</span>
<span class="truncate text-[10px] text-muted">{{
item.description()
}}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</TabsContent>
</TabsRoot>
</template>
</DialogContent>
</DialogPortal>
</DialogRoot>
<div
class="flex w-full shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-2"
>
<span class="text-xs text-muted">{{ panels.createVariable }}</span>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-add-variable"
class="flex cursor-pointer items-center gap-1.5 rounded bg-hover px-2.5 py-1.5 text-xs text-surface hover:bg-border"
>
<icon-lucide-plus class="size-3.5" />
{{ panels.add }}
<icon-lucide-chevron-down class="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
:side-offset="8"
align="end"
:class="addVariableMenuCls.content"
>
<DropdownMenuItem
v-for="item in variableTypes"
:key="item.type"
:class="menuCls.item"
v-test-id="variablesAddTestId(item.type)"
@select="ctx.addVariable(item.type)"
>
<component :is="variableTypeIcons[item.type]" :class="menuCls.icon" />
<span class="flex min-w-0 flex-1 flex-col">
<span>{{ item.label() }}</span>
<span class="truncate text-[10px] text-muted">{{ item.description() }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</TabsContent>
</TabsRoot>
</template>
</AppDialogRoot>
</template>

28
src/theme/dialog.ts Normal file
View file

@ -0,0 +1,28 @@
export default {
slots: {
overlay: 'fixed inset-0 z-40 bg-black/50',
content:
'fixed top-1/2 left-1/2 z-50 flex max-h-[min(90vh,48rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-panel shadow-[0_8px_30px_rgb(0_0_0/0.4)] outline-none',
header: 'flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-3',
heading: 'min-w-0',
title: 'text-sm font-semibold text-surface data-[visually-hidden=true]:sr-only',
description: 'text-xs text-muted',
close:
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface',
body: 'min-h-0 flex-1 overflow-y-auto p-4',
footer: 'flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3'
},
variants: {
size: {
sm: { content: 'w-[min(24rem,92vw)]' },
md: { content: 'w-[min(32rem,94vw)]' },
lg: { content: 'w-[min(46rem,94vw)]' },
xl: { content: 'w-[min(64rem,96vw)]' }
},
height: {
auto: {},
tall: { content: 'h-[min(75vh,48rem)]' },
full: { content: 'h-[min(90vh,56rem)]' }
}
}
} as const