@hadoku/task-ui-components 0.0.0-stage → 4.20.4

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +917 -2
  3. package/dist/app-header.css +288 -0
  4. package/dist/bento.css +143 -0
  5. package/dist/button.css +227 -0
  6. package/dist/components/AppHeader.d.ts +61 -0
  7. package/dist/components/AppHeader.d.ts.map +1 -0
  8. package/dist/components/AppHeader.js +23 -0
  9. package/dist/components/AppHeader.js.map +1 -0
  10. package/dist/components/BentoCard.d.ts +23 -0
  11. package/dist/components/BentoCard.d.ts.map +1 -0
  12. package/dist/components/BentoCard.js +49 -0
  13. package/dist/components/BentoCard.js.map +1 -0
  14. package/dist/components/BentoGrid.d.ts +24 -0
  15. package/dist/components/BentoGrid.d.ts.map +1 -0
  16. package/dist/components/BentoGrid.js +22 -0
  17. package/dist/components/BentoGrid.js.map +1 -0
  18. package/dist/components/Button.d.ts +28 -0
  19. package/dist/components/Button.d.ts.map +1 -0
  20. package/dist/components/Button.js +51 -0
  21. package/dist/components/Button.js.map +1 -0
  22. package/dist/components/ConnectedSettings.d.ts +53 -0
  23. package/dist/components/ConnectedSettings.d.ts.map +1 -0
  24. package/dist/components/ConnectedSettings.js +368 -0
  25. package/dist/components/ConnectedSettings.js.map +1 -0
  26. package/dist/components/ConnectedThemePicker.d.ts +52 -0
  27. package/dist/components/ConnectedThemePicker.d.ts.map +1 -0
  28. package/dist/components/ConnectedThemePicker.js +32 -0
  29. package/dist/components/ConnectedThemePicker.js.map +1 -0
  30. package/dist/components/ContextMenu.d.ts +22 -0
  31. package/dist/components/ContextMenu.d.ts.map +1 -0
  32. package/dist/components/ContextMenu.js +23 -0
  33. package/dist/components/ContextMenu.js.map +1 -0
  34. package/dist/components/HadokuThemePicker.d.ts +27 -0
  35. package/dist/components/HadokuThemePicker.d.ts.map +1 -0
  36. package/dist/components/HadokuThemePicker.js +19 -0
  37. package/dist/components/HadokuThemePicker.js.map +1 -0
  38. package/dist/components/LoadingSkeleton.d.ts +14 -0
  39. package/dist/components/LoadingSkeleton.d.ts.map +1 -0
  40. package/dist/components/LoadingSkeleton.js +62 -0
  41. package/dist/components/LoadingSkeleton.js.map +1 -0
  42. package/dist/components/Modal.d.ts +39 -0
  43. package/dist/components/Modal.d.ts.map +1 -0
  44. package/dist/components/Modal.js +106 -0
  45. package/dist/components/Modal.js.map +1 -0
  46. package/dist/components/NumberInput.d.ts +26 -0
  47. package/dist/components/NumberInput.d.ts.map +1 -0
  48. package/dist/components/NumberInput.js +131 -0
  49. package/dist/components/NumberInput.js.map +1 -0
  50. package/dist/components/Select.d.ts +52 -0
  51. package/dist/components/Select.d.ts.map +1 -0
  52. package/dist/components/Select.js +281 -0
  53. package/dist/components/Select.js.map +1 -0
  54. package/dist/components/SettingsModal.d.ts +69 -0
  55. package/dist/components/SettingsModal.d.ts.map +1 -0
  56. package/dist/components/SettingsModal.js +141 -0
  57. package/dist/components/SettingsModal.js.map +1 -0
  58. package/dist/components/ThemeIcons.d.ts +26 -0
  59. package/dist/components/ThemeIcons.d.ts.map +1 -0
  60. package/dist/components/ThemeIcons.js +74 -0
  61. package/dist/components/ThemeIcons.js.map +1 -0
  62. package/dist/components/ThemePicker.d.ts +28 -0
  63. package/dist/components/ThemePicker.d.ts.map +1 -0
  64. package/dist/components/ThemePicker.js +101 -0
  65. package/dist/components/ThemePicker.js.map +1 -0
  66. package/dist/components/Toast.d.ts +13 -0
  67. package/dist/components/Toast.d.ts.map +1 -0
  68. package/dist/components/Toast.js +70 -0
  69. package/dist/components/Toast.js.map +1 -0
  70. package/dist/components/Toaster.d.ts +19 -0
  71. package/dist/components/Toaster.d.ts.map +1 -0
  72. package/dist/components/Toaster.js +19 -0
  73. package/dist/components/Toaster.js.map +1 -0
  74. package/dist/components/Tooltip.d.ts +49 -0
  75. package/dist/components/Tooltip.d.ts.map +1 -0
  76. package/dist/components/Tooltip.js +143 -0
  77. package/dist/components/Tooltip.js.map +1 -0
  78. package/dist/context-menu.css +11 -0
  79. package/dist/controls.css +137 -0
  80. package/dist/hooks/useReducedMotion.d.ts +7 -0
  81. package/dist/hooks/useReducedMotion.d.ts.map +1 -0
  82. package/dist/hooks/useReducedMotion.js +25 -0
  83. package/dist/hooks/useReducedMotion.js.map +1 -0
  84. package/dist/hooks/useToast.d.ts +18 -0
  85. package/dist/hooks/useToast.d.ts.map +1 -0
  86. package/dist/hooks/useToast.js +28 -0
  87. package/dist/hooks/useToast.js.map +1 -0
  88. package/dist/icon-btn.css +64 -0
  89. package/dist/index.d.ts +49 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +75 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/lib/menuPlacement.d.ts +16 -0
  94. package/dist/lib/menuPlacement.d.ts.map +1 -0
  95. package/dist/lib/menuPlacement.js +19 -0
  96. package/dist/lib/menuPlacement.js.map +1 -0
  97. package/dist/lib/selectMenus.d.ts +30 -0
  98. package/dist/lib/selectMenus.d.ts.map +1 -0
  99. package/dist/lib/selectMenus.js +236 -0
  100. package/dist/lib/selectMenus.js.map +1 -0
  101. package/dist/lib/settingsClient.d.ts +144 -0
  102. package/dist/lib/settingsClient.d.ts.map +1 -0
  103. package/dist/lib/settingsClient.js +157 -0
  104. package/dist/lib/settingsClient.js.map +1 -0
  105. package/dist/lib/themeScope.d.ts +19 -0
  106. package/dist/lib/themeScope.d.ts.map +1 -0
  107. package/dist/lib/themeScope.js +10 -0
  108. package/dist/lib/themeScope.js.map +1 -0
  109. package/dist/lib/titleTooltips.d.ts +38 -0
  110. package/dist/lib/titleTooltips.d.ts.map +1 -0
  111. package/dist/lib/titleTooltips.js +166 -0
  112. package/dist/lib/titleTooltips.js.map +1 -0
  113. package/dist/lib/tooltipPlacement.d.ts +14 -0
  114. package/dist/lib/tooltipPlacement.d.ts.map +1 -0
  115. package/dist/lib/tooltipPlacement.js +34 -0
  116. package/dist/lib/tooltipPlacement.js.map +1 -0
  117. package/dist/modal.css +129 -0
  118. package/dist/number-input.css +131 -0
  119. package/dist/scrollbar.css +165 -0
  120. package/dist/select.css +251 -0
  121. package/dist/styles.css +1866 -0
  122. package/dist/task-ui-components.css +1866 -0
  123. package/dist/theme-picker.css +219 -0
  124. package/dist/toaster.css +159 -0
  125. package/dist/tooltip.css +103 -0
  126. package/dist/types/index.d.ts +37 -0
  127. package/dist/types/index.d.ts.map +1 -0
  128. package/package.json +60 -4
  129. package/src/app-header.css +288 -0
  130. package/src/bento.css +143 -0
  131. package/src/button.css +227 -0
  132. package/src/components/AppHeader.tsx +87 -0
  133. package/src/components/BentoCard.tsx +72 -0
  134. package/src/components/BentoGrid.tsx +48 -0
  135. package/src/components/Button.tsx +81 -0
  136. package/src/components/ConnectedSettings.tsx +545 -0
  137. package/src/components/ConnectedThemePicker.tsx +79 -0
  138. package/src/components/ContextMenu.tsx +48 -0
  139. package/src/components/HadokuThemePicker.tsx +39 -0
  140. package/src/components/LoadingSkeleton.tsx +84 -0
  141. package/src/components/Modal.tsx +171 -0
  142. package/src/components/NumberInput.tsx +161 -0
  143. package/src/components/Select.tsx +359 -0
  144. package/src/components/SettingsModal.tsx +235 -0
  145. package/src/components/ThemeIcons.tsx +127 -0
  146. package/src/components/ThemePicker.tsx +149 -0
  147. package/src/components/Toast.tsx +92 -0
  148. package/src/components/Toaster.tsx +43 -0
  149. package/src/components/Tooltip.tsx +212 -0
  150. package/src/context-menu.css +11 -0
  151. package/src/controls.css +137 -0
  152. package/src/hooks/useReducedMotion.ts +36 -0
  153. package/src/hooks/useToast.ts +57 -0
  154. package/src/icon-btn.css +64 -0
  155. package/src/index.ts +153 -0
  156. package/src/lib/menuPlacement.ts +31 -0
  157. package/src/lib/selectMenus.ts +303 -0
  158. package/src/lib/settingsClient.ts +425 -0
  159. package/src/lib/themeScope.ts +23 -0
  160. package/src/lib/titleTooltips.ts +236 -0
  161. package/src/lib/tooltipPlacement.ts +59 -0
  162. package/src/modal.css +129 -0
  163. package/src/number-input.css +131 -0
  164. package/src/scrollbar.css +165 -0
  165. package/src/select.css +251 -0
  166. package/src/theme-picker.css +219 -0
  167. package/src/toaster.css +159 -0
  168. package/src/tooltip.css +103 -0
  169. package/src/types/index.ts +43 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Select Component
