@adea-ai/ui 0.87.0 → 0.89.1

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 (44) hide show
  1. package/README.md +22 -0
  2. package/dist/components/composites/settings/index.d.ts +2 -0
  3. package/dist/components/composites/settings/index.d.ts.map +1 -1
  4. package/dist/components/composites/settings/index.js +3 -1
  5. package/dist/components/composites/settings/settings-layout.d.ts +28 -0
  6. package/dist/components/composites/settings/settings-layout.d.ts.map +1 -0
  7. package/dist/components/composites/settings/settings-layout.js +62 -0
  8. package/dist/components/composites/settings/settings-layout.js.map +1 -0
  9. package/dist/components/composites/settings/settings-navigation.d.ts +41 -0
  10. package/dist/components/composites/settings/settings-navigation.d.ts.map +1 -0
  11. package/dist/components/composites/settings/settings-navigation.js +102 -0
  12. package/dist/components/composites/settings/settings-navigation.js.map +1 -0
  13. package/dist/components/layout/sidebar-nav/index.d.ts +1 -1
  14. package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
  15. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +18 -2
  16. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts.map +1 -1
  17. package/dist/components/layout/sidebar-nav/sidebar-nav.js +42 -11
  18. package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
  19. package/dist/components/layout/top-bar/top-bar.d.ts +3 -1
  20. package/dist/components/layout/top-bar/top-bar.d.ts.map +1 -1
  21. package/dist/components/layout/top-bar/top-bar.js +4 -4
  22. package/dist/components/layout/top-bar/top-bar.js.map +1 -1
  23. package/dist/components/ui/tabs/tabs.d.ts.map +1 -1
  24. package/dist/components/ui/tabs/tabs.js +70 -13
  25. package/dist/components/ui/tabs/tabs.js.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/r/registry.json +12 -0
  28. package/dist/r/settings.json +12 -0
  29. package/dist/r/src/components/composites/settings/index.ts +7 -0
  30. package/dist/r/src/components/composites/settings/settings-layout.tsx +65 -0
  31. package/dist/r/src/components/composites/settings/settings-navigation.tsx +135 -0
  32. package/dist/r/src/components/layout/sidebar-nav/index.ts +2 -0
  33. package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +71 -16
  34. package/dist/r/src/components/layout/top-bar/top-bar.tsx +9 -5
  35. package/dist/r/src/components/ui/tabs/tabs.tsx +80 -10
  36. package/package.json +2 -1
  37. package/registry.json +12 -0
  38. package/src/components/composites/settings/index.ts +7 -0
  39. package/src/components/composites/settings/settings-layout.tsx +65 -0
  40. package/src/components/composites/settings/settings-navigation.tsx +135 -0
  41. package/src/components/layout/sidebar-nav/index.ts +2 -0
  42. package/src/components/layout/sidebar-nav/sidebar-nav.tsx +71 -16
  43. package/src/components/layout/top-bar/top-bar.tsx +9 -5
  44. package/src/components/ui/tabs/tabs.tsx +80 -10
@@ -5,6 +5,15 @@ import type { ComponentProps, JSX, ValidComponent } from 'solid-js'
5
5
  import { Show, createSignal, splitProps } from 'solid-js'
6
6
  import { cn } from '../../../lib/utils'
7
7
 
8
+ function invokeEventHandler(handler: unknown, event: Event) {
9
+ if (Array.isArray(handler)) {
10
+ const [callback, data] = handler as [(data: unknown, event: Event) => void, unknown]
11
+ callback(data, event)
12
+ } else if (typeof handler === 'function') {
13
+ ;(handler as (event: Event) => void)(event)
14
+ }
15
+ }
16
+
8
17
  /**
9
18
  * SidebarNav.
10
19
  *
@@ -96,36 +105,69 @@ export function SidebarNavFooter(props: ComponentProps<'div'>) {
96
105
  )
97
106
  }
98
107
 
108
+ /** Props forwarded to a collapsible section's disclosure button. */
109
+ export type SidebarNavSectionTriggerProps = Omit<
110
+ ComponentProps<'button'>,
111
+ 'type' | 'aria-expanded' | 'onClick' | 'onKeyDown' | 'children' | 'class' | 'style'
112
+ > & {
113
+ /** Called for Alt+ArrowUp/Down; the host owns ordering and row identity. */
114
+ onReorder?: (direction: 'up' | 'down') => void
115
+ /** Runs before the section toggle and may cancel it with `preventDefault()`. */
116
+ onClick?: ComponentProps<'button'>['onClick']
117
+ /** Runs before shared Alt+Arrow handling and may cancel it with `preventDefault()`. */
118
+ onKeyDown?: ComponentProps<'button'>['onKeyDown']
119
+ }
120
+
99
121
  /**
100
122
  * A titled, collapsible group. Collapsible because a list of projects
101
123
  * inevitably outgrows the column, and the user needs to put the ones they are
102
124
  * not using away *without* them disappearing into a menu.
103
125
  */
104
- export function SidebarNavSection(
105
- props: ComponentProps<'div'> & {
106
- label: string
107
- /** Render a disclosure control instead of a plain heading. */
108
- collapsible?: boolean
109
- defaultOpen?: boolean
110
- /** Count shown beside the label. */
111
- count?: number
112
- /** A control at the trailing edge of the heading row. */
113
- action?: JSX.Element
114
- /** Semantic heading element for a host's section hierarchy. */
115
- headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
116
- }
117
- ) {
126
+ export type SidebarNavSectionProps = ComponentProps<'div'> & {
127
+ label: string
128
+ /** Render a disclosure control instead of a plain heading. */
129
+ collapsible?: boolean
130
+ defaultOpen?: boolean
131
+ /** Controlled disclosure state. When supplied, the host owns changes. */
132
+ open?: boolean
133
+ /** Called for user-initiated disclosure changes. */
134
+ onOpenChange?: (open: boolean) => void
135
+ /** Native props and reorder callbacks for the disclosure control. */
136
+ triggerProps?: SidebarNavSectionTriggerProps
137
+ /** Count shown beside the label. */
138
+ count?: number
139
+ /** A control at the trailing edge of the heading row. */
140
+ action?: JSX.Element
141
+ /** Semantic heading element for a host's section hierarchy. */
142
+ headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
143
+ }
144
+
145
+ export function SidebarNavSection(props: SidebarNavSectionProps) {
118
146
  const [local, rest] = splitProps(props, [
119
147
  'class',
120
148
  'label',
121
149
  'collapsible',
122
150
  'defaultOpen',
151
+ 'open',
152
+ 'onOpenChange',
153
+ 'triggerProps',
123
154
  'count',
124
155
  'action',
125
156
  'headingAs',
126
157
  'children',
127
158
  ])
128
- const [open, setOpen] = createSignal(local.defaultOpen ?? true)
159
+ const [uncontrolledOpen, setUncontrolledOpen] = createSignal(local.defaultOpen ?? true)
160
+ const open = () => local.open ?? uncontrolledOpen()
161
+ const setOpen = (next: boolean) => {
162
+ if (local.open === undefined) setUncontrolledOpen(next)
163
+ local.onOpenChange?.(next)
164
+ }
165
+
166
+ const [trigger, triggerRest] = splitProps(local.triggerProps ?? {}, [
167
+ 'onClick',
168
+ 'onKeyDown',
169
+ 'onReorder',
170
+ ])
129
171
 
130
172
  const heading = (
131
173
  <>
@@ -154,9 +196,22 @@ export function SidebarNavSection(
154
196
  >
155
197
  <Show when={local.collapsible} fallback={heading}>
156
198
  <button
199
+ {...(triggerRest as ComponentProps<'button'>)}
157
200
  type="button"
158
201
  aria-expanded={open()}
159
- onClick={() => setOpen((value) => !value)}
202
+ onClick={(event) => {
203
+ invokeEventHandler(trigger.onClick, event)
204
+ if (event.defaultPrevented) return
205
+ setOpen(!open())
206
+ }}
207
+ onKeyDown={(event) => {
208
+ invokeEventHandler(trigger.onKeyDown, event)
209
+ if (event.defaultPrevented || !event.altKey) return
210
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
211
+ event.preventDefault()
212
+ event.stopPropagation()
213
+ trigger.onReorder?.(event.key === 'ArrowUp' ? 'up' : 'down')
214
+ }}
160
215
  class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide text-sidebar-muted-foreground uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle"
161
216
  >
162
217
  {heading}
@@ -41,7 +41,7 @@ export function TopBar(props: TopBarProps) {
41
41
  <header
42
42
  data-slot="top-bar"
43
43
  class={cn(
44
- 'h-topbar relative z-(--z-sticky) grid shrink-0 grid-cols-[minmax(0,1fr)_clamp(240px,22vw,480px)_minmax(0,1fr)] items-center gap-3 border-b border-border px-3',
44
+ 'h-topbar relative z-(--z-sticky) grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-border bg-card text-card-foreground px-3 md:grid-cols-[minmax(0,1fr)_clamp(240px,22vw,480px)_minmax(0,1fr)]',
45
45
  local.glass && 'glass-panel',
46
46
  local.draggable && 'window-drag',
47
47
  local.macosInset && 'window-inset-macos',
@@ -61,7 +61,7 @@ export function TopBarSection(props: ComponentProps<'div'> & { align?: 'start' |
61
61
  <div
62
62
  data-slot="top-bar-section"
63
63
  class={cn(
64
- 'flex min-w-0 items-center gap-2 overflow-hidden',
64
+ 'window-no-drag flex min-w-0 items-center gap-2 overflow-x-auto overflow-y-hidden',
65
65
  local.align === 'end' && 'justify-end',
66
66
  local.align !== 'end' && 'justify-start',
67
67
  local.class
@@ -71,12 +71,16 @@ export function TopBarSection(props: ComponentProps<'div'> & { align?: 'start' |
71
71
  )
72
72
  }
73
73
 
74
- export function TopBarTitle(props: ComponentProps<'div'>) {
75
- const [local, rest] = splitProps(props, ['class'])
74
+ export function TopBarTitle(props: ComponentProps<'div'> & { align?: 'start' | 'center' }) {
75
+ const [local, rest] = splitProps(props, ['class', 'align'])
76
76
  return (
77
77
  <div
78
78
  data-slot="top-bar-title"
79
- class={cn('truncate text-sm font-medium', local.class)}
79
+ class={cn(
80
+ 'truncate text-sm font-medium',
81
+ local.align === 'center' && 'text-center',
82
+ local.class
83
+ )}
80
84
  {...rest}
81
85
  />
82
86
  )
@@ -1,8 +1,31 @@
1
1
  import { Tabs as KobalteTabs } from '@kobalte/core/tabs'
2
- import type { ComponentProps } from 'solid-js'
3
- import { splitProps } from 'solid-js'
2
+ import type { Accessor, ComponentProps } from 'solid-js'
3
+ import {
4
+ createContext,
5
+ createRenderEffect,
6
+ createSignal,
7
+ createUniqueId,
8
+ onCleanup,
9
+ splitProps,
10
+ useContext,
11
+ } from 'solid-js'
4
12
  import { cn } from '../../../lib/utils'
5
13
 
14
+ type TabsIdContextValue = {
15
+ id: Accessor<string>
16
+ triggerIds: Accessor<ReadonlyMap<string, string>>
17
+ registerTriggerId: (value: string, id: string) => void
18
+ unregisterTriggerId: (value: string, id: string) => void
19
+ }
20
+
21
+ const TabsIdContext = createContext<TabsIdContextValue>()
22
+
23
+ function useTabsId() {
24
+ const tabs = useContext(TabsIdContext)
25
+ if (!tabs) throw new Error('[adea-ui]: Tabs parts must be used within Tabs.')
26
+ return tabs
27
+ }
28
+
6
29
  /**
7
30
  * Tabs.
8
31
  *
@@ -14,12 +37,37 @@ import { cn } from '../../../lib/utils'
14
37
  * where a full-width underline would collide with the panel's own header rule.
15
38
  */
16
39
  export function Tabs(props: ComponentProps<typeof KobalteTabs>) {
17
- const [local, rest] = splitProps(props, ['class'])
40
+ const [local, rest] = splitProps(props, ['class', 'id'])
41
+ const generatedId = `tabs-${createUniqueId()}`
42
+ const id = () => local.id ?? generatedId
43
+ const [triggerIds, setTriggerIds] = createSignal<ReadonlyMap<string, string>>(new Map())
44
+ const tabs: TabsIdContextValue = {
45
+ id,
46
+ triggerIds,
47
+ registerTriggerId: (value, triggerId) => {
48
+ setTriggerIds((current) => new Map(current).set(value, triggerId))
49
+ },
50
+ unregisterTriggerId: (value, triggerId) => {
51
+ setTriggerIds((current) => {
52
+ if (current.get(value) !== triggerId) return current
53
+ const next = new Map(current)
54
+ next.delete(value)
55
+ return next
56
+ })
57
+ },
58
+ }
59
+
18
60
  return (
19
- <KobalteTabs
20
- class={cn('flex flex-col gap-2 data-[orientation=vertical]:flex-row', local.class)}
21
- {...rest}
22
- />
61
+ <TabsIdContext.Provider value={tabs}>
62
+ <KobalteTabs
63
+ id={id()}
64
+ class={cn(
65
+ 'flex min-h-0 min-w-0 flex-col gap-2 data-[orientation=vertical]:flex-row',
66
+ local.class
67
+ )}
68
+ {...rest}
69
+ />
70
+ </TabsIdContext.Provider>
23
71
  )
24
72
  }
25
73
 
@@ -50,10 +98,21 @@ export type TabsTriggerProps = ComponentProps<typeof KobalteTabs.Trigger> & {
50
98
  }
51
99
 
52
100
  export function TabsTrigger(props: TabsTriggerProps) {
53
- const [local, rest] = splitProps(props, ['class', 'appearance'])
101
+ const [local, rest] = splitProps(props, ['class', 'appearance', 'id'])
102
+ const tabs = useTabsId()
103
+ const triggerId = () => local.id ?? `${tabs.id()}-trigger-${rest.value}`
104
+
105
+ // Register before panels render, including SSR where createEffect is skipped.
106
+ createRenderEffect(() => {
107
+ const value = rest.value
108
+ const id = triggerId()
109
+ tabs.registerTriggerId(value, id)
110
+ onCleanup(() => tabs.unregisterTriggerId(value, id))
111
+ })
54
112
 
55
113
  return (
56
114
  <KobalteTabs.Trigger
115
+ id={triggerId()}
57
116
  class={cn(
58
117
  'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium',
59
118
  'transition-colors ease-out outline-none select-none',
@@ -72,6 +131,17 @@ export function TabsTrigger(props: TabsTriggerProps) {
72
131
  }
73
132
 
74
133
  export function TabsContent(props: ComponentProps<typeof KobalteTabs.Content>) {
75
- const [local, rest] = splitProps(props, ['class'])
76
- return <KobalteTabs.Content class={cn('flex-1 outline-none', local.class)} {...rest} />
134
+ const [local, rest] = splitProps(props, ['class', 'aria-labelledby'])
135
+ const tabs = useTabsId()
136
+ return (
137
+ <KobalteTabs.Content
138
+ aria-labelledby={
139
+ local['aria-labelledby'] ??
140
+ tabs.triggerIds().get(rest.value) ??
141
+ `${tabs.id()}-trigger-${rest.value}`
142
+ }
143
+ class={cn('min-h-0 min-w-0 flex-1 outline-none', local.class)}
144
+ {...rest}
145
+ />
146
+ )
77
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.87.0",
3
+ "version": "0.89.1",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
@@ -107,6 +107,7 @@
107
107
  "check:packed-layout-renderer": "bun scripts/check-packed-layout-renderer.ts",
108
108
  "check:packed-appearance": "bun scripts/check-packed-appearance.ts",
109
109
  "check:packed-native-select": "bun scripts/check-packed-native-select.ts",
110
+ "check:packed-settings-navigation": "bun scripts/check-packed-settings-navigation.ts",
110
111
  "check:packed-paste-model": "node scripts/check-packed-paste-model.mjs"
111
112
  },
112
113
  "dependencies": {
package/registry.json CHANGED
@@ -1960,6 +1960,8 @@
1960
1960
  "description": "",
1961
1961
  "dependencies": [],
1962
1962
  "registryDependencies": [
1963
+ "@adea-ai/ui/sidebar-nav",
1964
+ "@adea-ai/ui/tabs",
1963
1965
  "lib"
1964
1966
  ],
1965
1967
  "files": [
@@ -1968,6 +1970,16 @@
1968
1970
  "type": "registry:ui",
1969
1971
  "target": "components/composites/settings/index.ts"
1970
1972
  },
1973
+ {
1974
+ "path": "src/components/composites/settings/settings-layout.tsx",
1975
+ "type": "registry:ui",
1976
+ "target": "components/composites/settings/settings-layout.tsx"
1977
+ },
1978
+ {
1979
+ "path": "src/components/composites/settings/settings-navigation.tsx",
1980
+ "type": "registry:ui",
1981
+ "target": "components/composites/settings/settings-navigation.tsx"
1982
+ },
1971
1983
  {
1972
1984
  "path": "src/components/composites/settings/settings.tsx",
1973
1985
  "type": "registry:ui",
@@ -1 +1,8 @@
1
1
  export { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings'
2
+ export { SettingsLayout, type SettingsLayoutProps } from './settings-layout'
3
+ export {
4
+ SettingsNavigation,
5
+ type SettingsNavigationGroup,
6
+ type SettingsNavigationItem,
7
+ type SettingsNavigationProps,
8
+ } from './settings-navigation'
@@ -0,0 +1,65 @@
1
+ import type { ComponentProps, JSX } from 'solid-js'
2
+ import { splitProps } from 'solid-js'
3
+ import { cn } from '#lib/utils'
4
+ import { Tabs } from '../../ui/tabs'
5
+ import { SettingsNavigation, type SettingsNavigationGroup } from './settings-navigation'
6
+
7
+ export type SettingsLayoutProps = Omit<
8
+ ComponentProps<typeof Tabs>,
9
+ 'children' | 'orientation' | 'value' | 'defaultValue' | 'onChange'
10
+ > & {
11
+ /** Grouped navigation entries; the host owns their values and panel content. */
12
+ groups: readonly SettingsNavigationGroup[]
13
+ /** Accessible name for the vertical tab list. */
14
+ 'aria-label': string
15
+ /** The host-controlled selected destination. */
16
+ value: string
17
+ /** Updates the host-controlled selected destination. */
18
+ onChange: (value: string) => void
19
+ /** Called when the already-selected destination is activated again. */
20
+ onReselect?: (value: string) => void
21
+ /** Reveal the selected row on value changes and window resizes. Defaults to nearest scroll. */
22
+ revealSelected?: boolean | ((trigger: HTMLButtonElement | undefined) => void)
23
+ /** Panels must be rendered as `TabsContent` descendants. */
24
+ children?: JSX.Element
25
+ }
26
+
27
+ /**
28
+ * SettingsLayout.
29
+ *
30
+ * A controlled, vertical settings rail beside its scrollable panel viewport.
31
+ * The host keeps ownership of selection, URL/hash state and panel content.
32
+ * At narrow viewports the rail contracts while both columns remain shrinkable.
33
+ */
34
+ export function SettingsLayout(props: SettingsLayoutProps) {
35
+ const [local, rest] = splitProps(props, [
36
+ 'class',
37
+ 'children',
38
+ 'groups',
39
+ 'aria-label',
40
+ 'value',
41
+ 'onReselect',
42
+ 'revealSelected',
43
+ ])
44
+
45
+ return (
46
+ <Tabs
47
+ {...rest}
48
+ orientation="vertical"
49
+ value={local.value}
50
+ class={cn('min-h-0 min-w-0 flex-1 gap-0', local.class)}
51
+ >
52
+ <SettingsNavigation
53
+ aria-label={local['aria-label']}
54
+ groups={local.groups}
55
+ value={local.value}
56
+ onReselect={local.onReselect}
57
+ revealSelected={local.revealSelected}
58
+ class="w-24 md:w-sidebar"
59
+ />
60
+ <div data-slot="settings-layout-panels" class="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
61
+ {local.children}
62
+ </div>
63
+ </Tabs>
64
+ )
65
+ }
@@ -0,0 +1,135 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createEffect, createUniqueId, onCleanup, splitProps } from 'solid-js'
3
+ import { cn } from '#lib/utils'
4
+ import { sidebarNavItemClass, sidebarNavItemStateClass } from '../../layout/sidebar-nav/sidebar-nav'
5
+ import { TabsList, TabsTrigger, type TabsListProps } from '../../ui/tabs'
6
+
7
+ /** One host-owned destination in a settings tab list. Values must be unique. */
8
+ export type SettingsNavigationItem = {
9
+ value: string
10
+ label: string
11
+ icon?: JSX.Element
12
+ disabled?: boolean
13
+ }
14
+
15
+ /** A visible, accessibly named group of settings destinations. */
16
+ export type SettingsNavigationGroup = {
17
+ label: string
18
+ items: readonly SettingsNavigationItem[]
19
+ }
20
+
21
+ export type SettingsNavigationProps = Omit<
22
+ TabsListProps,
23
+ 'appearance' | 'children' | 'orientation'
24
+ > & {
25
+ /** Groups are presentation only; the host owns the tab values and panels. */
26
+ groups: readonly SettingsNavigationGroup[]
27
+ /** The same controlled value supplied to the enclosing `Tabs`. */
28
+ value: string
29
+ /** Called when the already-selected destination is activated again. */
30
+ onReselect?: (value: string) => void
31
+ /** Reveal the selected row on value changes and window resizes. Defaults to nearest scroll. */
32
+ revealSelected?: boolean | ((trigger: HTMLButtonElement | undefined) => void)
33
+ /** Every tab list needs a label independent of its visible group headings. */
34
+ 'aria-label': string
35
+ }
36
+
37
+ /**
38
+ * SettingsNavigation.
39
+ *
40
+ * A grouped vertical tab list for sibling settings panels. It composes the
41
+ * shared Tabs behaviour with the shared sidebar row recipe; the host supplies
42
+ * the controlled value, panel content, icons and any persistence or routing.
43
+ * Use ordinary tabs for a short, ungrouped row of view choices.
44
+ *
45
+ * Kobalte owns roving focus, arrow keys, selection and tab/panel ARIA. This
46
+ * component adds no URL, storage or host-specific state. Set
47
+ * `revealSelected={false}` when the surrounding layout does not use an internal
48
+ * scrolling viewport.
49
+ */
50
+ export function SettingsNavigation(props: SettingsNavigationProps) {
51
+ const [local, rest] = splitProps(props, [
52
+ 'class',
53
+ 'aria-label',
54
+ 'groups',
55
+ 'value',
56
+ 'onReselect',
57
+ 'revealSelected',
58
+ ])
59
+ const triggers = new Map<string, HTMLButtonElement>()
60
+ const groupIdPrefix = `settings-navigation-${createUniqueId()}`
61
+
62
+ createEffect(() => {
63
+ const value = local.value
64
+ const reveal = local.revealSelected
65
+ if (reveal === false) return
66
+
67
+ const revealCurrent = () => {
68
+ const trigger = triggers.get(value)
69
+ if (typeof reveal === 'function') reveal(trigger)
70
+ else trigger?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
71
+ }
72
+
73
+ revealCurrent()
74
+ window.addEventListener('resize', revealCurrent)
75
+ onCleanup(() => window.removeEventListener('resize', revealCurrent))
76
+ })
77
+
78
+ return (
79
+ <TabsList
80
+ data-slot="settings-navigation"
81
+ aria-label={local['aria-label']}
82
+ orientation="vertical"
83
+ class={cn(
84
+ 'flex max-h-full min-h-0 min-w-0 shrink-0 flex-col items-stretch justify-start gap-2 overflow-x-hidden overflow-y-auto px-2 py-2',
85
+ local.class
86
+ )}
87
+ {...rest}
88
+ >
89
+ {local.groups.map((group, groupIndex) => {
90
+ const groupId = `${groupIdPrefix}-group-${groupIndex}`
91
+ return (
92
+ <div
93
+ role="none"
94
+ data-slot="settings-navigation-group"
95
+ class="flex min-w-0 flex-col gap-0.5"
96
+ >
97
+ <span id={groupId} class="sr-only">
98
+ {group.label}
99
+ </span>
100
+ <span
101
+ aria-hidden="true"
102
+ class="px-2 py-1.5 text-2xs font-medium tracking-wide text-sidebar-muted-foreground uppercase"
103
+ >
104
+ {group.label}
105
+ </span>
106
+ {group.items.map((item) => (
107
+ <TabsTrigger
108
+ data-slot="settings-navigation-trigger"
109
+ orientation="vertical"
110
+ aria-describedby={groupId}
111
+ value={item.value}
112
+ disabled={item.disabled}
113
+ ref={(element: HTMLButtonElement | undefined) => {
114
+ if (element) triggers.set(item.value, element)
115
+ else triggers.delete(item.value)
116
+ }}
117
+ onClick={() => {
118
+ if (local.value === item.value) local.onReselect?.(item.value)
119
+ }}
120
+ class={cn(
121
+ sidebarNavItemClass,
122
+ sidebarNavItemStateClass.idle,
123
+ 'h-row-lg w-full justify-start border-0 px-3 py-2 text-left data-[selected]:bg-primary-subtle data-[selected]:text-foreground'
124
+ )}
125
+ >
126
+ {item.icon}
127
+ <span class="min-w-0 truncate">{item.label}</span>
128
+ </TabsTrigger>
129
+ ))}
130
+ </div>
131
+ )
132
+ })}
133
+ </TabsList>
134
+ )
135
+ }
@@ -11,6 +11,8 @@ export {
11
11
  sidebarNavItemStateClass,
12
12
  type SidebarNavItemProps,
13
13
  type SidebarNavProps,
14
+ type SidebarNavSectionProps,
15
+ type SidebarNavSectionTriggerProps,
14
16
  type SidebarNavTitleProps,
15
17
  } from './sidebar-nav'
16
18
  export {
@@ -5,6 +5,15 @@ import type { ComponentProps, JSX, ValidComponent } from 'solid-js'
5
5
  import { Show, createSignal, splitProps } from 'solid-js'
6
6
  import { cn } from '#lib/utils'
7
7
 
8
+ function invokeEventHandler(handler: unknown, event: Event) {
9
+ if (Array.isArray(handler)) {
10
+ const [callback, data] = handler as [(data: unknown, event: Event) => void, unknown]
11
+ callback(data, event)
12
+ } else if (typeof handler === 'function') {
13
+ ;(handler as (event: Event) => void)(event)
14
+ }
15
+ }
16
+
8
17
  /**
9
18
  * SidebarNav.
10
19
  *
@@ -96,36 +105,69 @@ export function SidebarNavFooter(props: ComponentProps<'div'>) {
96
105
  )
97
106
  }
98
107
 
108
+ /** Props forwarded to a collapsible section's disclosure button. */
109
+ export type SidebarNavSectionTriggerProps = Omit<
110
+ ComponentProps<'button'>,
111
+ 'type' | 'aria-expanded' | 'onClick' | 'onKeyDown' | 'children' | 'class' | 'style'
112
+ > & {
113
+ /** Called for Alt+ArrowUp/Down; the host owns ordering and row identity. */
114
+ onReorder?: (direction: 'up' | 'down') => void
115
+ /** Runs before the section toggle and may cancel it with `preventDefault()`. */
116
+ onClick?: ComponentProps<'button'>['onClick']
117
+ /** Runs before shared Alt+Arrow handling and may cancel it with `preventDefault()`. */
118
+ onKeyDown?: ComponentProps<'button'>['onKeyDown']
119
+ }
120
+
99
121
  /**
100
122
  * A titled, collapsible group. Collapsible because a list of projects
101
123
  * inevitably outgrows the column, and the user needs to put the ones they are
102
124
  * not using away *without* them disappearing into a menu.
103
125
  */
104
- export function SidebarNavSection(
105
- props: ComponentProps<'div'> & {
106
- label: string
107
- /** Render a disclosure control instead of a plain heading. */
108
- collapsible?: boolean
109
- defaultOpen?: boolean
110
- /** Count shown beside the label. */
111
- count?: number
112
- /** A control at the trailing edge of the heading row. */
113
- action?: JSX.Element
114
- /** Semantic heading element for a host's section hierarchy. */
115
- headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
116
- }
117
- ) {
126
+ export type SidebarNavSectionProps = ComponentProps<'div'> & {
127
+ label: string
128
+ /** Render a disclosure control instead of a plain heading. */
129
+ collapsible?: boolean
130
+ defaultOpen?: boolean
131
+ /** Controlled disclosure state. When supplied, the host owns changes. */
132
+ open?: boolean
133
+ /** Called for user-initiated disclosure changes. */
134
+ onOpenChange?: (open: boolean) => void
135
+ /** Native props and reorder callbacks for the disclosure control. */
136
+ triggerProps?: SidebarNavSectionTriggerProps
137
+ /** Count shown beside the label. */
138
+ count?: number
139
+ /** A control at the trailing edge of the heading row. */
140
+ action?: JSX.Element
141
+ /** Semantic heading element for a host's section hierarchy. */
142
+ headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
143
+ }
144
+
145
+ export function SidebarNavSection(props: SidebarNavSectionProps) {
118
146
  const [local, rest] = splitProps(props, [
119
147
  'class',
120
148
  'label',
121
149
  'collapsible',
122
150
  'defaultOpen',
151
+ 'open',
152
+ 'onOpenChange',
153
+ 'triggerProps',
123
154
  'count',
124
155
  'action',
125
156
  'headingAs',
126
157
  'children',
127
158
  ])
128
- const [open, setOpen] = createSignal(local.defaultOpen ?? true)
159
+ const [uncontrolledOpen, setUncontrolledOpen] = createSignal(local.defaultOpen ?? true)
160
+ const open = () => local.open ?? uncontrolledOpen()
161
+ const setOpen = (next: boolean) => {
162
+ if (local.open === undefined) setUncontrolledOpen(next)
163
+ local.onOpenChange?.(next)
164
+ }
165
+
166
+ const [trigger, triggerRest] = splitProps(local.triggerProps ?? {}, [
167
+ 'onClick',
168
+ 'onKeyDown',
169
+ 'onReorder',
170
+ ])
129
171
 
130
172
  const heading = (
131
173
  <>
@@ -154,9 +196,22 @@ export function SidebarNavSection(
154
196
  >
155
197
  <Show when={local.collapsible} fallback={heading}>
156
198
  <button
199
+ {...(triggerRest as ComponentProps<'button'>)}
157
200
  type="button"
158
201
  aria-expanded={open()}
159
- onClick={() => setOpen((value) => !value)}
202
+ onClick={(event) => {
203
+ invokeEventHandler(trigger.onClick, event)
204
+ if (event.defaultPrevented) return
205
+ setOpen(!open())
206
+ }}
207
+ onKeyDown={(event) => {
208
+ invokeEventHandler(trigger.onKeyDown, event)
209
+ if (event.defaultPrevented || !event.altKey) return
210
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
211
+ event.preventDefault()
212
+ event.stopPropagation()
213
+ trigger.onReorder?.(event.key === 'ArrowUp' ? 'up' : 'down')
214
+ }}
160
215
  class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide text-sidebar-muted-foreground uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle"
161
216
  >
162
217
  {heading}