@adea-ai/ui 0.115.1 → 0.117.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 (80) hide show
  1. package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts +6 -0
  2. package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts.map +1 -1
  3. package/dist/components/composites/appearance-editor/accent-swatch-groups.js +13 -6
  4. package/dist/components/composites/appearance-editor/accent-swatch-groups.js.map +1 -1
  5. package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
  6. package/dist/components/composites/appearance-editor/appearance-choices.js +85 -22
  7. package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
  8. package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
  9. package/dist/components/composites/appearance-editor/appearance-editor.js +39 -12
  10. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  11. package/dist/components/composites/appearance-editor/appearance-types.d.ts +26 -0
  12. package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
  13. package/dist/components/conversation/transcript-composition.d.ts +6 -0
  14. package/dist/components/conversation/transcript-composition.d.ts.map +1 -1
  15. package/dist/components/conversation/transcript-composition.js +9 -4
  16. package/dist/components/conversation/transcript-composition.js.map +1 -1
  17. package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts +2 -0
  18. package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts.map +1 -1
  19. package/dist/components/layout/contextual-sidebar/contextual-sidebar.js +3 -0
  20. package/dist/components/layout/contextual-sidebar/contextual-sidebar.js.map +1 -1
  21. package/dist/components/layout/contextual-sidebar/mobile-contextual-sidebar.d.ts.map +1 -1
  22. package/dist/components/layout/contextual-sidebar/mobile-contextual-sidebar.js +1 -1
  23. package/dist/components/layout/contextual-sidebar/mobile-contextual-sidebar.js.map +1 -1
  24. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts +2 -0
  25. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts.map +1 -1
  26. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js +3 -1
  27. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js.map +1 -1
  28. package/dist/components/ui/command/command.d.ts.map +1 -1
  29. package/dist/components/ui/command/command.js +1 -1
  30. package/dist/components/ui/command/command.js.map +1 -1
  31. package/dist/components/ui/resizable/resizable.d.ts +7 -2
  32. package/dist/components/ui/resizable/resizable.d.ts.map +1 -1
  33. package/dist/components/ui/resizable/resizable.js +7 -6
  34. package/dist/components/ui/resizable/resizable.js.map +1 -1
  35. package/dist/components/ui/tabs/tabs.js +1 -1
  36. package/dist/components/ui/tabs/tabs.js.map +1 -1
  37. package/dist/components/ui/toggle/toggle.d.ts +4 -1
  38. package/dist/components/ui/toggle/toggle.d.ts.map +1 -1
  39. package/dist/components/ui/toggle/toggle.js +5 -2
  40. package/dist/components/ui/toggle/toggle.js.map +1 -1
  41. package/dist/components/ui/tooltip/tooltip.d.ts.map +1 -1
  42. package/dist/components/ui/tooltip/tooltip.js +5 -0
  43. package/dist/components/ui/tooltip/tooltip.js.map +1 -1
  44. package/dist/lib/tooltip-focus-gate.d.ts +29 -0
  45. package/dist/lib/tooltip-focus-gate.d.ts.map +1 -0
  46. package/dist/lib/tooltip-focus-gate.js +134 -0
  47. package/dist/lib/tooltip-focus-gate.js.map +1 -0
  48. package/dist/r/appearance-editor.json +1 -0
  49. package/dist/r/lib.json +5 -0
  50. package/dist/r/registry.json +6 -0
  51. package/dist/r/src/components/composites/appearance-editor/accent-swatch-groups.tsx +10 -2
  52. package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +55 -17
  53. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +25 -8
  54. package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +26 -0
  55. package/dist/r/src/components/conversation/transcript-composition.tsx +14 -2
  56. package/dist/r/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +3 -0
  57. package/dist/r/src/components/layout/contextual-sidebar/mobile-contextual-sidebar.tsx +6 -1
  58. package/dist/r/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +3 -1
  59. package/dist/r/src/components/ui/command/command.tsx +5 -1
  60. package/dist/r/src/components/ui/resizable/resizable.tsx +15 -4
  61. package/dist/r/src/components/ui/tabs/tabs.tsx +1 -1
  62. package/dist/r/src/components/ui/toggle/toggle.tsx +5 -2
  63. package/dist/r/src/components/ui/tooltip/tooltip.tsx +10 -0
  64. package/dist/r/src/lib/tooltip-focus-gate.ts +218 -0
  65. package/package.json +1 -1
  66. package/registry.json +6 -0
  67. package/src/components/composites/appearance-editor/accent-swatch-groups.tsx +10 -2
  68. package/src/components/composites/appearance-editor/appearance-choices.tsx +55 -17
  69. package/src/components/composites/appearance-editor/appearance-editor.tsx +25 -8
  70. package/src/components/composites/appearance-editor/appearance-types.ts +26 -0
  71. package/src/components/conversation/transcript-composition.tsx +14 -2
  72. package/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +3 -0
  73. package/src/components/layout/contextual-sidebar/mobile-contextual-sidebar.tsx +6 -1
  74. package/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +3 -1
  75. package/src/components/ui/command/command.tsx +5 -1
  76. package/src/components/ui/resizable/resizable.tsx +15 -4
  77. package/src/components/ui/tabs/tabs.tsx +1 -1
  78. package/src/components/ui/toggle/toggle.tsx +5 -2
  79. package/src/components/ui/tooltip/tooltip.tsx +10 -0
  80. package/src/lib/tooltip-focus-gate.ts +218 -0
