@shendeguize/dsh-agent-sidecar 0.1.1 → 0.2.0

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.
Files changed (53) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +8659 -7589
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +115 -61
  8. package/src/client/board/board.module.css +72 -151
  9. package/src/client/board/project-view-logic.ts +21 -34
  10. package/src/client/board/project-view.module.css +41 -96
  11. package/src/client/board/project-view.tsx +29 -13
  12. package/src/client/board/strings.ts +68 -107
  13. package/src/client/commands.ts +30 -15
  14. package/src/client/detail/SessionDetail.tsx +92 -58
  15. package/src/client/detail/detail.module.css +62 -218
  16. package/src/client/detail/strings.ts +64 -88
  17. package/src/client/detail-view.module.css +30 -55
  18. package/src/client/detail-view.tsx +80 -108
  19. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  20. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  21. package/src/client/dsh-tools/dsh-tools.module.css +50 -139
  22. package/src/client/dsh-tools/strings.ts +42 -77
  23. package/src/client/index.ts +285 -124
  24. package/src/client/inject/InjectPanel.tsx +31 -64
  25. package/src/client/inject/inject.module.css +97 -198
  26. package/src/client/lifecycle/handoff.ts +83 -0
  27. package/src/client/locales/en.ts +74 -2
  28. package/src/client/locales/host.ts +131 -0
  29. package/src/client/locales/index.ts +196 -8
  30. package/src/client/locales/react.ts +10 -0
  31. package/src/client/locales/view.ts +38 -0
  32. package/src/client/locales/zh.ts +194 -134
  33. package/src/client/mount.tsx +72 -38
  34. package/src/client/navigation/CenterOverlay.tsx +40 -0
  35. package/src/client/navigation/center-overlay.module.css +51 -0
  36. package/src/client/navigation/center.ts +45 -0
  37. package/src/client/navigation/modal-isolation.ts +152 -0
  38. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  39. package/src/client/navigation/sidebar-entry.module.css +73 -0
  40. package/src/client/navigation/sidebar-entry.ts +309 -0
  41. package/src/client/primitives/StaticPill.tsx +21 -0
  42. package/src/client/settings-card.module.css +35 -119
  43. package/src/client/settings-card.tsx +31 -153
  44. package/src/client/settings-fields.tsx +131 -0
  45. package/src/client/sidebar/SidebarTab.tsx +156 -0
  46. package/src/client/sidebar/model.ts +65 -0
  47. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  48. package/src/client/sidebar-tab.tsx +46 -320
  49. package/src/client/theme/agsc.module.css +31 -0
  50. package/src/client/theme/parts.ts +56 -0
  51. package/src/client/ui-integration.ts +86 -0
  52. package/src/client/widget.tsx +9 -8
  53. package/src/client/inject/overlay.module.css +0 -22
@@ -0,0 +1,21 @@
1
+ import { Pill } from '@deepseek-ai/dsh-client-ui-primitives'
2
+ import type { HTMLAttributes, ReactElement } from 'react'
3
+
4
+ export type StaticPillProps = HTMLAttributes<HTMLSpanElement>
5
+
6
+ /**
7
+ * Preserve native span attributes around the rc.2 Pill static branch, which
8
+ * currently omits its rest props. The inner primitive remains the sole owner
9
+ * of the DSH pill visuals.
10
+ */
11
+ export function StaticPill({
12
+ className,
13
+ children,
14
+ ...rest
15
+ }: StaticPillProps): ReactElement {
16
+ return (
17
+ <span className={className} {...rest}>
18
+ <Pill>{children}</Pill>
19
+ </span>
20
+ )
21
+ }
@@ -1,12 +1,13 @@
1
- /* Agent Sidecar settings card. Design language follows the shipped
2
- * ui-settings-plugins PluginCard/fields chrome (disclosure header over a
3
- * sectioned form body); every color rides a dsh --dsw-alias-* design token,
4
- * no literal colors. */
1
+ /* Layout specific to the Agent Sidecar settings composition. Control chrome
2
+ * comes from dsh primitives; native select/checkbox styles stay token-backed. */
5
3
 
6
4
  .card {
5
+ width: 100%;
6
+ max-width: 760px;
7
+ box-sizing: border-box;
7
8
  list-style: none;
8
9
  border: 1px solid var(--dsw-alias-border-l2);
9
- border-radius: 12px;
10
+ border-radius: var(--agsc-radius-card);
10
11
  background: var(--dsw-alias-bg-layer-3);
11
12
  transition: border-color .16s, background .16s;
12
13
  }
