mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 09:57:05 +08:00
fix(desktop): harden setup wizard dialogs
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { Radio as RadioPrimitive } from '@base-ui/react/radio'
|
||||
import { RadioGroup as RadioGroupPrimitive } from '@base-ui/react/radio-group'
|
||||
import { cn } from '../../lib/utils.ts'
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props): JSX.Element {
|
||||
return (
|
||||
<RadioGroupPrimitive
|
||||
className={cn('grid w-full gap-3', className)}
|
||||
data-slot="radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props): JSX.Element {
|
||||
return (
|
||||
<RadioPrimitive.Root
|
||||
className={cn(
|
||||
'group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-2xl border border-transparent bg-input/90 outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
|
||||
className,
|
||||
)}
|
||||
data-slot="radio-group-item"
|
||||
{...props}
|
||||
>
|
||||
<RadioPrimitive.Indicator
|
||||
className="flex size-4 items-center justify-center"
|
||||
data-slot="radio-group-indicator"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground dark:size-2.5" />
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
SkipForward,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '../components/ui/dialog.tsx'
|
||||
import { Label } from '../components/ui/label.tsx'
|
||||
import { RadioGroup, RadioGroupItem } from '../components/ui/radio-group.tsx'
|
||||
import { Switch } from '../components/ui/switch.tsx'
|
||||
import { DesktopFrame } from '../shared/DesktopFrame.tsx'
|
||||
|
||||
@@ -146,29 +146,27 @@ function finish(selection: DesktopSetupWizardSelection): void {
|
||||
}
|
||||
|
||||
function Choice({
|
||||
id,
|
||||
value,
|
||||
title,
|
||||
body,
|
||||
selected,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly id: string
|
||||
readonly value: string
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly selected: boolean
|
||||
readonly disabled?: boolean
|
||||
readonly onSelect: () => void
|
||||
}): JSX.Element {
|
||||
return <button
|
||||
aria-checked={selected}
|
||||
className={`flex w-full items-start gap-3 rounded-xl border p-4 text-left outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/30 ${selected ? 'border-primary bg-muted/70' : 'hover:bg-muted/40'}`}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
role="radio"
|
||||
type="button"
|
||||
return <Label
|
||||
className={`flex w-full items-start gap-3 rounded-xl border p-4 text-left leading-normal outline-none transition-colors ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${selected ? 'border-primary bg-muted/70' : 'hover:bg-muted/40'}`}
|
||||
htmlFor={id}
|
||||
>
|
||||
<span aria-hidden="true" className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border ${selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/60'}`}>{selected ? <Check className="size-3" /> : null}</span>
|
||||
<RadioGroupItem className="mt-0.5" disabled={disabled} id={id} value={value} />
|
||||
<span className="min-w-0"><span className="block text-sm font-medium">{title}</span><span className="mt-1 block text-xs leading-relaxed text-muted-foreground">{body}</span></span>
|
||||
</button>
|
||||
</Label>
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
@@ -212,7 +210,7 @@ function Page({
|
||||
readonly subtitle: string
|
||||
readonly children: ReactNode
|
||||
}): JSX.Element {
|
||||
return <div className="mx-auto flex w-full max-w-2xl flex-col py-5" data-setup-step={step}>
|
||||
return <div className="mx-auto flex w-full max-w-2xl flex-col py-5" data-orientation="vertical" data-setup-step={step}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{subtitle}</p>
|
||||
@@ -237,14 +235,25 @@ function ModeOptions({
|
||||
{ value: 'extended', title: copy.extendedMode, body: input.platform === 'linux' ? copy.unavailableOnLinux : copy.extendedModeBody },
|
||||
{ value: 'advanced', title: copy.advancedMode, body: input.platform === 'linux' ? copy.unavailableOnLinux : copy.advancedModeBody },
|
||||
]
|
||||
return <div aria-orientation="vertical" className="space-y-3" role="radiogroup">{modes.map(option => <Choice
|
||||
return <RadioGroup
|
||||
aria-label={copy.presentationTitle}
|
||||
aria-orientation="vertical"
|
||||
name="setup-window-mode"
|
||||
onValueChange={value => {
|
||||
if (value === 'compatibility' || value === 'extended' || value === 'advanced') {
|
||||
update({ ...selection, mode: value })
|
||||
}
|
||||
}}
|
||||
value={selection.mode}
|
||||
>{modes.map(option => <Choice
|
||||
body={option.body}
|
||||
disabled={input.platform === 'linux' && option.value !== 'compatibility'}
|
||||
id={`setup-window-mode-${option.value}`}
|
||||
key={option.value}
|
||||
onSelect={() => { update({ ...selection, mode: option.value }) }}
|
||||
selected={selection.mode === option.value}
|
||||
title={option.title}
|
||||
/>)}</div>
|
||||
value={option.value}
|
||||
/>)}</RadioGroup>
|
||||
}
|
||||
|
||||
type MaterialOption = {
|
||||
@@ -283,14 +292,23 @@ function MaterialOptions({
|
||||
update({ ...selection, windowsMaterial: value })
|
||||
}
|
||||
}
|
||||
return <div aria-orientation="vertical" className="space-y-3" role="radiogroup">{options.map(option => <Choice
|
||||
return <RadioGroup
|
||||
aria-label={copy.windowMaterial}
|
||||
aria-orientation="vertical"
|
||||
name="setup-window-material"
|
||||
onValueChange={value => {
|
||||
if (value === 'off' || value === 'transparent' || value === 'acrylic' || value === 'mica') choose(value)
|
||||
}}
|
||||
value={selected}
|
||||
>{options.map(option => <Choice
|
||||
body={option.body}
|
||||
disabled={input.platform === 'linux'}
|
||||
id={`setup-window-material-${option.value}`}
|
||||
key={option.value}
|
||||
onSelect={() => { choose(option.value) }}
|
||||
selected={selected === option.value}
|
||||
title={option.title}
|
||||
/>)}</div>
|
||||
value={option.value}
|
||||
/>)}</RadioGroup>
|
||||
}
|
||||
|
||||
function MarketOptions({
|
||||
@@ -307,13 +325,24 @@ function MarketOptions({
|
||||
{ value: 'community-market', title: copy.communityMarket, body: copy.communityMarketBody },
|
||||
{ value: 'dsh-market', title: copy.dshMarket, body: copy.dshMarketBody },
|
||||
]
|
||||
return <div aria-orientation="vertical" className="space-y-3" role="radiogroup">{markets.map(option => <Choice
|
||||
return <RadioGroup
|
||||
aria-label={copy.marketTitle}
|
||||
aria-orientation="vertical"
|
||||
name="setup-plugin-market"
|
||||
onValueChange={value => {
|
||||
if (value === 'disabled' || value === 'community-market' || value === 'dsh-market') {
|
||||
update({ ...selection, market: value })
|
||||
}
|
||||
}}
|
||||
value={selection.market}
|
||||
>{markets.map(option => <Choice
|
||||
body={option.body}
|
||||
id={`setup-plugin-market-${option.value}`}
|
||||
key={option.value}
|
||||
onSelect={() => { update({ ...selection, market: option.value }) }}
|
||||
selected={selection.market === option.value}
|
||||
title={option.title}
|
||||
/>)}</div>
|
||||
value={option.value}
|
||||
/>)}</RadioGroup>
|
||||
}
|
||||
|
||||
function NotificationOptions({
|
||||
@@ -328,7 +357,7 @@ function NotificationOptions({
|
||||
const set = (key: keyof DesktopSetupWizardNotifications, checked: boolean): void => {
|
||||
update({ ...notifications, [key]: checked })
|
||||
}
|
||||
return <div aria-orientation="vertical" className="space-y-3">
|
||||
return <div className="space-y-3" data-orientation="vertical">
|
||||
<ToggleRow checked={notifications.enabled} id="setup-notifications-enabled" label={copy.notificationsEnabled} onChange={checked => { set('enabled', checked) }} />
|
||||
<div className="space-y-3 border-l pl-4">
|
||||
<ToggleRow checked={notifications.notifyOnTurnCompletion} disabled={!notifications.enabled} id="setup-turn-completion" label={copy.turnCompletion} onChange={checked => { set('notifyOnTurnCompletion', checked) }} />
|
||||
@@ -355,10 +384,19 @@ function BrowserOptions({
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold">{copy.networkExposure}</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{copy.networkExposureBody}</p>
|
||||
<div aria-orientation="vertical" className="mt-3 space-y-3" role="radiogroup">
|
||||
<Choice body={copy.loopbackBody} onSelect={() => { requestExposure('loopback') }} selected={selection.networkExposure === 'loopback'} title={copy.loopback} />
|
||||
<Choice body={copy.lanBody} onSelect={() => { requestExposure('lan') }} selected={selection.networkExposure === 'lan'} title={copy.lan} />
|
||||
</div>
|
||||
<RadioGroup
|
||||
aria-label={copy.networkExposure}
|
||||
aria-orientation="vertical"
|
||||
className="mt-3"
|
||||
name="setup-network-exposure"
|
||||
onValueChange={value => {
|
||||
if (value === 'loopback' || value === 'lan') requestExposure(value)
|
||||
}}
|
||||
value={selection.networkExposure}
|
||||
>
|
||||
<Choice body={copy.loopbackBody} id="setup-network-exposure-loopback" selected={selection.networkExposure === 'loopback'} title={copy.loopback} value="loopback" />
|
||||
<Choice body={copy.lanBody} id="setup-network-exposure-lan" selected={selection.networkExposure === 'lan'} title={copy.lan} value="lan" />
|
||||
</RadioGroup>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -467,8 +505,8 @@ export function SetupWizardLanConfirmation({ copy, confirm, cancel }: {
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} type="button" variant="outline">{copy.cancelLan}</Button>
|
||||
<Button autoFocus onClick={confirm} type="button" variant="destructive"><AlertTriangle />{copy.confirmLan}</Button>
|
||||
<DialogClose render={<Button autoFocus type="button" variant="outline" />}>{copy.cancelLan}</DialogClose>
|
||||
<Button onClick={confirm} type="button" variant="destructive"><AlertTriangle />{copy.confirmLan}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
@layer base {
|
||||
* { @apply border-border outline-ring/50; }
|
||||
html, body, #root { width: 100%; height: 100%; }
|
||||
#root { isolation: isolate; }
|
||||
html.dshDesktopDialogDocument,
|
||||
html.dshDesktopDialogDocument body,
|
||||
html.dshDesktopDialogDocument #root { height: auto; min-height: 0; }
|
||||
|
||||
@@ -17,4 +17,8 @@ describe('Desktop-owned native UI frame', () => {
|
||||
expect(theme).toContain('.dshNativeFrame ~ .dshNativeContent')
|
||||
expect(theme).not.toContain('.dshNativeFrame + .dshNativeContent')
|
||||
})
|
||||
|
||||
it('isolates the app root so body-level dialogs cover the high native frame layer', () => {
|
||||
expect(theme).toMatch(/#root\s*\{\s*isolation:\s*isolate;/u)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
SetupWizardStepPage,
|
||||
SetupWizardSuccess,
|
||||
} from '../src/native-ui/setup-wizard/App.tsx'
|
||||
import { Button } from '../src/native-ui/components/ui/button.tsx'
|
||||
import { DialogClose } from '../src/native-ui/components/ui/dialog.tsx'
|
||||
import { desktopSetupWizardCopy } from '../src/setup-wizard-copy.ts'
|
||||
|
||||
const input: DesktopSetupWizardInput = {
|
||||
@@ -70,6 +72,16 @@ function elementText(node: ReactNode): string {
|
||||
return elementText((node.props as { readonly children?: ReactNode }).children)
|
||||
}
|
||||
|
||||
function elementTree(node: ReactNode): readonly ReactElement[] {
|
||||
const elements: ReactElement[] = []
|
||||
Children.forEach(node, child => {
|
||||
if (!isValidElement(child)) return
|
||||
elements.push(child)
|
||||
elements.push(...elementTree((child.props as { readonly children?: ReactNode }).children))
|
||||
})
|
||||
return elements
|
||||
}
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('Setup Wizard step flow', () => {
|
||||
@@ -122,10 +134,22 @@ describe('Setup Wizard setting pages', () => {
|
||||
it.each(['mode', 'material', 'market', 'notifications', 'browser'] as const)(
|
||||
'lays out the %s page options vertically',
|
||||
(step) => {
|
||||
expect(renderStep(step)).toContain('aria-orientation="vertical"')
|
||||
expect(renderStep(step)).toContain('data-orientation="vertical"')
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['mode', copy.presentationTitle],
|
||||
['material', copy.windowMaterial],
|
||||
['market', copy.marketTitle],
|
||||
['browser', copy.networkExposure],
|
||||
] as const)('uses a named shadcn RadioGroup for the %s choices', (step, accessibleName) => {
|
||||
const markup = renderStep(step)
|
||||
expect(markup).toContain('data-slot="radio-group"')
|
||||
expect(markup).toContain(`aria-label="${accessibleName}"`)
|
||||
expect(markup).toContain('data-slot="radio-group-item"')
|
||||
})
|
||||
|
||||
it('does not combine the plugin market and browser settings', () => {
|
||||
const market = renderStep('market')
|
||||
const browser = renderStep('browser')
|
||||
@@ -238,6 +262,15 @@ describe('Setup Wizard native UI boundaries', () => {
|
||||
expect(text).toContain('这样很危险,所有在你局域网内的人都能直接操作你的电脑,请谨慎开启')
|
||||
expect(text).toContain('确认开启局域网访问')
|
||||
expect(text).toContain('保持仅本机访问')
|
||||
const descendants = elementTree(content)
|
||||
const close = descendants.find(element => element.type === DialogClose)
|
||||
const confirm = descendants.find(element => element.type === Button
|
||||
&& elementText(element).includes(copy.confirmLan)
|
||||
&& (element.props as { readonly variant?: string }).variant === 'destructive')
|
||||
expect(close).toBeDefined()
|
||||
expect((close?.props as { readonly render?: ReactElement }).render?.props).toMatchObject({ autoFocus: true })
|
||||
expect(confirm).toBeDefined()
|
||||
expect((confirm?.props as { readonly autoFocus?: boolean }).autoFocus).not.toBe(true)
|
||||
})
|
||||
|
||||
it('decodes only the exact bounded state tuple emitted by the owner window', () => {
|
||||
|
||||
Reference in New Issue
Block a user