@@ -105,6 +105,13 @@ export function AccentChoices(props: AppearanceEditorProps) {
105
105
  const resolvedAppearance = () =>
106
106
  props.draft.mode === 'system' ? props.resolvedAppearance : props.draft.mode
107
107
  const accentSelection = () => (isCustomAccent(props) ? 'custom' : props.draft.accent)
108
+ const showThemeDefault = () => props.showThemeDefaultAccent ?? true
109
+ // The named-chip style feeds the same resolved preset list to both entries:
110
+ // "Theme default" leads only when the host has not folded it into a preset.
111
+ const labelEntries = () => {
112
+ const presets = props.accentOptions.filter((option) => option.id !== 'theme')
113
+ return showThemeDefault() ? [{ id: 'theme', label: 'Theme default' }, ...presets] : presets
114
+ }
108
115
  return (
109
116
  <Radio
110
117
  value={accentSelection()}
@@ -118,24 +125,55 @@ export function AccentChoices(props: AppearanceEditorProps) {
118
125
  })
119
126
  }
120
127
  >
121
- <AccentSwatchGroups
122
- accentOptions={props.accentOptions}
123
- resolvedAppearance={resolvedAppearance()}
124
- themeDefaultColor={
125
- resolvedAppearance() === 'light'
126
- ? props.lightTheme.colors.accent
127
- : props.darkTheme.colors.accent
128
+ <Show
129
+ when={(props.accentEntryStyle ?? 'swatches') === 'labels'}
130
+ fallback={
131
+ <>
132
+ <AccentSwatchGroups
133
+ accentOptions={props.accentOptions}
134
+ resolvedAppearance={resolvedAppearance()}
135
+ showThemeDefault={showThemeDefault()}
136
+ themeDefaultColor={
137
+ resolvedAppearance() === 'light'
138
+ ? props.lightTheme.colors.accent
139
+ : props.darkTheme.colors.accent
140
+ }
141
+ />
142
+ <div class="flex flex-col gap-1">
143
+ <Show when={props.showCustomAccentCaption ?? true}>
144
+ <span class="text-xs font-medium text-muted-foreground">Custom</span>
145
+ </Show>
146
+ <Radio.Item value="custom">
147
+ <Radio.ItemInput />
148
+ <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
149
+ Custom
150
+ </Radio.ItemLabel>
151
+ </Radio.Item>
152
+ </div>
153
+ </>
128
154
  }
129
- />
130
- <div class="flex flex-col gap-1">
131
- <span class="text-xs font-medium text-muted-foreground">Custom</span>
132
- <Radio.Item value="custom">
133
- <Radio.ItemInput />
134
- <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
135
- Custom
136
- </Radio.ItemLabel>
137
- </Radio.Item>
138
- </div>
155
+ >
156
+ {/* The named-chip style: one grid, every choice a labelled control,
157
+ including the custom colour — nothing to caption from above. */}
158
+ <div class="grid grid-cols-2 gap-2 sm:grid-cols-3" data-accent-grid>
159
+ <For each={labelEntries()}>
160
+ {(option) => (
161
+ <Radio.Item value={option.id} class="min-w-0" data-accent-option={option.id}>
162
+ <Radio.ItemInput />
163
+ <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
164
+ {option.label}
165
+ </Radio.ItemLabel>
166
+ </Radio.Item>
167
+ )}
168
+ </For>
169
+ <Radio.Item value="custom" class="min-w-0">
170
+ <Radio.ItemInput />
171
+ <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
172
+ Custom
173
+ </Radio.ItemLabel>
174
+ </Radio.Item>
175
+ </div>
176
+ </Show>
139
177
  </Radio>
140
178
  )
141
179
  }
@@ -34,6 +34,7 @@
34
34
  import { EyeOff, Palette, PanelsTopLeft, SlidersHorizontal } from 'lucide-solid'
35
35
  import { Show, createSignal, createUniqueId } from 'solid-js'
36
36
  import { cn } from '../../../lib/utils'
37
+ import { ActionButton } from '../action-button'
37
38
  import { Button } from '../../ui/button/button'
38
39
  import { Input } from '../../ui/input/input'
39
40
  import { AppearanceFontSettingsGroup } from './font-settings-group'
