@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.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. 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
+ })