@linxin666/dsh-usage 0.4.10 → 0.4.12

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,838 @@
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
+ /**
114
+ * Render children even when the settings namespace is not exposed by the
115
+ * host. The 'not exposed' notice still explains the card above them; a card
116
+ * that wants the rows alone hides it through `hideNotExposedNotice`.
117
+ */
118
+ renderChildrenWhenNotExposed?: boolean
119
+ /** Hide the 'not exposed' notice, whichever branch renders the card body. */
120
+ hideNotExposedNotice?: boolean
121
+ /**
122
+ * Compact chrome for a topic card rendered inside another card's body: a
123
+ * smaller title, a tighter header and body inset, and a one-line description
124
+ * whose full text stays on the header's own title. Defaults to the
125
+ * first-level chrome, which is unchanged.
126
+ */
127
+ density?: 'nested'
128
+ /** The plugin's controls. */
129
+ children: ReactNode
130
+ }
131
+
132
+ /** Length of the body's disclosure motion; it matches `--settings-card-motion` on the card root. */
133
+ const BODY_MOTION_MS = 180
134
+
135
+ /**
136
+ * Lifecycle of one card body. `collapsed` is the mounted-but-closed frame the
137
+ * enter leg starts from, `entering` and `closing` are the two moving legs, and
138
+ * `shown` is the settled open body, which releases its clipping box.
139
+ */
140
+ type BodyPhase = 'hidden' | 'collapsed' | 'entering' | 'shown' | 'closing'
141
+
142
+ /**
143
+ * Render one plugin settings card.
144
+ * @param props - the plugin's copy keys, its form state, and its controls.
145
+ * @returns the card, or nothing while the namespace is still loading.
146
+ */
147
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
148
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
149
+ const { state, alwaysOpen } = props
150
+ const expanded = alwaysOpen === true || open
151
+ const [bodyPhase, setBodyPhase] = useState<BodyPhase>(expanded ? 'shown' : 'hidden')
152
+ const bodyRef = useRef<HTMLDivElement | null>(null)
153
+ // Enter and leave run the same pair: a body that should open is mounted
154
+ // closed for one frame so its transition has a state to leave, and a closing
155
+ // one stays mounted until its leg has run.
156
+ useLayoutEffect(() => {
157
+ if (expanded) {
158
+ if (bodyPhase === 'hidden') setBodyPhase('collapsed')
159
+ else if (bodyPhase === 'collapsed') {
160
+ const node = bodyRef.current
161
+ if (node !== null) void node.offsetHeight
162
+ setBodyPhase('entering')
163
+ } else if (bodyPhase === 'closing') setBodyPhase('entering')
164
+ return
165
+ }
166
+ if (bodyPhase === 'collapsed') setBodyPhase('hidden')
167
+ else if (bodyPhase === 'entering' || bodyPhase === 'shown') setBodyPhase('closing')
168
+ }, [expanded, bodyPhase])
169
+ useEffect(() => {
170
+ if (bodyPhase !== 'entering' && bodyPhase !== 'closing') return
171
+ const timer = setTimeout(() => {
172
+ setBodyPhase(bodyPhase === 'entering' ? 'shown' : 'hidden')
173
+ }, BODY_MOTION_MS)
174
+ return () => { clearTimeout(timer) }
175
+ }, [bodyPhase])
176
+ if (!state.available) return null
177
+ const title = props.t(props.titleKey)
178
+ const description = props.t(props.descriptionKey)
179
+ const descriptionTitle = props.descriptionTitleKey === undefined ? description : props.t(props.descriptionTitleKey)
180
+ const baseClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
181
+ const cardClass = props.density === 'nested' ? `${baseClass} ${css.cardNested}` : baseClass
182
+ /** The body region both legs share; a hidden phase renders nothing at all. */
183
+ const bodyRegion = (content: ReactNode) => bodyPhase === 'hidden'
184
+ ? null
185
+ : (
186
+ <div className={css.disclosure} data-state={bodyPhase} ref={bodyRef}>
187
+ <div className={css.body}>{content}</div>
188
+ </div>
189
+ )
190
+ // With alwaysOpen the nav entry already provides the selection, so the
191
+ // header is a static title row instead of a disclosure button.
192
+ const header = alwaysOpen === true
193
+ ? (
194
+ <div className={css.headerStatic}>
195
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
196
+ <span className={css.headText}>
197
+ <span className={css.name} id={props.titleId} title={title}>{title}</span>
198
+ <span className={css.description} title={descriptionTitle}>{props.descriptionNode ?? description}</span>
199
+ </span>
200
+ {props.headerAction !== undefined ? <span className={css.headerAction}>{props.headerAction}</span> : null}
201
+ </div>
202
+ )
203
+ : (
204
+ <button
205
+ type="button"
206
+ className={css.header}
207
+ aria-expanded={open}
208
+ aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
209
+ onClick={() => { setOpen(!open) }}
210
+ >
211
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
212
+ <span className={css.headText}>
213
+ <span className={css.name} id={props.titleId} title={title}>{title}</span>
214
+ <span className={css.description} title={descriptionTitle}>{props.descriptionNode ?? description}</span>
215
+ </span>
216
+ {props.headerAction !== undefined ? <span className={css.headerAction}>{props.headerAction}</span> : null}
217
+ <svg
218
+ width="14"
219
+ height="14"
220
+ viewBox="0 0 14 14"
221
+ fill="none"
222
+ xmlns="http://www.w3.org/2000/svg"
223
+ className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
224
+ >
225
+ <path
226
+ 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"
227
+ fill="currentColor"
228
+ />
229
+ </svg>
230
+ </button>
231
+ )
232
+ // The namespace exists but the Host does not serve it to this client (the
233
+ // official settings allowlist omits third-party namespaces): show a card
234
+ // that explains the gap instead of vanishing, so a missing card never
235
+ // reads as a missing plugin. A card that keeps its rows anyway keeps the
236
+ // same explanation above them, unless it hides the notice outright.
237
+ const notExposedNotice = state.exposed || props.hideNotExposedNotice === true
238
+ ? null
239
+ : <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
240
+ if (!state.exposed && props.renderChildrenWhenNotExposed !== true) {
241
+ return (
242
+ <li className={cardClass}>
243
+ {header}
244
+ {bodyRegion(notExposedNotice)}
245
+ </li>
246
+ )
247
+ }
248
+ // A header-only card has no body region at all: the divider would otherwise
249
+ // sit above an empty box.
250
+ const bodyVisible = props.headerOnly !== true
251
+ return (
252
+ <li className={cardClass}>
253
+ {header}
254
+ {bodyVisible
255
+ ? bodyRegion(
256
+ <>
257
+ {notExposedNotice}
258
+ {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
259
+ <CardApply.Provider value={{ commit: props.onSave, revert: props.onDiscard }}>
260
+ {props.children}
261
+ </CardApply.Provider>
262
+ {props.hideFooter !== true && state.failed
263
+ ? (
264
+ <p className={css.failed} role="status">
265
+ {props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
266
+ </p>
267
+ )
268
+ : null}
269
+ </>,
270
+ )
271
+ : null}
272
+ </li>
273
+ )
274
+ }
275
+
276
+ /** Props every field control needs regardless of its value type. */
277
+ export interface FieldProps {
278
+ /** Stable id associating the label with its control. */
279
+ id: string
280
+ /** Visible label. */
281
+ label: string
282
+ /** One-line explanation rendered under the control. */
283
+ hint: string
284
+ /**
285
+ * Full sentence behind the visible hint, exposed as the paragraph's native
286
+ * `title` so a card can shorten the line without losing the text. Omitted:
287
+ * the paragraph carries no title attribute at all.
288
+ */
289
+ hintTitle?: string
290
+ /** Draft text this control renders. */
291
+ text: string
292
+ /** True when the field holds a user-layer entry over the inherited value. */
293
+ overridden: boolean
294
+ /** True when the draft is not a value this field accepts. */
295
+ invalid: boolean
296
+ /** Copy for the overridden badge. */
297
+ overriddenLabel: string
298
+ /** Copy for the reset control. */
299
+ resetLabel: string
300
+ /** Copy shown in place of the hint while the draft is invalid. */
301
+ invalidLabel: string
302
+ /** Disables every control (read-only document, or an unavailable namespace). */
303
+ disabled: boolean
304
+ /** Take new draft text; the card writes it as the field's entry rule says. */
305
+ onEdit: (text: string) => void
306
+ /** Clear the field at once so it re-inherits the composition layer. */
307
+ onReset: () => void
308
+ }
309
+
310
+ /**
311
+ * A typed value field. Its draft is written after a pause in typing, at once on
312
+ * Enter or when focus leaves, and Escape puts the stored value back. `numeric`
313
+ * only hints the keypad: which drafts a field accepts is decided by its spec.
314
+ * A field given a `range` also offers the coarse slider bubble: dragging fills
315
+ * the draft live and releasing writes it.
316
+ */
317
+ export function ValueField(props: FieldProps & {
318
+ /** Hints a numeric keypad without narrowing what the control accepts. */
319
+ numeric?: boolean
320
+ /** Placeholder shown while the draft is empty. */
321
+ placeholder?: string
322
+ /** Bounds of a numeric field, for the coarse slider; the spec still judges every draft. */
323
+ range?: { min: number, max: number, step: number }
324
+ }) {
325
+ const apply = useContext(CardApply)
326
+ // Enter and Escape settle the draft themselves and then leave the field, so
327
+ // the blur they cause is that same settlement reaching the field's edge, not
328
+ // a second write. Without this an Escape reported a discard and then a save,
329
+ // which reads to a consumer as a write still waiting to happen.
330
+ const settledByKey = useRef(false)
331
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
332
+ if (apply === null || (event.key !== 'Enter' && event.key !== 'Escape')) return
333
+ event.preventDefault()
334
+ settledByKey.current = true
335
+ if (event.key === 'Enter') apply.commit()
336
+ else apply.revert()
337
+ event.currentTarget.blur()
338
+ }
339
+ const input = (
340
+ <input
341
+ id={props.id}
342
+ className={props.invalid ? css.inputInvalid : css.input}
343
+ type="text"
344
+ {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
345
+ {...props.invalid ? { 'aria-invalid': true } : {}}
346
+ value={props.text}
347
+ placeholder={props.placeholder ?? ''}
348
+ disabled={props.disabled}
349
+ onChange={(event) => { props.onEdit(event.target.value) }}
350
+ onKeyDown={onKeyDown}
351
+ onFocus={() => { settledByKey.current = false }}
352
+ onBlur={() => {
353
+ if (settledByKey.current) {
354
+ settledByKey.current = false
355
+ return
356
+ }
357
+ apply?.commit()
358
+ }}
359
+ />
360
+ )
361
+ const { range } = props
362
+ const typed = Number(props.text)
363
+ return (
364
+ <div className={css.field}>
365
+ <div className={css.head}>
366
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
367
+ {props.overridden
368
+ ? (
369
+ <span className={css.badges}>
370
+ <span className={css.badge}>{props.overriddenLabel}</span>
371
+ <button
372
+ type="button"
373
+ className={css.reset}
374
+ disabled={props.disabled}
375
+ onClick={props.onReset}
376
+ >
377
+ {props.resetLabel}
378
+ </button>
379
+ </span>
380
+ )
381
+ : null}
382
+ </div>
383
+ {range === undefined
384
+ ? input
385
+ : (
386
+ <RangeBubble
387
+ min={range.min}
388
+ max={range.max}
389
+ step={range.step}
390
+ value={props.text.trim() === '' || Number.isNaN(typed) ? range.min : typed}
391
+ label={`${props.label} ${range.min}–${range.max}`}
392
+ disabled={props.disabled}
393
+ onInput={(value) => { props.onEdit(String(value)) }}
394
+ onCommit={(value) => {
395
+ props.onEdit(String(value))
396
+ apply?.commit()
397
+ }}
398
+ >
399
+ {input}
400
+ </RangeBubble>
401
+ )}
402
+ <p className={props.invalid ? css.invalid : css.hint} title={props.hintTitle}>
403
+ {props.invalid ? props.invalidLabel : props.hint}
404
+ </p>
405
+ </div>
406
+ )
407
+ }
408
+
409
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
410
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
411
+ function isSkinActive(): boolean {
412
+ const datasetList = Object.keys(document.body.dataset)
413
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
414
+ return isActive
415
+ }
416
+
417
+ interface SelectOption {
418
+ value: string
419
+ label: string
420
+ }
421
+
422
+ const SELECT_CLOSE_MS = 100
423
+
424
+ /**
425
+ * The shared dual-mode select control. While an appearance skin is active it
426
+ * renders the legacy native `<select>` untouched, so element-level skin
427
+ * selectors keep working; under the default appearance it renders a
428
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
429
+ * Cards reach it through BooleanField/ChoiceField; editors outside a card
430
+ * (the side-card prefs) bind it directly through onEdit.
431
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
432
+ */
433
+ export function SelectField(props: {
434
+ id: string
435
+ options: ReadonlyArray<SelectOption>
436
+ value: string
437
+ disabled: boolean
438
+ invalid: boolean
439
+ onEdit: (text: string) => void
440
+ }) {
441
+ const { id, options, value } = props
442
+ const [open, setOpen] = useState(false)
443
+ const [closing, setClosing] = useState(false)
444
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
445
+ const [activeIndex, setActiveIndex] = useState(0)
446
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
447
+ const wrapRef = useRef<HTMLDivElement | null>(null)
448
+ const popupRef = useRef<HTMLDivElement | null>(null)
449
+
450
+ const currentIndex = () => {
451
+ const index = options.findIndex(option => option.value === value)
452
+ return index >= 0 ? index : 0
453
+ }
454
+
455
+ const close = useCallback(() => {
456
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
457
+ setClosing(true)
458
+ closeTimer.current = setTimeout(() => {
459
+ setClosing(false)
460
+ setOpen(false)
461
+ }, SELECT_CLOSE_MS)
462
+ }, [])
463
+
464
+ const openPopup = () => {
465
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
466
+ setActiveIndex(currentIndex())
467
+ setPhase('initial')
468
+ setClosing(false)
469
+ setOpen(true)
470
+ }
471
+
472
+ const commit = (index: number) => {
473
+ const option = options[index]
474
+ if (option) props.onEdit(option.value)
475
+ close()
476
+ }
477
+
478
+ const onTriggerClick = () => {
479
+ if (props.disabled) return
480
+ if (open && !closing) close()
481
+ else openPopup()
482
+ }
483
+
484
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
485
+ if (props.disabled) return
486
+ const count = options.length
487
+ switch (event.key) {
488
+ case 'ArrowDown':
489
+ case 'ArrowUp':
490
+ case 'Enter':
491
+ case ' ':
492
+ event.preventDefault()
493
+ if (!open) {
494
+ openPopup()
495
+ } else if (!closing) {
496
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
497
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
498
+ else commit(activeIndex)
499
+ }
500
+ break
501
+ case 'Escape':
502
+ if (open) {
503
+ event.preventDefault()
504
+ event.stopPropagation()
505
+ close()
506
+ }
507
+ break
508
+ case 'Tab':
509
+ if (open) close()
510
+ break
511
+ }
512
+ }
513
+
514
+ useEffect(() => () => {
515
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
516
+ }, [])
517
+ useLayoutEffect(() => {
518
+ if (open && !closing && phase === 'initial') {
519
+ void popupRef.current?.offsetHeight
520
+ setPhase('open')
521
+ }
522
+ }, [open, closing, phase])
523
+
524
+ useEffect(() => {
525
+ if (!open) return
526
+ const onPointerDown = (event: PointerEvent) => {
527
+ const target = event.target
528
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
529
+ }
530
+ document.addEventListener('pointerdown', onPointerDown)
531
+ return () => document.removeEventListener('pointerdown', onPointerDown)
532
+ }, [open, close])
533
+
534
+ useEffect(() => {
535
+ if (props.disabled && open) close()
536
+ }, [props.disabled, open, close])
537
+
538
+ // 使用了皮肤则退回原生select样式防止样式冲突。
539
+ if (isSkinActive()) {
540
+ return (
541
+ <select
542
+ id={id}
543
+ className={css.select}
544
+ value={value}
545
+ disabled={props.disabled}
546
+ onChange={(event) => { props.onEdit(event.target.value) }}
547
+ >
548
+ {options.map(option => (
549
+ <option key={option.value} value={option.value}>{option.label}</option>
550
+ ))}
551
+ </select>
552
+ )
553
+ }
554
+
555
+ const label = options.find(option => option.value === value)?.label ?? ''
556
+ const popupClass = closing
557
+ ? `${css.selectPopup} ${css.selectPopupClose}`
558
+ : phase === 'open'
559
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
560
+ : css.selectPopup
561
+ return (
562
+ <div className={css.selectWrap} ref={wrapRef}>
563
+ <button
564
+ type="button"
565
+ id={id}
566
+ className={`${css.select} ${css.selectButton}`}
567
+ disabled={props.disabled}
568
+ aria-haspopup="listbox"
569
+ aria-expanded={open}
570
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
571
+ aria-invalid={props.invalid || undefined}
572
+ onClick={onTriggerClick}
573
+ onKeyDown={onKeyDown}
574
+ >
575
+ <span className={css.selectLabel}>{label}</span>
576
+ <svg
577
+ width="14"
578
+ height="14"
579
+ viewBox="0 0 14 14"
580
+ fill="none"
581
+ xmlns="http://www.w3.org/2000/svg"
582
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
583
+ aria-hidden="true"
584
+ >
585
+ <path
586
+ 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"
587
+ fill="currentColor"
588
+ />
589
+ </svg>
590
+ </button>
591
+ {open
592
+ ? (
593
+ <div className={popupClass} role="listbox" ref={popupRef}>
594
+ {options.map((option, index) => (
595
+ <div
596
+ key={option.value}
597
+ id={`${id}-o${index}`}
598
+ role="option"
599
+ aria-selected={option.value === value}
600
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
601
+ onClick={() => { commit(index) }}
602
+ >
603
+ {option.label}
604
+ </div>
605
+ ))}
606
+ </div>
607
+ )
608
+ : null}
609
+ </div>
610
+ )
611
+ }
612
+
613
+ /**
614
+ * A visible segmented group for a small set of mutually exclusive values.
615
+ * Every option stays on screen, so inherit/on/off and short enum fields take
616
+ * one click without opening a popup. The pressed option carries the group's
617
+ * sliding highlight and `aria-pressed`; the group takes the field label as its
618
+ * accessible name.
619
+ */
620
+ export function SegmentField(props: {
621
+ id: string
622
+ label: string
623
+ options: ReadonlyArray<SelectOption>
624
+ value: string
625
+ disabled: boolean
626
+ invalid: boolean
627
+ onEdit: (text: string) => void
628
+ }) {
629
+ return (
630
+ <SegmentGroup
631
+ id={props.id}
632
+ role="group"
633
+ aria-label={props.label}
634
+ aria-invalid={props.invalid || undefined}
635
+ >
636
+ {props.options.map((option, index) => {
637
+ const active = option.value === props.value
638
+ return (
639
+ <button
640
+ key={option.value}
641
+ type="button"
642
+ className={css.segment}
643
+ aria-pressed={active}
644
+ disabled={props.disabled}
645
+ data-active={active ? '' : undefined}
646
+ data-segment-index={index}
647
+ onClick={() => {
648
+ if (!active) props.onEdit(option.value)
649
+ }}
650
+ >
651
+ {option.label}
652
+ </button>
653
+ )
654
+ })}
655
+ </SegmentGroup>
656
+ )
657
+ }
658
+
659
+ /** How a small single-choice field presents its options. */
660
+ export type FieldPresentation = 'select' | 'segment'
661
+
662
+ /** A boolean field: 继承 / 开 / 关. */
663
+ export function BooleanField(props: FieldProps & {
664
+ /** Copy for the inherit option. */
665
+ inheritLabel: string
666
+ /** Copy for the on option. */
667
+ onLabel: string
668
+ /** Copy for the off option. */
669
+ offLabel: string
670
+ /** Defaults to the visible segments: the three answers always fit on one row. */
671
+ presentation?: FieldPresentation
672
+ }) {
673
+ const segmented = props.presentation !== 'select'
674
+ return (
675
+ <div className={css.field}>
676
+ <div className={css.head}>
677
+ {segmented
678
+ ? <span className={css.label}>{props.label}</span>
679
+ : <label className={css.label} htmlFor={props.id}>{props.label}</label>}
680
+ {props.overridden
681
+ ? (
682
+ <span className={css.badges}>
683
+ <span className={css.badge}>{props.overriddenLabel}</span>
684
+ <button
685
+ type="button"
686
+ className={css.reset}
687
+ disabled={props.disabled}
688
+ onClick={props.onReset}
689
+ >
690
+ {props.resetLabel}
691
+ </button>
692
+ </span>
693
+ )
694
+ : null}
695
+ </div>
696
+ {segmented
697
+ ? (
698
+ <SegmentField
699
+ id={props.id}
700
+ label={props.label}
701
+ options={[
702
+ { value: '', label: props.inheritLabel },
703
+ { value: 'true', label: props.onLabel },
704
+ { value: 'false', label: props.offLabel },
705
+ ]}
706
+ value={props.text}
707
+ disabled={props.disabled}
708
+ invalid={props.invalid}
709
+ onEdit={props.onEdit}
710
+ />
711
+ )
712
+ : (
713
+ <SelectField
714
+ id={props.id}
715
+ options={[
716
+ { value: '', label: props.inheritLabel },
717
+ { value: 'true', label: props.onLabel },
718
+ { value: 'false', label: props.offLabel },
719
+ ]}
720
+ value={props.text}
721
+ disabled={props.disabled}
722
+ invalid={props.invalid}
723
+ onEdit={props.onEdit}
724
+ />
725
+ )}
726
+ <p className={css.hint} title={props.hintTitle}>{props.hint}</p>
727
+ </div>
728
+ )
729
+ }
730
+
731
+ /** An enumerated field, selecting or segmenting its small choice set. */
732
+ export function ChoiceField(props: FieldProps & {
733
+ /** Copy for the inherit option (draft text is the empty string). */
734
+ inheritLabel: string
735
+ /** Choices rendered in order; `value` is the draft/stored text. */
736
+ choices: ReadonlyArray<{ value: string; label: string }>
737
+ /**
738
+ * Visible segmented options for a short fixed set; defaults to the compact
739
+ * select, which also suits a list whose length follows the deployment.
740
+ */
741
+ presentation?: FieldPresentation
742
+ }) {
743
+ const segmented = props.presentation === 'segment'
744
+ const options = [{ value: '', label: props.inheritLabel }, ...props.choices]
745
+ return (
746
+ <div className={css.field}>
747
+ <div className={css.head}>
748
+ {segmented
749
+ ? <span className={css.label}>{props.label}</span>
750
+ : <label className={css.label} htmlFor={props.id}>{props.label}</label>}
751
+ {props.overridden
752
+ ? (
753
+ <span className={css.badges}>
754
+ <span className={css.badge}>{props.overriddenLabel}</span>
755
+ <button
756
+ type="button"
757
+ className={css.reset}
758
+ disabled={props.disabled}
759
+ onClick={props.onReset}
760
+ >
761
+ {props.resetLabel}
762
+ </button>
763
+ </span>
764
+ )
765
+ : null}
766
+ </div>
767
+ {segmented
768
+ ? (
769
+ <SegmentField
770
+ id={props.id}
771
+ label={props.label}
772
+ options={options}
773
+ value={props.text}
774
+ disabled={props.disabled}
775
+ invalid={props.invalid}
776
+ onEdit={props.onEdit}
777
+ />
778
+ )
779
+ : (
780
+ <SelectField
781
+ id={props.id}
782
+ options={options}
783
+ value={props.text}
784
+ disabled={props.disabled}
785
+ invalid={props.invalid}
786
+ onEdit={props.onEdit}
787
+ />
788
+ )}
789
+ <p className={props.invalid ? css.invalid : css.hint} title={props.hintTitle}>
790
+ {props.invalid ? props.invalidLabel : props.hint}
791
+ </p>
792
+ </div>
793
+ )
794
+ }
795
+
796
+ /**
797
+ * A boolean toggle drawn as a switch track, for a setting that is only on or
798
+ * off and applies on flip. It is a real button with
799
+ * `role="switch"` and `aria-checked`, so keyboard and assistive activation
800
+ * arrive as a click and the state is announced instead of inferred from a
801
+ * checkbox tick.
802
+ *
803
+ * The thumb bounces across only once the reader has pressed this switch: the
804
+ * press writes the mark that arms the bounce, so a page opening, a TAB
805
+ * switching back or a value arriving from elsewhere draws every thumb at rest.
806
+ */
807
+ export function ToggleSwitch(props: {
808
+ /** Id of the element naming the switch (the card's title). */
809
+ labelledBy?: string
810
+ /** Accessible name when no element labels it. */
811
+ ariaLabel?: string
812
+ /** Current state. */
813
+ checked: boolean
814
+ /** Disables the switch while a write is in flight or the Host refuses writes. */
815
+ disabled?: boolean
816
+ /** Called with the requested next state. */
817
+ onChange: (next: boolean) => void
818
+ }) {
819
+ const [moved, setMoved] = useState(false)
820
+ return (
821
+ <button
822
+ type="button"
823
+ className={css.toggle}
824
+ role="switch"
825
+ aria-checked={props.checked}
826
+ aria-labelledby={props.labelledBy}
827
+ aria-label={props.labelledBy === undefined ? props.ariaLabel : undefined}
828
+ disabled={props.disabled}
829
+ data-moved={moved ? '' : undefined}
830
+ onClick={() => {
831
+ setMoved(true)
832
+ props.onChange(!props.checked)
833
+ }}
834
+ >
835
+ <span className={css.toggleTrack}><span className={css.toggleThumb} /></span>
836
+ </button>
837
+ )
838
+ }