From 0a46ff64ebef9ac6e4386ed21de527ee09853cc6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 26 Jul 2026 14:07:15 +0300 Subject: [PATCH] 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 --- src/components/assets-panel/AssetsPanel.vue | 246 ++++---- src/components/chat/AcpPermissionDialog.vue | 96 ++- src/components/ui/dialog.ts | 28 - .../ui/dialog/AppAlertDialogRoot.vue | 47 ++ src/components/ui/dialog/AppDialogBody.vue | 12 + src/components/ui/dialog/AppDialogClose.vue | 20 + src/components/ui/dialog/AppDialogFooter.vue | 12 + src/components/ui/dialog/AppDialogHeader.vue | 51 ++ src/components/ui/dialog/AppDialogRoot.vue | 46 ++ src/components/ui/dialog/Dialog.stories.ts | 60 ++ src/components/ui/dialog/index.ts | 8 + src/components/ui/dialog/ui.ts | 34 ++ src/components/variables/VariablesDialog.vue | 578 +++++++++--------- src/theme/dialog.ts | 28 + 14 files changed, 755 insertions(+), 511 deletions(-) delete mode 100644 src/components/ui/dialog.ts create mode 100644 src/components/ui/dialog/AppAlertDialogRoot.vue create mode 100644 src/components/ui/dialog/AppDialogBody.vue create mode 100644 src/components/ui/dialog/AppDialogClose.vue create mode 100644 src/components/ui/dialog/AppDialogFooter.vue create mode 100644 src/components/ui/dialog/AppDialogHeader.vue create mode 100644 src/components/ui/dialog/AppDialogRoot.vue create mode 100644 src/components/ui/dialog/Dialog.stories.ts create mode 100644 src/components/ui/dialog/index.ts create mode 100644 src/components/ui/dialog/ui.ts create mode 100644 src/theme/dialog.ts diff --git a/src/components/assets-panel/AssetsPanel.vue b/src/components/assets-panel/AssetsPanel.vue index dab433dee..e1ca8ea88 100644 --- a/src/components/assets-panel/AssetsPanel.vue +++ b/src/components/assets-panel/AssetsPanel.vue @@ -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() { - - - - +
+
+ +
+ {{ + selectedAsset.name + }} +

+ {{ + selectedAsset.node.type === 'COMPONENT_SET' ? panels.componentSet : panels.component + }} + + · {{ selectedAsset.variantCount }} variants +

+
+
+ -
-
- + +
+ +
+
+
+ +
+ -
- {{ - selectedAsset.name - }} -

- {{ - selectedAsset.node.type === 'COMPONENT_SET' - ? panels.componentSet - : panels.component - }} - - · {{ selectedAsset.variantCount }} variants -

-
+ +

+ {{ selectedAsset.name }} +

- + +
+ +
+
+

+ {{ panels.description }} +

+

+ {{ selectedAsset.description }} +

+
+ +
+

+ {{ panels.assetLibraryBadge }} +

+

+ {{ selectedAsset.sourceLibraryKey }} +

+
+ +
+

+ {{ panels.documentation }} +

+
+ + {{ panels.openDocs }} + + -
-
+
+

+ {{ panels.properties }} +

+
- -
- - -

- {{ selectedAsset.name }} -

-
+
{{ variant.name }}
+
{{ variant.values.join(', ') }}
-
- -
-
-

- {{ panels.description }} -

-

- {{ selectedAsset.description }} -

-
- -
-

- {{ panels.assetLibraryBadge }} -

-

- {{ selectedAsset.sourceLibraryKey }} -

-
- -
-

- {{ panels.documentation }} -

- -
- -
-

- {{ panels.properties }} -

-
-
-
{{ variant.name }}
-
{{ variant.values.join(', ') }}
-
-
-
-
-
- - - + +
+
+ diff --git a/src/components/chat/AcpPermissionDialog.vue b/src/components/chat/AcpPermissionDialog.vue index 309e10a10..575de60c0 100644 --- a/src/components/chat/AcpPermissionDialog.vue +++ b/src/components/chat/AcpPermissionDialog.vue @@ -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() { diff --git a/src/components/ui/dialog.ts b/src/components/ui/dialog.ts deleted file mode 100644 index 0ae7c3a9c..000000000 --- a/src/components/ui/dialog.ts +++ /dev/null @@ -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 }) - } -} diff --git a/src/components/ui/dialog/AppAlertDialogRoot.vue b/src/components/ui/dialog/AppAlertDialogRoot.vue new file mode 100644 index 000000000..06b377a5b --- /dev/null +++ b/src/components/ui/dialog/AppAlertDialogRoot.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/ui/dialog/AppDialogBody.vue b/src/components/ui/dialog/AppDialogBody.vue new file mode 100644 index 000000000..f0d65a8c8 --- /dev/null +++ b/src/components/ui/dialog/AppDialogBody.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/dialog/AppDialogClose.vue b/src/components/ui/dialog/AppDialogClose.vue new file mode 100644 index 000000000..90109b0d0 --- /dev/null +++ b/src/components/ui/dialog/AppDialogClose.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/dialog/AppDialogFooter.vue b/src/components/ui/dialog/AppDialogFooter.vue new file mode 100644 index 000000000..2887ebc07 --- /dev/null +++ b/src/components/ui/dialog/AppDialogFooter.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/dialog/AppDialogHeader.vue b/src/components/ui/dialog/AppDialogHeader.vue new file mode 100644 index 000000000..c43348ab8 --- /dev/null +++ b/src/components/ui/dialog/AppDialogHeader.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/ui/dialog/AppDialogRoot.vue b/src/components/ui/dialog/AppDialogRoot.vue new file mode 100644 index 000000000..3a1a0797b --- /dev/null +++ b/src/components/ui/dialog/AppDialogRoot.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/ui/dialog/Dialog.stories.ts b/src/components/ui/dialog/Dialog.stories.ts new file mode 100644 index 000000000..4955398bb --- /dev/null +++ b/src/components/ui/dialog/Dialog.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Standard: Story = { + render: () => ({ + components: { + AppDialogBody, + AppDialogFooter, + AppDialogHeader, + AppDialogRoot, + DialogClose + }, + setup() { + const open = ref(false) + return { open } + }, + template: ` + + + + +

Dialog bodies scroll independently of their header and footer.

+
+ + + + + +
+ ` + }), + 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() + } +} diff --git a/src/components/ui/dialog/index.ts b/src/components/ui/dialog/index.ts new file mode 100644 index 000000000..110de28fb --- /dev/null +++ b/src/components/ui/dialog/index.ts @@ -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' diff --git a/src/components/ui/dialog/ui.ts b/src/components/ui/dialog/ui.ts new file mode 100644 index 000000000..0acd04f2e --- /dev/null +++ b/src/components/ui/dialog/ui.ts @@ -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 + +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 }) + } +} diff --git a/src/components/variables/VariablesDialog.vue b/src/components/variables/VariablesDialog.vue index e2eb14210..717376d53 100644 --- a/src/components/variables/VariablesDialog.vue +++ b/src/components/variables/VariablesDialog.vue @@ -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('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) { diff --git a/src/theme/dialog.ts b/src/theme/dialog.ts new file mode 100644 index 000000000..c19dcb7ae --- /dev/null +++ b/src/theme/dialog.ts @@ -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