refactor(gui): copy-free settings shell; ui-settings-general owns ownerless copy
The shell is now a pure composition face: no dictionaries, no locale dependency, and three new chrome content seats (settings.trigger / settings.header / settings.close) whose slot content also carries the accessible names (trigger text, dialog aria-labelledby, visually hidden close label). ui-settings-general returns as the owner of copy that belongs to no single feature: chrome content, the General section with its item slot, and the settings dictionaries. Slot types split homes — trigger/header/close/section live in the shell contract; the settings.general.item entry moves to the locale package (the common dependency of every item registrant), with ui-theme consuming it through a re-export seam; the verbatim duplicate merges are gone and the dependency graph is a clean DAG.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-settings
|
||||
|
||||
Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering).
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet.
|
||||
- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general.
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -47,7 +46,6 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/* General section rows (figma 501:29983 'Options'): stacked groups, 16px
|
||||
* vertical padding each, hairline separator under all but the last child
|
||||
* (feature-contributed rows carry their own row chrome and separators; the
|
||||
* :last-child rule strips the trailing one wherever the column ends). */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Tool Call mode cubes share an 8px gap. */
|
||||
.cubeRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
|
||||
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
|
||||
.modeCube {
|
||||
box-sizing: border-box;
|
||||
width: 418px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
|
||||
* step has no alias-layer name). */
|
||||
.selected {
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
border-color: var(--dsw-static-neutral-bluish-400);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Shell-owned General section (figma 501:29983 'Options'): Permission and
|
||||
* Tool Call skeleton rows, then the feature-contributed preference rows from
|
||||
* the `settings.general.item` slot (locale → Language, ui-theme →
|
||||
* Appearance). The section column stacks rows; each row draws its own
|
||||
* internals and separator.
|
||||
*/
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GeneralSectionComponentProps } from './contract/slots.ts'
|
||||
import css from './GeneralSection.module.css'
|
||||
|
||||
/**
|
||||
* Render the General section content column.
|
||||
* @param props - composed slot props (contract/slots.ts).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) {
|
||||
return (
|
||||
<div className={css.section}>
|
||||
{/* Permission (skeleton): disabled selector pill. */}
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('permission.title')}</div>
|
||||
<div className={css.desc}>{t('permission.desc')}</div>
|
||||
</div>
|
||||
<button type="button" className={css.selector} disabled>
|
||||
{t('permission.value')}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.title}>{t('toolcall.title')}</div>
|
||||
<div className={css.cubeRow}>
|
||||
<div className={`${css.modeCube} ${css.selected}`}>
|
||||
<div className={css.title}>{t('toolcall.schema.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
|
||||
</div>
|
||||
<div className={css.modeCube}>
|
||||
<div className={css.title}>{t('toolcall.code.title')}</div>
|
||||
<div className={css.desc}>{t('toolcall.code.desc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature-owned preference rows (Language, Appearance, …). */}
|
||||
{renderSlot('settings.general.item', {})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -190,3 +190,13 @@
|
||||
padding: 0 24px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Visually-hidden text seat (close button accessible name from slot content). */
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Settings shell root: the sidebar-foot trigger row plus the centered modal
|
||||
* panel (figma 501:29947, 1080x700) with the section nav rail. Modal open
|
||||
* state and the active section id are component-local viewing state; the
|
||||
* section ledger arrives through the injected face (nav labels are
|
||||
* registrant-localized — the shell owns no locale/theme subscription).
|
||||
* 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.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
@@ -20,7 +20,6 @@ function navIcon(id: string) {
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
translate: SettingsRootComponentProps['translate']
|
||||
rows: ReturnType<SettingsRootComponentProps['sections']>
|
||||
renderSlot: SettingsRootComponentProps['renderSlot']
|
||||
onClose: () => void
|
||||
@@ -31,11 +30,12 @@ type PanelProps = {
|
||||
* header button, a mask click, and document-level Escape (mounted only while
|
||||
* open, so the listener lifetime is the panel's).
|
||||
*/
|
||||
function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
|
||||
function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
// Local selection; entries can unmount underneath it, so the render-time
|
||||
// projection falls back to the first row when the id is gone.
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -52,9 +52,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
|
||||
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-label={translate('settings:title')}>
|
||||
<nav className={css.nav} aria-label={translate('settings:title')}>
|
||||
<div className={css.navTitle}>{translate('settings:title')}</div>
|
||||
<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
|
||||
@@ -72,8 +72,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
|
||||
</nav>
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<button ref={closeButton} type="button" className={css.close} aria-label={translate('settings:close')} onClick={onClose}>
|
||||
<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}>
|
||||
@@ -91,13 +92,13 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) {
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props
|
||||
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
|
||||
// The ledger tick is the shell's only subscription: sections re-register
|
||||
// with freshly localized labels on locale change, so the version bump also
|
||||
// re-renders the shell's own translate()-read chrome copy.
|
||||
// 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.
|
||||
// State = ledger version: same-version notifications dedupe to no render.
|
||||
const [, setSectionsRev] = useState(() => sectionsVersion())
|
||||
useEffect(
|
||||
@@ -111,15 +112,13 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.trigger, !wide && css.rail)}
|
||||
aria-label={translate('settings:trigger')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(true) }}
|
||||
>
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide && <span className={css.triggerLabel}>{translate('settings:trigger')}</span>}
|
||||
{renderSlot('settings.trigger', { wide })}
|
||||
</button>
|
||||
{open && <SettingsPanel translate={translate} rows={rows} renderSlot={renderSlot} onClose={close} />}
|
||||
{open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Settings shell slot contract. The shell occupies the sidebar-owned
|
||||
* `sidebar.settings` hole, declares the `settings.section` list slot that
|
||||
* feature plugins contribute top-level pages into, and ships the first
|
||||
* section itself: General, whose `settings.general.item` list slot receives
|
||||
* preference rows from the features that own them (locale → Language,
|
||||
* ui-theme → Appearance). A feature owns its settings surface — adding a
|
||||
* setting never means editing the shell.
|
||||
* Settings shell slot contract — the canonical home of every settings slot
|
||||
* type. The shell is a pure composition face with zero copy of its own: it
|
||||
* occupies the sidebar-owned `sidebar.settings` hole and declares the slots
|
||||
* below; ALL text (trigger label, panel title, close aria, section content)
|
||||
* arrives from registrants. A feature owns its settings surface — adding a
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
@@ -14,28 +14,54 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* The sidebar-foot trigger row content: icon + label, supplied as slot
|
||||
* content (the accessible name comes from the content — rail state
|
||||
* renders the label visually hidden). The shell renders the button
|
||||
* chrome and owns open state. Absent contribution degrades to an
|
||||
* icon-only button without an accessible name (broken-composition state;
|
||||
* the shipped composition always registers the seat).
|
||||
*/
|
||||
'settings.trigger': { kind: 'single'; scope: 'root'; owner: SettingsTriggerOwnerProps }
|
||||
/**
|
||||
* The panel title text seat. Content renders inside the nav heading row;
|
||||
* the dialog's accessible name points at that node via aria-labelledby.
|
||||
* Absent contribution leaves the heading empty.
|
||||
*/
|
||||
'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
|
||||
/**
|
||||
* The close button's visually-hidden label text (the button itself —
|
||||
* icon, geometry, focus — is shell chrome). Absent contribution leaves
|
||||
* the button without an accessible name (broken-composition state).
|
||||
*/
|
||||
'settings.close': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
|
||||
/**
|
||||
* One settings page per list entry. Registrant options carry the nav
|
||||
* identity: `id` (section key, drives `only` filtering), `order` (nav
|
||||
* position), `label` (registrant-localized display text — the registrant
|
||||
* re-registers with fresh text on locale change, so the shell never
|
||||
* subscribes locale/theme state; the ledger bump doubles as the shell's
|
||||
* subscribes locale state; the ledger bump doubles as the shell's
|
||||
* re-render trigger). Sections render inside the panel content column.
|
||||
* (`settings.general.item`, declared by ui-settings-general's General
|
||||
* entry, is typed in the locale package — the common dependency of every
|
||||
* item registrant; the shell neither declares nor renders it.)
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* One preference row inside the General section, contributed by the
|
||||
* feature plugin that owns the preference (locale → Language, ui-theme →
|
||||
* Appearance). Options: `id` (row key), `order` (row position). Rows
|
||||
* draw their own internals (row layout, separators via CSS); the section
|
||||
* column only stacks them. NOTE: packages/client/locale and ui-theme
|
||||
* repeat this entry verbatim (reference-cycle avoidance) — declaration
|
||||
* merging enforces the copies stay identical; edit all three together.
|
||||
*/
|
||||
'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of the trigger content seat: the sidebar column state. */
|
||||
export interface SettingsTriggerOwnerProps {
|
||||
/** Whether the sidebar renders wide content (false = 56px rail, icon only). */
|
||||
wide: boolean
|
||||
}
|
||||
|
||||
/** Owner share of the header title seat (the shell supplies nothing). */
|
||||
export interface SettingsHeaderOwnerProps {
|
||||
/** Marker field: header owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of a settings section entry. The shell owns modal visibility
|
||||
* and navigation; sections receive nothing but the render site (their data
|
||||
@@ -48,16 +74,9 @@ export interface SettingsSectionOwnerProps {
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): locale-resolved nav labels come through `translate`.
|
||||
* apply): ledger projections only — the shell reads no locale state.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
/**
|
||||
* Resolve a "<ns>:<key>" locale reference to the active-locale text —
|
||||
* shell chrome copy only (trigger/title/close); nav labels arrive already
|
||||
* localized. Read at render time; the locale-change re-render rides the
|
||||
* section ledger bump, not a shell-owned subscription.
|
||||
*/
|
||||
translate: (ref: string) => string
|
||||
/** Read the settings.section ledger version (nav invalidation). */
|
||||
sectionsVersion: () => number
|
||||
/** Subscribe to settings.section ledger changes. */
|
||||
@@ -68,27 +87,11 @@ export type SettingsRootInjected = {
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared section render share and the injected
|
||||
* face. No store is registered — modal open state and active section id are
|
||||
* (wide/rail state) plus the declared render shares and the injected face.
|
||||
* No store is registered — modal open state and active section id are
|
||||
* component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected
|
||||
|
||||
/**
|
||||
* Injected share of the shell-owned General section: the shell's own
|
||||
* `settings` namespace translate function for the skeleton rows (Permission,
|
||||
* Tool Call). Live preference rows arrive through the item slot with their
|
||||
* own faces.
|
||||
*/
|
||||
export type GeneralSectionInjected = {
|
||||
/** Translate a `settings` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the shell-owned General section: the section owner
|
||||
* share, the declared item render share, and the injected face.
|
||||
*/
|
||||
export type GeneralSectionComponentProps =
|
||||
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
|
||||
& SettingsRootInjected
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
/**
|
||||
* Settings shell plugin, browser half. Occupies the sidebar-owned
|
||||
* `sidebar.settings` hole with the trigger row + modal panel, declares the
|
||||
* `settings.section` list slot, projects that ledger into the panel
|
||||
* navigation, and ships the first section itself: General, which declares
|
||||
* the `settings.general.item` slot that feature plugins contribute
|
||||
* preference rows into. Export discipline: packages/client/AGENTS.md.
|
||||
* Settings shell plugin, browser half. A pure composition face: occupies the
|
||||
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
|
||||
* panel, declares the `settings.trigger` / `settings.header` /
|
||||
* `settings.section` slots, and projects the section ledger into the panel
|
||||
* navigation. The shell ships no copy and reads no locale state — all text
|
||||
* arrives from registrants (ui-settings-general owns the chrome and General
|
||||
* content; features own their rows and sections). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale,
|
||||
// 'locale/change') into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts'
|
||||
import type { SettingsRootInjected } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type {
|
||||
GeneralSectionComponentProps, GeneralSectionInjected,
|
||||
SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps,
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -27,29 +24,15 @@ export type {
|
||||
* constrained (dshClient.inject edges are informational); registration goes
|
||||
* through declaration-aware deferral.
|
||||
*/
|
||||
export const inject = ['slots', 'locale']
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Register the settings shell into `sidebar.settings` and the shell-owned
|
||||
* General section into `settings.section`, each once its declaration is on
|
||||
* the ledger.
|
||||
* Register the settings shell into `sidebar.settings` once the declaration is
|
||||
* on the ledger.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register('settings', 'zh', zh),
|
||||
ctx.locale.register('settings', 'en', en),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings: shell copy dictionaries')
|
||||
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
translate: (ref) => {
|
||||
const colon = ref.indexOf(':')
|
||||
if (colon === -1) return ref
|
||||
return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
|
||||
},
|
||||
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
||||
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
sections: () => ctx.slots.entries('settings.section')
|
||||
@@ -65,33 +48,14 @@ export function apply(ctx: ClientContext): void {
|
||||
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||
ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: injected,
|
||||
}, SettingsRoot))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'ui-settings: shell registration')
|
||||
|
||||
// The shell's own General section: first page, declares the item slot the
|
||||
// feature plugins (locale, ui-theme, …) contribute preference rows into.
|
||||
const generalInjected = (): GeneralSectionInjected => ({
|
||||
t: ctx.locale.bind('settings'),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'general',
|
||||
order: 0,
|
||||
label: ctx.locale.bind('settings')('general.nav'),
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
inject: generalInjected,
|
||||
}, GeneralSection))
|
||||
// Nav labels are registrant-localized: refresh on locale change so the
|
||||
// ledger carries fresh text (the version bump re-renders the shell).
|
||||
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
|
||||
return () => {
|
||||
offLocale()
|
||||
deferred.dispose()
|
||||
}
|
||||
}, 'ui-settings: general section registration')
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* `settings` namespace dictionaries: shell chrome plus the shell-owned
|
||||
* General section (nav label, skeleton rows). Skeleton-row technical copy
|
||||
* (Read only / Schema mode / Code mode and their descriptions) is shared
|
||||
* verbatim across locales per the Figma design. Feature-owned rows
|
||||
* (Language, Appearance) ship their copy in their own packages.
|
||||
*/
|
||||
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const SHARED = {
|
||||
'permission.value': 'Read only',
|
||||
'toolcall.schema.title': 'Schema mode',
|
||||
'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time',
|
||||
'toolcall.code.title': 'Code mode',
|
||||
'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration',
|
||||
} satisfies LocaleDict
|
||||
|
||||
/** Simplified Chinese dictionary. */
|
||||
export const zh: LocaleDict = {
|
||||
...SHARED,
|
||||
'trigger': '设置',
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'permission.title': '权限',
|
||||
'permission.desc': '选择默认权限模式',
|
||||
'toolcall.title': '工具调用',
|
||||
}
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: LocaleDict = {
|
||||
...SHARED,
|
||||
'trigger': 'Settings',
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'general.nav': 'General',
|
||||
'permission.title': 'Permission',
|
||||
'permission.desc': 'Choose default permission mode',
|
||||
'toolcall.title': 'Tool Call',
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
/** Settings shell registration: declaration-aware deferral, the injected face, and HMR recovery. */
|
||||
/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService): () => void {
|
||||
@@ -28,17 +24,27 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
|
||||
return (entry.inject as () => SettingsRootInjected)()
|
||||
}
|
||||
|
||||
/** The shell's four child declarations (chrome seats + the section list). */
|
||||
const CHILD_SPECS = {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
} as const
|
||||
|
||||
describe('ui-settings apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
})
|
||||
|
||||
it('registers the shell for declarations that arrive before or after apply', async () => {
|
||||
it('registers the shell and declares the four child slots, before or after the declaration', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
|
||||
expect(before.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
|
||||
for (const [name, spec] of Object.entries(CHILD_SPECS)) {
|
||||
expect(before.slots.spec(name as never)).toEqual(spec)
|
||||
}
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -50,43 +56,17 @@ describe('ui-settings apply', () => {
|
||||
expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('registers the zh/en shell dictionaries and disposes them with the fiber', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.locale.bind('settings')('title')).toBe('设置')
|
||||
b.locale.setLocale('en')
|
||||
expect(b.locale.bind('settings')('close')).toBe('Close')
|
||||
await fiber.dispose()
|
||||
// The (ns, locale) seats are free again — the dictionary disposers ran.
|
||||
expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
|
||||
expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('exposes translate over "<ns>:<key>" refs with literal echo for plain text', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
expect(injected.translate('settings:title')).toBe('设置')
|
||||
expect(injected.translate('no colon ref')).toBe('no colon ref')
|
||||
})
|
||||
|
||||
it('projects the section ledger into ordered nav rows with option defaults', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
// The shell ships its own General section (order 0) — the ledger is never
|
||||
// empty once apply settles.
|
||||
expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }])
|
||||
// The shell ships no sections of its own — registrants fill the ledger.
|
||||
expect(injected.sections()).toEqual([])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
// No order and no label: both projection defaults apply (order 0 ties
|
||||
// keep registration sequence, so 'a' lands after the General entry).
|
||||
// No order and no label: both projection defaults apply.
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
expect(injected.sections()).toEqual([
|
||||
{ id: 'general', order: 0, label: '通用设置' },
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
@@ -104,92 +84,28 @@ describe('ui-settings apply', () => {
|
||||
const redeclare = declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
|
||||
// Declarer unload: the cascade removes our entry and the slot spec while
|
||||
// our local disposer variable goes stale.
|
||||
// Declarer unload: the cascade removes our entry and every child
|
||||
// declaration while our local disposer variable goes stale.
|
||||
redeclare()
|
||||
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
|
||||
expect(b.slots.spec('settings.trigger')).toBeUndefined()
|
||||
declare(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
|
||||
expect(b.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' })
|
||||
for (const [name, spec] of Object.entries(CHILD_SPECS)) {
|
||||
expect(b.slots.spec(name as never)).toEqual(spec)
|
||||
}
|
||||
})
|
||||
|
||||
it('unregisters the shell and collapses settings.section on teardown', async () => {
|
||||
it('unregisters the shell and collapses all four child slots on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
|
||||
expect(b.slots.spec('settings.section')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-settings general section', () => {
|
||||
it('registers the shell-owned General entry and declares the item slot', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(GeneralSection)
|
||||
expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
|
||||
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
const injected = (entry.inject as () => GeneralSectionInjected)()
|
||||
expect(injected.t('permission.title')).toBe('权限')
|
||||
})
|
||||
|
||||
it('re-registers with fresh label text on locale change', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
|
||||
b.locale.setLocale('zh')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('locale change while settings.section is undeclared stays a no-op', async () => {
|
||||
const b = await bench()
|
||||
// No sidebar.settings declaration: the shell never registers, so
|
||||
// settings.section is never declared either.
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the whole chain (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(1)
|
||||
// Root declarer unload: the cascade removes the shell entry, the
|
||||
// settings.section declaration, and the General entry below it.
|
||||
redeclare()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.spec('settings.general.item')).toBeUndefined()
|
||||
declare(b.slots)
|
||||
// Two deferral hops: the shell re-registers (re-declaring
|
||||
// settings.section), then General re-registers into it.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(GeneralSection)
|
||||
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
// The recovered registration still rides the locale path.
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General')
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
it('removes the General entry and its item declaration on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.spec('settings.general.item')).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.spec('settings.general.item')).toBeUndefined()
|
||||
for (const name of Object.keys(CHILD_SPECS)) {
|
||||
expect(b.slots.spec(name as never)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount() {
|
||||
const renderSlot = vi.fn(
|
||||
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
|
||||
)
|
||||
// Global standard kit stubs: the section consumes neither hook.
|
||||
const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never
|
||||
const props: GeneralSectionComponentProps = {
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
t: (key) => en[key] ?? key,
|
||||
renderSlot,
|
||||
}
|
||||
const view = render(<GeneralSection {...props} />)
|
||||
return { view, renderSlot }
|
||||
}
|
||||
|
||||
describe('GeneralSection', () => {
|
||||
it('renders the Permission skeleton row with the disabled selector', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Permission')).toBeTruthy()
|
||||
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
||||
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
||||
expect(selector.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Tool Call skeleton cubes with schema pinned selected', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Tool Call')).toBeTruthy()
|
||||
const schema = screen.getByText('Schema mode')
|
||||
const code = screen.getByText('Code mode')
|
||||
expect(schema.parentElement!.className).toContain('selected')
|
||||
expect(code.parentElement!.className).not.toContain('selected')
|
||||
expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy()
|
||||
expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the feature-contributed item slot after the skeleton rows', () => {
|
||||
const { renderSlot } = mount()
|
||||
expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {})
|
||||
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -6,14 +6,15 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const DICT: Record<string, string> = {
|
||||
'settings:trigger': 'Settings',
|
||||
'settings:title': 'Settings',
|
||||
'settings:close': 'Close',
|
||||
}
|
||||
|
||||
type Row = { id: string; order: number; label: string }
|
||||
|
||||
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
|
||||
const SEAT_CONTENT: Record<string, string> = {
|
||||
'settings.trigger': 'Settings',
|
||||
'settings.header': 'Settings Title',
|
||||
'settings.close': 'Close',
|
||||
}
|
||||
|
||||
function mount({
|
||||
wide = true,
|
||||
rows = [
|
||||
@@ -26,8 +27,10 @@ function mount({
|
||||
let version = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const renderSlot = vi.fn(
|
||||
((_key: string, _owner: unknown, opts?: { only?: string }) =>
|
||||
<div data-testid={`section-${opts?.only ?? 'all'}`} />) as SettingsRootComponentProps['renderSlot'],
|
||||
((key: string, _owner: unknown, opts?: { only?: string }) => {
|
||||
if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
|
||||
return SEAT_CONTENT[key]
|
||||
}) as SettingsRootComponentProps['renderSlot'],
|
||||
)
|
||||
// Global standard kit stubs: the shell consumes neither hook.
|
||||
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
|
||||
@@ -35,7 +38,6 @@ function mount({
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
translate: (ref) => DICT[ref] ?? ref,
|
||||
sectionsVersion: () => version,
|
||||
subscribeSections: (listener) => {
|
||||
listeners.add(listener)
|
||||
@@ -60,19 +62,41 @@ function openPanel() {
|
||||
}
|
||||
|
||||
describe('SettingsRoot trigger', () => {
|
||||
it('renders the wide row with the label and opens the dialog', () => {
|
||||
mount()
|
||||
it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
|
||||
const { renderSlot } = mount()
|
||||
const trigger = screen.getByRole('button', { name: 'Settings' })
|
||||
expect(trigger.textContent).toContain('Settings')
|
||||
expect(trigger.hasAttribute('aria-label')).toBe(false)
|
||||
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the label in the rail state', () => {
|
||||
mount({ wide: false })
|
||||
expect(screen.getByRole('button', { name: 'Settings' }).textContent).toBe('')
|
||||
it('hands the rail state to the trigger seat', () => {
|
||||
const { renderSlot } = mount({ wide: false })
|
||||
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsPanel chrome seats', () => {
|
||||
it('names the dialog via aria-labelledby pointing at the header seat node', () => {
|
||||
mount()
|
||||
openPanel()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const titleId = dialog.getAttribute('aria-labelledby')!
|
||||
expect(titleId).toBeTruthy()
|
||||
const title = document.getElementById(titleId)!
|
||||
expect(title.textContent).toBe('Settings Title')
|
||||
expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('names the close button through the visually-hidden close seat text', () => {
|
||||
mount()
|
||||
openPanel()
|
||||
const close = screen.getByRole('button', { name: 'Close' })
|
||||
expect(close.hasAttribute('aria-label')).toBe(false)
|
||||
expect(close.textContent).toContain('Close')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,7 +167,8 @@ describe('SettingsPanel navigation', () => {
|
||||
const { renderSlot } = mount({ rows: [] })
|
||||
openPanel()
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(renderSlot).not.toHaveBeenCalled()
|
||||
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
|
||||
expect(sectionCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops the ledger subscription on unmount', () => {
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user