@hadoku/task-ui-components 0.0.0-stage → 4.20.3
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/LICENSE +21 -0
- package/README.md +917 -2
- package/dist/app-header.css +288 -0
- package/dist/bento.css +143 -0
- package/dist/button.css +227 -0
- package/dist/components/AppHeader.d.ts +61 -0
- package/dist/components/AppHeader.d.ts.map +1 -0
- package/dist/components/AppHeader.js +23 -0
- package/dist/components/AppHeader.js.map +1 -0
- package/dist/components/BentoCard.d.ts +23 -0
- package/dist/components/BentoCard.d.ts.map +1 -0
- package/dist/components/BentoCard.js +49 -0
- package/dist/components/BentoCard.js.map +1 -0
- package/dist/components/BentoGrid.d.ts +24 -0
- package/dist/components/BentoGrid.d.ts.map +1 -0
- package/dist/components/BentoGrid.js +22 -0
- package/dist/components/BentoGrid.js.map +1 -0
- package/dist/components/Button.d.ts +28 -0
- package/dist/components/Button.d.ts.map +1 -0
- package/dist/components/Button.js +51 -0
- package/dist/components/Button.js.map +1 -0
- package/dist/components/ConnectedSettings.d.ts +53 -0
- package/dist/components/ConnectedSettings.d.ts.map +1 -0
- package/dist/components/ConnectedSettings.js +302 -0
- package/dist/components/ConnectedSettings.js.map +1 -0
- package/dist/components/ConnectedThemePicker.d.ts +52 -0
- package/dist/components/ConnectedThemePicker.d.ts.map +1 -0
- package/dist/components/ConnectedThemePicker.js +32 -0
- package/dist/components/ConnectedThemePicker.js.map +1 -0
- package/dist/components/ContextMenu.d.ts +22 -0
- package/dist/components/ContextMenu.d.ts.map +1 -0
- package/dist/components/ContextMenu.js +23 -0
- package/dist/components/ContextMenu.js.map +1 -0
- package/dist/components/HadokuThemePicker.d.ts +27 -0
- package/dist/components/HadokuThemePicker.d.ts.map +1 -0
- package/dist/components/HadokuThemePicker.js +19 -0
- package/dist/components/HadokuThemePicker.js.map +1 -0
- package/dist/components/LoadingSkeleton.d.ts +14 -0
- package/dist/components/LoadingSkeleton.d.ts.map +1 -0
- package/dist/components/LoadingSkeleton.js +62 -0
- package/dist/components/LoadingSkeleton.js.map +1 -0
- package/dist/components/Modal.d.ts +39 -0
- package/dist/components/Modal.d.ts.map +1 -0
- package/dist/components/Modal.js +106 -0
- package/dist/components/Modal.js.map +1 -0
- package/dist/components/NumberInput.d.ts +26 -0
- package/dist/components/NumberInput.d.ts.map +1 -0
- package/dist/components/NumberInput.js +131 -0
- package/dist/components/NumberInput.js.map +1 -0
- package/dist/components/Select.d.ts +52 -0
- package/dist/components/Select.d.ts.map +1 -0
- package/dist/components/Select.js +281 -0
- package/dist/components/Select.js.map +1 -0
- package/dist/components/SettingsModal.d.ts +69 -0
- package/dist/components/SettingsModal.d.ts.map +1 -0
- package/dist/components/SettingsModal.js +141 -0
- package/dist/components/SettingsModal.js.map +1 -0
- package/dist/components/ThemeIcons.d.ts +26 -0
- package/dist/components/ThemeIcons.d.ts.map +1 -0
- package/dist/components/ThemeIcons.js +74 -0
- package/dist/components/ThemeIcons.js.map +1 -0
- package/dist/components/ThemePicker.d.ts +28 -0
- package/dist/components/ThemePicker.d.ts.map +1 -0
- package/dist/components/ThemePicker.js +101 -0
- package/dist/components/ThemePicker.js.map +1 -0
- package/dist/components/Toast.d.ts +13 -0
- package/dist/components/Toast.d.ts.map +1 -0
- package/dist/components/Toast.js +70 -0
- package/dist/components/Toast.js.map +1 -0
- package/dist/components/Toaster.d.ts +19 -0
- package/dist/components/Toaster.d.ts.map +1 -0
- package/dist/components/Toaster.js +19 -0
- package/dist/components/Toaster.js.map +1 -0
- package/dist/components/Tooltip.d.ts +49 -0
- package/dist/components/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip.js +143 -0
- package/dist/components/Tooltip.js.map +1 -0
- package/dist/context-menu.css +11 -0
- package/dist/controls.css +137 -0
- package/dist/hooks/useReducedMotion.d.ts +7 -0
- package/dist/hooks/useReducedMotion.d.ts.map +1 -0
- package/dist/hooks/useReducedMotion.js +25 -0
- package/dist/hooks/useReducedMotion.js.map +1 -0
- package/dist/hooks/useToast.d.ts +18 -0
- package/dist/hooks/useToast.d.ts.map +1 -0
- package/dist/hooks/useToast.js +28 -0
- package/dist/hooks/useToast.js.map +1 -0
- package/dist/icon-btn.css +64 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/menuPlacement.d.ts +16 -0
- package/dist/lib/menuPlacement.d.ts.map +1 -0
- package/dist/lib/menuPlacement.js +19 -0
- package/dist/lib/menuPlacement.js.map +1 -0
- package/dist/lib/selectMenus.d.ts +30 -0
- package/dist/lib/selectMenus.d.ts.map +1 -0
- package/dist/lib/selectMenus.js +236 -0
- package/dist/lib/selectMenus.js.map +1 -0
- package/dist/lib/settingsClient.d.ts +120 -0
- package/dist/lib/settingsClient.d.ts.map +1 -0
- package/dist/lib/settingsClient.js +140 -0
- package/dist/lib/settingsClient.js.map +1 -0
- package/dist/lib/themeScope.d.ts +19 -0
- package/dist/lib/themeScope.d.ts.map +1 -0
- package/dist/lib/themeScope.js +10 -0
- package/dist/lib/themeScope.js.map +1 -0
- package/dist/lib/titleTooltips.d.ts +38 -0
- package/dist/lib/titleTooltips.d.ts.map +1 -0
- package/dist/lib/titleTooltips.js +166 -0
- package/dist/lib/titleTooltips.js.map +1 -0
- package/dist/lib/tooltipPlacement.d.ts +14 -0
- package/dist/lib/tooltipPlacement.d.ts.map +1 -0
- package/dist/lib/tooltipPlacement.js +34 -0
- package/dist/lib/tooltipPlacement.js.map +1 -0
- package/dist/modal.css +129 -0
- package/dist/number-input.css +131 -0
- package/dist/scrollbar.css +165 -0
- package/dist/select.css +251 -0
- package/dist/styles.css +1866 -0
- package/dist/task-ui-components.css +1866 -0
- package/dist/theme-picker.css +219 -0
- package/dist/toaster.css +159 -0
- package/dist/tooltip.css +103 -0
- package/dist/types/index.d.ts +37 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +60 -4
- package/src/app-header.css +288 -0
- package/src/bento.css +143 -0
- package/src/button.css +227 -0
- package/src/components/AppHeader.tsx +87 -0
- package/src/components/BentoCard.tsx +72 -0
- package/src/components/BentoGrid.tsx +48 -0
- package/src/components/Button.tsx +81 -0
- package/src/components/ConnectedSettings.tsx +465 -0
- package/src/components/ConnectedThemePicker.tsx +79 -0
- package/src/components/ContextMenu.tsx +48 -0
- package/src/components/HadokuThemePicker.tsx +39 -0
- package/src/components/LoadingSkeleton.tsx +84 -0
- package/src/components/Modal.tsx +171 -0
- package/src/components/NumberInput.tsx +161 -0
- package/src/components/Select.tsx +359 -0
- package/src/components/SettingsModal.tsx +235 -0
- package/src/components/ThemeIcons.tsx +127 -0
- package/src/components/ThemePicker.tsx +149 -0
- package/src/components/Toast.tsx +92 -0
- package/src/components/Toaster.tsx +43 -0
- package/src/components/Tooltip.tsx +212 -0
- package/src/context-menu.css +11 -0
- package/src/controls.css +137 -0
- package/src/hooks/useReducedMotion.ts +36 -0
- package/src/hooks/useToast.ts +57 -0
- package/src/icon-btn.css +64 -0
- package/src/index.ts +140 -0
- package/src/lib/menuPlacement.ts +31 -0
- package/src/lib/selectMenus.ts +303 -0
- package/src/lib/settingsClient.ts +377 -0
- package/src/lib/themeScope.ts +23 -0
- package/src/lib/titleTooltips.ts +236 -0
- package/src/lib/tooltipPlacement.ts +59 -0
- package/src/modal.css +129 -0
- package/src/number-input.css +131 -0
- package/src/scrollbar.css +165 -0
- package/src/select.css +251 -0
- package/src/theme-picker.css +219 -0
- package/src/toaster.css +159 -0
- package/src/tooltip.css +103 -0
- 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
|
+
}
|