@adea-ai/ui 0.115.1 → 0.116.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.
- package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts +6 -0
- package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/accent-swatch-groups.js +13 -6
- package/dist/components/composites/appearance-editor/accent-swatch-groups.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-choices.js +85 -22
- package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.js +39 -12
- package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-types.d.ts +26 -0
- package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
- package/dist/components/ui/command/command.d.ts.map +1 -1
- package/dist/components/ui/command/command.js +1 -1
- package/dist/components/ui/command/command.js.map +1 -1
- package/dist/components/ui/tabs/tabs.js +1 -1
- package/dist/components/ui/tabs/tabs.js.map +1 -1
- package/dist/components/ui/toggle/toggle.d.ts +4 -1
- package/dist/components/ui/toggle/toggle.d.ts.map +1 -1
- package/dist/components/ui/toggle/toggle.js +5 -2
- package/dist/components/ui/toggle/toggle.js.map +1 -1
- package/dist/components/ui/tooltip/tooltip.d.ts.map +1 -1
- package/dist/components/ui/tooltip/tooltip.js +5 -0
- package/dist/components/ui/tooltip/tooltip.js.map +1 -1
- package/dist/lib/tooltip-focus-gate.d.ts +29 -0
- package/dist/lib/tooltip-focus-gate.d.ts.map +1 -0
- package/dist/lib/tooltip-focus-gate.js +134 -0
- package/dist/lib/tooltip-focus-gate.js.map +1 -0
- package/dist/r/appearance-editor.json +1 -0
- package/dist/r/lib.json +5 -0
- package/dist/r/registry.json +6 -0
- package/dist/r/src/components/composites/appearance-editor/accent-swatch-groups.tsx +10 -2
- package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +55 -17
- package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +25 -8
- package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +26 -0
- package/dist/r/src/components/ui/command/command.tsx +5 -1
- package/dist/r/src/components/ui/tabs/tabs.tsx +1 -1
- package/dist/r/src/components/ui/toggle/toggle.tsx +5 -2
- package/dist/r/src/components/ui/tooltip/tooltip.tsx +10 -0
- package/dist/r/src/lib/tooltip-focus-gate.ts +218 -0
- package/package.json +1 -1
- package/registry.json +6 -0
- package/src/components/composites/appearance-editor/accent-swatch-groups.tsx +10 -2
- package/src/components/composites/appearance-editor/appearance-choices.tsx +55 -17
- package/src/components/composites/appearance-editor/appearance-editor.tsx +25 -8
- package/src/components/composites/appearance-editor/appearance-types.ts +26 -0
- package/src/components/ui/command/command.tsx +5 -1
- package/src/components/ui/tabs/tabs.tsx +1 -1
- package/src/components/ui/toggle/toggle.tsx +5 -2
- package/src/components/ui/tooltip/tooltip.tsx +10 -0
- 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
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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={
|
|
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 & {
|
|
@@ -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
|
-
|
|
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}
|
|
@@ -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-
|
|
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-
|
|
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
|
+
}
|