refactor(client): the host's Checkbox where its string label fits

Two of the market's five checkboxes are a plain box plus a short label
(Advanced → automatic backup; the export dialog's "include configuration"), and
those now render the host's `Checkbox` — matching the settings page around
them. Older hosts keep the market's label+input.

The other three deliberately do not, and the reasons are written down where the
next person will ask:

- the export picker's per-plugin rows carry rich label content (name, spec
  kind, resolved spec), and `Checkbox` takes a STRING label — routing them
  through it would either drop that content or print the name twice;
- the recovery panel's checkbox has no visible label at all;
- the gist mode picker is a pair of RADIOS, and the host ships no radio
  primitive (0.1.7-rc.2: Checkbox, Switch, SegmentedControl, Tag — no radio).

Verified: 1808 unit tests, npm run check clean, and a mutation that ignores the
host component turns the new test red.
This commit is contained in:
fkysly
2026-09-25 18:37:35 +08:00
parent f4294b4912
commit c9705fd9cd
4 changed files with 106 additions and 18 deletions
+29 -12
View File
File diff suppressed because one or more lines are too long
+27 -6
View File
@@ -36,7 +36,7 @@ import {
IconSparkle16,
IconWarningOutline16,
} from './icons.ts'
import { HostSwitch, HostTag } from './optional-primitives.ts'
import { HostCheckbox, HostSwitch, HostTag } from './optional-primitives.ts'
import css from './Market.module.css'
import { MARK_BLOCK_RADIUS, MARK_BLOCK_SIZE, MARK_GRID_BLOCKS, MARK_PLUG_BLOCK, MARK_VIEW_BOX } from './market-mark.ts'
import { CommentsModal } from './CommentsModal.tsx'
@@ -4011,6 +4011,20 @@ export function MarketSection(props: MarketSectionProps) {
</button>
))
/**
* A labelled checkbox: the host's when it has one, the market's label+input
* otherwise. Only the simple ones go through here — see `HostCheckbox` for
* why the export rows and the recovery panel keep their own markup.
*/
const labelledCheckbox = (opts: { label: string; checked: boolean; disabled?: boolean; className?: string; onChange: (next: boolean) => void }) => (HostCheckbox !== null
? <HostCheckbox checked={opts.checked} onChange={opts.onChange} label={opts.label} disabled={opts.disabled} className={opts.className} />
: (
<label className={opts.className}>
<input type="checkbox" checked={opts.checked} disabled={opts.disabled} onChange={event => opts.onChange(event.target.checked)} />
{opts.label}
</label>
))
const pluginCard = (p: RegistryPlugin) => {
const desc = (p.description && (p.description[lang] || p.description.en)) || ''
const done = doneUrls.includes(p.url) || hotUrls.includes(p.url)
@@ -4921,7 +4935,12 @@ export function MarketSection(props: MarketSectionProps) {
<Button variant="primary" size="sm" disabled={backupBusy || webdavUrl.trim() === ''} onClick={() => runWebdav('backup')}>{backupBusy ? t('backupWorking') : t('webdavUpload')}</Button>
<Button variant="outline" size="sm" disabled={backupBusy || webdavUrl.trim() === ''} onClick={() => runWebdav('restore')}>{t('webdavRestore')}</Button>
</div>
<label className={css.backupCheck}><input type="checkbox" checked={autoBackup} onChange={e => setAutoBackup(e.target.checked)} />{t('autoBackup')}</label>
{labelledCheckbox({
label: t('autoBackup'),
checked: autoBackup,
className: css.backupCheck,
onChange: setAutoBackup,
})}
<p>{t('webdavNote')}</p>
<p className={css.backupWarn}>{t('credsWarning')}</p>
</section>
@@ -6394,10 +6413,12 @@ export function MarketSection(props: MarketSectionProps) {
</label>
))}
</div>
<label className={css.backupCheck}>
<input type="checkbox" checked={exportIncludeConfig} onChange={e => setExportIncludeConfig(e.target.checked)} />
{t('gistIncludeConfig')}
</label>
{labelledCheckbox({
label: t('gistIncludeConfig'),
checked: exportIncludeConfig,
className: css.backupCheck,
onChange: setExportIncludeConfig,
})}
{exportIncludeConfig && <p className={css.backupWarn}>{t('credsWarning')}</p>}
{exportError !== null && <p className={css.backupWarn}>{exportError}</p>}
</>
+20
View File
@@ -68,3 +68,23 @@ export type SwitchComponent = (props: {
}) => ReactNode
export const HostSwitch: SwitchComponent | null = optionalComponent<SwitchComponent>('Switch')
/**
* `Checkbox` — the host's labelled checkbox. Null before 0.1.7-rc.2.
*
* It takes a STRING label, which is what limits its use here: the market's
* checkboxes that carry rich label content (the export rows: name, spec kind,
* resolved spec) or no visible label at all (the recovery panel's) stay native
* markup — routing those through this would either lose the row's content or
* duplicate it.
*/
export type CheckboxComponent = (props: {
checked: boolean
onChange: (next: boolean) => void
label: string
disabled?: boolean
title?: string
className?: string
}) => ReactNode
export const HostCheckbox: CheckboxComponent | null = optionalComponent<CheckboxComponent>('Checkbox')
@@ -20,6 +20,8 @@ vi.mock('@deepseek-ai/dsh-client-ui-primitives', async (importOriginal) => ({
<span data-host-tag={tone ?? 'outline'}>{children}</span>,
Switch: ({ checked, label }: { checked: boolean; label: string }) =>
<button type="button" data-host-switch={String(checked)} aria-label={label} />,
Checkbox: ({ checked, label }: { checked: boolean; label: string }) =>
<label data-host-checkbox={String(checked)}>{label}</label>,
}))
import { MarketSection, resetMarketPortalHost } from '../../src/client/MarketSection.tsx'
@@ -104,3 +106,31 @@ describe('the on/off control on a host that has Switch', () => {
expect(switches[0]!.getAttribute('aria-label')).toMatch(/Disable dsh-tagged/)
})
})
describe('labelled checkboxes on a host that has Checkbox', () => {
it('uses the host checkbox for the market simple labelled ones', async () => {
// The Advanced tab's auto-backup flag is a plain labelled checkbox, which
// is exactly what the host's component is for. Its rich-label siblings
// (the export rows) keep native markup — `HostCheckbox` says why.
vi.stubGlobal('fetch', vi.fn((input: unknown) => {
const path = String(input).split('?')[0]
if (path === '/dsh-market/registry') return Promise.resolve(new Response(JSON.stringify({ source: 'live', hostVersion: '0.1.2-alpha.2', registry: REGISTRY })))
if (path === '/dsh-market/installed') return Promise.resolve(new Response(JSON.stringify({ profile: 'web', installed: {}, live: [], disabled: [], groups: {}, groupOrder: [], favorites: [] })))
if (path === '/dsh-market/status') return Promise.resolve(new Response(JSON.stringify({ active: false, pnpm: true, boot: 'boot-1', restart: true, installed: {} })))
if (path === '/dsh-market/updates') return Promise.resolve(new Response(JSON.stringify({ updates: {} })))
return Promise.reject(new Error(`unstubbed fetch: ${path}`))
}))
render(<MarketSection {...props()} />)
await screen.findByText('dsh-tagged')
const advanced = screen.getAllByRole('button', { name: /^(Advanced|高级)$/u })
.find(button => /(^|_)tab(_|$)/u.test(button.className))
expect(advanced, 'no Advanced tab').toBeTruthy()
const { fireEvent } = await import('@testing-library/react')
fireEvent.click(advanced!)
const boxes = await screen.findAllByText(en.autoBackup)
expect(boxes.length).toBeGreaterThan(0)
const host = document.querySelector('[data-host-checkbox]')
expect(host).toBeTruthy()
expect(host!.textContent).toBe(en.autoBackup)
})
})