@@ -20,8 +21,6 @@
20
21
  border-color: var(--dsw-alias-label-dimmed);
21
22
  }
22
23
 
23
- /* ── disclosure header ─────────────────────────────────────────────────── */
24
-
25
24
  .header {
26
25
  width: 100%;
27
26
  appearance: none;
@@ -35,7 +34,7 @@
35
34
  align-items: center;
36
35
  gap: 12px;
37
36
  padding: 14px 16px;
38
- border-radius: 12px;
37
+ border-radius: var(--agsc-radius-card);
39
38
  }
40
39
 
41
40
  .header:focus-visible {
@@ -64,37 +63,21 @@
64
63
  color: var(--dsw-alias-label-tertiary);
65
64
  }
66
65
 
67
- /* Carried on the header so a collapsed card still says it holds edits. */
68
66
  .pending {
69
67
  flex: none;
70
- border-radius: 999px;
71
- padding: 1px 8px;
72
- font-size: 11px;
73
- line-height: 17px;
74
- font-weight: 500;
75
68
  white-space: nowrap;
76
- background: var(--dsw-alias-bg-module-platform);
77
- color: var(--dsw-alias-label-secondary);
78
69
  }
79
70
 
80
- /* CSS-drawn caret (ui-primitives icons are not on this package's dependency
81
- * surface, so the chevron is a rotated border square). */
82
71
  .chevron {
83
72
  flex: none;
84
- width: 8px;
85
- height: 8px;
86
- border-right: 1.5px solid var(--dsw-alias-label-tertiary);
87
- border-bottom: 1.5px solid var(--dsw-alias-label-tertiary);
88
- transform: rotate(45deg) translateY(-2px);
73
+ color: var(--dsw-alias-label-tertiary);
89
74
  transition: transform .16s;
90
75
  }
91
76
 
92
77
  .chevronOpen {
93
- transform: rotate(225deg) translateY(-2px);
78
+ transform: rotate(180deg);
94
79
  }
95
80
 
96
- /* ── body ──────────────────────────────────────────────────────────────── */
97
-
98
81
  .body {
99
82
  border-top: 1px solid var(--dsw-alias-border-l2);
100
83
  margin: 0 16px;
@@ -124,8 +107,6 @@
124
107
  color: var(--dsw-alias-label-primary);
125
108
  }
126
109
 
127
- /* ── fields ────────────────────────────────────────────────────────────── */
128
-
129
110
  .field {
130
111
  padding: 8px 0;
131
112
  display: flex;
@@ -150,46 +131,47 @@
150
131
  margin: 0;
151
132
  font-size: 12px;
152
133
  line-height: 1.5;
153
- color: var(--dsw-alias-label-error);
134
+ color: var(--dsw-alias-state-error-primary);
135
+ }
136
+
137
+ .input {
138
+ width: 100%;
139
+ max-width: 420px;
140
+ box-sizing: border-box;
141
+ }
142
+
143
+ .inputInvalid {
144
+ border-color: var(--dsw-alias-state-error-primary);
145
+ }
146
+
147
+ .input:has(input:disabled) {
148
+ opacity: .5;
154
149
  }
155
150
 
156
- .input,
157
151
  .select {
158
- appearance: none;
152
+ width: 100%;
159
153
  max-width: 420px;
154
+ height: 32px;
155
+ box-sizing: border-box;
160
156
  border: 1px solid var(--dsw-alias-border-l2);
161
- border-radius: 8px;
162
- background: var(--dsw-alias-bg-layer-3);
163
- padding: 6px 10px;
157
+ border-radius: var(--agsc-radius-control);
158
+ background: var(--dsw-alias-bg-layer-1);
159
+ padding: 0 8px;
164
160
  font: inherit;
165
161
  font-size: 13px;
166
162
  line-height: 1.5;
167
163
  color: var(--dsw-alias-label-primary);
168
164
  }
169
165
 
170
- .input:focus-visible,
171
166
  .select:focus-visible {
172
167
  outline: none;
173
168
  border-color: var(--dsw-alias-brand-primary);
174
169
  }
175
170
 
176
- .input::placeholder {
177
- color: var(--dsw-alias-label-tertiary);
178
- }
179
-
180
- .input:disabled,
181
171
  .select:disabled {
182
172
  opacity: 0.5;
183
173
  cursor: default;
184
174
  }