3
+ * Themed single-select dropdown. The open list is a real ARIA listbox with
4
+ * full keyboard support, so it can replace a native <select> wherever the
5
+ * browser's own popup (unthemeable, OS-drawn) looks out of place.
6
+ *
7
+ * The list is portalled into the anchor's theme scope (see themeScopeOf) and
8
+ * positioned fixed from the trigger's rect, so it keeps the theme's tokens and
9
+ * escapes both `overflow: hidden` and transformed ancestors.
10
+ */
11
+
12
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
13
+ import { createPortal } from 'react-dom'
14
+ import { Icon, type IconName } from '@wolffm/themes'
15
+ import '../select.css'
16
+ import '../scrollbar.css'
17
+ import { themeScopeOf } from '../lib/themeScope'
18
+ import { placeMenu, type MenuPosition } from '../lib/menuPlacement'
19
+
20
+ export type SelectVariant = 'outline' | 'filled' | 'ghost' | 'underline'
21
+ export type SelectSize = 'sm' | 'md' | 'lg'
22
+
23
+ export interface SelectOption<V extends string = string> {
24
+ value: V
25
+ /** Plain text so typeahead can match it. */
26
+ label: string
27
+ icon?: IconName
28
+ /** Secondary line under the label in the open list. */
29
+ description?: string
30
+ disabled?: boolean
31
+ }
32
+
33
+ export interface SelectProps<V extends string = string> {
34
+ value: V | null
35
+ onChange: (value: V) => void
36
+ options: SelectOption<V>[]
37
+ /** Shown while `value` is null. */
38
+ placeholder?: string
39
+ variant?: SelectVariant
40
+ size?: SelectSize
41
+ disabled?: boolean
42
+ /** Required when no visible <label htmlFor={id}> names the control. */
43
+ 'aria-label'?: string
44
+ id?: string
45
+ className?: string
46
+ }
47
+
48
+ const TYPEAHEAD_RESET_MS = 600
49
+
50
+ export function Select<V extends string = string>({
51
+ value,
52
+ onChange,
53
+ options,
54
+ placeholder = 'Select…',
55
+ variant = 'outline',
56
+ size = 'md',
57
+ disabled = false,
58
+ 'aria-label': ariaLabel,
59
+ id,
60
+ className
61
+ }: SelectProps<V>) {
62
+ const autoId = useId()
63
+ const triggerId = id ?? `${autoId}-trigger`
64
+ const listId = `${autoId}-list`
65
+ const optionId = (i: number) => `${autoId}-opt-${i}`
66
+
67
+ const [open, setOpen] = useState(false)
68
+ const [active, setActive] = useState(-1)
69
+ const [position, setPosition] = useState<MenuPosition | null>(null)
70
+ const [scope, setScope] = useState<HTMLElement | null>(null)
71
+ const triggerRef = useRef<HTMLButtonElement>(null)
72
+ const listRef = useRef<HTMLUListElement>(null)
73
+ const typeahead = useRef({ text: '', at: 0 })
74
+
75
+ const selectedIndex = options.findIndex(o => o.value === value)
76
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : null
77
+
78
+ const step = useCallback(
79
+ (from: number, dir: 1 | -1) => {
80
+ for (let i = 1; i <= options.length; i++) {
81
+ const next = (from + dir * i + options.length) % options.length
82
+ if (!options[next].disabled) return next
83
+ }
84
+ return from
85
+ },
86
+ [options]
87
+ )
88
+
89
+ const edge = useCallback(
90
+ (dir: 1 | -1) => step(dir === 1 ? -1 : options.length, dir),
91
+ [options.length, step]
92
+ )
93
+
94
+ const openMenu = (start?: 'first' | 'last') => {
95
+ if (disabled || options.length === 0) return
96
+ let initial = selectedIndex
97
+ if (start === 'first' || initial < 0) initial = edge(1)
98
+ if (start === 'last') initial = edge(-1)
99
+ setActive(initial)
100
+ setScope(themeScopeOf(triggerRef.current))
101
+ setOpen(true)
102
+ }
103
+
104
+ const closeMenu = (refocus: boolean) => {
105
+ setOpen(false)
106
+ setPosition(null)
107
+ if (refocus) triggerRef.current?.focus()
108
+ }
109
+
110
+ const choose = (index: number) => {
111
+ const option = options[index]
112
+ if (!option || option.disabled) return
113
+ onChange(option.value)
114
+ closeMenu(true)
115
+ }
116
+
117
+ const place = useCallback(() => {
118
+ const trigger = triggerRef.current
119
+ if (!trigger) return
120
+ setPosition(placeMenu(trigger.getBoundingClientRect(), listRef.current?.scrollHeight ?? 0))
121
+ }, [])
122
+
123
+ // Measure once the list exists, then follow the trigger while open.
124
+ useLayoutEffect(() => {
125
+ if (open && scope) place()
126
+ }, [open, scope, place])
127
+
128
+ // Focus only once placed: until then the list is visibility: hidden, and a
129
+ // hidden element silently refuses focus — keys would stay on the trigger.
130
+ const placed = position !== null
131
+ useLayoutEffect(() => {
132
+ if (open && placed) listRef.current?.focus({ preventScroll: true })
133
+ }, [open, placed])
134
+
135
+ useEffect(() => {
136
+ if (!open) return
137
+ const onPointerDown = (e: PointerEvent) => {
138
+ const target = e.target as Node
139
+ if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return
140
+ closeMenu(false)
141
+ }
142
+ // Capture, so scrolling ANY ancestor re-places the list.
143
+ window.addEventListener('scroll', place, true)
144
+ window.addEventListener('resize', place)
145
+ document.addEventListener('pointerdown', onPointerDown)
146
+ return () => {
147
+ window.removeEventListener('scroll', place, true)
148
+ window.removeEventListener('resize', place)
149
+ document.removeEventListener('pointerdown', onPointerDown)
150
+ }
151
+ }, [open, place])
152
+
153
+ // Keep the active option in view as the keyboard moves it.
154
+ useEffect(() => {
155
+ if (!open || active < 0) return
156
+ document.getElementById(`${autoId}-opt-${active}`)?.scrollIntoView({ block: 'nearest' })
157
+ }, [open, active, autoId])
158
+
159
+ const matchTypeahead = (key: string) => {
160
+ const now = Date.now()
161
+ const t = typeahead.current
162
+ t.text = now - t.at > TYPEAHEAD_RESET_MS ? key : t.text + key
163
+ t.at = now
164
+ const needle = t.text.toLowerCase()
165
+ const start = Math.max(active, 0)
166
+ for (let i = 0; i < options.length; i++) {
167
+ // A single repeated key cycles; a longer prefix stays on the current match.
168
+ const offset = t.text.length === 1 ? i + 1 : i
169
+ const idx = (start + offset) % options.length
170
+ const o = options[idx]
171
+ if (!o.disabled && o.label.toLowerCase().startsWith(needle)) return idx
172
+ }
173
+ return -1
174
+ }
175
+
176
+ const onTriggerKeyDown = (e: React.KeyboardEvent) => {
177
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
178
+ e.preventDefault()
179
+ openMenu()
180
+ } else if (e.key === 'ArrowUp') {
181
+ e.preventDefault()
182
+ openMenu('last')
183
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
184
+ // Typeahead on a closed select changes the value, like a native one.
185
+ const idx = matchTypeahead(e.key)
186
+ if (idx >= 0) onChange(options[idx].value)
187
+ }
188
+ }
189
+
190
+ const onListKeyDown = (e: React.KeyboardEvent) => {
191
+ switch (e.key) {
192
+ case 'ArrowDown':
193
+ e.preventDefault()
194
+ setActive(a => step(a, 1))
195
+ break
196
+ case 'ArrowUp':
197
+ e.preventDefault()
198
+ setActive(a => step(a, -1))
199
+ break
200
+ case 'Home':
201
+ e.preventDefault()
202
+ setActive(edge(1))
203
+ break
204
+ case 'End':
205
+ e.preventDefault()
206
+ setActive(edge(-1))
207
+ break
208
+ case 'Enter':
209
+ case ' ':
210
+ e.preventDefault()
211
+ choose(active)
212
+ break
213
+ case 'Escape':
214
+ // Stop here so an enclosing Modal does not close along with the list.
215
+ e.preventDefault()
216
+ e.stopPropagation()
217
+ closeMenu(true)
218
+ break
219
+ case 'Tab':
220
+ closeMenu(false)
221
+ break
222
+ default:
223
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
224
+ const idx = matchTypeahead(e.key)
225
+ if (idx >= 0) setActive(idx)
226
+ }
227
+ }
228
+ }
229
+
230
+ const style: React.CSSProperties | undefined = position
231
+ ? {
232
+ left: position.left,
233
+ minWidth: position.width,
234
+ top: position.top,
235
+ bottom: position.bottom,
236
+ maxHeight: position.maxHeight
237
+ }
238
+ : { visibility: 'hidden' }
239
+
240
+ const iconSize = size === 'lg' ? 18 : size === 'sm' ? 14 : 16
241
+
242
+ return (
243
+ <div
244
+ className={[
245
+ 'hdk-select',
246
+ `hdk-select--${variant}`,
247
+ `hdk-select--${size}`,
248
+ open && 'hdk-select--open',
249
+ className
250
+ ]
251
+ .filter(Boolean)
252
+ .join(' ')}
253
+ >
254
+ <button
255
+ ref={triggerRef}
256
+ id={triggerId}
257
+ type="button"
258
+ className="hdk-select__trigger"
259
+ aria-haspopup="listbox"
260
+ aria-expanded={open}
261
+ aria-controls={open ? listId : undefined}
262
+ aria-label={ariaLabel}
263
+ disabled={disabled}
264
+ onClick={() => (open ? closeMenu(true) : openMenu())}
265
+ onKeyDown={onTriggerKeyDown}
266
+ >
267
+ {selected?.icon && <Icon name={selected.icon} size={iconSize} />}
268
+ <span className={`hdk-select__value ${selected ? '' : 'hdk-select__value--placeholder'}`}>
269
+ {selected ? selected.label : placeholder}
270
+ </span>
271
+ <Icon name="chevron-down" size={iconSize} className="hdk-select__chevron" />
272
+ </button>
273
+
274
+ {open &&
275
+ scope &&
276
+ createPortal(
277
+ <ul
278
+ ref={listRef}
279
+ id={listId}
280
+ role="listbox"
281
+ tabIndex={-1}
282
+ aria-labelledby={triggerId}
283
+ aria-activedescendant={active >= 0 ? optionId(active) : undefined}
284
+ className="hdk-select__menu hdk-scroll hdk-scroll--thin"
285
+ style={style}
286
+ onKeyDown={onListKeyDown}
287
+ >
288
+ {options.map((o, i) => (
289
+ <li
290
+ key={o.value}
291
+ id={optionId(i)}
292
+ role="option"
293
+ aria-selected={i === selectedIndex}
294
+ aria-disabled={o.disabled || undefined}
295
+ className={[
296
+ 'hdk-select__option',
297
+ i === active && 'hdk-select__option--active',
298
+ i === selectedIndex && 'hdk-select__option--selected'
299
+ ]
300
+ .filter(Boolean)
301
+ .join(' ')}
302
+ onPointerMove={() => !o.disabled && i !== active && setActive(i)}
303
+ onClick={() => choose(i)}
304
+ >
305
+ {o.icon && <Icon name={o.icon} size={iconSize} />}
306
+ <span className="hdk-select__option-text">
307
+ <span className="hdk-select__option-label">{o.label}</span>
308
+ {o.description && (
309
+ <span className="hdk-select__option-desc">{o.description}</span>
310
+ )}
311
+ </span>
312
+ {i === selectedIndex && (
313
+ <Icon name="check" size={iconSize} className="hdk-select__check" />
314
+ )}
315
+ </li>
316
+ ))}
317
+ </ul>,
318
+ scope
319
+ )}
320
+ </div>
321
+ )
322
+ }
323
+
324
+ export interface NativeSelectProps
325
+ extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
326
+ variant?: SelectVariant
327
+ size?: SelectSize
328
+ }
329
+
330
+ /**
331
+ * A native <select> wearing the same trigger as <Select>. Use it where the
332
+ * OS picker is the better UX — long lists on mobile, forms that must submit
333
+ * natively. The popup itself stays OS-drawn; `color-scheme` from the theme
334
+ * gets it to the right light/dark mode.
335
+ */
336
+ export function NativeSelect({
337
+ variant = 'outline',
338
+ size = 'md',
339
+ className,
340
+ children,
341
+ ...rest
342
+ }: NativeSelectProps) {
343
+ return (
344
+ <span
345
+ className={['hdk-select', `hdk-select--${variant}`, `hdk-select--${size}`, className]
346
+ .filter(Boolean)
347
+ .join(' ')}
348
+ >
349
+ <select className="hdk-select__trigger hdk-select__native" {...rest}>
350
+ {children}
351
+ </select>
352
+ <Icon
353
+ name="chevron-down"
354
+ size={size === 'lg' ? 18 : size === 'sm' ? 14 : 16}
355
+ className="hdk-select__chevron hdk-select__chevron--native"
356
+ />
357
+ </span>
358
+ )
359
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * SettingsModal Component
3
+ * Generic settings dialog that can be customized with sections
4
+ */
5
+
6
+ import React from 'react'
7
+ import { Modal } from './Modal'
8
+ import { Select } from './Select'
9
+
10
+ export interface SettingsSection {
11
+ id: string
12
+ title: string
13
+ fields: SettingsField[]
14
+ }
15
+
16
+ export type SettingsField =
17
+ | SettingsToggleField
18
+ | SettingsTextInputField
19
+ | SettingsPasswordField
20
+ | SettingsSelectField
21
+ | SettingsButtonField
22
+ | SettingsCustomField
23
+
24
+ export interface BaseSettingsField {
25
+ id: string
26
+ label: string
27
+ description?: string
28
+ disabled?: boolean
29
+ }
30
+
31
+ export interface SettingsToggleField extends BaseSettingsField {
32
+ type: 'toggle'
33
+ value: boolean
34
+ onChange: (value: boolean) => void
35
+ }
36
+
37
+ export interface SettingsTextInputField extends BaseSettingsField {
38
+ type: 'text'
39
+ value: string
40
+ onChange: (value: string) => void
41
+ placeholder?: string
42
+ }
43
+
44
+ export interface SettingsPasswordField extends BaseSettingsField {
45
+ type: 'password'
46
+ value: string
47
+ onChange: (value: string) => void
48
+ placeholder?: string
49
+ showButton?: boolean
50
+ buttonLabel?: string
51
+ onButtonClick?: () => void | Promise<void>
52
+ buttonDisabled?: boolean
53
+ error?: string | null
54
+ autoComplete?: string
55
+ }
56
+
57
+ export interface SettingsSelectField extends BaseSettingsField {
58
+ type: 'select'
59
+ value: string
60
+ onChange: (value: string) => void
61
+ options: Array<{ value: string; label: string }>
62
+ }
63
+
64
+ export interface SettingsButtonField extends BaseSettingsField {
65
+ type: 'button'
66
+ buttonLabel: string
67
+ onClick: () => void | Promise<void>
68
+ variant?: 'primary' | 'danger' | 'default'
69
+ }
70
+
71
+ export interface SettingsCustomField extends BaseSettingsField {
72
+ type: 'custom'
73
+ render: () => React.ReactNode
74
+ }
75
+
76
+ export interface SettingsModalProps {
77
+ isOpen: boolean
78
+ title?: string
79
+ sections: SettingsSection[]
80
+ onClose: () => void
81
+ className?: string
82
+ showCloseButton?: boolean
83
+ }
84
+
85
+ export function SettingsModal({
86
+ isOpen,
87
+ title = 'Settings',
88
+ sections,
89
+ onClose,
90
+ className = '',
91
+ showCloseButton = true
92
+ }: SettingsModalProps) {
93
+ const renderField = (field: SettingsField) => {
94
+ switch (field.type) {
95
+ case 'toggle':
96
+ return (
97
+ <div key={field.id} className="settings-field">
98
+ <div className="settings-field-header">
99
+ <label className="settings-field-label" htmlFor={field.id}>
100
+ {field.label}
101
+ </label>
102
+ <input
103
+ type="checkbox"
104
+ id={field.id}
105
+ className="settings-toggle"
106
+ checked={field.value}
107
+ onChange={e => field.onChange(e.target.checked)}
108
+ disabled={field.disabled}
109
+ />
110
+ </div>
111
+ {field.description && <p className="settings-field-description">{field.description}</p>}
112
+ </div>
113
+ )
114
+
115
+ case 'text':
116
+ return (
117
+ <div key={field.id} className="settings-field">
118
+ <label className="settings-field-label" htmlFor={field.id}>
119
+ {field.label}
120
+ </label>
121
+ {field.description && <p className="settings-field-description">{field.description}</p>}
122
+ <input
123
+ type="text"
124
+ id={field.id}
125
+ className="settings-text-input"
126
+ value={field.value}
127
+ onChange={e => field.onChange(e.target.value)}
128
+ placeholder={field.placeholder}
129
+ disabled={field.disabled}
130
+ />
131
+ </div>
132
+ )
133
+
134
+ case 'password':
135
+ return (
136
+ <div key={field.id} className="settings-field">
137
+ <label className="settings-field-label" htmlFor={field.id}>
138
+ {field.label}
139
+ </label>
140
+ {field.description && <p className="settings-field-description">{field.description}</p>}
141
+ <div className="settings-field-input-group">
142
+ <input
143
+ type="password"
144
+ id={field.id}
145
+ name={field.autoComplete || field.id}
146
+ autoComplete={field.autoComplete || 'off'}
147
+ className="settings-text-input"
148
+ value={field.value}
149
+ onChange={e => field.onChange(e.target.value)}
150
+ placeholder={field.placeholder}
151
+ disabled={field.disabled}
152
+ />
153
+ {field.showButton && field.onButtonClick && (
154
+ <button
155
+ className="settings-button"
156
+ onClick={field.onButtonClick}
157
+ disabled={field.buttonDisabled || field.disabled}
158
+ >
159
+ {field.buttonLabel || 'Submit'}
160
+ </button>
161
+ )}
162
+ </div>
163
+ {field.error && <p className="settings-field-error">{field.error}</p>}
164
+ </div>
165
+ )
166
+
167
+ case 'select':
168
+ return (
169
+ <div key={field.id} className="settings-field">
170
+ <label className="settings-field-label" htmlFor={field.id}>
171
+ {field.label}
172
+ </label>
173
+ {field.description && <p className="settings-field-description">{field.description}</p>}
174
+ <Select
175
+ id={field.id}
176
+ className="settings-select"
177
+ value={field.value}
178
+ onChange={field.onChange}
179
+ options={field.options}
180
+ disabled={field.disabled}
181
+ />
182
+ </div>
183
+ )
184
+
185
+ case 'button':
186
+ return (
187
+ <div key={field.id} className="settings-field">
188
+ {field.label && <label className="settings-field-label">{field.label}</label>}
189
+ {field.description && <p className="settings-field-description">{field.description}</p>}
190
+ <button
191
+ className={`settings-button settings-button--${field.variant || 'default'}`}
192
+ onClick={field.onClick}
193
+ disabled={field.disabled}
194
+ >
195
+ {field.buttonLabel}
196
+ </button>
197
+ </div>
198
+ )
199
+
200
+ case 'custom':
201
+ return (
202
+ <div key={field.id} className="settings-field">
203
+ {field.label && <label className="settings-field-label">{field.label}</label>}
204
+ {field.description && <p className="settings-field-description">{field.description}</p>}
205
+ {field.render()}
206
+ </div>
207
+ )
208
+
209
+ default:
210
+ return null
211
+ }
212
+ }
213
+
214
+ return (
215
+ <Modal
216
+ isOpen={isOpen}
217
+ title={title}
218
+ onClose={onClose}
219
+ onConfirm={showCloseButton ? onClose : undefined}
220
+ confirmLabel="Close"
221
+ showCancel={false}
222
+ showConfirm={showCloseButton}
223
+ className={`settings-modal ${className}`}
224
+ >
225
+ <div className="settings-content">
226
+ {sections.map(section => (
227
+ <div key={section.id} className="settings-section">
228
+ <h4 className="settings-section-title">{section.title}</h4>
229
+ {section.fields.map(field => renderField(field))}
230
+ </div>
231
+ ))}
232
+ </div>
233
+ </Modal>
234
+ )
235
+ }