diff --git a/dsh-plugin-desktop/src/native-ui/components/ui/radio-group.tsx b/dsh-plugin-desktop/src/native-ui/components/ui/radio-group.tsx new file mode 100644 index 0000000000..df0fd535cb --- /dev/null +++ b/dsh-plugin-desktop/src/native-ui/components/ui/radio-group.tsx @@ -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 ( + + ) +} + +function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props): JSX.Element { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx index 4fd8980b7e..1251ab62b7 100644 --- a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx +++ b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx @@ -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 + } function ToggleRow({ @@ -212,7 +210,7 @@ function Page({ readonly subtitle: string readonly children: ReactNode }): JSX.Element { - return
+ return

{title}

{subtitle}

@@ -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
{modes.map(option => { + if (value === 'compatibility' || value === 'extended' || value === 'advanced') { + update({ ...selection, mode: value }) + } + }} + value={selection.mode} + >{modes.map(option => { update({ ...selection, mode: option.value }) }} selected={selection.mode === option.value} title={option.title} - />)}
+ value={option.value} + />)} } type MaterialOption = { @@ -283,14 +292,23 @@ function MaterialOptions({ update({ ...selection, windowsMaterial: value }) } } - return
{options.map(option => { + if (value === 'off' || value === 'transparent' || value === 'acrylic' || value === 'mica') choose(value) + }} + value={selected} + >{options.map(option => { choose(option.value) }} selected={selected === option.value} title={option.title} - />)}
+ value={option.value} + />)} } 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
{markets.map(option => { + if (value === 'disabled' || value === 'community-market' || value === 'dsh-market') { + update({ ...selection, market: value }) + } + }} + value={selection.market} + >{markets.map(option => { update({ ...selection, market: option.value }) }} selected={selection.market === option.value} title={option.title} - />)}
+ value={option.value} + />)} } function NotificationOptions({ @@ -328,7 +357,7 @@ function NotificationOptions({ const set = (key: keyof DesktopSetupWizardNotifications, checked: boolean): void => { update({ ...notifications, [key]: checked }) } - return
+ return
{ set('enabled', checked) }} />
{ set('notifyOnTurnCompletion', checked) }} /> @@ -355,10 +384,19 @@ function BrowserOptions({

{copy.networkExposure}

{copy.networkExposureBody}

-
- { requestExposure('loopback') }} selected={selection.networkExposure === 'loopback'} title={copy.loopback} /> - { requestExposure('lan') }} selected={selection.networkExposure === 'lan'} title={copy.lan} /> -
+ { + if (value === 'loopback' || value === 'lan') requestExposure(value) + }} + value={selection.networkExposure} + > + + +
} @@ -467,8 +505,8 @@ export function SetupWizardLanConfirmation({ copy, confirm, cancel }: {
- - + }>{copy.cancelLan} + diff --git a/dsh-plugin-desktop/src/native-ui/shared/theme.css b/dsh-plugin-desktop/src/native-ui/shared/theme.css index eafc2a054f..e0cb93219f 100644 --- a/dsh-plugin-desktop/src/native-ui/shared/theme.css +++ b/dsh-plugin-desktop/src/native-ui/shared/theme.css @@ -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; } diff --git a/dsh-plugin-desktop/tests/native-ui-frame.spec.ts b/dsh-plugin-desktop/tests/native-ui-frame.spec.ts index 7b609d49c1..b9ff42ceea 100644 --- a/dsh-plugin-desktop/tests/native-ui-frame.spec.ts +++ b/dsh-plugin-desktop/tests/native-ui-frame.spec.ts @@ -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) + }) }) diff --git a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts index 1a06518b0d..be4e015a41 100644 --- a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts +++ b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts @@ -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', () => {