185
-
186
- /* Modifier applied ALONGSIDE .input (no `composes`: the tsdown class-map
187
- * emitter keeps only the local hashed name and would drop composed bases). */
188
- .inputInvalid {
189
- border-color: var(--dsw-alias-label-error);
190
- }
191
-
192
- /* Toggle row: label text left, native checkbox right-aligned with it. */
193
175
  .toggleRow {
194
176
  display: flex;
195
177
  align-items: center;
@@ -209,8 +191,6 @@
209
191
  cursor: default;
210
192
  }
211
193
 
212
- /* ── notes (safety / daemon guidance) ──────────────────────────────────── */
213
-
214
194
  .note {
215
195
  margin: 6px 0 2px;
216
196
  border-radius: 8px;
@@ -221,8 +201,6 @@
221
201
  color: var(--dsw-alias-label-secondary);
222
202
  }
223
203
 
224
- /* ── daemon status row ─────────────────────────────────────────────────── */
225
-
226
204
  .statusRow {
227
205
  display: flex;
228
206
  align-items: center;
@@ -235,16 +213,15 @@
235
213
  width: 8px;
236
214
  height: 8px;
237
215
  border-radius: 50%;
238
- background: var(--dsw-alias-label-tertiary);
216
+ background: var(--agsc-fg-dimmed);
239
217
  }
240
218
 
241
- /* Modifiers applied alongside .statusDot (same no-`composes` rationale). */
242
219
  .statusOk {
243
- background: var(--dsw-alias-brand-primary);
220
+ background: var(--agsc-ok);
244
221
  }
245
222
 
246
223
  .statusError {
247
- background: var(--dsw-alias-label-error);
224
+ background: var(--agsc-err);
248
225
  }
249
226
 
250
227
  .statusText {
@@ -260,30 +237,9 @@
260
237
  }
261
238
 
262
239
  .retry {
263
- appearance: none;
264
- border: 1px solid var(--dsw-alias-border-l2);
265
- border-radius: 8px;
266
- background: none;
267
- padding: 3px 12px;
268
- font: inherit;
269
- font-size: 12px;
270
- line-height: 1.5;
271
- color: var(--dsw-alias-label-secondary);
272
- cursor: pointer;
273
- }
274
-
275
- .retry:hover:not(:disabled) {
276
- color: var(--dsw-alias-label-primary);
277
- border-color: var(--dsw-alias-label-dimmed);
278
- }
279
-
280
- .retry:focus-visible {
281
- outline: 2px solid var(--dsw-alias-brand-primary);
282
- outline-offset: 1px;
240
+ margin-left: auto;
283
241
  }
284
242
 
285
- /* ── footer ────────────────────────────────────────────────────────────── */
286
-
287
243
  .footer {
288
244
  display: flex;
289
245
  align-items: center;
@@ -311,49 +267,9 @@
311
267
  text-align: right;
312
268
  font-size: 12px;
313
269
  line-height: 1.5;
314
- color: var(--dsw-alias-label-error);
270
+ color: var(--dsw-alias-state-error-primary);
315
271
  }
316
272
 
317
273
  .spacer {
318
274
  flex: 1;
319
275
  }
320
-
321
- .discard,
322
- .save {
323
- appearance: none;
324
- border: 1px solid transparent;
325
- border-radius: 8px;
326
- padding: 5px 14px;
327
- font: inherit;
328
- font-size: 13px;
329
- line-height: 1.5;
330
- cursor: pointer;
331
- }
332
-
333
- .discard {
334
- border-color: var(--dsw-alias-border-l2);
335
- background: none;
336
- color: var(--dsw-alias-label-secondary);
337
- }
338
-
339
- .discard:hover:not(:disabled) {
340
- color: var(--dsw-alias-label-primary);
341
- border-color: var(--dsw-alias-label-dimmed);
342
- }
343
-
344
- .save {
345
- background: var(--dsw-alias-label-primary);
346
- color: var(--dsw-alias-bg-layer-3);
347
- }
348
-
349
- .discard:disabled,
350
- .save:disabled {
351
- opacity: 0.4;
352
- cursor: default;
353
- }
354
-
355
- .discard:focus-visible,
356
- .save:focus-visible {
357
- outline: 2px solid var(--dsw-alias-brand-primary);
358
- outline-offset: 1px;
359
- }
@@ -30,10 +30,22 @@
30
30
  * the sidecar card reads native next to first-party ones.