@@ -144,12 +145,17 @@ export function AppearanceEditor(props: AppearanceEditorProps) {
144
145
  </Show>
145
146
  </div>
146
147
  </Show>
147
- <AppearanceFontSettingsGroup
148
- settings={props.draft.fonts ?? DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS}
149
- menuPortalMount={props.menuPortalMount}
150
- disabled={props.saving}
151
- onChange={(fonts) => props.onChange({ fonts })}
152
- />
148
+ {/* Inline, the typefaces read as one more palette choice; `end` hands
149
+ the closing slot to them for hosts whose order is palette and
150
+ surface first, text last. */}
151
+ <Show when={props.fontSettingsPlacement !== 'end'}>
152
+ <AppearanceFontSettingsGroup
153
+ settings={props.draft.fonts ?? DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS}
154
+ menuPortalMount={props.menuPortalMount}
155
+ disabled={props.saving}
156
+ onChange={(fonts) => props.onChange({ fonts })}
157
+ />
158
+ </Show>
153
159
  <SettingsRow title="Glass" icon={<PanelsTopLeft />} description={glassDescription()}>
154
160
  <GlassChoices {...props} />
155
161
  </SettingsRow>
@@ -189,6 +195,14 @@ export function AppearanceEditor(props: AppearanceEditorProps) {
189
195
  Manage themes
190
196
  </Button>
191
197
  </SettingsRow>
198
+ <Show when={props.fontSettingsPlacement === 'end'}>
199
+ <AppearanceFontSettingsGroup
200
+ settings={props.draft.fonts ?? DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS}
201
+ menuPortalMount={props.menuPortalMount}
202
+ disabled={props.saving}
203
+ onChange={(fonts) => props.onChange({ fonts })}
204
+ />
205
+ </Show>
192
206
  </div>
193
207
  <Show when={!props.hideActions}>
194
208
  {/* Sticky so Save/Cancel stay reachable in a tall scroll container: the
@@ -262,11 +276,14 @@ export function AppearancePopover(props: AppearancePopoverProps) {
262
276
 
263
277
  return (
264
278
  <Sheet open={props.open} onOpenChange={(open) => (open ? props.onOpen() : props.onDismiss())}>
279
+ {/* The trigger is an icon-only action, so it explains itself through the
280
+ shared ActionButton's tooltip (keyboard focus included) rather than a
281
+ bare aria-label. */}
265
282
  <SheetTrigger
266
- as={Button}
267
- type="button"
283
+ as={ActionButton}
268
284
  variant="ghost"
269
285
  size="icon-sm"
286
+ tooltip="Open appearance settings"
270
287
  aria-label="Appearance settings"
271
288
  >
272
289
  <Palette />
@@ -57,6 +57,32 @@ export type AppearanceEditorProps = {
57
57
  class?: string
58
58
  /** Leave Reset/Cancel/Save to the host, which renders `AppearanceEditorActions`. */
59
59
  hideActions?: boolean
60
+ /**
61
+ * Where the typeface settings sit in the option list. `inline` — the default
62
+ * — renders them between the accent and glass rows, where they read as one
63
+ * more palette choice. `end` closes the list with them: hosts whose option
64
+ * order is palette-and-surface first, text last, pass `end` instead of
65
+ * recomposing the editor.
66
+ */
67
+ fontSettingsPlacement?: 'inline' | 'end'
68
+ /**
69
+ * How the accent row renders its choices. `swatches` — the default — paints
70
+ * each choice as a colour circle. `labels` renders every choice as a named
71
+ * chip, for hosts whose accent contract is textual rather than chromatic.
72
+ */
73
+ accentEntryStyle?: 'swatches' | 'labels'
74
+ /**
75
+ * Whether the accent row leads with a "Theme default" swatch. Default on.
76
+ * Hosts whose default accent already is one of the catalogue presets pass
77
+ * `false`: the extra entry would duplicate the preset's own colour.
78
+ */
79
+ showThemeDefaultAccent?: boolean
80
+ /**
81
+ * Whether the swatch accent style captions its custom-colour entry. Default
82
+ * on. `false` leaves the custom chip to name itself — for hosts where the
83
+ * caption reads as a second "Custom" label on the control.
84
+ */
85
+ showCustomAccentCaption?: boolean
60
86
  }
61
87
 
62
88
  export type AppearancePopoverProps = AppearanceEditorProps & {
@@ -87,6 +87,12 @@ export type TranscriptCompositionProps<T> = Omit<ComponentProps<'div'>, 'childre
87
87
  /** Optional controlled disclosure map keyed by `transcriptDisclosureKey`. */
88
88
  disclosure?: ReadonlyMap<string, boolean>
89
89
  onDisclosureChange?: (key: string, expanded: boolean) => void
90
+ /**
91
+ * Row rhythm. `compact` (default) packs rows 4px apart; `comfortable` spaces
92
+ * them 12px apart and stacks each row's rendered parts, for a chat stream
93
+ * whose rows are separate messages rather than one running log.
94
+ */
95
+ density?: 'compact' | 'comfortable'
90
96
  }
91
97
 
92
98
  type TurnPresentation = NonNullable<TranscriptCompositionRowContext['turn']>
@@ -388,6 +394,7 @@ function TranscriptRowEntry<T>(props: {
388
394
  <div
389
395
  id={id()}
390
396
  data-slot="transcript-row-content"
397
+ class="group-data-[density=comfortable]/transcript:flex group-data-[density=comfortable]/transcript:flex-col"
391
398
  hidden={collapsed()}
392
399
  aria-hidden={collapsed() || undefined}
393
400
  >
@@ -418,6 +425,7 @@ export function TranscriptComposition<T>(props: TranscriptCompositionProps<T>) {
418
425
  'foldMode',
419
426
  'foldState',
420
427
  'onFoldStateChange',
428
+ 'density',
421
429
  'disclosure',
422
430
  'onDisclosureChange',
423
431
  ])
@@ -512,7 +520,8 @@ export function TranscriptComposition<T>(props: TranscriptCompositionProps<T>) {
512
520
  <div
513
521
  data-slot="transcript-composition"
514
522
  data-fold-state={foldState()}
515
- class={cn('flex w-full min-w-0 flex-col gap-1', local.class)}
523
+ data-density={local.density ?? 'compact'}
524
+ class={cn('group/transcript flex w-full min-w-0 flex-col gap-1', local.class)}
516
525
  {...rest}
517
526
  >
518
527
  <Show when={local.onFoldStateChange}>
@@ -534,7 +543,10 @@ export function TranscriptComposition<T>(props: TranscriptCompositionProps<T>) {
534
543
  </Button>
535
544
  </div>
536
545
  </Show>
537
- <div data-slot="transcript-rows" class="flex min-w-0 flex-col gap-1">
546
+ <div
547
+ data-slot="transcript-rows"
548
+ class={cn('flex min-w-0 flex-col', local.density === 'comfortable' ? 'gap-3' : 'gap-1')}
549
+ >
538
550
  <For each={handles()}>
539
551
  {(handle) => (
540
552
  <TranscriptRowEntry
@@ -45,6 +45,8 @@ export type ContextualSidebarProps = {
45
45
  /** Pass the host's initial viewport seed to distinguish stale desktop state. */
46
46
  wideViewportAtLoad?: boolean
47
47
  resizeLabel?: string
48
+ /** The resize edge's grip: the default bordered chip, or the quieter rung. */
49
+ resizeGrip?: 'chip' | 'rung'
48
50
  /** Stable external opener used when the modal mobile sheet closes. */
49
51
  restoreFocusRef?: Accessor<HTMLElement | undefined>
50
52
  /** Called with the nav node so a host can project width onto its layout root. */
@@ -173,6 +175,7 @@ export function ContextualSidebar(props: ContextualSidebarProps) {
173
175
  maximum={props.maximum}
174
176
  step={props.step}
175
177
  label={resizeLabel()}
178
+ grip={props.resizeGrip}
176
179
  controls={navigationId}
177
180
  onChange={props.onWidthChange}
178
181
  onCommit={props.onWidthCommit}
@@ -18,7 +18,12 @@ export function MobileSidebar(props: MobileContextualSidebarProps) {
18
18
  <SheetContent
19
19
  side="start"
20
20
  aria-label={props.label}
21
- class={cn('gap-0 p-0', props.sheetClass)}
21
+ // The sheet leaves the app rail visible beside it: at most 19rem, never
22
+ // under 9rem, and otherwise the viewport less the rail's width.
23
+ class={cn(
24
+ 'w-[min(19rem,max(9rem,calc(100vw-var(--rail-width,3.5rem))))] max-w-[min(19rem,max(9rem,calc(100vw-var(--rail-width,3.5rem))))] gap-0 p-0',
25
+ props.sheetClass
26
+ )}
22
27
  restoreFocusRef={props.restoreFocusRef}
23
28
  >
24
29
  {props.renderSidebar()}
@@ -13,6 +13,8 @@ export type PixelResizeHandleProps = {
13
13
  label?: string
14
14
  controls?: string
15
15
  class?: string
16
+ /** The grip drawn on the edge: the default bordered chip, or the quieter rung. */
17
+ grip?: 'chip' | 'rung'
16
18
  onChange(value: number): void
17
19
  onCommit?(value: number): void
18
20
  }
@@ -93,7 +95,7 @@ export function PixelResizeHandle(props: PixelResizeHandleProps) {
93
95
  aria-hidden="true"
94
96
  />
95
97
  <ResizableHandle
96
- withHandle
98
+ withHandle={props.grip === 'rung' ? 'rung' : true}
97
99
  label={props.label ?? `Resize ${props.side} pane`}
98
100
  aria-controls={props.controls}
99
101
  aria-valuemin={minimum()}
@@ -243,7 +243,11 @@ export function CommandItem(props: ComponentProps<typeof CmdkItem>) {
243
243
  class={cn(
244
244
  commandItem,
245
245
  'data-[disabled=true]:pointer-events-none data-[disabled=true]:text-muted-foreground',
246
- 'group/command-item text-popover-foreground data-[selected=true]:bg-card data-[selected=true]:text-popover-foreground',
246
+ // The selected row sits on the accent rung — the same primary-subtle
247
+ // fill the settings navigation's selected row owns — instead of the
248
+ // popover rung, so what is chosen keeps the accent the checked menus
249
+ // already carry in their indicators.
250
+ 'group/command-item text-popover-foreground data-[selected=true]:bg-primary-subtle data-[selected=true]:text-foreground',
247
251
  local.class
248
252
  )}
249
253
  {...rest}
@@ -70,8 +70,13 @@ export function ResizablePanel(props: ComponentProps<typeof Resizable.Panel> & {
70
70
 
71
71
  export type ResizableHandleProps = ComponentProps<typeof Resizable.Handle> & {
72
72
  class?: string
73
- /** Draw a visible grip in the middle of the divider. */
74
- withHandle?: boolean
73
+ /**
74
+ * Draw a visible grip in the middle of the divider: `true` is the bordered
75
+ * chip with its grip icon; `'rung'` is a quieter translucent bar with no
76
+ * icon, for edges that should read as barely there. Either way the divider
77
+ * keeps its full 24px hit target.
78
+ */
79
+ withHandle?: boolean | 'rung'
75
80
  /**
76
81
  * The divider's accessible name. It is a focusable control — arrow keys move
77
82
  * the split — so it needs one; without it a screen reader announces an
@@ -97,7 +102,8 @@ export function ResizableHandle(props: ResizableHandleProps) {
97
102
  a hairline, but a hairline is far too small to grab. 24px on the
98
103
  drag axis meets WCAG 2.5.8's minimum target. */
99
104
  'after:absolute after:inset-y-0 after:-inset-x-3 after:w-6',
100
- 'transition-colors ease-out hover:bg-primary',
105
+ 'transition-colors ease-out',
106
+ local.withHandle === 'rung' ? 'hover:bg-primary/45' : 'hover:bg-primary',
101
107
  'focus-visible:bg-primary focus-visible:outline-none',
102
108
  'data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full',
103
109
  'data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-3 data-[orientation=vertical]:after:h-6 data-[orientation=vertical]:after:w-full',
@@ -105,7 +111,12 @@ export function ResizableHandle(props: ResizableHandleProps) {
105
111
  )}
106
112
  {...rest}
107
113
  >
108
- {local.withHandle ? (
114
+ {local.withHandle === 'rung' ? (
115
+ <div
116
+ data-slot="resizable-grip"
117
+ class="z-(--z-docked) h-11 w-1.5 flex-none rounded-full bg-border/55 group-data-[orientation=vertical]/resize:h-1.5 group-data-[orientation=vertical]/resize:w-11"
118
+ />
119
+ ) : local.withHandle ? (
109
120
  <div class="bg-border z-(--z-docked) flex h-4 w-2.5 items-center justify-center rounded-sm border border-border">
110
121
  <GripVertical class="size-2.5 text-muted-foreground" />
111
122
  </div>
@@ -142,7 +142,7 @@ export function TabsTrigger(props: TabsTriggerProps) {
142
142
  'text-foreground',
143
143
  '[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
144
144
  local.appearance === 'segmented'
145
- ? 'h-control-sm rounded-md px-control-sm data-[selected]:bg-card data-[selected]:shadow-xs'
145
+ ? 'h-control-sm rounded-md px-control-sm data-[selected]:bg-primary-subtle data-[selected]:text-foreground'
146
146
  : tabs.orientation() === 'vertical'
147
147
  ? 'h-control-md justify-start rounded-none border-s-2 border-transparent ps-3 pe-2 data-[selected]:border-primary'
148
148
  : 'h-control-md -mb-px rounded-none border-b-2 border-transparent px-2 pb-2 data-[selected]:border-primary',
@@ -14,10 +14,13 @@ import { cn } from '../../../lib/utils'
14
14
  *
15
15
  * Kobalte supplies the pressed state and the keyboard behaviour; the styling
16
16
  * adds the one thing a headless primitive cannot know — that a pressed toggle
17
- * in a toolbar should read as *filled*, not merely outlined.
17
+ * in a toolbar should read as *filled*, not merely outlined. Pressed reads on
18
+ * the accent rung (`primary-subtle` with the accent glyph): pressed is the
19
+ * control's on state, the same family the checked checkbox and switch own, so
20
+ * it keeps a touch of the user's accent instead of a neutral fill.
18
21
  */
19
22
  export const toggleVariants = cva(
20
- `${controlInteractive} inline-flex items-center justify-center rounded-md font-medium whitespace-nowrap select-none hover:bg-surface-hover hover:text-foreground data-[pressed]:bg-surface-active data-[pressed]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0`,
23
+ `${controlInteractive} inline-flex items-center justify-center rounded-md font-medium whitespace-nowrap select-none hover:bg-surface-hover hover:text-foreground data-[pressed]:bg-primary-subtle data-[pressed]:text-primary disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0`,
21
24
  {
22
25
  variants: {
23
26
  variant: {
@@ -12,6 +12,7 @@ import {
12
12
  } from 'solid-js'
13
13
  import { isServer } from 'solid-js/web'
14
14
  import { tooltipTip, topLayerProps } from '../../../lib/overlay'
15
+ import { acquireTooltipFocusGate, releaseTooltipFocusGate } from '../../../lib/tooltip-focus-gate'
15
16
  import { cn } from '../../../lib/utils'
16
17
 
17
18
  /**
@@ -161,6 +162,15 @@ export function TooltipProvider(props: TooltipProviderProps) {
161
162
  export type TooltipRootProps = ComponentProps<typeof KobalteTooltip>
162
163
 
163
164
  export function Tooltip(props: TooltipRootProps) {
165
+ // One document-level focus gate for every tooltip in the document: the first
166
+ // tooltip acquires it, the last to unmount releases it. This is what keeps a
167
+ // dialog's autofocus and an overlay's focus restoration from opening a tip —
168
+ // see #lib/tooltip-focus-gate for the contract and the two over-reach traps
169
+ // (pointer gestures and separator resize grips) it must not swallow.
170
+ if (!isServer) {
171
+ acquireTooltipFocusGate()
172
+ onCleanup(releaseTooltipFocusGate)
173
+ }
164
174
  const timing = useContext(TooltipTimingContext)
165
175
  const [local, rest] = splitProps(props, ['open', 'defaultOpen', 'onOpenChange', 'disabled'])
166
176
  const id = createUniqueId()
@@ -0,0 +1,218 @@
1
+ /*
2
+ * Tooltips open on active hover over the control, or on keyboard focus that
3
+ * follows a Tab key press. They must never open from programmatic focus: the
4
+ * autofocus a dialog or sheet gives its first control, and the focus a closing
5
+ * overlay restores to its opener, both land on buttons that carry tooltips —
6
+ * and the tooltip primitive opens immediately on focus and pins the tooltip
7
+ * until blur. That is the "tooltip auto-displays when a sheet opens" report,
8
+ * and the pinned tooltip is the "tooltip gets stuck and never disappears"
9
+ * report: the pointer is nowhere near the control, so no pointer leave ever
10
+ * closes it.
11
+ *
12
+ * The trigger listens for the native `focus` event on the trigger element, and
13
+ * composites build tooltips internally (ActionButton renders one from its
14
+ * `tooltip` prop), so the single interception point that covers every tooltip
15
+ * is a capture-phase `focus` listener on `document`. When focus lands without a
16
+ * keyboard-intent signal on a button-like element, the event is stopped before
17
+ * any element listener runs: the tooltip never learns about the focus. Hover
18
+ * opens are untouched (they ride pointer events), and the separate `focusin`
19
+ * event still flows, so dialog focus-cycle tracking, `:focus-visible` styling,
20
+ * and focus restoration all keep working. Focus still lands — only the event
21
+ * is stopped — so `document.activeElement` and scripted `.blur()` behave.
22
+ *
23
+ * The keyboard-intent signal is a Tab key press immediately before the focus —
24
+ * the only key that moves focus natively. Enter and Space open overlays too,
25
+ * so any "recent key" window wide enough for those would let an overlay's own
26
+ * autofocus back through; Tab-only keeps autofocus suppressed no matter how
27
+ * the overlay was opened, while normal Tab navigation still announces each
28
+ * control's tooltip (hover descriptions stay reachable from the keyboard).
29
+ *
30
+ * Suppression is scoped to button-like targets (`button`, `a[href]`,
31
+ * `[role="button"]`, `[role="link"]`). Composite widgets that move focus
32
+ * programmatically — menu items, listbox options, grid cells — are other
33
+ * element types whose roving-focus highlighting must keep receiving focus
34
+ * events, and text controls keep their own focus behaviour (select on focus,
35
+ * caret placement).
36
+ *
37
+ * Suppressed focus is real as far as the DOM is concerned (`activeElement`
38
+ * moves; only the event was stopped), and the tooltip interaction model treats
39
+ * a trigger that is `activeElement` as keyboard-held: while a tooltip on it is
40
+ * open, pointer movement away only *requests* a close and the request is
41
+ * refused for as long as the pointer keeps moving. Left alone, a later real
42
+ * hover on that control would open the tip and never let it leave — the
43
+ * residual "stuck" case. The gate therefore treats a suppressed focus as a
44
+ * phantom and releases it: the first pointer activity after the focus (a
45
+ * pointer session taking over from a script) blurs the element. Keyboard
46
+ * sessions never release it and never need to — keyboard focus moves by Tab,
47
+ * which is never suppressed, so nothing pins; and the release only runs for
48
+ * elements still holding `activeElement`, so a Tab already taken from the
49
+ * phantom is left alone.
50
+ *
51
+ * Focus that arrives while a pointer button is held belongs to a pointer
52
+ * gesture, not to a tooltip: the pixel resize handle focuses its handle when a
53
+ * drag starts and cancels the drag on blur, so a gesture focus must pass
54
+ * through untouched (no suppression, no phantom, no release). A gesture's own
55
+ * click behaviour already keeps tooltips closed, and the autofocus cases this
56
+ * gate exists for all happen with the pointer up.
57
+ *
58
+ * Independently of the gesture window, resize grips render as
59
+ * `<button role="separator">` and carry no tooltip, so they are excluded from
60
+ * the button-like scope entirely (see `isButtonLikeFocusTarget`).
61
+ *
62
+ * The gate is installed by the `Tooltip` root for the lifetime of the first
63
+ * tooltip in a document and removed when the last one unmounts, so every
64
+ * consumer of the primitive — including composites that build their own
65
+ * tooltips — inherits the contract without wiring of its own.
66
+ */
67
+
68
+ /** How long after a Tab key press a resulting focus event still counts as
69
+ * keyboard navigation. The native focus move follows the key press
70
+ * immediately; the window only has to bridge that dispatch, not a later
71
+ * script-driven focus. */
72
+ export const TOOLTIP_FOCUS_INTENT_WINDOW_MS = 250
73
+
74
+ /** Elements a tooltip-bearing control is rendered as. Everything else keeps
75
+ * its native focus behaviour, and so do resize grips (see below). */
76
+ const BUTTON_LIKE_SELECTOR = 'button, a[href], [role="button"], [role="link"]'
77
+
78
+ /**
79
+ * Whether a focus event landing on this target could open a tooltip on a
80
+ * button-like control. Accepts any Element (an SVG glyph inside a control,
81
+ * for instance) and duck-types so the decision is testable without a DOM.
82
+ */
83
+ export function isButtonLikeFocusTarget(target: unknown): boolean {
84
+ const element = target as Element | null | undefined
85
+ if (typeof element?.closest !== 'function') return false
86
+ const control = element.closest(BUTTON_LIKE_SELECTOR)
87
+ if (control === null) return false
88
+ // Resize grips render as `<button role="separator">` and focus themselves
89
+ // when a pointer drag starts. They carry no tooltip, and the blur that
90
+ // releases a suppressed focus would end the drag on its first move.
91
+ return control.getAttribute?.('role') !== 'separator'
92
+ }
93
+
94
+ /**
95
+ * The suppression decision: a button-like focus target hides a tooltip only
96
+ * when no keyboard navigation is in flight. Anything else — a Tab-driven
97
+ * focus, or focus on a non-button element — passes through untouched.
98
+ */
99
+ export function shouldSuppressTooltipFocus(input: {
100
+ isButtonLike: boolean
101
+ keyboardIntentActive: boolean
102
+ }): boolean {
103
+ return input.isButtonLike && !input.keyboardIntentActive
104
+ }
105
+
106
+ const installedDocuments = new WeakSet<Document>()
107
+
108
+ /**
109
+ * Install the gate on a document. Idempotent per document; returns a disposer
110
+ * that removes the listeners (used by tests and by the tooltip root, which
111
+ * releases the gate when its document's last tooltip unmounts).
112
+ */
113
+ export function installTooltipFocusGate(doc: Document = document): () => void {
114
+ if (installedDocuments.has(doc)) return () => {}
115
+
116
+ let lastTabKeyDownAt = 0
117
+ /** True between a pointerdown and its pointerup/pointercancel: focus events
118
+ * inside that window belong to a pointer gesture (drag bootstrap, press)
119
+ * and must reach their element untouched. */
120
+ let pointerGestureActive = false
121
+ /** The element holding a suppressed programmatic focus, if it still holds
122
+ * `activeElement`. One slot: a newer suppressed focus replaces an older
123
+ * one, and anything that already lost focus needs no release. */
124
+ let phantomFocusTarget: Element | undefined
125
+ /** The phantom-release `pointermove` listener exists only while a phantom
126
+ * does: a gate that parks a permanent pointer listener on every document
127
+ * with a tooltip in it costs every page a standing listener for a state
128
+ * that is live for a few hundred milliseconds at most. */
129
+ let phantomReleaseAttached = false
130
+ const onKeyDown = (event: KeyboardEvent) => {
131
+ if (event.key === 'Tab') lastTabKeyDownAt = Date.now()
132
+ }
133
+ const onPointerDown = () => {
134
+ pointerGestureActive = true
135
+ releasePhantomFocus()
136
+ }
137
+ const onPointerGestureEnd = () => {
138
+ pointerGestureActive = false
139
+ }
140
+ const dropPhantom = () => {
141
+ phantomFocusTarget = undefined
142
+ if (phantomReleaseAttached) {
143
+ phantomReleaseAttached = false
144
+ doc.removeEventListener('pointermove', onPointerMove, true)
145
+ }
146
+ }
147
+ const onFocus = (event: FocusEvent) => {
148
+ // Focus that moves on retires the phantom: there is nothing left to
149
+ // release, so the parked pointer listener goes with it.
150
+ if (phantomFocusTarget && event.target !== phantomFocusTarget) dropPhantom()
151
+ if (pointerGestureActive) return
152
+ if (!isButtonLikeFocusTarget(event.target)) return
153
+ const keyboardIntentActive = Date.now() - lastTabKeyDownAt <= TOOLTIP_FOCUS_INTENT_WINDOW_MS
154
+ if (!shouldSuppressTooltipFocus({ isButtonLike: true, keyboardIntentActive })) return
155
+ event.stopPropagation()
156
+ phantomFocusTarget = event.target as Element
157
+ if (!phantomReleaseAttached) {
158
+ phantomReleaseAttached = true
159
+ doc.addEventListener('pointermove', onPointerMove, true)
160
+ }
161
+ }
162
+ const releasePhantomFocus = () => {
163
+ if (pointerGestureActive) return
164
+ if (!phantomFocusTarget) return
165
+ const phantom = phantomFocusTarget
166
+ dropPhantom()
167
+ if ((doc as Document).activeElement !== phantom) return
168
+ if (typeof (phantom as HTMLElement).blur === 'function') (phantom as HTMLElement).blur()
169
+ }
170
+ const onPointerMove = () => releasePhantomFocus()
171
+
172
+ doc.addEventListener('keydown', onKeyDown, true)
173
+ doc.addEventListener('focus', onFocus, true)
174
+ doc.addEventListener('pointerdown', onPointerDown, true)
175
+ doc.addEventListener('pointerup', onPointerGestureEnd, true)
176
+ doc.addEventListener('pointercancel', onPointerGestureEnd, true)
177
+ installedDocuments.add(doc)
178
+ return () => {
179
+ doc.removeEventListener('keydown', onKeyDown, true)
180
+ doc.removeEventListener('focus', onFocus, true)
181
+ doc.removeEventListener('pointerdown', onPointerDown, true)
182
+ doc.removeEventListener('pointerup', onPointerGestureEnd, true)
183
+ doc.removeEventListener('pointercancel', onPointerGestureEnd, true)
184
+ doc.removeEventListener('pointermove', onPointerMove, true)
185
+ installedDocuments.delete(doc)
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Reference-counted install for the tooltip root: the first tooltip in a
191
+ * document acquires the gate, the last one to unmount releases it. The document
192
+ * parameter exists so tests can drive the counter without a global DOM; the
193
+ * production call site guards on the server render before calling.
194
+ */
195
+ const gateHolders = new WeakMap<Document, number>()
196
+ const gateDisposers = new WeakMap<Document, () => void>()
197
+
198
+ export function acquireTooltipFocusGate(targetDoc?: Document): void {
199
+ if (typeof document === 'undefined' && !targetDoc) return
200
+ const doc = targetDoc ?? document
201
+ const holders = gateHolders.get(doc) ?? 0
202
+ gateHolders.set(doc, holders + 1)
203
+ if (holders === 0) gateDisposers.set(doc, installTooltipFocusGate(doc))
204
+ }
205
+
206
+ export function releaseTooltipFocusGate(targetDoc?: Document): void {
207
+ if (typeof document === 'undefined' && !targetDoc) return
208
+ const doc = targetDoc ?? document
209
+ const holders = gateHolders.get(doc) ?? 0
210
+ if (holders === 0) return
211
+ if (holders === 1) {
212
+ gateHolders.delete(doc)
213
+ gateDisposers.get(doc)?.()
214
+ gateDisposers.delete(doc)
215
+ return
216
+ }
217
+ gateHolders.set(doc, holders - 1)
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.115.1",
3
+ "version": "0.117.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
@@ -1998,6 +1998,7 @@
1998
1998
  "lucide-solid@^1.51.0"
1999
1999
  ],
2000
2000
  "registryDependencies": [
2001
+ "@adea-ai/ui/action-button",
2001
2002
  "@adea-ai/ui/button",
2002
2003
  "@adea-ai/ui/dropdown-menu",
2003
2004
  "@adea-ai/ui/input",
@@ -2639,6 +2640,11 @@
2639
2640
  "type": "registry:lib",
2640
2641
  "target": "lib/tokens.ts"
2641
2642
  },
2643
+ {
2644
+ "path": "src/lib/tooltip-focus-gate.ts",
2645
+ "type": "registry:lib",
2646
+ "target": "lib/tooltip-focus-gate.ts"
2647
+ },
2642
2648
  {
2643
2649
  "path": "src/lib/utils.ts",
2644
2650
  "type": "registry:lib",