@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
@@ -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>
@@ -1,4 +1,13 @@
1
- import { createMemo, createSignal, For, Show, splitProps, type JSX } from 'solid-js'
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ createSignal,
5
+ For,
6
+ onCleanup,
7
+ Show,
8
+ splitProps,
9
+ type JSX,
10
+ } from 'solid-js'
2
11
  import { cva, type VariantProps } from '../../../lib/variants'
3
12
  import { cn } from '../../../lib/utils'
4
13
 
@@ -88,6 +97,50 @@ export function Board<T>(props: BoardProps<T>) {
88
97
  const [dragging, setDragging] = createSignal<T | null>(null)
89
98
  const [overColumn, setOverColumn] = createSignal<string | null>(null)
90
99
  const [announcement, setAnnouncement] = createSignal('')
100
+ const cardElements = new Map<string, HTMLElement>()
101
+ const [pendingFocus, setPendingFocus] = createSignal<{
102
+ itemId: string
103
+ columnId: string
104
+ source: HTMLElement
105
+ } | null>(null)
106
+ const cancelOnPointer = () => setPendingFocus(null)
107
+
108
+ // A controlled move can settle after a server response. Restore only the
109
+ // focus that belonged to the moved card; another focus action cancels it.
110
+ createEffect(() => {
111
+ const pending = pendingFocus()
112
+ if (!pending) return
113
+ const document = pending.source.ownerDocument
114
+ const cancelOnFocus = (event: FocusEvent) => {
115
+ if (!pending.source.contains(event.target as Node)) setPendingFocus(null)
116
+ }
117
+ document.addEventListener('focusin', cancelOnFocus)
118
+ document.addEventListener('pointerdown', cancelOnPointer)
119
+ onCleanup(() => {
120
+ document.removeEventListener('focusin', cancelOnFocus)
121
+ document.removeEventListener('pointerdown', cancelOnPointer)
122
+ })
123
+ })
124
+
125
+ createEffect(() => {
126
+ const pending = pendingFocus()
127
+ if (!pending) return
128
+ const moved = local.items.find((item) => local.itemId(item) === pending.itemId)
129
+ if (!moved || local.itemColumn(moved) !== pending.columnId) return
130
+ queueMicrotask(() => {
131
+ if (pendingFocus() !== pending) return
132
+ const target = cardElements.get(pending.itemId)
133
+ setPendingFocus(null)
134
+ if (target?.isConnected) target.focus()
135
+ })
136
+ })
137
+
138
+ const prepareFocus = (item: T, columnId: string, source?: HTMLElement) => {
139
+ const card = source ?? cardElements.get(local.itemId(item))
140
+ if (card?.contains(card.ownerDocument.activeElement)) {
141
+ setPendingFocus({ itemId: local.itemId(item), columnId, source: card })
142
+ }
143
+ }
91
144
 
92
145
  const byColumn = createMemo(() => {
93
146
  const grouped = new Map<string, T[]>()
@@ -106,9 +159,11 @@ export function Board<T>(props: BoardProps<T>) {
106
159
  const drop = (column: BoardColumn) => {
107
160
  const item = dragging()
108
161
  const from = item ? local.itemColumn(item) : null
162
+ const accepted = accepts(column)
109
163
  setDragging(null)
110
164
  setOverColumn(null)
111
- if (!item || !from || from === column.id || !accepts(column)) return
165
+ if (!item || !from || from === column.id || !accepted) return
166
+ prepareFocus(item, column.id)
112
167
  local.onMove({ itemId: local.itemId(item), from, to: column.id })
113
168
  }
114
169
 
@@ -117,11 +172,12 @@ export function Board<T>(props: BoardProps<T>) {
117
172
  * thing within its container" that does not collide with scrolling or with the
118
173
  * text caret, and it is the same chord a browser tab or a spreadsheet uses.
119
174
  */
120
- const moveByKeyboard = (item: T, direction: -1 | 1) => {
175
+ const moveByKeyboard = (item: T, direction: -1 | 1, source: HTMLElement) => {
121
176
  const from = local.itemColumn(item)
122
177
  const index = local.columns.findIndex((column) => column.id === from)
123
- const candidates = local.columns.slice(index + direction)
124
- const ordered = direction === 1 ? candidates : candidates.toReversed()
178
+ if (index < 0) return
179
+ const ordered =
180
+ direction === 1 ? local.columns.slice(index + 1) : local.columns.slice(0, index).toReversed()
125
181
  const target = ordered.find(
126
182
  (column) => !column.disabled && local.canDrop(item, from, column.id)
127
183
  )
@@ -129,6 +185,7 @@ export function Board<T>(props: BoardProps<T>) {
129
185
  setAnnouncement(`No column after ${from} accepts this item`)
130
186
  return
131
187
  }
188
+ prepareFocus(item, target.id, source)
132
189
  local.onMove({ itemId: local.itemId(item), from, to: target.id })
133
190
  setAnnouncement(`Moved to ${target.label}`)
134
191
  }
@@ -185,6 +242,13 @@ export function Board<T>(props: BoardProps<T>) {
185
242
  }
186
243
  return (
187
244
  <article
245
+ ref={(element) => {
246
+ const id = local.itemId(item)
247
+ cardElements.set(id, element)
248
+ onCleanup(() => {
249
+ if (cardElements.get(id) === element) cardElements.delete(id)
250
+ })
251
+ }}
188
252
  class={cn(
189
253
  'rounded-md border border-border bg-card text-sm shadow-xs',
190
254
  cardDragState[isDragging() ? 'dragging' : 'idle']
@@ -203,7 +267,11 @@ export function Board<T>(props: BoardProps<T>) {
203
267
  if (!(event.ctrlKey || event.metaKey)) return
204
268
  if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
205
269
  event.preventDefault()
206
- moveByKeyboard(item, event.key === 'ArrowRight' ? 1 : -1)
270
+ moveByKeyboard(
271
+ item,
272
+ event.key === 'ArrowRight' ? 1 : -1,
273
+ event.currentTarget
274
+ )
207
275
  }}
208
276
  >
209
277
  {local.children(item, { dragging: isDragging() })}
@@ -16,6 +16,12 @@ import { createEffect, createSignal, splitProps } from 'solid-js'
16
16
  import { menuItem } from '../../../lib/overlay'
17
17
  import { cn } from '../../../lib/utils'
18
18
 
19
+ // cmdk emits data-disabled="false" on enabled items; Kobalte omits it.
20
+ // Presence selectors would dim and disable every command in an app palette.
21
+ const commandItem = menuItem
22
+ .replace('data-[disabled]:pointer-events-none', '')
23
+ .replace('data-[disabled]:opacity-50', '')
24
+
19
25
  /**
20
26
  * Command.
21
27
  *
@@ -181,8 +187,9 @@ export function CommandItem(props: ComponentProps<typeof CmdkItem>) {
181
187
  return (
182
188
  <CmdkItem
183
189
  class={cn(
184
- menuItem,
185
- 'data-[selected=true]:bg-surface-hover data-[selected=true]:text-foreground',
190
+ commandItem,
191
+ 'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
192
+ 'text-popover-foreground data-[selected=true]:bg-surface-hover data-[selected=true]:text-popover-foreground',
186
193
  local.class
187
194
  )}
188
195
  {...rest}
@@ -205,14 +212,18 @@ export function CommandLoading(props: ComponentProps<typeof CmdkLoading>) {
205
212
  )
206
213
  }
207
214
 
208
- /** The keyboard hint a palette item usually carries. */
215
+ /**
216
+ * The keyboard hint a palette item usually carries.
217
+ * Muted canvas text can fail contrast on a raised or selected command row;
218
+ * hierarchy comes from its size and monospace face, with the row's readable ink.
219
+ */
209
220
  export function CommandShortcut(props: {
210
221
  class?: string
211
222
  children?: ComponentProps<'span'>['children']
212
223
  }) {
213
224
  return (
214
225
  <span
215
- class={cn('text-muted-foreground ms-auto font-mono text-2xs tracking-widest', props.class)}
226
+ class={cn('text-popover-foreground ms-auto font-mono text-2xs tracking-widest', props.class)}
216
227
  >
217
228
  {props.children}
218
229
  </span>
@@ -1 +1 @@
1
- export { Input } from './input'
1
+ export { Input, type InputProps } from './input'