31
31
  */
32
32
 
33
- import { useEffect, useId, useState } from 'react'
33
+ import {
34
+ Button,
35
+ IconChevronDownOutline14,
36
+ Pill,
37
+ } from '@deepseek-ai/dsh-client-ui-primitives'
38
+ import { useState } from 'react'
34
39
  import type { ReactNode } from 'react'
35
40
  import { t as defaultT } from './locales/index.ts'
36
41
  import type { SidecarLocaleKey } from './locales/index.ts'
42
+ import {
43
+ NumberField,
44
+ SelectField,
45
+ TextField,
46
+ ToggleField,
47
+ } from './settings-fields.tsx'
48
+ import { surfaceProps } from './theme/parts.ts'
37
49
  import css from './settings-card.module.css'
38
50
 
39
51
  /**
@@ -176,147 +188,6 @@ function Section(props: SectionProps): ReactNode {
176
188
  )
177
189
  }
178
190
 
179
- interface SelectFieldProps {
180
- label: string
181
- hint: string
182
- value: string
183
- options: readonly { value: string, label: string }[]
184
- disabled: boolean
185
- onCommit: (value: string) => void
186
- }
187
-
188
- function SelectField(props: SelectFieldProps): ReactNode {
189
- const id = useId()
190
- return (
191
- <div className={css['field']}>
192
- <label className={css['label']} htmlFor={id}>{props.label}</label>
193
- <select
194
- id={id}
195
- className={css['select']}
196
- value={props.value}
197
- disabled={props.disabled}
198
- onChange={(event) => { props.onCommit(event.target.value) }}
199
- >
200
- {props.options.map(option => (
201
- <option key={option.value} value={option.value}>{option.label}</option>
202
- ))}
203
- </select>
204
- <p className={css['hint']}>{props.hint}</p>
205
- </div>
206
- )
207
- }
208
-
209
- interface ToggleFieldProps {
210
- label: string
211
- hint: string
212
- checked: boolean
213
- disabled: boolean
214
- onCommit: (checked: boolean) => void
215
- }
216
-
217
- function ToggleField(props: ToggleFieldProps): ReactNode {
218
- return (
219
- <div className={css['field']}>
220
- <label className={css['toggleRow']}>
221
- <input
222
- type="checkbox"
223
- checked={props.checked}
224
- disabled={props.disabled}
225
- onChange={(event) => { props.onCommit(event.target.checked) }}
226
- />
227
- <span className={css['label']}>{props.label}</span>
228
- </label>
229
- <p className={css['hint']}>{props.hint}</p>
230
- </div>
231
- )
232
- }
233
-
234
- interface TextFieldProps {
235
- label: string
236
- hint: string
237
- value: string
238
- placeholder?: string
239
- disabled: boolean
240
- onCommit: (value: string) => void
241
- }
242
-
243
- /** Text field with a local draft so typing survives a non-echoing beat. */
244
- function TextField(props: TextFieldProps): ReactNode {
245
- const id = useId()
246
- const [draft, setDraft] = useState(props.value)
247
- useEffect(() => { setDraft(props.value) }, [props.value])
248
- return (
249
- <div className={css['field']}>
250
- <label className={css['label']} htmlFor={id}>{props.label}</label>
251
- <input
252
- id={id}
253
- className={css['input']}
254
- type="text"
255
- value={draft}
256
- placeholder={props.placeholder ?? ''}
257
- disabled={props.disabled}
258
- onChange={(event) => {
259
- setDraft(event.target.value)
260
- props.onCommit(event.target.value)
261
- }}
262
- />
263
- <p className={css['hint']}>{props.hint}</p>
264
- </div>
265
- )
266
- }
267
-
268
- interface NumberFieldProps {
269
- label: string
270
- hint: string
271
- /** Copy shown while the draft is not an acceptable integer. */
272
- invalidHint: string
273
- /** Schema lower bound (host Config schemastery `.min()`). */
274
- min: number
275
- value: number
276
- disabled: boolean
277
- onCommit: (value: number) => void
278
- }
279
-
280
- /**
281
- * Integer field with a local draft: invalid intermediate text (empty,
282
- * non-numeric, below the schema minimum) shows the invalid hint and commits
283
- * nothing, so the staged value can never leave the schema's domain.
284
- */
285
- function NumberField(props: NumberFieldProps): ReactNode {
286
- const id = useId()
287
- const [draft, setDraft] = useState(String(props.value))
288
- const [invalid, setInvalid] = useState(false)
289
- useEffect(() => {
290
- setDraft(String(props.value))
291
- setInvalid(false)
292
- }, [props.value])
293
- return (
294
- <div className={css['field']}>
295
- <label className={css['label']} htmlFor={id}>{props.label}</label>
296
- <input
297
- id={id}
298
- className={invalid ? `${css['input']} ${css['inputInvalid']}` : css['input']}
299
- type="text"
300
- inputMode="numeric"
301
- value={draft}
302
- disabled={props.disabled}
303
- {...invalid ? { 'aria-invalid': true } : {}}
304
- onChange={(event) => {
305
- const text = event.target.value
306
- setDraft(text)
307
- const parsed = Number(text)
308
- const acceptable = text.trim() !== '' && Number.isInteger(parsed) && parsed >= props.min
309
- setInvalid(!acceptable)
310
- if (acceptable && parsed !== props.value) props.onCommit(parsed)
311
- }}
312
- />
313
- {invalid
314
- ? <p className={css['invalidHint']} role="alert">{props.invalidHint}</p>
315
- : <p className={css['hint']}>{props.hint}</p>}
316
- </div>
317
- )
318
- }
319
-
320
191
  /**
321
192
  * Render the Agent Sidecar settings card.
322
193
  * @param props - staged values, form state, and the wiring callbacks.
@@ -334,9 +205,10 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
334
205
  : props.daemon?.state === 'failed'
335
206
  ? t('settings.daemonFailedNote')
336
207
  : undefined
208
+ const cardClassName = `${css['card']} ${open ? css['cardOpen'] : ''}`
337
209
 
338
210
  return (
339
- <li className={`${css['card']} ${open ? css['cardOpen'] : ''}`}>
211
+ <li {...surfaceProps('settings-card', cardClassName)}>
340
212
  <button
341
213
  type="button"
342
214
  className={css['header']}
@@ -348,8 +220,10 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
348
220
  <span className={css['name']}>{title}</span>
349
221
  <span className={css['description']}>{t('settings.cardDescription')}</span>
350
222
  </span>
351
- {props.dirty ? <span className={css['pending']}>{t('settings.unsaved')}</span> : null}
352
- <span className={`${css['chevron']} ${open ? css['chevronOpen'] : ''}`} aria-hidden />
223
+ {props.dirty ? <Pill className={css['pending']}>{t('settings.unsaved')}</Pill> : null}
224
+ <IconChevronDownOutline14
225
+ className={`${css['chevron']} ${open ? css['chevronOpen'] : ''}`}
226
+ />
353
227
  </button>
354
228
  {open
355
229
  ? (
@@ -380,13 +254,15 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
380
254
  : null}
381
255
  {props.daemon.state === 'failed' && props.onDaemonRetry !== undefined
382
256
  ? (
383
- <button
257
+ <Button
384
258
  type="button"
259
+ size="sm"
260
+ variant="outline"
385
261
  className={css['retry']}
386
262
  onClick={props.onDaemonRetry}
387
263
  >
388
264
  {t('settings.daemonRetry')}
389
- </button>
265
+ </Button>
390
266
  )
391
267
  : null}
392
268
  </div>
@@ -540,22 +416,24 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
540
416
  {props.saveFailed === true
541
417
  ? <p className={css['failed']} role="status">{t('settings.saveFailed')}</p>
542
418
  : <span className={css['spacer']} />}
543
- <button
419
+ <Button
544
420
  type="button"
545
- className={css['discard']}
421
+ size="sm"
422
+ variant="outline"
546
423
  disabled={!props.dirty || props.saving}
547
424
  onClick={props.onDiscard}
548
425
  >
549
426
  {t('settings.discard')}
550
- </button>
551
- <button
427
+ </Button>
428
+ <Button
552
429
  type="button"
553
- className={css['save']}
430
+ size="sm"
431
+ variant="primary"
554
432
  disabled={!props.dirty || props.saving || !props.writable}
555
433
  onClick={props.onSave}
556
434
  >
557
435
  {t(props.saving ? 'settings.saving' : 'settings.save')}
558
- </button>
436
+ </Button>
559
437
  </div>
560
438
  </div>
561
439
  )
@@ -0,0 +1,131 @@
1
+ import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
2
+ import { useEffect, useId, useState } from 'react'
3
+ import type { ReactElement } from 'react'
4
+ import css from './settings-card.module.css'
5
+
6
+ interface FieldCopy {
7
+ label: string
8
+ hint: string
9
+ disabled: boolean
10
+ }
11
+
12
+ interface SelectFieldProps extends FieldCopy {
13
+ value: string
14
+ options: readonly { value: string, label: string }[]
15
+ onCommit: (value: string) => void
16
+ }
17
+
18
+ export function SelectField(props: SelectFieldProps): ReactElement {
19
+ const id = useId()
20
+ return (
21
+ <div className={css['field']}>
22
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
23
+ <select
24
+ id={id}
25
+ className={css['select']}
26
+ value={props.value}
27
+ disabled={props.disabled}
28
+ onChange={(event) => { props.onCommit(event.target.value) }}
29
+ >
30
+ {props.options.map(option => (
31
+ <option key={option.value} value={option.value}>{option.label}</option>
32
+ ))}
33
+ </select>
34
+ <p className={css['hint']}>{props.hint}</p>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ interface ToggleFieldProps extends FieldCopy {
40
+ checked: boolean
41
+ onCommit: (checked: boolean) => void
42
+ }
43
+
44
+ export function ToggleField(props: ToggleFieldProps): ReactElement {
45
+ return (
46
+ <div className={css['field']}>
47
+ <label className={css['toggleRow']}>
48
+ <input
49
+ type="checkbox"
50
+ checked={props.checked}
51
+ disabled={props.disabled}
52
+ onChange={(event) => { props.onCommit(event.target.checked) }}
53
+ />
54
+ <span className={css['label']}>{props.label}</span>
55
+ </label>
56
+ <p className={css['hint']}>{props.hint}</p>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ interface TextFieldProps extends FieldCopy {
62
+ value: string
63
+ placeholder?: string
64
+ onCommit: (value: string) => void
65
+ }
66
+
67
+ export function TextField(props: TextFieldProps): ReactElement {
68
+ const id = useId()
69
+ const [draft, setDraft] = useState(props.value)
70
+ useEffect(() => { setDraft(props.value) }, [props.value])
71
+ return (
72
+ <div className={css['field']}>
73
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
74
+ <Input
75
+ id={id}
76
+ className={css['input']}
77
+ type="text"
78
+ value={draft}
79
+ placeholder={props.placeholder ?? ''}
80
+ disabled={props.disabled}
81
+ onChange={(event) => {
82
+ setDraft(event.target.value)
83
+ props.onCommit(event.target.value)
84
+ }}
85
+ />
86
+ <p className={css['hint']}>{props.hint}</p>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ interface NumberFieldProps extends FieldCopy {
92
+ invalidHint: string
93
+ min: number
94
+ value: number
95
+ onCommit: (value: number) => void
96
+ }
97
+
98
+ export function NumberField(props: NumberFieldProps): ReactElement {
99
+ const id = useId()
100
+ const [draft, setDraft] = useState(String(props.value))
101
+ const [invalid, setInvalid] = useState(false)
102
+ useEffect(() => {
103
+ setDraft(String(props.value))
104
+ setInvalid(false)
105
+ }, [props.value])
106
+ return (
107
+ <div className={css['field']}>
108
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
109
+ <Input
110
+ id={id}
111
+ className={`${css['input']} ${invalid ? css['inputInvalid'] : ''}`}
112
+ type="text"
113
+ inputMode="numeric"
114
+ value={draft}
115
+ disabled={props.disabled}
116
+ {...invalid ? { 'aria-invalid': true } : {}}
117
+ onChange={(event) => {
118
+ const text = event.target.value
119
+ const parsed = Number(text)
120
+ const acceptable = text.trim() !== '' && Number.isInteger(parsed) && parsed >= props.min
121
+ setDraft(text)
122
+ setInvalid(!acceptable)
123
+ if (acceptable && parsed !== props.value) props.onCommit(parsed)
124
+ }}
125
+ />
126
+ <p className={invalid ? css['invalidHint'] : css['hint']} {...invalid ? { role: 'alert' } : {}}>
127
+ {invalid ? props.invalidHint : props.hint}
128
+ </p>
129
+ </div>
130
+ )
131
+ }