@octanejs/radix 0.1.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- package/src/useId.ts +15 -0
package/src/Tooltip.ts
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-tooltip (source:
|
|
2
|
+
// .radix-primitives/packages/react/tooltip/src/tooltip.tsx). Provider manages the shared
|
|
3
|
+
// delay/skip-delay state; Trigger is the Popper anchor (pointer-move opens after the
|
|
4
|
+
// delay, focus opens instantly, click/blur/pointer-down close); Content positions via
|
|
5
|
+
// Popper inside a DismissableLayer, closes when another tooltip opens or the trigger
|
|
6
|
+
// scrolls, and — unless `disableHoverableContent` — keeps itself open while the pointer
|
|
7
|
+
// travels the trigger→content convex-hull grace area. An a11y copy of the children
|
|
8
|
+
// renders in a VisuallyHidden `role=tooltip` (octane note: instead of Radix's `Slottable`
|
|
9
|
+
// split, the user children render inside a Fragment alongside the hidden copy).
|
|
10
|
+
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'octane';
|
|
11
|
+
|
|
12
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
13
|
+
import { useComposedRefs } from './compose-refs';
|
|
14
|
+
import { createContextScope } from './context';
|
|
15
|
+
import { DismissableLayer } from './DismissableLayer';
|
|
16
|
+
import { S, subSlot } from './internal';
|
|
17
|
+
import * as PopperPrimitive from './Popper';
|
|
18
|
+
import { createPopperScope } from './Popper';
|
|
19
|
+
import { Portal as PortalPrimitive } from './Portal';
|
|
20
|
+
import { Presence } from './Presence';
|
|
21
|
+
import { Primitive } from './Primitive';
|
|
22
|
+
import { useControllableState } from './useControllableState';
|
|
23
|
+
import { useId } from './useId';
|
|
24
|
+
import * as VisuallyHiddenPrimitive from './VisuallyHidden';
|
|
25
|
+
|
|
26
|
+
type Point = { x: number; y: number };
|
|
27
|
+
type Polygon = Point[];
|
|
28
|
+
|
|
29
|
+
const [createTooltipContext, createTooltipScope] = createContextScope('Tooltip', [
|
|
30
|
+
createPopperScope,
|
|
31
|
+
]);
|
|
32
|
+
export { createTooltipScope };
|
|
33
|
+
const usePopperScope = createPopperScope();
|
|
34
|
+
|
|
35
|
+
const DEFAULT_DELAY_DURATION = 700;
|
|
36
|
+
const TOOLTIP_OPEN = 'tooltip.open';
|
|
37
|
+
|
|
38
|
+
interface ProviderContextValue {
|
|
39
|
+
isOpenDelayedRef: { current: boolean };
|
|
40
|
+
delayDuration: number;
|
|
41
|
+
onOpen(): void;
|
|
42
|
+
onClose(): void;
|
|
43
|
+
onPointerInTransitChange(inTransit: boolean): void;
|
|
44
|
+
isPointerInTransitRef: { current: boolean };
|
|
45
|
+
disableHoverableContent: boolean;
|
|
46
|
+
}
|
|
47
|
+
const [TooltipProviderContextProvider, useTooltipProviderContext] =
|
|
48
|
+
createTooltipContext<ProviderContextValue>('TooltipProvider');
|
|
49
|
+
|
|
50
|
+
export function Provider(props: any): any {
|
|
51
|
+
const slot = S('Tooltip.Provider');
|
|
52
|
+
const {
|
|
53
|
+
__scopeTooltip,
|
|
54
|
+
delayDuration = DEFAULT_DELAY_DURATION,
|
|
55
|
+
skipDelayDuration = 300,
|
|
56
|
+
disableHoverableContent = false,
|
|
57
|
+
children,
|
|
58
|
+
} = props ?? {};
|
|
59
|
+
const isOpenDelayedRef = useRef(true, subSlot(slot, 'delayed'));
|
|
60
|
+
const isPointerInTransitRef = useRef(false, subSlot(slot, 'transit'));
|
|
61
|
+
const skipDelayTimerRef = useRef(0, subSlot(slot, 'timer'));
|
|
62
|
+
useEffect(
|
|
63
|
+
() => {
|
|
64
|
+
const skipDelayTimer = skipDelayTimerRef.current;
|
|
65
|
+
return () => window.clearTimeout(skipDelayTimer);
|
|
66
|
+
},
|
|
67
|
+
[],
|
|
68
|
+
subSlot(slot, 'e:cleanup'),
|
|
69
|
+
);
|
|
70
|
+
return createElement(TooltipProviderContextProvider, {
|
|
71
|
+
scope: __scopeTooltip,
|
|
72
|
+
isOpenDelayedRef,
|
|
73
|
+
delayDuration,
|
|
74
|
+
onOpen: useCallback(
|
|
75
|
+
() => {
|
|
76
|
+
if (skipDelayDuration <= 0) return;
|
|
77
|
+
window.clearTimeout(skipDelayTimerRef.current);
|
|
78
|
+
isOpenDelayedRef.current = false;
|
|
79
|
+
},
|
|
80
|
+
[skipDelayDuration],
|
|
81
|
+
subSlot(slot, 'onOpen'),
|
|
82
|
+
),
|
|
83
|
+
onClose: useCallback(
|
|
84
|
+
() => {
|
|
85
|
+
if (skipDelayDuration <= 0) return;
|
|
86
|
+
window.clearTimeout(skipDelayTimerRef.current);
|
|
87
|
+
skipDelayTimerRef.current = window.setTimeout(
|
|
88
|
+
() => (isOpenDelayedRef.current = true),
|
|
89
|
+
skipDelayDuration,
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
[skipDelayDuration],
|
|
93
|
+
subSlot(slot, 'onClose'),
|
|
94
|
+
),
|
|
95
|
+
isPointerInTransitRef,
|
|
96
|
+
onPointerInTransitChange: useCallback(
|
|
97
|
+
(inTransit: boolean) => {
|
|
98
|
+
isPointerInTransitRef.current = inTransit;
|
|
99
|
+
},
|
|
100
|
+
[],
|
|
101
|
+
subSlot(slot, 'transitChange'),
|
|
102
|
+
),
|
|
103
|
+
disableHoverableContent,
|
|
104
|
+
children,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface TooltipContextValue {
|
|
109
|
+
contentId: string;
|
|
110
|
+
open: boolean;
|
|
111
|
+
stateAttribute: 'closed' | 'delayed-open' | 'instant-open';
|
|
112
|
+
trigger: HTMLElement | null;
|
|
113
|
+
onTriggerChange(trigger: HTMLElement | null): void;
|
|
114
|
+
onTriggerEnter(): void;
|
|
115
|
+
onTriggerLeave(): void;
|
|
116
|
+
onOpen(): void;
|
|
117
|
+
onClose(): void;
|
|
118
|
+
disableHoverableContent: boolean;
|
|
119
|
+
}
|
|
120
|
+
const [TooltipContextProvider, useTooltipContext] =
|
|
121
|
+
createTooltipContext<TooltipContextValue>('Tooltip');
|
|
122
|
+
|
|
123
|
+
export function Root(props: any): any {
|
|
124
|
+
const slot = S('Tooltip.Root');
|
|
125
|
+
const {
|
|
126
|
+
__scopeTooltip,
|
|
127
|
+
children,
|
|
128
|
+
open: openProp,
|
|
129
|
+
defaultOpen,
|
|
130
|
+
onOpenChange,
|
|
131
|
+
disableHoverableContent: disableHoverableContentProp,
|
|
132
|
+
delayDuration: delayDurationProp,
|
|
133
|
+
} = props ?? {};
|
|
134
|
+
const providerContext = useTooltipProviderContext('Tooltip', __scopeTooltip);
|
|
135
|
+
const popperScope = usePopperScope(__scopeTooltip, subSlot(slot, 'popper'));
|
|
136
|
+
const [trigger, setTrigger] = useState<HTMLElement | null>(null, subSlot(slot, 'trigger'));
|
|
137
|
+
const contentId = useId(subSlot(slot, 'id'));
|
|
138
|
+
const openTimerRef = useRef(0, subSlot(slot, 'timer'));
|
|
139
|
+
const disableHoverableContent =
|
|
140
|
+
disableHoverableContentProp ?? providerContext.disableHoverableContent;
|
|
141
|
+
const delayDuration = delayDurationProp ?? providerContext.delayDuration;
|
|
142
|
+
const wasOpenDelayedRef = useRef(false, subSlot(slot, 'wasDelayed'));
|
|
143
|
+
const [open, setOpen] = useControllableState<boolean>(
|
|
144
|
+
{
|
|
145
|
+
prop: openProp,
|
|
146
|
+
defaultProp: defaultOpen ?? false,
|
|
147
|
+
onChange: (isOpen: boolean) => {
|
|
148
|
+
if (isOpen) {
|
|
149
|
+
providerContext.onOpen();
|
|
150
|
+
// as `onChange` is called within a lifecycle method we avoid
|
|
151
|
+
// dispatching via `dispatchDiscreteCustomEvent`.
|
|
152
|
+
document.dispatchEvent(new CustomEvent(TOOLTIP_OPEN));
|
|
153
|
+
} else {
|
|
154
|
+
providerContext.onClose();
|
|
155
|
+
}
|
|
156
|
+
onOpenChange?.(isOpen);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
subSlot(slot, 'open'),
|
|
160
|
+
);
|
|
161
|
+
const stateAttribute = useMemo(
|
|
162
|
+
() => (open ? (wasOpenDelayedRef.current ? 'delayed-open' : 'instant-open') : 'closed'),
|
|
163
|
+
[open],
|
|
164
|
+
subSlot(slot, 'state'),
|
|
165
|
+
) as TooltipContextValue['stateAttribute'];
|
|
166
|
+
const handleOpen = useCallback(
|
|
167
|
+
() => {
|
|
168
|
+
window.clearTimeout(openTimerRef.current);
|
|
169
|
+
openTimerRef.current = 0;
|
|
170
|
+
wasOpenDelayedRef.current = false;
|
|
171
|
+
setOpen(true);
|
|
172
|
+
},
|
|
173
|
+
[setOpen],
|
|
174
|
+
subSlot(slot, 'handleOpen'),
|
|
175
|
+
);
|
|
176
|
+
const handleClose = useCallback(
|
|
177
|
+
() => {
|
|
178
|
+
window.clearTimeout(openTimerRef.current);
|
|
179
|
+
openTimerRef.current = 0;
|
|
180
|
+
setOpen(false);
|
|
181
|
+
},
|
|
182
|
+
[setOpen],
|
|
183
|
+
subSlot(slot, 'handleClose'),
|
|
184
|
+
);
|
|
185
|
+
const handleDelayedOpen = useCallback(
|
|
186
|
+
() => {
|
|
187
|
+
window.clearTimeout(openTimerRef.current);
|
|
188
|
+
openTimerRef.current = window.setTimeout(() => {
|
|
189
|
+
wasOpenDelayedRef.current = true;
|
|
190
|
+
setOpen(true);
|
|
191
|
+
openTimerRef.current = 0;
|
|
192
|
+
}, delayDuration);
|
|
193
|
+
},
|
|
194
|
+
[delayDuration, setOpen],
|
|
195
|
+
subSlot(slot, 'delayedOpen'),
|
|
196
|
+
);
|
|
197
|
+
useEffect(
|
|
198
|
+
() => {
|
|
199
|
+
return () => {
|
|
200
|
+
if (openTimerRef.current) {
|
|
201
|
+
window.clearTimeout(openTimerRef.current);
|
|
202
|
+
openTimerRef.current = 0;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
[],
|
|
207
|
+
subSlot(slot, 'e:timer'),
|
|
208
|
+
);
|
|
209
|
+
return createElement(PopperPrimitive.Root, {
|
|
210
|
+
...popperScope,
|
|
211
|
+
children: createElement(TooltipContextProvider, {
|
|
212
|
+
scope: __scopeTooltip,
|
|
213
|
+
contentId,
|
|
214
|
+
open,
|
|
215
|
+
stateAttribute,
|
|
216
|
+
trigger,
|
|
217
|
+
onTriggerChange: setTrigger,
|
|
218
|
+
onTriggerEnter: useCallback(
|
|
219
|
+
() => {
|
|
220
|
+
if (providerContext.isOpenDelayedRef.current) handleDelayedOpen();
|
|
221
|
+
else handleOpen();
|
|
222
|
+
},
|
|
223
|
+
[handleDelayedOpen, handleOpen],
|
|
224
|
+
subSlot(slot, 'enter'),
|
|
225
|
+
),
|
|
226
|
+
onTriggerLeave: useCallback(
|
|
227
|
+
() => {
|
|
228
|
+
if (disableHoverableContent) {
|
|
229
|
+
handleClose();
|
|
230
|
+
} else {
|
|
231
|
+
// Clear the timer in case the pointer leaves the trigger before the
|
|
232
|
+
// tooltip is opened.
|
|
233
|
+
window.clearTimeout(openTimerRef.current);
|
|
234
|
+
openTimerRef.current = 0;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
[handleClose, disableHoverableContent],
|
|
238
|
+
subSlot(slot, 'leave'),
|
|
239
|
+
),
|
|
240
|
+
onOpen: handleOpen,
|
|
241
|
+
onClose: handleClose,
|
|
242
|
+
disableHoverableContent,
|
|
243
|
+
children,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function Trigger(props: any): any {
|
|
249
|
+
const slot = S('Tooltip.Trigger');
|
|
250
|
+
const { __scopeTooltip, ref: forwardedRef, ...triggerProps } = props ?? {};
|
|
251
|
+
const context = useTooltipContext('TooltipTrigger', __scopeTooltip);
|
|
252
|
+
const providerContext = useTooltipProviderContext('TooltipTrigger', __scopeTooltip);
|
|
253
|
+
const popperScope = usePopperScope(__scopeTooltip, subSlot(slot, 'popper'));
|
|
254
|
+
const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
|
|
255
|
+
const composedRefs = useComposedRefs(
|
|
256
|
+
forwardedRef,
|
|
257
|
+
ref,
|
|
258
|
+
context.onTriggerChange,
|
|
259
|
+
subSlot(slot, 'refs'),
|
|
260
|
+
);
|
|
261
|
+
const isPointerDownRef = useRef(false, subSlot(slot, 'down'));
|
|
262
|
+
const hasPointerMoveOpenedRef = useRef(false, subSlot(slot, 'moved'));
|
|
263
|
+
const handlePointerUp = useCallback(
|
|
264
|
+
() => (isPointerDownRef.current = false),
|
|
265
|
+
[],
|
|
266
|
+
subSlot(slot, 'up'),
|
|
267
|
+
);
|
|
268
|
+
useEffect(
|
|
269
|
+
() => {
|
|
270
|
+
return () => document.removeEventListener('pointerup', handlePointerUp);
|
|
271
|
+
},
|
|
272
|
+
[],
|
|
273
|
+
subSlot(slot, 'e:up'),
|
|
274
|
+
);
|
|
275
|
+
return createElement(PopperPrimitive.Anchor, {
|
|
276
|
+
asChild: true,
|
|
277
|
+
...popperScope,
|
|
278
|
+
// We purposefully avoid adding `type=button` here because tooltip triggers are
|
|
279
|
+
// also commonly anchors, where the `type` attribute signifies MIME type.
|
|
280
|
+
children: createElement(Primitive.button, {
|
|
281
|
+
'aria-describedby': context.open ? context.contentId : undefined,
|
|
282
|
+
'data-state': context.stateAttribute,
|
|
283
|
+
...triggerProps,
|
|
284
|
+
ref: composedRefs,
|
|
285
|
+
onPointerMove: composeEventHandlers(props?.onPointerMove, (event: PointerEvent) => {
|
|
286
|
+
if (event.pointerType === 'touch') return;
|
|
287
|
+
if (!hasPointerMoveOpenedRef.current && !providerContext.isPointerInTransitRef.current) {
|
|
288
|
+
context.onTriggerEnter();
|
|
289
|
+
hasPointerMoveOpenedRef.current = true;
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
onPointerLeave: composeEventHandlers(props?.onPointerLeave, () => {
|
|
293
|
+
context.onTriggerLeave();
|
|
294
|
+
hasPointerMoveOpenedRef.current = false;
|
|
295
|
+
}),
|
|
296
|
+
onPointerDown: composeEventHandlers(props?.onPointerDown, () => {
|
|
297
|
+
if (context.open) context.onClose();
|
|
298
|
+
isPointerDownRef.current = true;
|
|
299
|
+
document.addEventListener('pointerup', handlePointerUp, { once: true });
|
|
300
|
+
}),
|
|
301
|
+
onFocus: composeEventHandlers(props?.onFocus, () => {
|
|
302
|
+
if (!isPointerDownRef.current) context.onOpen();
|
|
303
|
+
}),
|
|
304
|
+
onBlur: composeEventHandlers(props?.onBlur, context.onClose),
|
|
305
|
+
onClick: composeEventHandlers(props?.onClick, context.onClose),
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const [PortalProvider, usePortalContext] = createTooltipContext<{ forceMount?: boolean }>(
|
|
311
|
+
'TooltipPortal',
|
|
312
|
+
{ forceMount: undefined },
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
export function Portal(props: any): any {
|
|
316
|
+
const { __scopeTooltip, forceMount, children, container } = props ?? {};
|
|
317
|
+
const context = useTooltipContext('TooltipPortal', __scopeTooltip);
|
|
318
|
+
return createElement(PortalProvider, {
|
|
319
|
+
scope: __scopeTooltip,
|
|
320
|
+
forceMount,
|
|
321
|
+
children: createElement(Presence, {
|
|
322
|
+
present: forceMount || context.open,
|
|
323
|
+
children: createElement(PortalPrimitive, {
|
|
324
|
+
asChild: typeof children !== 'function',
|
|
325
|
+
container,
|
|
326
|
+
children,
|
|
327
|
+
}),
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function Content(props: any): any {
|
|
333
|
+
const portalContext = usePortalContext('TooltipContent', props?.__scopeTooltip);
|
|
334
|
+
const { forceMount = portalContext.forceMount, side = 'top', ...contentProps } = props ?? {};
|
|
335
|
+
const context = useTooltipContext('TooltipContent', props?.__scopeTooltip);
|
|
336
|
+
return createElement(Presence, {
|
|
337
|
+
present: forceMount || context.open,
|
|
338
|
+
children: context.disableHoverableContent
|
|
339
|
+
? createElement(ContentImpl, { side, ...contentProps })
|
|
340
|
+
: createElement(ContentHoverable, { side, ...contentProps }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function ContentHoverable(props: any): any {
|
|
345
|
+
const slot = S('Tooltip.ContentHoverable');
|
|
346
|
+
const { ref: forwardedRef, ...rest } = props;
|
|
347
|
+
const context = useTooltipContext('TooltipContent', props.__scopeTooltip);
|
|
348
|
+
const providerContext = useTooltipProviderContext('TooltipContent', props.__scopeTooltip);
|
|
349
|
+
const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
|
|
350
|
+
const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
|
|
351
|
+
const [pointerGraceArea, setPointerGraceArea] = useState<Polygon | null>(
|
|
352
|
+
null,
|
|
353
|
+
subSlot(slot, 'grace'),
|
|
354
|
+
);
|
|
355
|
+
const { trigger, onClose } = context;
|
|
356
|
+
const content = ref.current;
|
|
357
|
+
const { onPointerInTransitChange } = providerContext;
|
|
358
|
+
|
|
359
|
+
const handleRemoveGraceArea = useCallback(
|
|
360
|
+
() => {
|
|
361
|
+
setPointerGraceArea(null);
|
|
362
|
+
onPointerInTransitChange(false);
|
|
363
|
+
},
|
|
364
|
+
[onPointerInTransitChange],
|
|
365
|
+
subSlot(slot, 'remove'),
|
|
366
|
+
);
|
|
367
|
+
const handleCreateGraceArea = useCallback(
|
|
368
|
+
(event: PointerEvent, hoverTarget: HTMLElement) => {
|
|
369
|
+
const currentTarget = event.currentTarget as HTMLElement;
|
|
370
|
+
const exitPoint = { x: event.clientX, y: event.clientY };
|
|
371
|
+
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect());
|
|
372
|
+
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide);
|
|
373
|
+
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect());
|
|
374
|
+
const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]);
|
|
375
|
+
setPointerGraceArea(graceArea);
|
|
376
|
+
onPointerInTransitChange(true);
|
|
377
|
+
},
|
|
378
|
+
[onPointerInTransitChange],
|
|
379
|
+
subSlot(slot, 'create'),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
useEffect(
|
|
383
|
+
() => {
|
|
384
|
+
return () => handleRemoveGraceArea();
|
|
385
|
+
},
|
|
386
|
+
[handleRemoveGraceArea],
|
|
387
|
+
subSlot(slot, 'e:cleanup'),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
useEffect(
|
|
391
|
+
() => {
|
|
392
|
+
if (trigger && content) {
|
|
393
|
+
const handleTriggerLeave = (event: PointerEvent): void =>
|
|
394
|
+
handleCreateGraceArea(event, content);
|
|
395
|
+
const handleContentLeave = (event: PointerEvent): void =>
|
|
396
|
+
handleCreateGraceArea(event, trigger);
|
|
397
|
+
trigger.addEventListener('pointerleave', handleTriggerLeave);
|
|
398
|
+
content.addEventListener('pointerleave', handleContentLeave);
|
|
399
|
+
return () => {
|
|
400
|
+
trigger.removeEventListener('pointerleave', handleTriggerLeave);
|
|
401
|
+
content.removeEventListener('pointerleave', handleContentLeave);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
[trigger, content, handleCreateGraceArea],
|
|
406
|
+
subSlot(slot, 'e:leave'),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
useEffect(
|
|
410
|
+
() => {
|
|
411
|
+
if (pointerGraceArea) {
|
|
412
|
+
const handleTrackPointerGrace = (event: PointerEvent): void => {
|
|
413
|
+
const target = event.target as HTMLElement;
|
|
414
|
+
const pointerPosition = { x: event.clientX, y: event.clientY };
|
|
415
|
+
const hasEnteredTarget = trigger?.contains(target) || content?.contains(target);
|
|
416
|
+
const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, pointerGraceArea);
|
|
417
|
+
if (hasEnteredTarget) {
|
|
418
|
+
handleRemoveGraceArea();
|
|
419
|
+
} else if (isPointerOutsideGraceArea) {
|
|
420
|
+
handleRemoveGraceArea();
|
|
421
|
+
onClose();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
document.addEventListener('pointermove', handleTrackPointerGrace);
|
|
425
|
+
return () => document.removeEventListener('pointermove', handleTrackPointerGrace);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
[trigger, content, pointerGraceArea, onClose, handleRemoveGraceArea],
|
|
429
|
+
subSlot(slot, 'e:track'),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return createElement(ContentImpl, {
|
|
433
|
+
...rest,
|
|
434
|
+
__scopeTooltip: props.__scopeTooltip,
|
|
435
|
+
ref: composedRefs,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const [VisuallyHiddenContentContextProvider, useVisuallyHiddenContentContext] =
|
|
440
|
+
createTooltipContext<{ isInside: boolean }>('Tooltip', { isInside: false });
|
|
441
|
+
|
|
442
|
+
function ContentImpl(props: any): any {
|
|
443
|
+
const slot = S('Tooltip.ContentImpl');
|
|
444
|
+
const {
|
|
445
|
+
__scopeTooltip,
|
|
446
|
+
children,
|
|
447
|
+
'aria-label': ariaLabel,
|
|
448
|
+
onEscapeKeyDown,
|
|
449
|
+
onPointerDownOutside,
|
|
450
|
+
...contentProps
|
|
451
|
+
} = props;
|
|
452
|
+
const context = useTooltipContext('TooltipContent', __scopeTooltip);
|
|
453
|
+
const popperScope = usePopperScope(__scopeTooltip, subSlot(slot, 'popper'));
|
|
454
|
+
const { onClose } = context;
|
|
455
|
+
|
|
456
|
+
// Close this tooltip if another one opens.
|
|
457
|
+
useEffect(
|
|
458
|
+
() => {
|
|
459
|
+
document.addEventListener(TOOLTIP_OPEN, onClose);
|
|
460
|
+
return () => document.removeEventListener(TOOLTIP_OPEN, onClose);
|
|
461
|
+
},
|
|
462
|
+
[onClose],
|
|
463
|
+
subSlot(slot, 'e:open'),
|
|
464
|
+
);
|
|
465
|
+
// Close the tooltip if the trigger is scrolled.
|
|
466
|
+
useEffect(
|
|
467
|
+
() => {
|
|
468
|
+
if (context.trigger) {
|
|
469
|
+
const handleScroll = (event: Event): void => {
|
|
470
|
+
if (event.target instanceof Node && event.target.contains(context.trigger)) {
|
|
471
|
+
onClose();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener('scroll', handleScroll, { capture: true });
|
|
475
|
+
return () => window.removeEventListener('scroll', handleScroll, { capture: true });
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
[context.trigger, onClose],
|
|
479
|
+
subSlot(slot, 'e:scroll'),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
return createElement(DismissableLayer, {
|
|
483
|
+
asChild: true,
|
|
484
|
+
disableOutsidePointerEvents: false,
|
|
485
|
+
onEscapeKeyDown,
|
|
486
|
+
onPointerDownOutside,
|
|
487
|
+
onFocusOutside: (event: Event) => event.preventDefault(),
|
|
488
|
+
onDismiss: onClose,
|
|
489
|
+
children: createElement(PopperPrimitive.Content, {
|
|
490
|
+
'data-state': context.stateAttribute,
|
|
491
|
+
...popperScope,
|
|
492
|
+
...contentProps,
|
|
493
|
+
style: {
|
|
494
|
+
...contentProps.style,
|
|
495
|
+
// re-namespace exposed content custom properties
|
|
496
|
+
'--radix-tooltip-content-transform-origin': 'var(--radix-popper-transform-origin)',
|
|
497
|
+
'--radix-tooltip-content-available-width': 'var(--radix-popper-available-width)',
|
|
498
|
+
'--radix-tooltip-content-available-height': 'var(--radix-popper-available-height)',
|
|
499
|
+
'--radix-tooltip-trigger-width': 'var(--radix-popper-anchor-width)',
|
|
500
|
+
'--radix-tooltip-trigger-height': 'var(--radix-popper-anchor-height)',
|
|
501
|
+
},
|
|
502
|
+
children: [
|
|
503
|
+
// octane: the user children render through a pass-through host (Radix uses
|
|
504
|
+
// `Slottable` purely for asChild-splitting, which octane handles via props).
|
|
505
|
+
createElement(ChildrenHost, { key: 'c', children }),
|
|
506
|
+
createElement(VisuallyHiddenContentContextProvider, {
|
|
507
|
+
key: 'vh',
|
|
508
|
+
scope: __scopeTooltip,
|
|
509
|
+
isInside: true,
|
|
510
|
+
children: createElement(VisuallyHiddenPrimitive.Root, {
|
|
511
|
+
id: context.contentId,
|
|
512
|
+
role: 'tooltip',
|
|
513
|
+
children: ariaLabel || children,
|
|
514
|
+
}),
|
|
515
|
+
}),
|
|
516
|
+
],
|
|
517
|
+
}),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function Arrow(props: any): any {
|
|
522
|
+
const slot = S('Tooltip.Arrow');
|
|
523
|
+
const { __scopeTooltip, ...arrowProps } = props ?? {};
|
|
524
|
+
const popperScope = usePopperScope(__scopeTooltip, subSlot(slot, 'popper'));
|
|
525
|
+
const visuallyHiddenContentContext = useVisuallyHiddenContentContext(
|
|
526
|
+
'TooltipArrow',
|
|
527
|
+
__scopeTooltip,
|
|
528
|
+
);
|
|
529
|
+
// If the arrow is inside the VisuallyHidden a11y copy, don't render it at all —
|
|
530
|
+
// the duplicate would break arrow positioning.
|
|
531
|
+
return visuallyHiddenContentContext.isInside
|
|
532
|
+
? null
|
|
533
|
+
: createElement(PopperPrimitive.Arrow, { ...popperScope, ...arrowProps });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Pass-through: renders its children (function or descriptor) as-is.
|
|
537
|
+
function ChildrenHost(props: any): any {
|
|
538
|
+
return props.children;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* ----------------------------- grace-area geometry ----------------------------- */
|
|
542
|
+
|
|
543
|
+
function getExitSideFromRect(point: Point, rect: DOMRect): 'top' | 'bottom' | 'left' | 'right' {
|
|
544
|
+
const top = Math.abs(rect.top - point.y);
|
|
545
|
+
const bottom = Math.abs(rect.bottom - point.y);
|
|
546
|
+
const right = Math.abs(rect.right - point.x);
|
|
547
|
+
const left = Math.abs(rect.left - point.x);
|
|
548
|
+
switch (Math.min(top, bottom, right, left)) {
|
|
549
|
+
case left:
|
|
550
|
+
return 'left';
|
|
551
|
+
case right:
|
|
552
|
+
return 'right';
|
|
553
|
+
case top:
|
|
554
|
+
return 'top';
|
|
555
|
+
case bottom:
|
|
556
|
+
return 'bottom';
|
|
557
|
+
default:
|
|
558
|
+
throw new Error('unreachable');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function getPaddedExitPoints(exitPoint: Point, exitSide: string, padding = 5): Point[] {
|
|
563
|
+
const paddedExitPoints: Point[] = [];
|
|
564
|
+
switch (exitSide) {
|
|
565
|
+
case 'top':
|
|
566
|
+
paddedExitPoints.push(
|
|
567
|
+
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
|
568
|
+
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
|
569
|
+
);
|
|
570
|
+
break;
|
|
571
|
+
case 'bottom':
|
|
572
|
+
paddedExitPoints.push(
|
|
573
|
+
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
|
574
|
+
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
|
575
|
+
);
|
|
576
|
+
break;
|
|
577
|
+
case 'left':
|
|
578
|
+
paddedExitPoints.push(
|
|
579
|
+
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
|
580
|
+
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
|
581
|
+
);
|
|
582
|
+
break;
|
|
583
|
+
case 'right':
|
|
584
|
+
paddedExitPoints.push(
|
|
585
|
+
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
|
586
|
+
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
|
587
|
+
);
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
return paddedExitPoints;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function getPointsFromRect(rect: DOMRect): Point[] {
|
|
594
|
+
const { top, right, bottom, left } = rect;
|
|
595
|
+
return [
|
|
596
|
+
{ x: left, y: top },
|
|
597
|
+
{ x: right, y: top },
|
|
598
|
+
{ x: right, y: bottom },
|
|
599
|
+
{ x: left, y: bottom },
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Determine if a point is inside of a polygon (github.com/substack/point-in-polygon).
|
|
604
|
+
function isPointInPolygon(point: Point, polygon: Polygon): boolean {
|
|
605
|
+
const { x, y } = point;
|
|
606
|
+
let inside = false;
|
|
607
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
608
|
+
const xi = polygon[i]!.x;
|
|
609
|
+
const yi = polygon[i]!.y;
|
|
610
|
+
const xj = polygon[j]!.x;
|
|
611
|
+
const yj = polygon[j]!.y;
|
|
612
|
+
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
613
|
+
if (intersect) inside = !inside;
|
|
614
|
+
}
|
|
615
|
+
return inside;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Convex hull (nayuki.io/page/convex-hull-algorithm).
|
|
619
|
+
function getHull<P extends Point>(points: ReadonlyArray<P>): P[] {
|
|
620
|
+
const newPoints: P[] = points.slice();
|
|
621
|
+
newPoints.sort((a: Point, b: Point) => {
|
|
622
|
+
if (a.x < b.x) return -1;
|
|
623
|
+
else if (a.x > b.x) return +1;
|
|
624
|
+
else if (a.y < b.y) return -1;
|
|
625
|
+
else if (a.y > b.y) return +1;
|
|
626
|
+
else return 0;
|
|
627
|
+
});
|
|
628
|
+
return getHullPresorted(newPoints);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Convex hull of pre-sorted points, O(n).
|
|
632
|
+
function getHullPresorted<P extends Point>(points: ReadonlyArray<P>): P[] {
|
|
633
|
+
if (points.length <= 1) return points.slice();
|
|
634
|
+
const upperHull: P[] = [];
|
|
635
|
+
for (let i = 0; i < points.length; i++) {
|
|
636
|
+
const p = points[i]!;
|
|
637
|
+
while (upperHull.length >= 2) {
|
|
638
|
+
const q = upperHull[upperHull.length - 1]!;
|
|
639
|
+
const r = upperHull[upperHull.length - 2]!;
|
|
640
|
+
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upperHull.pop();
|
|
641
|
+
else break;
|
|
642
|
+
}
|
|
643
|
+
upperHull.push(p);
|
|
644
|
+
}
|
|
645
|
+
upperHull.pop();
|
|
646
|
+
const lowerHull: P[] = [];
|
|
647
|
+
for (let i = points.length - 1; i >= 0; i--) {
|
|
648
|
+
const p = points[i]!;
|
|
649
|
+
while (lowerHull.length >= 2) {
|
|
650
|
+
const q = lowerHull[lowerHull.length - 1]!;
|
|
651
|
+
const r = lowerHull[lowerHull.length - 2]!;
|
|
652
|
+
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lowerHull.pop();
|
|
653
|
+
else break;
|
|
654
|
+
}
|
|
655
|
+
lowerHull.push(p);
|
|
656
|
+
}
|
|
657
|
+
lowerHull.pop();
|
|
658
|
+
if (
|
|
659
|
+
upperHull.length === 1 &&
|
|
660
|
+
lowerHull.length === 1 &&
|
|
661
|
+
upperHull[0]!.x === lowerHull[0]!.x &&
|
|
662
|
+
upperHull[0]!.y === lowerHull[0]!.y
|
|
663
|
+
) {
|
|
664
|
+
return upperHull;
|
|
665
|
+
}
|
|
666
|
+
return upperHull.concat(lowerHull);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export { Root as Tooltip, Provider as TooltipProvider };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-visually-hidden (source:
|
|
2
|
+
// .radix-primitives/packages/react/visually-hidden/src/visually-hidden.tsx). Hides content
|
|
3
|
+
// visually while keeping it available to assistive technology.
|
|
4
|
+
import { createElement } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { Primitive } from './Primitive';
|
|
7
|
+
|
|
8
|
+
export const VISUALLY_HIDDEN_STYLES = Object.freeze({
|
|
9
|
+
// See: https://github.com/twbs/bootstrap/blob/main/scss/mixins/_visually-hidden.scss
|
|
10
|
+
position: 'absolute',
|
|
11
|
+
border: 0,
|
|
12
|
+
width: 1,
|
|
13
|
+
height: 1,
|
|
14
|
+
padding: 0,
|
|
15
|
+
margin: -1,
|
|
16
|
+
overflow: 'hidden',
|
|
17
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
18
|
+
whiteSpace: 'nowrap',
|
|
19
|
+
wordWrap: 'normal',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function VisuallyHidden(props: any): any {
|
|
23
|
+
return createElement(Primitive.span, {
|
|
24
|
+
...props,
|
|
25
|
+
style: { ...VISUALLY_HIDDEN_STYLES, ...props?.style },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { VisuallyHidden as Root };
|