mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
/**
|
|
* Settings shell root: the sidebar-foot trigger row plus the centered modal
|
|
* panel (figma 501:29947, 1080x700) with the section nav rail. The shell is
|
|
* a pure composition face — every piece of text (trigger label, panel title,
|
|
* close label, sections) arrives from registrants through slots; accessible
|
|
* names resolve to that content (trigger: its own text; dialog:
|
|
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
|
* open state and the active section id are component-local viewing state;
|
|
* the onboarding coordinator mounts exactly one ordered registrant while the
|
|
* sessions-derived empty-Hero fact is active.
|
|
*/
|
|
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import clsx from 'clsx'
|
|
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
|
import css from './SettingsRoot.module.css'
|
|
|
|
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
|
function navIcon(id: string) {
|
|
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
|
|
return <IconSettingsOutline16 className={css.navIcon} size={16} />
|
|
}
|
|
|
|
type PanelProps = {
|
|
rows: readonly SettingsSectionRow[]
|
|
renderSlot: SettingsRootComponentProps['renderSlot']
|
|
activeId: string | undefined
|
|
onSelect: (id: string) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
/**
|
|
* The modal layer: full-viewport mask + centered panel. Close paths: the
|
|
* header button, a mask click, and document-level Escape (mounted only while
|
|
* open, so the listener lifetime is the panel's).
|
|
*/
|
|
function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
|
|
// Entries can unmount underneath the requested id, so the render-time
|
|
// projection falls back to the first row when the id is gone.
|
|
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
|
|
const titleId = useId()
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', onKeyDown)
|
|
return () => { document.removeEventListener('keydown', onKeyDown) }
|
|
}, [onClose])
|
|
|
|
// Baseline focus management: entering the dialog lands on the close button.
|
|
const closeButton = useRef<HTMLButtonElement | null>(null)
|
|
useEffect(() => { closeButton.current?.focus() }, [])
|
|
|
|
return (
|
|
<div className={css.overlay} role="presentation">
|
|
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
|
<div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
|
<nav className={css.nav}>
|
|
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
|
<div className={css.navList}>
|
|
{rows.map(row => (
|
|
<button
|
|
key={row.id}
|
|
type="button"
|
|
className={clsx(css.navCell, row.id === active && css.active)}
|
|
aria-current={row.id === active ? 'true' : undefined}
|
|
onClick={() => { onSelect(row.id) }}
|
|
>
|
|
{navIcon(row.id)}
|
|
<span className={css.navLabel}>{row.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
<div className={css.content}>
|
|
<div className={css.header}>
|
|
<button ref={closeButton} type="button" className={css.close} onClick={onClose}>
|
|
<IconCloseOutline16 size={14} />
|
|
<span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
|
|
</button>
|
|
</div>
|
|
<div className={css.options}>
|
|
{active !== undefined && renderSlot('settings.section', {}, { only: active })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render the settings trigger and panel.
|
|
* @param props - composed slot props (contract/slots.ts).
|
|
* @returns the settings shell element tree.
|
|
*/
|
|
export function SettingsRoot(props: SettingsRootComponentProps) {
|
|
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
|
|
const [open, setOpen] = useState(false)
|
|
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
|
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
|
const close = useCallback(() => {
|
|
setOpen(false)
|
|
setActiveId(undefined)
|
|
}, [])
|
|
const openSection = useCallback((id: string) => {
|
|
setActiveId(id)
|
|
setOpen(true)
|
|
}, [])
|
|
|
|
// The ledger tick keeps the nav rows fresh: registrants re-register with
|
|
// freshly localized text on locale change, and the trigger/header/close
|
|
// seats re-render through their own outlets' subscriptions.
|
|
const rows = useSections(s => s)
|
|
const onboardingSteps = useOnboardingSteps(s => s)
|
|
const onboardingActive = useSessions(state =>
|
|
state.phase === 'ready'
|
|
&& (state.current === undefined || state.byId[state.current]?.blank === true))
|
|
const onboardingStep = onboardingActive
|
|
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
|
|
: undefined
|
|
|
|
useEffect(() => {
|
|
if (onboardingActive) return
|
|
setCompletedOnboarding(new Set())
|
|
}, [onboardingActive])
|
|
|
|
const completeOnboardingStep = useCallback((id: string) => {
|
|
setCompletedOnboarding((previous) => {
|
|
if (previous.has(id)) return previous
|
|
return new Set([...previous, id])
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (onboardingStep === undefined) return
|
|
const appRoot = document.getElementById('root')
|
|
if (appRoot === null) return
|
|
appRoot.inert = true
|
|
return () => { appRoot.inert = false }
|
|
}, [onboardingStep])
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.trigger, !wide && css.rail)}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
onClick={() => { setOpen(true) }}
|
|
>
|
|
{renderSlot('settings.trigger', { wide })}
|
|
</button>
|
|
{open && (
|
|
<SettingsPanel
|
|
rows={rows}
|
|
renderSlot={renderSlot}
|
|
activeId={activeId}
|
|
onSelect={setActiveId}
|
|
onClose={close}
|
|
/>
|
|
)}
|
|
{onboardingStep !== undefined && createPortal((
|
|
<div className={css.onboardingOverlay} role="presentation">
|
|
<div className={css.onboardingMask} aria-hidden="true" />
|
|
<div className={css.onboardingStage}>
|
|
{renderSlot('settings.onboarding', {
|
|
stepId: onboardingStep.id,
|
|
complete: () => { completeOnboardingStep(onboardingStep.id) },
|
|
openSection,
|
|
}, { only: onboardingStep.id })}
|
|
</div>
|
|
</div>
|
|
), document.body)}
|
|
</>
|
|
)
|
|
}
|