@linxin666/dsh-usage 0.4.10 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,817 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the line
5
+ * that reports a refused write. A pick is written the moment it is made and a
6
+ * typed value after a pause, so the card has no save button and nothing waits
7
+ * on the reader. Renders nothing while the namespace is unavailable — a
8
+ * deployment that does not compose the owning plugin should show no trace of
9
+ * it. Inlined into each consumer's client bundle; mirrors the official
10
+ * ui-plugin-config PluginCard in a self-contained slice.
11
+ *
12
+ * Every field inside the body is one settings row: the label and its hint on
13
+ * the left, the control on the right, a hairline between two rows; a narrow
14
+ * card stacks the control under the text (settings-card.module.css).
15
+ */
16
+
17
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
18
+ import type { CardShell } from './settings-form.ts'
19
+ import { RangeBubble } from './RangeBubble.tsx'
20
+ import { SegmentGroup } from './SegmentGroup.tsx'
21
+ import css from './settings-card.module.css'
22
+
23
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
24
+ export const CARD_COPY_KEYS = [
25
+ 'settings.collapse',
26
+ 'settings.expand',
27
+ 'settings.notExposed',
28
+ 'settings.readOnly',
29
+ 'settings.saveFailed',
30
+ ] as const
31
+
32
+ /**
33
+ * What a text field inside a card reaches without its plugin wiring it: write
34
+ * the pending draft now (Enter, leaving the field) or put it back (Escape).
35
+ * Picks need neither; they are written the moment they are made.
36
+ */
37
+ const CardApply = createContext<{ commit(): void, revert(): void } | null>(null)
38
+
39
+ /** Copy key the card chrome itself reads. */
40
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
41
+
42
+ /** Key domain for the plugin's own copy (inferred per consumer). */
43
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
44
+
45
+ /** Card chrome shared by every plugin settings card. */
46
+ export interface PluginSettingsCardProps<TKey extends string = string> {
47
+ /** Locale reader for this card's copy. */
48
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
49
+ /** Locale key of the plugin's name. */
50
+ titleKey: TKey
51
+ /** Locale key of the line describing what this plugin's settings govern. */
52
+ descriptionKey: TKey
53
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
54
+ descriptionNode?: ReactNode
55
+ /**
56
+ * Locale key of the full sentence behind a shortened description: when
57
+ * present, the header's native `title` takes this text while the visible
58
+ * line keeps `descriptionKey`/`descriptionNode`, so a compact card can
59
+ * compress the sentence without keeping a second visible copy of it.
60
+ * Defaults to the description key.
61
+ */
62
+ descriptionTitleKey?: TKey
63
+ /**
64
+ * Optional leading mark identifying the plugin. A card without one keeps the
65
+ * plain official header shape, so the chrome stays a readable sibling of the
66
+ * built-in PluginCard wherever a family plugin lands in the official bundle
67
+ * seat.
68
+ */
69
+ icon?: ReactNode
70
+ /** The card's form state: availability, writability, and how the last write went. */
71
+ state: CardShell
72
+ /** Write every pending draft now: a text field's Enter or blur. */
73
+ onSave: () => void
74
+ /** Drop every pending draft: a text field's Escape. */
75
+ onDiscard: () => void
76
+ /**
77
+ * Id applied to the card's title element, so a header control (an immediate
78
+ * toggle in `headerAction`) can name itself through `aria-labelledby`
79
+ * instead of repeating the title as a string.
80
+ */
81
+ titleId?: string
82
+ /**
83
+ * Render the header only, with no body region. A card whose whole setting is
84
+ * the header control (a switch whose explanation is the description) has no
85
+ * controls to lay out below it, and an empty body would draw a divider line
86
+ * with nothing under it.
87
+ */
88
+ headerOnly?: boolean
89
+ /**
90
+ * Render the controls expanded on arrival (still collapsible). Defaults to
91
+ * true: promoted first-level sections show their settings immediately.
92
+ */
93
+ defaultOpen?: boolean
94
+ /**
95
+ * Render without the collapse affordance: a static header and an always
96
+ * visible body. Used by first-level settings sections whose nav entry
97
+ * already provides the selection.
98
+ */
99
+ alwaysOpen?: boolean
100
+ /**
101
+ * Hide the line under the body that reports a refused write: a card whose
102
+ * body applies its own changes (an embedded external settings section)
103
+ * reports its own failures.
104
+ */
105
+ hideFooter?: boolean
106
+ /**
107
+ * Optional control rendered at the header's trailing edge, before the
108
+ * chevron. A card whose setting is one immediate flip (it
109
+ * applies outside the card's form) puts its ToggleSwitch here, so the control
110
+ * is reachable without opening the card and matches a plugin card's shape.
111
+ */
112
+ headerAction?: ReactNode
113
+ /** Render children even when the settings namespace is not exposed by the host. */
114
+ renderChildrenWhenNotExposed?: boolean
115
+ /** Hide the 'not exposed' warning banner when the settings namespace is not exposed. */
116
+ hideNotExposedNotice?: boolean
117
+ /**
118
+ * Compact chrome for a topic card rendered inside another card's body: a
119
+ * smaller title, a tighter header and body inset, and a one-line description
120
+ * whose full text stays on the header's own title. Defaults to the
121
+ * first-level chrome, which is unchanged.
122
+ */
123
+ density?: 'nested'
124
+ /** The plugin's controls. */
125
+ children: ReactNode
126
+ }
127
+
128
+ /** Length of the body's disclosure motion; it matches `--settings-card-motion` on the card root. */
129
+ const BODY_MOTION_MS = 180
130
+
131
+ /**
132
+ * Lifecycle of one card body. `collapsed` is the mounted-but-closed frame the
133
+ * enter leg starts from, `entering` and `closing` are the two moving legs, and
134
+ * `shown` is the settled open body, which releases its clipping box.
135
+ */
136
+ type BodyPhase = 'hidden' | 'collapsed' | 'entering' | 'shown' | 'closing'
137
+
138
+ /**
139
+ * Render one plugin settings card.
140
+ * @param props - the plugin's copy keys, its form state, and its controls.
141
+ * @returns the card, or nothing while the namespace is still loading.
142
+ */
143
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
144
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
145
+ const { state, alwaysOpen } = props
146
+ const expanded = alwaysOpen === true || open
147
+ const [bodyPhase, setBodyPhase] = useState<BodyPhase>(expanded ? 'shown' : 'hidden')
148
+ const bodyRef = useRef<HTMLDivElement | null>(null)
149
+ // Enter and leave run the same pair: a body that should open is mounted
150
+ // closed for one frame so its transition has a state to leave, and a closing
151
+ // one stays mounted until its leg has run.
152
+ useLayoutEffect(() => {
153
+ if (expanded) {
154
+ if (bodyPhase === 'hidden') setBodyPhase('collapsed')
155
+ else if (bodyPhase === 'collapsed') {
156
+ const node = bodyRef.current
157
+ if (node !== null) void node.offsetHeight
158
+ setBodyPhase('entering')
159
+ } else if (bodyPhase === 'closing') setBodyPhase('entering')
160
+ return
161
+ }
162
+ if (bodyPhase === 'collapsed') setBodyPhase('hidden')
163
+ else if (bodyPhase === 'entering' || bodyPhase === 'shown') setBodyPhase('closing')
164
+ }, [expanded, bodyPhase])
165
+ useEffect(() => {
166
+ if (bodyPhase !== 'entering' && bodyPhase !== 'closing') return
167
+ const timer = setTimeout(() => {
168
+ setBodyPhase(bodyPhase === 'entering' ? 'shown' : 'hidden')
169
+ }, BODY_MOTION_MS)
170
+ return () => { clearTimeout(timer) }
171
+ }, [bodyPhase])
172
+ if (!state.available) return null
173
+ const title = props.t(props.titleKey)
174
+ const description = props.t(props.descriptionKey)
175
+ const descriptionTitle = props.descriptionTitleKey === undefined ? description : props.t(props.descriptionTitleKey)
176
+ const baseClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
177
+ const cardClass = props.density === 'nested' ? `${baseClass} ${css.cardNested}` : baseClass
178
+ /** The body region both legs share; a hidden phase renders nothing at all. */
179
+ const bodyRegion = (content: ReactNode) => bodyPhase === 'hidden'
180
+ ? null
181
+ : (
182
+ <div className={css.disclosure} data-state={bodyPhase} ref={bodyRef}>
183
+ <div className={css.body}>{content}</div>
184
+ </div>
185
+ )
186
+ // With alwaysOpen the nav entry already provides the selection, so the
187
+ // header is a static title row instead of a disclosure button.
188
+ const header = alwaysOpen === true
189
+ ? (
190
+ <div className={css.headerStatic}>
191
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
192
+ <span className={css.headText}>
193
+ <span className={css.name} id={props.titleId} title={title}>{title}</span>
194
+ <span className={css.description} title={descriptionTitle}>{props.descriptionNode ?? description}</span>
195
+ </span>
196
+ {props.headerAction !== undefined ? <span className={css.headerAction}>{props.headerAction}</span> : null}
197
+ </div>
198
+ )
199
+ : (
200
+ <button
201
+ type="button"
202
+ className={css.header}
203
+ aria-expanded={open}
204
+ aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
205
+ onClick={() => { setOpen(!open) }}
206
+ >
207
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
208
+ <span className={css.headText}>
209
+ <span className={css.name} id={props.titleId} title={title}>{title}</span>
210
+ <span className={css.description} title={descriptionTitle}>{props.descriptionNode ?? description}</span>
211
+ </span>
212
+ {props.headerAction !== undefined ? <span className={css.headerAction}>{props.headerAction}</span> : null}
213
+ <svg
214
+ width="14"
215
+ height="14"
216
+ viewBox="0 0 14 14"
217
+ fill="none"
218
+ xmlns="http://www.w3.org/2000/svg"
219
+ className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
220
+ >
221
+ <path
222
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
223
+ fill="currentColor"
224
+ />
225
+ </svg>
226
+ </button>
227
+ )
228
+ // The namespace exists but the Host does not serve it to this client (the
229
+ // official settings allowlist omits third-party namespaces): show a card
230
+ // that explains the gap instead of vanishing, so a missing card never
231
+ // reads as a missing plugin.
232
+ if (!state.exposed && props.renderChildrenWhenNotExposed !== true) {
233
+ const showNotice = props.hideNotExposedNotice !== true
234
+ return (
235
+ <li className={cardClass}>
236
+ {header}
237
+ {bodyRegion(showNotice ? <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p> : null)}
238
+ </li>
239
+ )
240
+ }
241
+ // A header-only card has no body region at all: the divider would otherwise
242
+ // sit above an empty box.
243
+ const bodyVisible = props.headerOnly !== true
244
+ return (
245
+ <li className={cardClass}>
246
+ {header}
247
+ {bodyVisible
248
+ ? bodyRegion(
249
+ <>
250
+ {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
251
+ <CardApply.Provider value={{ commit: props.onSave, revert: props.onDiscard }}>
252
+ {props.children}
253
+ </CardApply.Provider>
254
+ {props.hideFooter !== true && state.failed
255
+ ? (
256
+ <p className={css.failed} role="status">
257
+ {props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
258
+ </p>
259
+ )
260
+ : null}
261
+ </>,
262
+ )
263
+ : null}
264
+ </li>
265
+ )
266
+ }
267
+
268
+ /** Props every field control needs regardless of its value type. */
269
+ export interface FieldProps {
270
+ /** Stable id associating the label with its control. */
271
+ id: string
272
+ /** Visible label. */
273
+ label: string
274
+ /** One-line explanation rendered under the control. */
275
+ hint: string
276
+ /**
277
+ * Full sentence behind the visible hint, exposed as the paragraph's native
278
+ * `title` so a card can shorten the line without losing the text. Omitted:
279
+ * the paragraph carries no title attribute at all.
280
+ */
281
+ hintTitle?: string
282
+ /** Draft text this control renders. */
283
+ text: string
284
+ /** True when the field holds a user-layer entry over the inherited value. */
285
+ overridden: boolean
286
+ /** True when the draft is not a value this field accepts. */
287
+ invalid: boolean
288
+ /** Copy for the overridden badge. */
289
+ overriddenLabel: string
290
+ /** Copy for the reset control. */
291
+ resetLabel: string
292
+ /** Copy shown in place of the hint while the draft is invalid. */
293
+ invalidLabel: string
294
+ /** Disables every control (read-only document, or an unavailable namespace). */
295
+ disabled: boolean
296
+ /** Take new draft text; the card writes it as the field's entry rule says. */
297
+ onEdit: (text: string) => void
298
+ /** Clear the field at once so it re-inherits the composition layer. */
299
+ onReset: () => void
300
+ }
301
+
302
+ /**
303
+ * A typed value field. Its draft is written after a pause in typing, at once on
304
+ * Enter or when focus leaves, and Escape puts the stored value back. `numeric`
305
+ * only hints the keypad: which drafts a field accepts is decided by its spec.
306
+ * A field given a `range` also offers the coarse slider bubble: dragging fills
307
+ * the draft live and releasing writes it.
308
+ */
309
+ export function ValueField(props: FieldProps & {
310
+ /** Hints a numeric keypad without narrowing what the control accepts. */
311
+ numeric?: boolean
312
+ /** Placeholder shown while the draft is empty. */
313
+ placeholder?: string
314
+ /** Bounds of a numeric field, for the coarse slider; the spec still judges every draft. */
315
+ range?: { min: number, max: number, step: number }
316
+ }) {
317
+ const apply = useContext(CardApply)
318
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
319
+ if (apply === null || (event.key !== 'Enter' && event.key !== 'Escape')) return
320
+ event.preventDefault()
321
+ if (event.key === 'Enter') apply.commit()
322
+ else apply.revert()
323
+ event.currentTarget.blur()
324
+ }
325
+ const input = (
326
+ <input
327
+ id={props.id}
328
+ className={props.invalid ? css.inputInvalid : css.input}
329
+ type="text"
330
+ {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
331
+ {...props.invalid ? { 'aria-invalid': true } : {}}
332
+ value={props.text}
333
+ placeholder={props.placeholder ?? ''}
334
+ disabled={props.disabled}
335
+ onChange={(event) => { props.onEdit(event.target.value) }}
336
+ onKeyDown={onKeyDown}
337
+ onBlur={() => { apply?.commit() }}
338
+ />
339
+ )
340
+ const { range } = props
341
+ const typed = Number(props.text)
342
+ return (
343
+ <div className={css.field}>
344
+ <div className={css.head}>
345
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
346
+ {props.overridden
347
+ ? (
348
+ <span className={css.badges}>
349
+ <span className={css.badge}>{props.overriddenLabel}</span>
350
+ <button
351
+ type="button"
352
+ className={css.reset}
353
+ disabled={props.disabled}
354
+ onClick={props.onReset}
355
+ >
356
+ {props.resetLabel}
357
+ </button>
358
+ </span>
359
+ )
360
+ : null}
361
+ </div>
362
+ {range === undefined
363
+ ? input
364
+ : (
365
+ <RangeBubble
366
+ min={range.min}
367
+ max={range.max}
368
+ step={range.step}
369
+ value={props.text.trim() === '' || Number.isNaN(typed) ? range.min : typed}
370
+ label={`${props.label} ${range.min}–${range.max}`}
371
+ disabled={props.disabled}
372
+ onInput={(value) => { props.onEdit(String(value)) }}
373
+ onCommit={(value) => {
374
+ props.onEdit(String(value))
375
+ apply?.commit()
376
+ }}
377
+ >
378
+ {input}
379
+ </RangeBubble>
380
+ )}
381
+ <p className={props.invalid ? css.invalid : css.hint} title={props.hintTitle}>
382
+ {props.invalid ? props.invalidLabel : props.hint}
383
+ </p>
384
+ </div>
385
+ )
386
+ }
387
+
388
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
389
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
390
+ function isSkinActive(): boolean {
391
+ const datasetList = Object.keys(document.body.dataset)
392
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
393
+ return isActive
394
+ }
395
+
396
+ interface SelectOption {
397
+ value: string
398
+ label: string
399
+ }
400
+
401
+ const SELECT_CLOSE_MS = 100
402
+
403
+ /**
404
+ * The shared dual-mode select control. While an appearance skin is active it
405
+ * renders the legacy native `<select>` untouched, so element-level skin
406
+ * selectors keep working; under the default appearance it renders a
407
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
408
+ * Cards reach it through BooleanField/ChoiceField; editors outside a card
409
+ * (the side-card prefs) bind it directly through onEdit.
410
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
411
+ */
412
+ export function SelectField(props: {
413
+ id: string
414
+ options: ReadonlyArray<SelectOption>
415
+ value: string
416
+ disabled: boolean
417
+ invalid: boolean
418
+ onEdit: (text: string) => void
419
+ }) {
420
+ const { id, options, value } = props
421
+ const [open, setOpen] = useState(false)
422
+ const [closing, setClosing] = useState(false)
423
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
424
+ const [activeIndex, setActiveIndex] = useState(0)
425
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
426
+ const wrapRef = useRef<HTMLDivElement | null>(null)
427
+ const popupRef = useRef<HTMLDivElement | null>(null)
428
+
429
+ const currentIndex = () => {
430
+ const index = options.findIndex(option => option.value === value)
431
+ return index >= 0 ? index : 0
432
+ }
433
+
434
+ const close = useCallback(() => {
435
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
436
+ setClosing(true)
437
+ closeTimer.current = setTimeout(() => {
438
+ setClosing(false)
439
+ setOpen(false)
440
+ }, SELECT_CLOSE_MS)
441
+ }, [])
442
+
443
+ const openPopup = () => {
444
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
445
+ setActiveIndex(currentIndex())
446
+ setPhase('initial')
447
+ setClosing(false)
448
+ setOpen(true)
449
+ }
450
+
451
+ const commit = (index: number) => {
452
+ const option = options[index]
453
+ if (option) props.onEdit(option.value)
454
+ close()
455
+ }
456
+
457
+ const onTriggerClick = () => {
458
+ if (props.disabled) return
459
+ if (open && !closing) close()
460
+ else openPopup()
461
+ }
462
+
463
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
464
+ if (props.disabled) return
465
+ const count = options.length
466
+ switch (event.key) {
467
+ case 'ArrowDown':
468
+ case 'ArrowUp':
469
+ case 'Enter':
470
+ case ' ':
471
+ event.preventDefault()
472
+ if (!open) {
473
+ openPopup()
474
+ } else if (!closing) {
475
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
476
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
477
+ else commit(activeIndex)
478
+ }
479
+ break
480
+ case 'Escape':
481
+ if (open) {
482
+ event.preventDefault()
483
+ event.stopPropagation()
484
+ close()
485
+ }
486
+ break
487
+ case 'Tab':
488
+ if (open) close()
489
+ break
490
+ }
491
+ }
492
+
493
+ useEffect(() => () => {
494
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
495
+ }, [])
496
+ useLayoutEffect(() => {
497
+ if (open && !closing && phase === 'initial') {
498
+ void popupRef.current?.offsetHeight
499
+ setPhase('open')
500
+ }
501
+ }, [open, closing, phase])
502
+
503
+ useEffect(() => {
504
+ if (!open) return
505
+ const onPointerDown = (event: PointerEvent) => {
506
+ const target = event.target
507
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
508
+ }
509
+ document.addEventListener('pointerdown', onPointerDown)
510
+ return () => document.removeEventListener('pointerdown', onPointerDown)
511
+ }, [open, close])
512
+
513
+ useEffect(() => {
514
+ if (props.disabled && open) close()
515
+ }, [props.disabled, open, close])
516
+
517
+ // 使用了皮肤则退回原生select样式防止样式冲突。
518
+ if (isSkinActive()) {
519
+ return (
520
+ <select
521
+ id={id}
522
+ className={css.select}
523
+ value={value}
524
+ disabled={props.disabled}
525
+ onChange={(event) => { props.onEdit(event.target.value) }}
526
+ >
527
+ {options.map(option => (
528
+ <option key={option.value} value={option.value}>{option.label}</option>
529
+ ))}
530
+ </select>
531
+ )
532
+ }
533
+
534
+ const label = options.find(option => option.value === value)?.label ?? ''
535
+ const popupClass = closing
536
+ ? `${css.selectPopup} ${css.selectPopupClose}`
537
+ : phase === 'open'
538
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
539
+ : css.selectPopup
540
+ return (
541
+ <div className={css.selectWrap} ref={wrapRef}>
542
+ <button
543
+ type="button"
544
+ id={id}
545
+ className={`${css.select} ${css.selectButton}`}
546
+ disabled={props.disabled}
547
+ aria-haspopup="listbox"
548
+ aria-expanded={open}
549
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
550
+ aria-invalid={props.invalid || undefined}
551
+ onClick={onTriggerClick}
552
+ onKeyDown={onKeyDown}
553
+ >
554
+ <span className={css.selectLabel}>{label}</span>
555
+ <svg
556
+ width="14"
557
+ height="14"
558
+ viewBox="0 0 14 14"
559
+ fill="none"
560
+ xmlns="http://www.w3.org/2000/svg"
561
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
562
+ aria-hidden="true"
563
+ >
564
+ <path
565
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
566
+ fill="currentColor"
567
+ />
568
+ </svg>
569
+ </button>
570
+ {open
571
+ ? (
572
+ <div className={popupClass} role="listbox" ref={popupRef}>
573
+ {options.map((option, index) => (
574
+ <div
575
+ key={option.value}
576
+ id={`${id}-o${index}`}
577
+ role="option"
578
+ aria-selected={option.value === value}
579
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
580
+ onClick={() => { commit(index) }}
581
+ >
582
+ {option.label}
583
+ </div>
584
+ ))}
585
+ </div>
586
+ )
587
+ : null}
588
+ </div>
589
+ )
590
+ }
591
+
592
+ /**
593
+ * A visible segmented group for a small set of mutually exclusive values.
594
+ * Every option stays on screen, so inherit/on/off and short enum fields take
595
+ * one click without opening a popup. The pressed option carries the group's
596
+ * sliding highlight and `aria-pressed`; the group takes the field label as its
597
+ * accessible name.
598
+ */
599
+ export function SegmentField(props: {
600
+ id: string
601
+ label: string
602
+ options: ReadonlyArray<SelectOption>
603
+ value: string
604
+ disabled: boolean
605
+ invalid: boolean
606
+ onEdit: (text: string) => void
607
+ }) {
608
+ return (
609
+ <SegmentGroup
610
+ id={props.id}
611
+ role="group"
612
+ aria-label={props.label}
613
+ aria-invalid={props.invalid || undefined}
614
+ >
615
+ {props.options.map((option, index) => {
616
+ const active = option.value === props.value
617
+ return (
618
+ <button
619
+ key={option.value}
620
+ type="button"
621
+ className={css.segment}
622
+ aria-pressed={active}
623
+ disabled={props.disabled}
624
+ data-active={active ? '' : undefined}
625
+ data-segment-index={index}
626
+ onClick={() => {
627
+ if (!active) props.onEdit(option.value)
628
+ }}
629
+ >
630
+ {option.label}
631
+ </button>
632
+ )
633
+ })}
634
+ </SegmentGroup>
635
+ )
636
+ }
637
+
638
+ /** How a small single-choice field presents its options. */
639
+ export type FieldPresentation = 'select' | 'segment'
640
+
641
+ /** A boolean field: 继承 / 开 / 关. */
642
+ export function BooleanField(props: FieldProps & {
643
+ /** Copy for the inherit option. */
644
+ inheritLabel: string
645
+ /** Copy for the on option. */
646
+ onLabel: string
647
+ /** Copy for the off option. */
648
+ offLabel: string
649
+ /** Defaults to the visible segments: the three answers always fit on one row. */
650
+ presentation?: FieldPresentation
651
+ }) {
652
+ const segmented = props.presentation !== 'select'
653
+ return (
654
+ <div className={css.field}>
655
+ <div className={css.head}>
656
+ {segmented
657
+ ? <span className={css.label}>{props.label}</span>
658
+ : <label className={css.label} htmlFor={props.id}>{props.label}</label>}
659
+ {props.overridden
660
+ ? (
661
+ <span className={css.badges}>
662
+ <span className={css.badge}>{props.overriddenLabel}</span>
663
+ <button
664
+ type="button"
665
+ className={css.reset}
666
+ disabled={props.disabled}
667
+ onClick={props.onReset}
668
+ >
669
+ {props.resetLabel}
670
+ </button>
671
+ </span>
672
+ )
673
+ : null}
674
+ </div>
675
+ {segmented
676
+ ? (
677
+ <SegmentField
678
+ id={props.id}
679
+ label={props.label}
680
+ options={[
681
+ { value: '', label: props.inheritLabel },
682
+ { value: 'true', label: props.onLabel },
683
+ { value: 'false', label: props.offLabel },
684
+ ]}
685
+ value={props.text}
686
+ disabled={props.disabled}
687
+ invalid={props.invalid}
688
+ onEdit={props.onEdit}
689
+ />
690
+ )
691
+ : (
692
+ <SelectField
693
+ id={props.id}
694
+ options={[
695
+ { value: '', label: props.inheritLabel },
696
+ { value: 'true', label: props.onLabel },
697
+ { value: 'false', label: props.offLabel },
698
+ ]}
699
+ value={props.text}
700
+ disabled={props.disabled}
701
+ invalid={props.invalid}
702
+ onEdit={props.onEdit}
703
+ />
704
+ )}
705
+ <p className={css.hint} title={props.hintTitle}>{props.hint}</p>
706
+ </div>
707
+ )
708
+ }
709
+
710
+ /** An enumerated field, selecting or segmenting its small choice set. */
711
+ export function ChoiceField(props: FieldProps & {
712
+ /** Copy for the inherit option (draft text is the empty string). */
713
+ inheritLabel: string
714
+ /** Choices rendered in order; `value` is the draft/stored text. */
715
+ choices: ReadonlyArray<{ value: string; label: string }>
716
+ /**
717
+ * Visible segmented options for a short fixed set; defaults to the compact
718
+ * select, which also suits a list whose length follows the deployment.
719
+ */
720
+ presentation?: FieldPresentation
721
+ }) {
722
+ const segmented = props.presentation === 'segment'
723
+ const options = [{ value: '', label: props.inheritLabel }, ...props.choices]
724
+ return (
725
+ <div className={css.field}>
726
+ <div className={css.head}>
727
+ {segmented
728
+ ? <span className={css.label}>{props.label}</span>
729
+ : <label className={css.label} htmlFor={props.id}>{props.label}</label>}
730
+ {props.overridden
731
+ ? (
732
+ <span className={css.badges}>
733
+ <span className={css.badge}>{props.overriddenLabel}</span>
734
+ <button
735
+ type="button"
736
+ className={css.reset}
737
+ disabled={props.disabled}
738
+ onClick={props.onReset}
739
+ >
740
+ {props.resetLabel}
741
+ </button>
742
+ </span>
743
+ )
744
+ : null}
745
+ </div>
746
+ {segmented
747
+ ? (
748
+ <SegmentField
749
+ id={props.id}
750
+ label={props.label}
751
+ options={options}
752
+ value={props.text}
753
+ disabled={props.disabled}
754
+ invalid={props.invalid}
755
+ onEdit={props.onEdit}
756
+ />
757
+ )
758
+ : (
759
+ <SelectField
760
+ id={props.id}
761
+ options={options}
762
+ value={props.text}
763
+ disabled={props.disabled}
764
+ invalid={props.invalid}
765
+ onEdit={props.onEdit}
766
+ />
767
+ )}
768
+ <p className={props.invalid ? css.invalid : css.hint} title={props.hintTitle}>
769
+ {props.invalid ? props.invalidLabel : props.hint}
770
+ </p>
771
+ </div>
772
+ )
773
+ }
774
+
775
+ /**
776
+ * A boolean toggle drawn as a switch track, for a setting that is only on or
777
+ * off and applies on flip. It is a real button with
778
+ * `role="switch"` and `aria-checked`, so keyboard and assistive activation
779
+ * arrive as a click and the state is announced instead of inferred from a
780
+ * checkbox tick.
781
+ *
782
+ * The thumb bounces across only once the reader has pressed this switch: the
783
+ * press writes the mark that arms the bounce, so a page opening, a TAB
784
+ * switching back or a value arriving from elsewhere draws every thumb at rest.
785
+ */
786
+ export function ToggleSwitch(props: {
787
+ /** Id of the element naming the switch (the card's title). */
788
+ labelledBy?: string
789
+ /** Accessible name when no element labels it. */
790
+ ariaLabel?: string
791
+ /** Current state. */
792
+ checked: boolean
793
+ /** Disables the switch while a write is in flight or the Host refuses writes. */
794
+ disabled?: boolean
795
+ /** Called with the requested next state. */
796
+ onChange: (next: boolean) => void
797
+ }) {
798
+ const [moved, setMoved] = useState(false)
799
+ return (
800
+ <button
801
+ type="button"
802
+ className={css.toggle}
803
+ role="switch"
804
+ aria-checked={props.checked}
805
+ aria-labelledby={props.labelledBy}
806
+ aria-label={props.labelledBy === undefined ? props.ariaLabel : undefined}
807
+ disabled={props.disabled}
808
+ data-moved={moved ? '' : undefined}
809
+ onClick={() => {
810
+ setMoved(true)
811
+ props.onChange(!props.checked)
812
+ }}
813
+ >
814
+ <span className={css.toggleTrack}><span className={css.toggleThumb} /></span>
815
+ </button>
816
+ )
817
+ }