@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.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +1773 -366
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +255 -0
- package/lib/types/client/RangeBubble.d.ts +46 -0
- package/lib/types/client/SegmentGroup.d.ts +28 -0
- package/lib/types/client/UsagePage.d.ts +19 -0
- package/lib/types/client/UsageSectionCard.d.ts +11 -18
- package/lib/types/client/locales.d.ts +13 -2
- package/lib/types/client/section-integration.d.ts +47 -0
- package/lib/types/client/settings-form.d.ts +235 -0
- package/lib/types/client/usage-settings-card.d.ts +48 -0
- package/lib/types/client/web-extension-page.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +838 -0
- package/src/client/RangeBubble.tsx +164 -0
- package/src/client/SegmentGroup.tsx +93 -0
- package/src/client/UsagePage.tsx +108 -0
- package/src/client/UsageSectionCard.tsx +173 -270
- package/src/client/index.ts +19 -30
- package/src/client/locales.ts +46 -24
- package/src/client/section-integration.ts +88 -0
- package/src/client/settings-card.module.css +821 -0
- package/src/client/settings-form.ts +510 -0
- package/src/client/usage-settings-card.ts +72 -0
- package/src/client/usage.module.css +74 -82
- package/src/client/web-extension-page.ts +151 -0
|
@@ -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
|
+
}
|