@talelk/newui-organisms 0.0.0-stage → 2.0.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.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cn,
|
|
3
|
+
createControllableSignal,
|
|
4
|
+
createId,
|
|
5
|
+
} from '@talelk/newui-atoms/internal'
|
|
6
|
+
import { Dialog } from '@talelk/newui-molecules'
|
|
7
|
+
import {
|
|
8
|
+
type Accessor,
|
|
9
|
+
createContext,
|
|
10
|
+
createSignal,
|
|
11
|
+
type JSX,
|
|
12
|
+
onMount,
|
|
13
|
+
type ParentComponent,
|
|
14
|
+
Show,
|
|
15
|
+
splitProps,
|
|
16
|
+
useContext,
|
|
17
|
+
} from 'solid-js'
|
|
18
|
+
import { Dynamic } from 'solid-js/web'
|
|
19
|
+
|
|
20
|
+
/** The selected panel id. A nav item and its panel share one. */
|
|
21
|
+
export interface SettingsDialogProps {
|
|
22
|
+
/** Controlled open state (P2). */
|
|
23
|
+
open?: boolean
|
|
24
|
+
/** Uncontrolled initial open state. */
|
|
25
|
+
defaultOpen?: boolean
|
|
26
|
+
onOpenChange?: (open: boolean) => void
|
|
27
|
+
/** Controlled selected panel id. */
|
|
28
|
+
value?: string
|
|
29
|
+
/** Uncontrolled initial selected panel id. */
|
|
30
|
+
defaultValue?: string
|
|
31
|
+
onValueChange?: (value: string) => void
|
|
32
|
+
/** Accessible dialog title, rendered visually hidden. Defaults to `Settings`. */
|
|
33
|
+
title?: JSX.Element
|
|
34
|
+
/** Accessible dialog description, rendered visually hidden. */
|
|
35
|
+
description?: JSX.Element
|
|
36
|
+
/** Merged into the dialog content. */
|
|
37
|
+
class?: string
|
|
38
|
+
children?: JSX.Element
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SettingsDialogContextValue {
|
|
42
|
+
value: Accessor<string | undefined>
|
|
43
|
+
setValue: (value: string) => void
|
|
44
|
+
baseId: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SettingsDialogContext = createContext<SettingsDialogContextValue>()
|
|
48
|
+
|
|
49
|
+
function useSettingsDialog(part: string): SettingsDialogContextValue {
|
|
50
|
+
const ctx = useContext(SettingsDialogContext)
|
|
51
|
+
if (!ctx) throw new Error(`${part} must be used within <SettingsDialog.Root>`)
|
|
52
|
+
return ctx
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A dialog for app settings: a sidebar of grouped tabs on the left and the
|
|
57
|
+
* selected panel on the right.
|
|
58
|
+
*
|
|
59
|
+
* Built on the `Dialog` compound, so focus trapping, Escape and the backdrop
|
|
60
|
+
* are Dialog's — this family only adds the two-pane layout. Panel selection is
|
|
61
|
+
* a plain controllable signal (no extra machine): a nav item and its panel
|
|
62
|
+
* share a `value`, and the selected one is reflected in `data-state` / ARIA.
|
|
63
|
+
*/
|
|
64
|
+
const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
65
|
+
const [local] = splitProps(props, [
|
|
66
|
+
'open',
|
|
67
|
+
'defaultOpen',
|
|
68
|
+
'onOpenChange',
|
|
69
|
+
'value',
|
|
70
|
+
'defaultValue',
|
|
71
|
+
'onValueChange',
|
|
72
|
+
'title',
|
|
73
|
+
'description',
|
|
74
|
+
'class',
|
|
75
|
+
'children',
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
const [value, setValue] = createControllableSignal<string>({
|
|
79
|
+
value: () => local.value,
|
|
80
|
+
defaultValue: local.defaultValue,
|
|
81
|
+
onChange: (next) => local.onValueChange?.(next),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const ctx: SettingsDialogContextValue = {
|
|
85
|
+
value,
|
|
86
|
+
setValue,
|
|
87
|
+
baseId: createId('settings-dialog'),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Dialog.Root
|
|
92
|
+
open={local.open}
|
|
93
|
+
defaultOpen={local.defaultOpen}
|
|
94
|
+
onOpenChange={local.onOpenChange}
|
|
95
|
+
>
|
|
96
|
+
<SettingsDialogContext.Provider value={ctx}>
|
|
97
|
+
<Dialog.Content
|
|
98
|
+
class={cn(
|
|
99
|
+
'flex h-[100dvh] min-h-0 w-screen max-w-none flex-col overflow-hidden rounded-none p-0',
|
|
100
|
+
'max-sm:fixed max-sm:inset-0',
|
|
101
|
+
'sm:h-[min(860px,calc(100vh-8rem))] sm:max-h-[calc(100vh_-_8rem)] sm:min-h-[560px] sm:w-[min(56rem,calc(100vw-2rem))] sm:max-w-4xl sm:flex-row sm:rounded-overlay',
|
|
102
|
+
local.class,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<Dialog.Title class="sr-only">
|
|
106
|
+
{local.title ?? 'Settings'}
|
|
107
|
+
</Dialog.Title>
|
|
108
|
+
<Dialog.Description class="sr-only">
|
|
109
|
+
{local.description ??
|
|
110
|
+
'Manage your settings across the panels in this dialog.'}
|
|
111
|
+
</Dialog.Description>
|
|
112
|
+
<Dialog.Close class="absolute end-2 top-2 z-raised grid size-10 place-items-center rounded-control text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring sm:size-8">
|
|
113
|
+
<span class="sr-only">Close settings</span>
|
|
114
|
+
<span class="lucide-x size-4" aria-hidden="true" />
|
|
115
|
+
</Dialog.Close>
|
|
116
|
+
{local.children}
|
|
117
|
+
</Dialog.Content>
|
|
118
|
+
</SettingsDialogContext.Provider>
|
|
119
|
+
</Dialog.Root>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SettingsSidebarProps {
|
|
124
|
+
/** Accessible name for the vertical tablist. @default 'Settings' */
|
|
125
|
+
label?: string
|
|
126
|
+
/** Merged into the sidebar. */
|
|
127
|
+
class?: string
|
|
128
|
+
children?: JSX.Element
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The navigation column. It is the `tablist`; put `SettingsNavGroup`s and
|
|
133
|
+
* `SettingsNavItem`s inside it.
|
|
134
|
+
*/
|
|
135
|
+
const Sidebar: ParentComponent<SettingsSidebarProps> = (props) => (
|
|
136
|
+
<div
|
|
137
|
+
role="tablist"
|
|
138
|
+
aria-label={props.label ?? 'Settings'}
|
|
139
|
+
aria-orientation="vertical"
|
|
140
|
+
data-slot="settings-sidebar"
|
|
141
|
+
class={cn(
|
|
142
|
+
'flex max-h-[38vh] w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-outline-gray-1 bg-surface-sidebar p-2',
|
|
143
|
+
'max-sm:pe-12 sm:max-h-none sm:w-[220px] sm:border-b-0 sm:border-e',
|
|
144
|
+
props.class,
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{props.children}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
export interface SettingsNavGroupProps {
|
|
152
|
+
/** Group heading shown above its items. */
|
|
153
|
+
label?: JSX.Element
|
|
154
|
+
/** Merged into the group. */
|
|
155
|
+
class?: string
|
|
156
|
+
children?: JSX.Element
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A labelled group of `SettingsNavItem`s. */
|
|
160
|
+
const NavGroup: ParentComponent<SettingsNavGroupProps> = (props) => (
|
|
161
|
+
<div
|
|
162
|
+
role="presentation"
|
|
163
|
+
data-slot="settings-nav-group"
|
|
164
|
+
class={cn('flex flex-col', props.class)}
|
|
165
|
+
>
|
|
166
|
+
<Show when={props.label}>
|
|
167
|
+
<div
|
|
168
|
+
role="presentation"
|
|
169
|
+
data-slot="settings-nav-group-label"
|
|
170
|
+
class="flex h-7 items-center px-2 text-base text-ink-gray-6"
|
|
171
|
+
>
|
|
172
|
+
{props.label}
|
|
173
|
+
</div>
|
|
174
|
+
</Show>
|
|
175
|
+
<div role="presentation" class="flex flex-col gap-0.5">
|
|
176
|
+
{props.children}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
export interface SettingsNavItemProps
|
|
182
|
+
extends Omit<
|
|
183
|
+
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
184
|
+
'value' | 'name' | 'prefix' | 'onClick'
|
|
185
|
+
> {
|
|
186
|
+
/** Unique panel id — must match the `SettingsPanel` it controls. */
|
|
187
|
+
value?: string
|
|
188
|
+
/** Alias for `value`. */
|
|
189
|
+
name?: string
|
|
190
|
+
/**
|
|
191
|
+
* Explicit selected state. Omit to derive it from the dialog's selected
|
|
192
|
+
* `value`, which is the usual case.
|
|
193
|
+
*/
|
|
194
|
+
active?: boolean
|
|
195
|
+
/** Leading icon or avatar. */
|
|
196
|
+
prefix?: JSX.Element
|
|
197
|
+
/** Trailing badge or count. */
|
|
198
|
+
suffix?: JSX.Element
|
|
199
|
+
/** Merged into the item. */
|
|
200
|
+
class?: string
|
|
201
|
+
children?: JSX.Element
|
|
202
|
+
onClick?: JSX.EventHandler<HTMLButtonElement, MouseEvent>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One tab in the sidebar. Selecting it moves the dialog's `value`. */
|
|
206
|
+
const NavItem: ParentComponent<SettingsNavItemProps> = (props) => {
|
|
207
|
+
const [local, rest] = splitProps(props, [
|
|
208
|
+
'value',
|
|
209
|
+
'name',
|
|
210
|
+
'active',
|
|
211
|
+
'prefix',
|
|
212
|
+
'suffix',
|
|
213
|
+
'class',
|
|
214
|
+
'children',
|
|
215
|
+
'disabled',
|
|
216
|
+
'onClick',
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
const ctx = useSettingsDialog('SettingsNavItem')
|
|
220
|
+
|
|
221
|
+
const itemValue = () => local.value ?? local.name
|
|
222
|
+
const isActive = () => {
|
|
223
|
+
const override = local.active
|
|
224
|
+
if (override !== undefined) return override
|
|
225
|
+
const current = itemValue()
|
|
226
|
+
return current !== undefined && ctx.value() === current
|
|
227
|
+
}
|
|
228
|
+
const tabId = () => {
|
|
229
|
+
const current = itemValue()
|
|
230
|
+
return current === undefined ? undefined : `${ctx.baseId}-tab-${current}`
|
|
231
|
+
}
|
|
232
|
+
const panelId = () => {
|
|
233
|
+
const current = itemValue()
|
|
234
|
+
return current === undefined ? undefined : `${ctx.baseId}-panel-${current}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
{...rest}
|
|
240
|
+
type="button"
|
|
241
|
+
role="tab"
|
|
242
|
+
id={tabId()}
|
|
243
|
+
aria-selected={isActive()}
|
|
244
|
+
aria-controls={panelId()}
|
|
245
|
+
tabindex={isActive() ? 0 : -1}
|
|
246
|
+
disabled={local.disabled}
|
|
247
|
+
data-slot="settings-nav-item"
|
|
248
|
+
data-state={isActive() ? 'active' : 'inactive'}
|
|
249
|
+
class={cn(
|
|
250
|
+
'flex min-h-8 w-full items-center gap-2 rounded-control px-2 py-1 text-start text-interface text-ink-gray-7 transition-colors motion-reduce:transition-none',
|
|
251
|
+
'focus-visible:focus-ring',
|
|
252
|
+
'min-h-10 sm:min-h-8',
|
|
253
|
+
'data-[state=inactive]:hover:bg-surface-gray-2',
|
|
254
|
+
'data-[state=active]:bg-surface-elevation-3 data-[state=active]:text-ink-gray-9 data-[state=active]:shadow-sm',
|
|
255
|
+
'disabled:cursor-not-allowed disabled:text-ink-gray-4',
|
|
256
|
+
local.class,
|
|
257
|
+
)}
|
|
258
|
+
onKeyDown={(event) => {
|
|
259
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
|
|
260
|
+
const tabs = Array.from(
|
|
261
|
+
event.currentTarget
|
|
262
|
+
.closest('[role="tablist"]')
|
|
263
|
+
?.querySelectorAll<HTMLButtonElement>(
|
|
264
|
+
'[role="tab"]:not(:disabled)',
|
|
265
|
+
) ?? [],
|
|
266
|
+
)
|
|
267
|
+
const index = tabs.indexOf(event.currentTarget)
|
|
268
|
+
const nextIndex =
|
|
269
|
+
event.key === 'Home'
|
|
270
|
+
? 0
|
|
271
|
+
: event.key === 'End'
|
|
272
|
+
? tabs.length - 1
|
|
273
|
+
: (index + (event.key === 'ArrowDown' ? 1 : -1) + tabs.length) %
|
|
274
|
+
tabs.length
|
|
275
|
+
const next = tabs[nextIndex]
|
|
276
|
+
if (!next) return
|
|
277
|
+
event.preventDefault()
|
|
278
|
+
next.focus()
|
|
279
|
+
next.click()
|
|
280
|
+
}}
|
|
281
|
+
onClick={(event) => {
|
|
282
|
+
const next = itemValue()
|
|
283
|
+
if (next !== undefined) ctx.setValue(next)
|
|
284
|
+
local.onClick?.(event)
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<Show when={local.prefix}>
|
|
288
|
+
<span
|
|
289
|
+
data-slot="settings-nav-item-prefix"
|
|
290
|
+
class="shrink-0 text-ink-gray-6"
|
|
291
|
+
>
|
|
292
|
+
{local.prefix}
|
|
293
|
+
</span>
|
|
294
|
+
</Show>
|
|
295
|
+
<span class="min-w-0 flex-1 truncate">{local.children}</span>
|
|
296
|
+
<Show when={local.suffix}>
|
|
297
|
+
<span data-slot="settings-nav-item-suffix" class="shrink-0">
|
|
298
|
+
{local.suffix}
|
|
299
|
+
</span>
|
|
300
|
+
</Show>
|
|
301
|
+
</button>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface SettingsContentProps {
|
|
306
|
+
/** Merged into the content column. */
|
|
307
|
+
class?: string
|
|
308
|
+
children?: JSX.Element
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The right-hand column that holds the panels. */
|
|
312
|
+
const Content: ParentComponent<SettingsContentProps> = (props) => (
|
|
313
|
+
<div
|
|
314
|
+
data-slot="settings-content"
|
|
315
|
+
class={cn('flex min-h-0 flex-1 justify-stretch', props.class)}
|
|
316
|
+
>
|
|
317
|
+
{props.children}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
export interface SettingsPanelProps {
|
|
322
|
+
/** Unique panel id — must match the `SettingsNavItem` that controls it. */
|
|
323
|
+
value?: string
|
|
324
|
+
/** Alias for `value`. */
|
|
325
|
+
name?: string
|
|
326
|
+
/** Merged into the panel. */
|
|
327
|
+
class?: string
|
|
328
|
+
children?: JSX.Element
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* One tab's content column (`role="tabpanel"`). Compose a `SettingsHeader`
|
|
333
|
+
* (fixed) and a `SettingsBody` (scrolls) inside so the header stays pinned.
|
|
334
|
+
*/
|
|
335
|
+
const Panel: ParentComponent<SettingsPanelProps> = (props) => {
|
|
336
|
+
const [local] = splitProps(props, ['value', 'name', 'class', 'children'])
|
|
337
|
+
const ctx = useSettingsDialog('SettingsPanel')
|
|
338
|
+
|
|
339
|
+
const itemValue = () => local.value ?? local.name
|
|
340
|
+
const isActive = () => {
|
|
341
|
+
const current = itemValue()
|
|
342
|
+
return current !== undefined && ctx.value() === current
|
|
343
|
+
}
|
|
344
|
+
const panelId = () => {
|
|
345
|
+
const current = itemValue()
|
|
346
|
+
return current === undefined ? undefined : `${ctx.baseId}-panel-${current}`
|
|
347
|
+
}
|
|
348
|
+
const tabId = () => {
|
|
349
|
+
const current = itemValue()
|
|
350
|
+
return current === undefined ? undefined : `${ctx.baseId}-tab-${current}`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
role="tabpanel"
|
|
356
|
+
id={panelId()}
|
|
357
|
+
aria-labelledby={tabId()}
|
|
358
|
+
hidden={!isActive()}
|
|
359
|
+
data-slot="settings-panel"
|
|
360
|
+
data-state={isActive() ? 'active' : 'inactive'}
|
|
361
|
+
class={cn(
|
|
362
|
+
'flex min-h-0 flex-1 flex-col focus-visible:outline-none',
|
|
363
|
+
'data-[state=inactive]:hidden',
|
|
364
|
+
local.class,
|
|
365
|
+
)}
|
|
366
|
+
>
|
|
367
|
+
{local.children}
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface SettingsHeaderProps {
|
|
373
|
+
/** Convenience heading. Omit and use `children` for a custom header. */
|
|
374
|
+
title?: JSX.Element
|
|
375
|
+
/** Optional sub-heading under the title. */
|
|
376
|
+
description?: JSX.Element
|
|
377
|
+
/** Actions on the right of the default title header. */
|
|
378
|
+
actions?: JSX.Element
|
|
379
|
+
/** Merged into the header. */
|
|
380
|
+
class?: string
|
|
381
|
+
/** Replaces the entire default header. */
|
|
382
|
+
children?: JSX.Element
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Fixed (non-scrolling) header region of a panel. */
|
|
386
|
+
const Header: ParentComponent<SettingsHeaderProps> = (props) => (
|
|
387
|
+
<div
|
|
388
|
+
data-slot="settings-header"
|
|
389
|
+
class={cn('shrink-0 ps-4 pe-12 pt-6 sm:ps-6', props.class)}
|
|
390
|
+
>
|
|
391
|
+
<Show
|
|
392
|
+
when={props.children}
|
|
393
|
+
fallback={
|
|
394
|
+
<div class="flex items-start justify-between gap-4">
|
|
395
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
396
|
+
<Show when={props.title}>
|
|
397
|
+
<h2
|
|
398
|
+
data-slot="settings-header-title"
|
|
399
|
+
class="text-section text-ink-gray-8"
|
|
400
|
+
>
|
|
401
|
+
{props.title}
|
|
402
|
+
</h2>
|
|
403
|
+
</Show>
|
|
404
|
+
<Show when={props.description}>
|
|
405
|
+
<p
|
|
406
|
+
data-slot="settings-header-description"
|
|
407
|
+
class="text-base text-ink-gray-6"
|
|
408
|
+
>
|
|
409
|
+
{props.description}
|
|
410
|
+
</p>
|
|
411
|
+
</Show>
|
|
412
|
+
</div>
|
|
413
|
+
<Show when={props.actions}>
|
|
414
|
+
<div
|
|
415
|
+
data-slot="settings-header-actions"
|
|
416
|
+
class="flex shrink-0 items-center gap-2"
|
|
417
|
+
>
|
|
418
|
+
{props.actions}
|
|
419
|
+
</div>
|
|
420
|
+
</Show>
|
|
421
|
+
</div>
|
|
422
|
+
}
|
|
423
|
+
>
|
|
424
|
+
{props.children}
|
|
425
|
+
</Show>
|
|
426
|
+
</div>
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
export interface SettingsBodyProps {
|
|
430
|
+
/** Merged into the scrolling region. */
|
|
431
|
+
class?: string
|
|
432
|
+
children?: JSX.Element
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The scrolling region of a panel. */
|
|
436
|
+
const Body: ParentComponent<SettingsBodyProps> = (props) => (
|
|
437
|
+
<div
|
|
438
|
+
data-slot="settings-body"
|
|
439
|
+
class={cn(
|
|
440
|
+
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6 sm:px-6',
|
|
441
|
+
props.class,
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
{props.children}
|
|
445
|
+
</div>
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
const CONTROL_SELECTOR =
|
|
449
|
+
'[data-slot="control"], [role="switch"], [role="checkbox"], input, select, textarea'
|
|
450
|
+
|
|
451
|
+
export interface SettingsRowProps {
|
|
452
|
+
/** Setting name, rendered on the left. */
|
|
453
|
+
title: JSX.Element
|
|
454
|
+
/** Optional helper text under the title. */
|
|
455
|
+
description?: JSX.Element
|
|
456
|
+
/** Explicit id to associate the title label with. Overrides auto-detection. */
|
|
457
|
+
labelFor?: string
|
|
458
|
+
/** Merged into the row. */
|
|
459
|
+
class?: string
|
|
460
|
+
/** The control on the right. */
|
|
461
|
+
children?: JSX.Element
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* A setting row: title and description on the left, the control on the right.
|
|
466
|
+
* The title becomes a `<label>` for the control when one is detected (or
|
|
467
|
+
* `labelFor` points at it), so the caller does not have to wire up an id.
|
|
468
|
+
*/
|
|
469
|
+
const Row: ParentComponent<SettingsRowProps> = (props) => {
|
|
470
|
+
const [local] = splitProps(props, [
|
|
471
|
+
'title',
|
|
472
|
+
'description',
|
|
473
|
+
'labelFor',
|
|
474
|
+
'class',
|
|
475
|
+
'children',
|
|
476
|
+
])
|
|
477
|
+
|
|
478
|
+
let control: HTMLDivElement | undefined
|
|
479
|
+
const [detectedFor, setDetectedFor] = createSignal<string | undefined>()
|
|
480
|
+
const resolvedFor = () => local.labelFor ?? detectedFor()
|
|
481
|
+
|
|
482
|
+
onMount(() => {
|
|
483
|
+
if (local.labelFor) return
|
|
484
|
+
const id = control?.querySelector<HTMLElement>(CONTROL_SELECTOR)?.id
|
|
485
|
+
setDetectedFor(id || undefined)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div
|
|
490
|
+
data-slot="settings-row"
|
|
491
|
+
class={cn('flex flex-wrap items-center gap-3 py-3 sm:gap-6', local.class)}
|
|
492
|
+
>
|
|
493
|
+
<div class="min-w-0 flex-1">
|
|
494
|
+
<Dynamic
|
|
495
|
+
component={resolvedFor() ? 'label' : 'div'}
|
|
496
|
+
{...(resolvedFor() ? { for: resolvedFor() } : {})}
|
|
497
|
+
data-slot="settings-row-title"
|
|
498
|
+
class={cn(
|
|
499
|
+
'block text-base-medium leading-tighter text-ink-gray-8',
|
|
500
|
+
resolvedFor() ? 'cursor-pointer' : '',
|
|
501
|
+
)}
|
|
502
|
+
>
|
|
503
|
+
{local.title}
|
|
504
|
+
</Dynamic>
|
|
505
|
+
<Show when={local.description}>
|
|
506
|
+
<div
|
|
507
|
+
data-slot="settings-row-description"
|
|
508
|
+
class="mt-1 text-base leading-5 text-ink-gray-6"
|
|
509
|
+
>
|
|
510
|
+
{local.description}
|
|
511
|
+
</div>
|
|
512
|
+
</Show>
|
|
513
|
+
</div>
|
|
514
|
+
<div
|
|
515
|
+
ref={(el) => {
|
|
516
|
+
control = el
|
|
517
|
+
}}
|
|
518
|
+
data-slot="settings-row-control"
|
|
519
|
+
class="flex min-w-0 max-w-full items-start justify-end"
|
|
520
|
+
>
|
|
521
|
+
{local.children}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export const SettingsDialog = {
|
|
528
|
+
Root,
|
|
529
|
+
Sidebar,
|
|
530
|
+
NavGroup,
|
|
531
|
+
NavItem,
|
|
532
|
+
Content,
|
|
533
|
+
Panel,
|
|
534
|
+
Header,
|
|
535
|
+
Body,
|
|
536
|
+
Row,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export {
|
|
540
|
+
Body as SettingsBody,
|
|
541
|
+
Content as SettingsContent,
|
|
542
|
+
Header as SettingsHeader,
|
|
543
|
+
NavGroup as SettingsNavGroup,
|
|
544
|
+
NavItem as SettingsNavItem,
|
|
545
|
+
Panel as SettingsPanel,
|
|
546
|
+
Row as SettingsRow,
|
|
547
|
+
Sidebar as SettingsSidebar,
|
|
548
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SettingsBody,
|
|
3
|
+
type SettingsBodyProps,
|
|
4
|
+
SettingsContent,
|
|
5
|
+
type SettingsContentProps,
|
|
6
|
+
SettingsDialog,
|
|
7
|
+
type SettingsDialogProps,
|
|
8
|
+
SettingsHeader,
|
|
9
|
+
type SettingsHeaderProps,
|
|
10
|
+
SettingsNavGroup,
|
|
11
|
+
type SettingsNavGroupProps,
|
|
12
|
+
SettingsNavItem,
|
|
13
|
+
type SettingsNavItemProps,
|
|
14
|
+
SettingsPanel,
|
|
15
|
+
type SettingsPanelProps,
|
|
16
|
+
SettingsRow,
|
|
17
|
+
type SettingsRowProps,
|
|
18
|
+
SettingsSidebar,
|
|
19
|
+
type SettingsSidebarProps,
|
|
20
|
+
} from './SettingsDialog'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Sidebar, SidebarItem, SidebarLabel } from './Sidebar'
|
|
4
|
+
|
|
5
|
+
describe('Sidebar (browser, real CSS)', () => {
|
|
6
|
+
it('reserves its declared width when expanded', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<Sidebar width="14rem" collapsed={false}>
|
|
9
|
+
<SidebarItem>Home</SidebarItem>
|
|
10
|
+
</Sidebar>
|
|
11
|
+
))
|
|
12
|
+
const nav = container.querySelector('[data-slot="sidebar"]') as HTMLElement
|
|
13
|
+
expect(nav.getBoundingClientRect().width).toBeGreaterThanOrEqual(200)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('shrinks to the collapsed width', () => {
|
|
17
|
+
const { container } = render(() => (
|
|
18
|
+
<Sidebar collapsedWidth="3rem" collapsed>
|
|
19
|
+
<SidebarItem>Home</SidebarItem>
|
|
20
|
+
</Sidebar>
|
|
21
|
+
))
|
|
22
|
+
const nav = container.querySelector('[data-slot="sidebar"]') as HTMLElement
|
|
23
|
+
expect(nav.getBoundingClientRect().width).toBeLessThan(80)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('gives a sidebar item a real row height', () => {
|
|
27
|
+
const { container } = render(() => (
|
|
28
|
+
<Sidebar collapsed={false}>
|
|
29
|
+
<SidebarItem>Home</SidebarItem>
|
|
30
|
+
</Sidebar>
|
|
31
|
+
))
|
|
32
|
+
const item = container.querySelector(
|
|
33
|
+
'[data-slot="sidebar-item"]',
|
|
34
|
+
) as HTMLElement
|
|
35
|
+
expect(item.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('draws the collapsed divider label', () => {
|
|
39
|
+
const { container } = render(() => (
|
|
40
|
+
<Sidebar collapsed>
|
|
41
|
+
<SidebarLabel divider>General</SidebarLabel>
|
|
42
|
+
</Sidebar>
|
|
43
|
+
))
|
|
44
|
+
const rule = container.querySelector(
|
|
45
|
+
'[data-slot="sidebar-label"] hr',
|
|
46
|
+
) as HTMLElement
|
|
47
|
+
expect(rule).toBeTruthy()
|
|
48
|
+
expect(
|
|
49
|
+
Number.parseFloat(getComputedStyle(rule).borderTopWidth),
|
|
50
|
+
).toBeGreaterThan(0)
|
|
51
|
+
})
|
|
52
|
+
})
|