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:
parent
1436b9cb23
commit
0a46ff64eb
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
47
src/components/ui/dialog/AppAlertDialogRoot.vue
Normal file
47
src/components/ui/dialog/AppAlertDialogRoot.vue
Normal 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>
|
||||
12
src/components/ui/dialog/AppDialogBody.vue
Normal file
12
src/components/ui/dialog/AppDialogBody.vue
Normal 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>
|
||||
20
src/components/ui/dialog/AppDialogClose.vue
Normal file
20
src/components/ui/dialog/AppDialogClose.vue
Normal 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>
|
||||
12
src/components/ui/dialog/AppDialogFooter.vue
Normal file
12
src/components/ui/dialog/AppDialogFooter.vue
Normal 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>
|
||||
51
src/components/ui/dialog/AppDialogHeader.vue
Normal file
51
src/components/ui/dialog/AppDialogHeader.vue
Normal 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>
|
||||
46
src/components/ui/dialog/AppDialogRoot.vue
Normal file
46
src/components/ui/dialog/AppDialogRoot.vue
Normal 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>
|
||||
60
src/components/ui/dialog/Dialog.stories.ts
Normal file
60
src/components/ui/dialog/Dialog.stories.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
8
src/components/ui/dialog/index.ts
Normal file
8
src/components/ui/dialog/index.ts
Normal 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'
|
||||
34
src/components/ui/dialog/ui.ts
Normal file
34
src/components/ui/dialog/ui.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
28
src/theme/dialog.ts
Normal 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
|
||||
Loading…
Reference in a new issue