feat(dialog): migrate Settings dialog to Reka-UI (Phase 3)

Closes the parity gap in the Reka renderer (maximize affordance,
headless layout mode, overlay class plumbing), then flips
useSettingsDialog onto the Reka path.

- dialog.variants: new `maximized` variant (centered+sized vs.
  inset-2 fullscreen) plus a DialogMaximize button wired through
  GlobalDialog when `maximizable` is set
- GlobalDialog: when `headless: true`, skip the inner padding
  wrapper so layout dialogs control their own chrome; forward
  `overlayClass` to DialogOverlay
- useSettingsDialog: renderer 'reka', size 'full', explicit
  contentClass matching BaseModalLayout sm (960px x 80vh),
  overlayClass 'p-8' when isCloud + teamWorkspacesEnabled
- Drop the now-dead workspace mask special-case + orphan
  .settings-dialog-workspace CSS from GlobalDialog
This commit is contained in:
dante01yoon
2026-05-12 15:53:52 +09:00
parent 9dcb9fac1a
commit 385069682f
8 changed files with 188 additions and 60 deletions

View File

@@ -8,9 +8,10 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogOverlay :class="item.dialogComponentProps.overlayClass" />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -25,28 +26,46 @@
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -55,7 +74,6 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -86,29 +104,20 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -119,20 +128,8 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
}
</script>
@@ -163,19 +160,6 @@ function getDialogPt(item: {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,10 +10,15 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md'
size: 'md',
maximized: false
}
})

View File

@@ -138,6 +138,8 @@
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"maximizeDialog": "Maximize dialog",
"restoreDialog": "Restore dialog",
"or": "or",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",

View File

@@ -0,0 +1,92 @@
/**
* Settings dialog migration regression net: `useSettingsDialog().show()` must
* open the Reka-renderer path with sizing that matches the previous
* `BaseModalLayout size="sm"` (960px × 80vh). Catches accidental reverts of
* the Phase 3 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const teamWorkspacesFlag = vi.hoisted(() => ({ value: false }))
const isCloudRef = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog: vi.fn() })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return teamWorkspacesFlag.value
}
}
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudRef.value
}
}))
vi.mock('@/i18n', () => ({ t: (k: string) => k }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
describe('useSettingsDialog', () => {
beforeEach(() => {
showDialog.mockReset()
teamWorkspacesFlag.value = false
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})
it('show() omits overlayClass when not in workspace mode', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBeUndefined()
})
it("show() sets overlayClass 'p-8' when isCloud && teamWorkspacesEnabled", () => {
isCloudRef.value = true
teamWorkspacesFlag.value = true
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBe('p-8')
})
it('show(panel) forwards defaultPanel to the dialog props', () => {
useSettingsDialog().show('about')
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
it('showAbout() opens the about panel', () => {
useSettingsDialog().showAbout()
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
})

View File

@@ -1,3 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -6,15 +8,20 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType, settingId?: string) {
const isWorkspaceMode = isCloud && flags.teamWorkspacesEnabled
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
@@ -22,6 +29,12 @@ export function useSettingsDialog() {
onClose: hide,
...(panel ? { defaultPanel: panel } : {}),
...(settingId ? { scrollToSettingId: settingId } : {})
},
dialogComponentProps: {
renderer: 'reka',
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined
}
})
}

View File

@@ -48,6 +48,11 @@ interface CustomDialogComponentProps {
* PrimeVue path — use `pt` for that renderer.
*/
contentClass?: HTMLAttributes['class']
/**
* Class applied to the Reka-UI `DialogOverlay` element. Ignored on the
* PrimeVue path — use `pt.mask` for that renderer.
*/
overlayClass?: HTMLAttributes['class']
}
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &