@adea-ai/ui 0.81.1 → 0.82.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 (65) hide show
  1. package/README.md +41 -8
  2. package/dist/components/composites/account-menu/account-menu.d.ts +5 -0
  3. package/dist/components/composites/account-menu/account-menu.d.ts.map +1 -1
  4. package/dist/components/composites/account-menu/account-menu.js +122 -70
  5. package/dist/components/composites/account-menu/account-menu.js.map +1 -1
  6. package/dist/components/composites/action-button/action-button.d.ts +27 -0
  7. package/dist/components/composites/action-button/action-button.d.ts.map +1 -0
  8. package/dist/components/composites/action-button/action-button.js +121 -0
  9. package/dist/components/composites/action-button/action-button.js.map +1 -0
  10. package/dist/components/composites/action-button/index.d.ts +3 -0
  11. package/dist/components/composites/action-button/index.d.ts.map +1 -0
  12. package/dist/components/composites/action-button/index.js +2 -0
  13. package/dist/components/conversation/thread-panel.d.ts +2 -0
  14. package/dist/components/conversation/thread-panel.d.ts.map +1 -1
  15. package/dist/components/conversation/thread-panel.js +36 -31
  16. package/dist/components/conversation/thread-panel.js.map +1 -1
  17. package/dist/components/layout/sidebar-nav/index.d.ts +1 -1
  18. package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
  19. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +6 -1
  20. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts.map +1 -1
  21. package/dist/components/layout/sidebar-nav/sidebar-nav.js +66 -62
  22. package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
  23. package/dist/components/ui/board/board.d.ts.map +1 -1
  24. package/dist/components/ui/board/board.js +54 -7
  25. package/dist/components/ui/board/board.js.map +1 -1
  26. package/dist/components/ui/command/command.d.ts +5 -1
  27. package/dist/components/ui/command/command.d.ts.map +1 -1
  28. package/dist/components/ui/command/command.js +8 -3
  29. package/dist/components/ui/command/command.js.map +1 -1
  30. package/dist/components/ui/input/index.d.ts +1 -1
  31. package/dist/components/ui/input/index.d.ts.map +1 -1
  32. package/dist/components/ui/input/input.d.ts +5 -1
  33. package/dist/components/ui/input/input.d.ts.map +1 -1
  34. package/dist/components/ui/input/input.js +36 -17
  35. package/dist/components/ui/input/input.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -1
  39. package/dist/r/account-menu.json +1 -0
  40. package/dist/r/action-button.json +24 -0
  41. package/dist/r/conversation.json +1 -0
  42. package/dist/r/registry.json +26 -0
  43. package/dist/r/src/components/composites/account-menu/account-menu.tsx +100 -44
  44. package/dist/r/src/components/composites/action-button/action-button.tsx +139 -0
  45. package/dist/r/src/components/composites/action-button/index.ts +2 -0
  46. package/dist/r/src/components/conversation/thread-panel.tsx +22 -12
  47. package/dist/r/src/components/layout/sidebar-nav/index.ts +1 -0
  48. package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +29 -21
  49. package/dist/r/src/components/ui/board/board.tsx +74 -6
  50. package/dist/r/src/components/ui/command/command.tsx +15 -4
  51. package/dist/r/src/components/ui/input/index.ts +1 -1
  52. package/dist/r/src/components/ui/input/input.tsx +38 -21
  53. package/package.json +1 -1
  54. package/registry.json +26 -0
  55. package/src/components/composites/account-menu/account-menu.tsx +100 -44
  56. package/src/components/composites/action-button/action-button.tsx +139 -0
  57. package/src/components/composites/action-button/index.ts +2 -0
  58. package/src/components/conversation/thread-panel.tsx +22 -12
  59. package/src/components/layout/sidebar-nav/index.ts +1 -0
  60. package/src/components/layout/sidebar-nav/sidebar-nav.tsx +29 -21
  61. package/src/components/ui/board/board.tsx +74 -6
  62. package/src/components/ui/command/command.tsx +15 -4
  63. package/src/components/ui/input/index.ts +1 -1
  64. package/src/components/ui/input/input.tsx +38 -21
  65. package/src/index.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import type { ComponentProps } from 'solid-js'
2
- import { splitProps } from 'solid-js'
2
+ import { createUniqueId, For, Show, splitProps } from 'solid-js'
3
3
  import { cn } from '../../../lib/utils'
4
4
 
5
5
  /**
@@ -14,28 +14,45 @@ import { cn } from '../../../lib/utils'
14
14
  * Height comes from `h-control-md`; an input cannot be given a new height
15
15
  * without a new token, which is what keeps it aligned with Button and Select.
16
16
  */
17
- export function Input(props: ComponentProps<'input'>) {
18
- const [local, rest] = splitProps(props, ['class', 'type'])
17
+ export type InputProps = ComponentProps<'input'> & {
18
+ /** Native text suggestions; custom values remain valid and submit unchanged. */
19
+ suggestions?: readonly string[]
20
+ }
21
+
22
+ export function Input(props: InputProps) {
23
+ const [local, rest] = splitProps(props, ['class', 'type', 'list', 'suggestions'])
24
+ const suggestionsId = createUniqueId()
25
+ const listId = () => local.list ?? `input-suggestions-${suggestionsId}`
19
26
 
20
27
  return (
21
- <input
22
- data-slot="input"
23
- type={local.type ?? 'text'}
24
- class={cn(
25
- 'h-control-md w-full min-w-0 rounded-md border border-input bg-transparent px-control-md py-1 text-sm',
26
- 'transition-[color,box-shadow,border-color] ease-out outline-none',
27
- 'placeholder:text-muted-foreground',
28
- 'selection:bg-primary selection:text-primary-foreground',
29
- 'file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
30
- /* A pointer click on a text field is a typing affordance, so the ring
28
+ <>
29
+ <input
30
+ data-slot="input"
31
+ type={local.type ?? 'text'}
32
+ list={local.suggestions ? listId() : local.list}
33
+ class={cn(
34
+ 'h-control-md w-full min-w-0 rounded-md border border-input bg-transparent px-control-md py-1 text-sm',
35
+ 'transition-[color,box-shadow,border-color] ease-out outline-none',
36
+ 'placeholder:text-muted-foreground',
37
+ 'selection:bg-primary selection:text-primary-foreground',
38
+ 'file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
39
+ /* A pointer click on a text field is a typing affordance, so the ring
31
40
  appears where the user agent already matches :focus-visible. */
32
- 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
33
- 'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive-subtle',
34
- 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
35
- 'read-only:bg-surface-hover',
36
- local.class
37
- )}
38
- {...rest}
39
- />
41
+ 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
42
+ 'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive-subtle',
43
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
44
+ 'read-only:bg-surface-hover',
45
+ local.class
46
+ )}
47
+ {...rest}
48
+ />
49
+ <Show when={local.suggestions}>
50
+ {(suggestions) => (
51
+ <datalist id={listId()}>
52
+ <For each={suggestions()}>{(value) => <option value={value} />}</For>
53
+ </datalist>
54
+ )}
55
+ </Show>
56
+ </>
40
57
  )
41
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.81.1",
3
+ "version": "0.82.0",
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",
package/registry.json CHANGED
@@ -1649,6 +1649,7 @@
1649
1649
  "registryDependencies": [
1650
1650
  "@adea-ai/ui/button",
1651
1651
  "@adea-ai/ui/dropdown-menu",
1652
+ "@adea-ai/ui/tooltip",
1652
1653
  "lib"
1653
1654
  ],
1654
1655
  "files": [
@@ -1664,6 +1665,30 @@
1664
1665
  }
1665
1666
  ]
1666
1667
  },
1668
+ {
1669
+ "name": "action-button",
1670
+ "type": "registry:ui",
1671
+ "title": "Action Button",
1672
+ "description": "A shared Button with optional tooltip and busy-state feedback.",
1673
+ "dependencies": [],
1674
+ "registryDependencies": [
1675
+ "@adea-ai/ui/button",
1676
+ "@adea-ai/ui/spinner",
1677
+ "@adea-ai/ui/tooltip"
1678
+ ],
1679
+ "files": [
1680
+ {
1681
+ "path": "src/components/composites/action-button/action-button.tsx",
1682
+ "type": "registry:ui",
1683
+ "target": "components/composites/action-button/action-button.tsx"
1684
+ },
1685
+ {
1686
+ "path": "src/components/composites/action-button/index.ts",
1687
+ "type": "registry:ui",
1688
+ "target": "components/composites/action-button/index.ts"
1689
+ }
1690
+ ]
1691
+ },
1667
1692
  {
1668
1693
  "name": "appearance-editor",
1669
1694
  "type": "registry:ui",
@@ -1853,6 +1878,7 @@
1853
1878
  "@adea-ai/ui/kbd",
1854
1879
  "@adea-ai/ui/spinner",
1855
1880
  "@adea-ai/ui/textarea",
1881
+ "@adea-ai/ui/tooltip",
1856
1882
  "lib"
1857
1883
  ],
1858
1884
  "files": [
@@ -9,10 +9,10 @@ import {
9
9
  Smartphone,
10
10
  UserRound,
11
11
  } from 'lucide-solid'
12
- import type { JSX } from 'solid-js'
13
- import { For, Show, splitProps } from 'solid-js'
12
+ import type { ComponentProps, JSX } from 'solid-js'
13
+ import { For, Show, createSignal, onCleanup, splitProps } from 'solid-js'
14
14
  import { cn } from '#lib/utils'
15
- import { Button } from '../../ui/button/button'
15
+ import { Button, type ButtonProps } from '../../ui/button/button'
16
16
  import {
17
17
  DropdownMenu,
18
18
  DropdownMenuContent,
@@ -21,6 +21,7 @@ import {
21
21
  DropdownMenuSeparator,
22
22
  DropdownMenuTrigger,
23
23
  } from '../../ui/dropdown-menu/dropdown-menu'
24
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip/tooltip'
24
25
 
25
26
  /**
26
27
  * AccountMenu.
@@ -49,10 +50,14 @@ export type AccountMenuItem = {
49
50
  /** Render only on this platform. Omit to always render. */
50
51
  platform?: 'desktop' | 'web'
51
52
  onSelect?: () => void
53
+ /** Run after the menu's close-focus cycle, with its trigger as a stable opener. */
54
+ onSelectAfterClose?: (trigger: HTMLButtonElement | undefined) => void
52
55
  }
53
56
 
54
57
  export type AccountMenuProps = {
55
58
  class?: string
59
+ /** The trigger size. Icon-only menus default to the standard icon size. */
60
+ size?: ButtonProps['size']
56
61
  /** The entries above the separator. */
57
62
  items: readonly AccountMenuItem[]
58
63
  /** Which platform this is rendered on, for items that declare one. */
@@ -81,6 +86,7 @@ const FALLBACK_ICONS: Record<string, typeof Settings2> = {
81
86
  export function AccountMenu(props: AccountMenuProps) {
82
87
  const [local, rest] = splitProps(props, [
83
88
  'class',
89
+ 'size',
84
90
  'items',
85
91
  'platform',
86
92
  'authenticated',
@@ -91,55 +97,105 @@ export function AccountMenu(props: AccountMenuProps) {
91
97
  'onIntent',
92
98
  ])
93
99
 
100
+ let trigger: HTMLButtonElement | undefined
101
+ let pendingAfterClose:
102
+ | { callback: NonNullable<AccountMenuItem['onSelectAfterClose']> }
103
+ | undefined
104
+ let scheduledAfterClose:
105
+ | { callback: NonNullable<AccountMenuItem['onSelectAfterClose']> }
106
+ | undefined
107
+ const [menuOpen, setMenuOpen] = createSignal(false)
108
+ const [tooltipOpen, setTooltipOpen] = createSignal(false)
109
+
110
+ onCleanup(() => {
111
+ pendingAfterClose = undefined
112
+ scheduledAfterClose = undefined
113
+ })
114
+
94
115
  const visible = () =>
95
116
  local.items.filter((item) => !item.platform || item.platform === (local.platform ?? 'desktop'))
96
117
 
97
118
  return (
98
- <DropdownMenu modal={false}>
99
- <DropdownMenuTrigger
100
- as={Button}
101
- variant="ghost"
102
- size="icon-md"
103
- class={cn('text-muted-foreground', local.class)}
104
- aria-label={local.label ?? 'Account and settings'}
105
- onFocus={() => local.onIntent?.()}
106
- onPointerEnter={() => local.onIntent?.()}
107
- {...rest}
119
+ <Tooltip open={tooltipOpen()} onOpenChange={(open) => setTooltipOpen(open && !menuOpen())}>
120
+ <DropdownMenu
121
+ modal={false}
122
+ onOpenChange={(open) => {
123
+ setMenuOpen(open)
124
+ if (open) setTooltipOpen(false)
125
+ }}
108
126
  >
109
- <UserRound aria-hidden="true" />
110
- </DropdownMenuTrigger>
111
- <DropdownMenuContent side="top" align="start" class="min-w-56">
112
- <DropdownMenuGroup>
113
- <For each={visible()}>
114
- {(item) => (
115
- <DropdownMenuItem
116
- disabled={item.disabled}
117
- onSelect={() => item.onSelect?.()}
118
- shortcut={item.shortcut}
119
- >
120
- {item.icon ?? renderFallbackIcon(item.id)}
121
- <span>{item.label}</span>
122
- </DropdownMenuItem>
123
- )}
124
- </For>
125
- </DropdownMenuGroup>
126
- <DropdownMenuSeparator />
127
- <DropdownMenuGroup>
128
- <DropdownMenuItem
129
- disabled={local.busy}
130
- onSelect={() => (local.authenticated ? local.onSignOut?.() : local.onSignIn?.())}
131
- >
132
- <Show when={local.authenticated} fallback={<LogIn aria-hidden="true" />}>
133
- <LogOut aria-hidden="true" />
134
- </Show>
135
- <span>{local.authenticated ? 'Sign out' : 'Sign in'}</span>
136
- </DropdownMenuItem>
137
- </DropdownMenuGroup>
138
- </DropdownMenuContent>
139
- </DropdownMenu>
127
+ <TooltipTrigger
128
+ as={AccountMenuButton}
129
+ ref={(element: HTMLButtonElement) => (trigger = element)}
130
+ variant="ghost"
131
+ size={local.size ?? 'icon-md'}
132
+ class={cn('text-muted-foreground', local.class)}
133
+ aria-label={local.label ?? 'Account and settings'}
134
+ onFocus={() => local.onIntent?.()}
135
+ onPointerEnter={() => local.onIntent?.()}
136
+ {...rest}
137
+ >
138
+ <UserRound aria-hidden="true" />
139
+ </TooltipTrigger>
140
+ <TooltipContent placement="top">{local.label ?? 'Account and settings'}</TooltipContent>
141
+ <DropdownMenuContent
142
+ placement="top-start"
143
+ class="min-w-56 max-h-(--kb-popper-available-height) overflow-x-hidden overflow-y-auto"
144
+ onCloseAutoFocus={(event) => {
145
+ const selection = pendingAfterClose
146
+ pendingAfterClose = undefined
147
+ if (!selection) return
148
+
149
+ event.preventDefault()
150
+ scheduledAfterClose = selection
151
+ queueMicrotask(() => {
152
+ if (scheduledAfterClose !== selection) return
153
+ scheduledAfterClose = undefined
154
+ selection.callback(trigger)
155
+ })
156
+ }}
157
+ >
158
+ <DropdownMenuGroup>
159
+ <For each={visible()}>
160
+ {(item) => (
161
+ <DropdownMenuItem
162
+ disabled={item.disabled}
163
+ onSelect={() => {
164
+ pendingAfterClose = item.onSelectAfterClose
165
+ ? { callback: item.onSelectAfterClose }
166
+ : undefined
167
+ item.onSelect?.()
168
+ }}
169
+ shortcut={item.shortcut}
170
+ >
171
+ {item.icon ?? renderFallbackIcon(item.id)}
172
+ <span>{item.label}</span>
173
+ </DropdownMenuItem>
174
+ )}
175
+ </For>
176
+ </DropdownMenuGroup>
177
+ <DropdownMenuSeparator />
178
+ <DropdownMenuGroup>
179
+ <DropdownMenuItem
180
+ disabled={local.busy}
181
+ onSelect={() => (local.authenticated ? local.onSignOut?.() : local.onSignIn?.())}
182
+ >
183
+ <Show when={local.authenticated} fallback={<LogIn aria-hidden="true" />}>
184
+ <LogOut aria-hidden="true" />
185
+ </Show>
186
+ <span>{local.authenticated ? 'Sign out' : 'Sign in'}</span>
187
+ </DropdownMenuItem>
188
+ </DropdownMenuGroup>
189
+ </DropdownMenuContent>
190
+ </DropdownMenu>
191
+ </Tooltip>
140
192
  )
141
193
  }
142
194
 
195
+ function AccountMenuButton(props: ComponentProps<typeof Button>) {
196
+ return <DropdownMenuTrigger as={Button} {...props} />
197
+ }
198
+
143
199
  /**
144
200
  * A known item id gets its conventional glyph, so a caller that only wants the
145
201
  * standard menu does not have to import six icons to get it.
@@ -0,0 +1,139 @@
1
+ import { Show, splitProps, type ComponentProps, type JSX, type ValidComponent } from 'solid-js'
2
+ import { Button, type ButtonProps } from '../../ui/button'
3
+ import { Spinner } from '../../ui/spinner'
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip'
5
+
6
+ /**
7
+ * ActionButton.
8
+ *
9
+ * A Button with optional explanatory tooltip and busy feedback. Use it for a
10
+ * simple action that needs these states; actions with product-specific rules
11
+ * should compose Button directly.
12
+ *
13
+ * The tooltip trigger renders the polymorphic Button itself. A disabled native
14
+ * control cannot receive pointer or keyboard events. When a tooltip is set,
15
+ * disabled actions therefore use focusable `aria-disabled` semantics and guard
16
+ * activation; without a tooltip, native buttons retain native disabling.
17
+ * Custom polymorphic components must forward aria, event, and href props for
18
+ * these guarantees to apply.
19
+ */
20
+ export type ActionButtonProps<T extends ValidComponent = 'button'> = ButtonProps<T> & {
21
+ /** A short explanation, also shown on keyboard focus. The Button still needs its own name. */
22
+ tooltip?: string
23
+ tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
24
+ /** Disables the action and announces the busy label through a polite status region. */
25
+ busy?: boolean
26
+ /** The activity announced while busy. The Button's accessible name stays unchanged. */
27
+ busyLabel?: string
28
+ }
29
+
30
+ function guardedClick(handler: unknown, disabled: () => boolean) {
31
+ return (event: MouseEvent) => {
32
+ if (disabled()) {
33
+ event.preventDefault()
34
+ event.stopImmediatePropagation()
35
+ return
36
+ }
37
+
38
+ if (Array.isArray(handler)) {
39
+ const [callback, data] = handler as [(data: unknown, event: MouseEvent) => void, unknown]
40
+ callback(data, event)
41
+ } else if (typeof handler === 'function') {
42
+ const callback = handler as (event: MouseEvent) => void
43
+ callback(event)
44
+ }
45
+ }
46
+ }
47
+
48
+ type ButtonTargetProps<T extends ValidComponent = 'button'> = Omit<ButtonProps<T>, 'as'> & {
49
+ actionAs?: T
50
+ }
51
+
52
+ /** Adapter keeps TooltipTrigger's `as` separate from Button's polymorphic `as`. */
53
+ function ButtonTarget<T extends ValidComponent = 'button'>(props: ButtonTargetProps<T>) {
54
+ const [local, rest] = splitProps(props as ButtonTargetProps, ['actionAs'])
55
+ const PolymorphicButton = Button as (targetProps: ButtonProps<T>) => JSX.Element
56
+ return <PolymorphicButton as={(local.actionAs ?? 'button') as T} {...(rest as ButtonProps<T>)} />
57
+ }
58
+
59
+ export function ActionButton<T extends ValidComponent = 'button'>(props: ActionButtonProps<T>) {
60
+ const [local, rest] = splitProps(props as ActionButtonProps, [
61
+ 'as',
62
+ 'variant',
63
+ 'size',
64
+ 'class',
65
+ 'children',
66
+ 'disabled',
67
+ 'aria-busy',
68
+ 'tooltip',
69
+ 'tooltipSide',
70
+ 'busy',
71
+ 'busyLabel',
72
+ ])
73
+ const busy = () => local.busy ?? false
74
+ const disabled = () => busy() || !!local.disabled
75
+ const busyStatus = () => local.busyLabel ?? 'Working'
76
+ const onClickCapture = (rest as { onClickCapture?: unknown }).onClickCapture
77
+ const PolymorphicButton = Button as (buttonProps: ButtonProps<T>) => JSX.Element
78
+ const PolymorphicButtonTarget = ButtonTarget as (targetProps: ButtonTargetProps<T>) => JSX.Element
79
+
80
+ const buttonProps = () => ({
81
+ ...rest,
82
+ variant: local.variant,
83
+ size: local.size,
84
+ class: [local.class, local.tooltip && disabled() && 'aria-disabled:pointer-events-auto']
85
+ .filter(Boolean)
86
+ .join(' '),
87
+ disabled: disabled() && !local.tooltip,
88
+ 'aria-disabled': disabled() ? true : rest['aria-disabled'],
89
+ tabIndex: local.as === 'a' && disabled() ? (local.tooltip ? 0 : -1) : rest.tabIndex,
90
+ role: local.as === 'a' && disabled() ? 'link' : rest.role,
91
+ href: local.as === 'a' && disabled() ? undefined : (rest as { href?: string }).href,
92
+ 'aria-busy': busy() || local['aria-busy'],
93
+ onClick: guardedClick(rest.onClick, disabled),
94
+ onClickCapture: onClickCapture ? guardedClick(onClickCapture, disabled) : undefined,
95
+ 'on:click': guardedClick((rest as Record<string, unknown>)['on:click'], disabled),
96
+ 'oncapture:click': guardedClick((rest as Record<string, unknown>)['oncapture:click'], disabled),
97
+ })
98
+
99
+ const contents = () => (
100
+ <>
101
+ <Show when={busy()}>
102
+ <Spinner size="sm" label={false} aria-hidden="true" data-icon="inline-start" />
103
+ </Show>
104
+ {local.children}
105
+ </>
106
+ )
107
+
108
+ return (
109
+ <>
110
+ <Show
111
+ when={local.tooltip}
112
+ fallback={
113
+ <PolymorphicButton
114
+ as={(local.as ?? 'button') as T}
115
+ {...(buttonProps() as unknown as ButtonProps<T>)}
116
+ >
117
+ {contents()}
118
+ </PolymorphicButton>
119
+ }
120
+ >
121
+ <Tooltip>
122
+ <TooltipTrigger
123
+ as={PolymorphicButtonTarget}
124
+ {...(buttonProps() as unknown as ComponentProps<typeof PolymorphicButtonTarget>)}
125
+ actionAs={(local.as ?? 'button') as T}
126
+ >
127
+ {contents()}
128
+ </TooltipTrigger>
129
+ <TooltipContent side={local.tooltipSide}>{local.tooltip}</TooltipContent>
130
+ </Tooltip>
131
+ </Show>
132
+ <Show when={busy()}>
133
+ <span role="status" aria-live="polite" class="visually-hidden">
134
+ {busyStatus()}
135
+ </span>
136
+ </Show>
137
+ </>
138
+ )
139
+ }
@@ -0,0 +1,2 @@
1
+ /** A shared Button with optional tooltip and busy-state feedback. */
2
+ export { ActionButton, type ActionButtonProps } from './action-button'
@@ -3,6 +3,7 @@ import type { ComponentProps, JSX } from 'solid-js'
3
3
  import { Show, splitProps } from 'solid-js'
4
4
  import { cn } from '#lib/utils'
5
5
  import { Button } from '../ui/button/button'
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip/tooltip'
6
7
 
7
8
  /**
8
9
  * ThreadPanel.
@@ -26,6 +27,8 @@ export type ThreadPanelProps = ComponentProps<'aside'> & {
26
27
  root?: JSX.Element
27
28
  /** The composer for a reply. */
28
29
  composer?: JSX.Element
30
+ /** Host actions, such as marking the thread unread, before the close control. */
31
+ headerActions?: JSX.Element
29
32
  onClose?: () => void
30
33
  }
31
34
 
@@ -36,6 +39,7 @@ export function ThreadPanel(props: ThreadPanelProps) {
36
39
  'count',
37
40
  'root',
38
41
  'composer',
42
+ 'headerActions',
39
43
  'onClose',
40
44
  'children',
41
45
  ])
@@ -45,7 +49,7 @@ export function ThreadPanel(props: ThreadPanelProps) {
45
49
  data-slot="thread-panel"
46
50
  aria-label={`Thread: ${local.label}`}
47
51
  class={cn(
48
- 'bg-surface flex h-full w-96 shrink-0 flex-col border-s border-border',
52
+ 'bg-surface flex h-full w-96 max-w-full shrink-0 flex-col border-s border-border',
49
53
  local.class
50
54
  )}
51
55
  {...rest}
@@ -59,17 +63,23 @@ export function ThreadPanel(props: ThreadPanelProps) {
59
63
  </span>
60
64
  </Show>
61
65
  </div>
62
- <Show when={local.onClose}>
63
- <Button
64
- variant="ghost"
65
- size="icon-sm"
66
- aria-label="Close thread"
67
- class="ms-auto text-muted-foreground"
68
- onClick={() => local.onClose?.()}
69
- >
70
- <X />
71
- </Button>
72
- </Show>
66
+ <div class="ms-auto flex shrink-0 items-center gap-1">
67
+ {local.headerActions}
68
+ <Show when={local.onClose}>
69
+ <Tooltip>
70
+ <TooltipTrigger
71
+ as={Button}
72
+ variant="ghost"
73
+ size="icon-sm"
74
+ aria-label="Close thread"
75
+ onClick={() => local.onClose?.()}
76
+ >
77
+ <X />
78
+ </TooltipTrigger>
79
+ <TooltipContent>Close thread</TooltipContent>
80
+ </Tooltip>
81
+ </Show>
82
+ </div>
73
83
  </header>
74
84
 
75
85
  <Show when={local.root}>
@@ -11,4 +11,5 @@ export {
11
11
  sidebarNavItemStateClass,
12
12
  type SidebarNavItemProps,
13
13
  type SidebarNavProps,
14
+ type SidebarNavTitleProps,
14
15
  } from './sidebar-nav'
@@ -56,13 +56,19 @@ export function SidebarNavHeader(props: ComponentProps<'div'>) {
56
56
  )
57
57
  }
58
58
 
59
- export function SidebarNavTitle(props: ComponentProps<'div'>) {
60
- const [local, rest] = splitProps(props, ['class'])
59
+ export type SidebarNavTitleProps<T extends ValidComponent = 'div'> = PolymorphicProps<
60
+ T,
61
+ { class?: string }
62
+ >
63
+
64
+ export function SidebarNavTitle<T extends ValidComponent = 'div'>(props: SidebarNavTitleProps<T>) {
65
+ const [local, rest] = splitProps(props as SidebarNavTitleProps, ['class'])
61
66
  return (
62
- <div
67
+ <Polymorphic
68
+ as="div"
63
69
  data-slot="sidebar-nav-title"
64
70
  class={cn('truncate text-sm font-semibold tracking-tight', local.class)}
65
- {...rest}
71
+ {...(rest as ComponentProps<'div'>)}
66
72
  />
67
73
  )
68
74
  }
@@ -105,6 +111,8 @@ export function SidebarNavSection(
105
111
  count?: number
106
112
  /** A control at the trailing edge of the heading row. */
107
113
  action?: JSX.Element
114
+ /** Semantic heading element for a host's section hierarchy. */
115
+ headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
108
116
  }
109
117
  ) {
110
118
  const [local, rest] = splitProps(props, [
@@ -114,6 +122,7 @@ export function SidebarNavSection(
114
122
  'defaultOpen',
115
123
  'count',
116
124
  'action',
125
+ 'headingAs',
117
126
  'children',
118
127
  ])
119
128
  const [open, setOpen] = createSignal(local.defaultOpen ?? true)
@@ -138,25 +147,24 @@ export function SidebarNavSection(
138
147
  return (
139
148
  <div data-slot="sidebar-nav-section" class={cn('flex flex-col gap-0.5', local.class)} {...rest}>
140
149
  <div class="group/section-header flex items-center gap-1 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/60">
141
- <Show
142
- when={local.collapsible}
143
- fallback={
144
- <div class="flex min-w-0 flex-1 items-center gap-1.5 text-2xs font-medium tracking-wide text-sidebar-muted-foreground uppercase">
145
- {heading}
146
- </div>
147
- }
150
+ <Polymorphic
151
+ as={local.headingAs ?? 'div'}
152
+ aria-label={local.headingAs ? local.label : undefined}
153
+ class="flex min-w-0 flex-1 items-center gap-1.5 text-2xs font-medium tracking-wide text-sidebar-muted-foreground uppercase"
148
154
  >
149
- <button
150
- type="button"
151
- aria-expanded={open()}
152
- onClick={() => setOpen((value) => !value)}
153
- 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"
154
- >
155
- {heading}
156
- </button>
157
- </Show>
155
+ <Show when={local.collapsible} fallback={heading}>
156
+ <button
157
+ type="button"
158
+ aria-expanded={open()}
159
+ onClick={() => setOpen((value) => !value)}
160
+ 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
+ >
162
+ {heading}
163
+ </button>
164
+ </Show>
165
+ </Polymorphic>
158
166
  <Show when={local.action}>
159
- <span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100">
167
+ <span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
160
168
  {local.action}
161
169
  </span>
162
170
  </Show>