@eifi1/ui-kit 0.24.1 → 0.25.1
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/README.md +6 -6
- package/dist/chart.d.ts +2 -2
- package/dist/components/account-chips.d.ts +1 -1
- package/dist/components/amount-input.d.ts +2 -2
- package/dist/components/button-group.d.ts +2 -2
- package/dist/components/calculator.d.ts +2 -2
- package/dist/components/column-mapper.d.ts +28 -9
- package/dist/components/column-mapper.js +63 -77
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +2 -2
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/danger-confirm.d.ts +2 -2
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-labels.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +1 -1
- package/dist/components/data-table-pagination.js +6 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +7 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -2
- package/dist/components/file-button.d.ts +2 -2
- package/dist/components/file-dropzone.d.ts +2 -2
- package/dist/components/form-actions.d.ts +2 -2
- package/dist/components/iban-input.d.ts +2 -2
- package/dist/components/language-select.d.ts +2 -2
- package/dist/components/money-field.d.ts +2 -2
- package/dist/components/number-field.d.ts +2 -2
- package/dist/components/number-input.d.ts +2 -2
- package/dist/components/numpad-sheet.d.ts +2 -2
- package/dist/components/phone-input.d.ts +2 -2
- package/dist/components/series-chart.d.ts +2 -2
- package/dist/components/settings-fields.d.ts +2 -2
- package/dist/components/share-card.d.ts +2 -2
- package/dist/components/swipeable-row.js +1 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +35 -1
- package/dist/components/table.js +4 -0
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +2 -2
- package/dist/components/time-input.d.ts +2 -2
- package/dist/components/tooltip.d.ts +103 -14
- package/dist/components/tooltip.js +194 -57
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +9 -1
- package/dist/components/translation-review-editor.js +11 -1
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +10 -0
- package/dist/components/translation-review-labels.js +5 -1
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +145 -8
- package/dist/components/translation-review.js +372 -91
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/ui.d.ts +2 -2
- package/dist/components/ui.js +55 -18
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -2
- package/dist/feedback/feedback-dialog.d.ts +2 -2
- package/dist/feedback/feedback-dialog.js +6 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +2 -2
- package/dist/feedback/feedback-inbox.js +2 -0
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
- package/dist/feedback.d.ts +2 -2
- package/dist/hooks/use-body-scroll-lock.js +16 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +2 -2
- package/dist/hooks/use-search-param-state.d.ts +58 -3
- package/dist/hooks/use-search-param-state.js +107 -35
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -2
- package/dist/i18n/german.d.ts +2 -2
- package/dist/i18n/german.js +8 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/languages.d.ts +2 -2
- package/dist/i18n/locales/de-CH.d.ts +2 -2
- package/dist/i18n/locales/en.d.ts +2 -2
- package/dist/i18n/locales/en.js +5 -1
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -2
- package/dist/i18n/locales/es.js +7 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -2
- package/dist/i18n/locales/fr.js +6 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -2
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -2
- package/dist/i18n/locales/it.js +6 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -2
- package/dist/i18n/locales/zh.js +6 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +2 -2
- package/dist/i18n/review.js +11 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/strip-fade.d.ts +84 -5
- package/dist/lib/strip-fade.js +109 -11
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/translation-review.d.ts +45 -1
- package/dist/lib/translation-review.js +38 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/rhf/fields.d.ts +2 -2
- package/dist/rhf/form.d.ts +2 -2
- package/dist/rhf.d.ts +2 -2
- package/dist/shell/app-shell.d.ts +2 -2
- package/dist/shell/top-bar-brand.d.ts +2 -2
- package/dist/shell.d.ts +2 -2
- package/dist/wizard/stepper-nav.d.ts +2 -2
- package/dist/wizard.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/column-mapper.tsx +120 -113
- package/src/components/data-table-pagination.tsx +10 -1
- package/src/components/data-table.tsx +34 -1
- package/src/components/swipeable-row.tsx +1 -1
- package/src/components/table.tsx +39 -0
- package/src/components/tooltip.tsx +487 -104
- package/src/components/translation-review-editor.tsx +21 -1
- package/src/components/translation-review-labels.ts +17 -0
- package/src/components/translation-review.tsx +657 -109
- package/src/components/ui.tsx +104 -4
- package/src/feedback/feedback-dialog.tsx +21 -0
- package/src/feedback/feedback-inbox.tsx +19 -2
- package/src/hooks/use-body-scroll-lock.ts +25 -0
- package/src/hooks/use-search-param-state.ts +283 -41
- package/src/i18n/german.ts +10 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +9 -0
- package/src/i18n/locales/fr.ts +9 -0
- package/src/i18n/locales/hu.ts +11 -0
- package/src/i18n/locales/it.ts +9 -0
- package/src/i18n/locales/zh.ts +8 -0
- package/src/i18n/review.ts +11 -0
- package/src/index.ts +12 -0
- package/src/lib/strip-fade.ts +220 -15
- package/src/lib/translation-review.ts +85 -0
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
2
|
cloneElement,
|
|
3
3
|
isValidElement,
|
|
4
|
+
useEffect,
|
|
4
5
|
useId,
|
|
5
6
|
useLayoutEffect,
|
|
6
7
|
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
type ComponentPropsWithoutRef,
|
|
10
|
+
type FocusEvent,
|
|
11
|
+
type MouseEvent,
|
|
12
|
+
type PointerEvent,
|
|
9
13
|
type ReactElement,
|
|
10
14
|
type ReactNode,
|
|
11
15
|
type RefObject,
|
|
12
16
|
} from "react";
|
|
13
17
|
import { createPortal } from "react-dom";
|
|
14
18
|
import { cn } from "../lib/cn";
|
|
15
|
-
import { useEscapeKey } from "../hooks/use-dismiss";
|
|
19
|
+
import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
|
|
16
20
|
import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
|
|
17
21
|
import { dirOf, type Direction } from "../lib/direction";
|
|
18
22
|
import { hasClippingAncestor } from "../lib/clipping";
|
|
@@ -30,6 +34,18 @@ export { CLIPS_ATTRIBUTE } from "../lib/clipping";
|
|
|
30
34
|
* mirror (a chart axis, a map). */
|
|
31
35
|
export type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
|
|
32
36
|
|
|
37
|
+
/** What a TAP — a touch or a pen press, never the mouse — on the trigger does to the
|
|
38
|
+
* bubble. See "A tap is not a hover" on {@link Tooltip}.
|
|
39
|
+
*
|
|
40
|
+
* - `"auto"` (the default): a tap that activates something shows nothing; a tap that
|
|
41
|
+
* activates nothing — on a disabled or `aria-disabled` control (a write lock's
|
|
42
|
+
* reason), or on a trigger with no control at all (a truncated name, a badge) —
|
|
43
|
+
* toggles the bubble, since showing it is then the tap's only answer.
|
|
44
|
+
* - `"toggle"`: every tap toggles it — for a control that exists only to explain, such
|
|
45
|
+
* as a "?" whose click does nothing.
|
|
46
|
+
* - `"ignore"`: a tap never shows it. */
|
|
47
|
+
export type TooltipTap = "auto" | "toggle" | "ignore";
|
|
48
|
+
|
|
33
49
|
/** The placement the maths works in: a logical side resolved against the trigger. */
|
|
34
50
|
type PhysicalSide = "top" | "bottom" | "left" | "right";
|
|
35
51
|
|
|
@@ -105,20 +121,30 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
|
|
|
105
121
|
lazy?: boolean;
|
|
106
122
|
/** Tag the bubble `data-private`, for a label that repeats the user's own data. */
|
|
107
123
|
redact?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* What a tap (touch or pen) on the trigger does — see {@link TooltipTap} and "A tap is
|
|
126
|
+
* not a hover" below. Default `"auto"`: a tap that activates the control shows nothing;
|
|
127
|
+
* one that activates nothing (a disabled control, plain text) toggles the bubble.
|
|
128
|
+
*/
|
|
129
|
+
tap?: TooltipTap;
|
|
108
130
|
children: ReactNode;
|
|
109
131
|
}
|
|
110
132
|
|
|
111
|
-
/** What each variant below takes: the resolved `side`, and every span attribute
|
|
112
|
-
* caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
|
|
113
|
-
type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & {
|
|
133
|
+
/** What each variant below takes: the resolved `side` and `tap`, and every span attribute
|
|
134
|
+
* the caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
|
|
135
|
+
type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy" | "tap"> & {
|
|
136
|
+
side: TooltipSide;
|
|
137
|
+
tap: TooltipTap;
|
|
138
|
+
};
|
|
114
139
|
|
|
115
140
|
/**
|
|
116
141
|
* Hover/focus label for a control.
|
|
117
142
|
*
|
|
118
143
|
* Two placements, and the choice matters more than it looks. In place, the bubble is
|
|
119
|
-
* always mounted next to the trigger
|
|
120
|
-
*
|
|
121
|
-
* only while it is up,
|
|
144
|
+
* always mounted next to the trigger — so a plain render test finds it without a hover —
|
|
145
|
+
* and shown while it is up (`display: none` otherwise, see "never widens the page"
|
|
146
|
+
* below). Portalled, the bubble is mounted in `document.body` only while it is up,
|
|
147
|
+
* positioned by measurement.
|
|
122
148
|
*
|
|
123
149
|
* ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
|
|
124
150
|
* `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
|
|
@@ -237,8 +263,8 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { si
|
|
|
237
263
|
* So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures
|
|
238
264
|
* it and, if it crosses the viewport edge minus the same margin the portalled bubble
|
|
239
265
|
* keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or
|
|
240
|
-
* down for the four side placements — with an inline `transform
|
|
241
|
-
*
|
|
266
|
+
* down for the four side placements — with an inline margin (a `transform` until 0.25; see
|
|
267
|
+
* {@link shiftStyle} for why that widened the page). The main axis is left alone on purpose: sliding a
|
|
242
268
|
* `start` bubble along the main axis would slide it over its own trigger, and turning it
|
|
243
269
|
* round is a measured-placement decision this CSS-placed bubble does not make (pass
|
|
244
270
|
* `portal` for that). The width is already capped to the viewport (see
|
|
@@ -247,13 +273,84 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { si
|
|
|
247
273
|
*
|
|
248
274
|
* Why on by default, with no prop? Because it is a no-op for every bubble that already
|
|
249
275
|
* fits — the shift is zero unless the bubble would overflow — so the only placements it
|
|
250
|
-
* changes are ones that were broken. The maths is in physical viewport pixels
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* an
|
|
276
|
+
* changes are ones that were broken. The maths is in physical viewport pixels: an RTL
|
|
277
|
+
* row puts its long label at the RIGHT edge and gets slid left by the same code, and the
|
|
278
|
+
* reading direction only decides which edge is kept when a bubble is wider than the room
|
|
279
|
+
* (its start, as {@link placeTooltip} keeps). Under jsdom there is no layout — every rect
|
|
280
|
+
* is zero-sized — and a zero-sized bubble is taken to be unmeasured, so tests see no
|
|
281
|
+
* transform at all. It is measured on open (and when the label or side changes while
|
|
282
|
+
* open), not on every scroll: an in-place bubble follows its trigger for free, and a page
|
|
283
|
+
* that scrolls sideways under an open tooltip is not a case worth a listener per tooltip.
|
|
284
|
+
*
|
|
285
|
+
* ⚠️ **An in-place bubble never widens the page (keksdose run 72, live #381).** Two
|
|
286
|
+
* holes let it, and a page wider than the screen is worse than a bubble cut off: on a
|
|
287
|
+
* phone Chrome then lays every `position: fixed` layer out against the WIDER page while
|
|
288
|
+
* the glass still shows the old width. Measured on a 406px phone: the sync icon at x=254,
|
|
289
|
+
* its `bottom` bubble at 102–422, the page 426px wide, and a FloatingPanel 17px off the
|
|
290
|
+
* glass. First, the clamp compared against `window.innerWidth` — and on a phone that is
|
|
291
|
+
* the layout viewport, which GROWS to the page's width once something sticks out (430 on
|
|
292
|
+
* a 406px screen, measured in Chromium's mobile emulation), so a bubble already past the
|
|
293
|
+
* edge found itself "inside" and stayed there. It now measures against the root's client
|
|
294
|
+
* width, which on a phone is the screen (the initial containing block, the width `100vw`
|
|
295
|
+
* and media queries see) whatever overflows, and on a desktop also leaves out a classic
|
|
296
|
+
* scrollbar; the window is the fallback where there is no layout (jsdom). The portalled
|
|
297
|
+
* bubble takes the same width, since a `fixed` bubble placed against a grown layout
|
|
298
|
+
* viewport is off the glass for the same reason. Second, the always-mounted bubble was
|
|
299
|
+
* `opacity: 0` while closed — invisible, but still laid out where the CSS puts it, so a
|
|
300
|
+
* bubble nobody opened, centred on a trigger near the end edge, widened the page all by
|
|
301
|
+
* itself (dev#488's phantom scroll, at the scale of the page instead of a scroller, where
|
|
302
|
+
* the auto-portal cannot help: the page is not an ancestor it can portal out of). It is
|
|
303
|
+
* now `display: none` until it is up, so a closed bubble takes no room at all, and an open
|
|
304
|
+
* one is slid onto the glass in the same layout pass that shows it, before paint. jsdom
|
|
305
|
+
* computes no Tailwind, so tests still find the closed bubble without a hover.
|
|
306
|
+
*
|
|
307
|
+
* ⚠️ **A tap is not a hover (keksdose run 72, live #379).** The bubble used to show on
|
|
308
|
+
* `mouseenter` and on `focus`, and hide only on `mouseleave` and `blur`. A tap on a phone
|
|
309
|
+
* fires an emulated `mouseenter` and, on a button, focuses it — and the virtual mouse and
|
|
310
|
+
* the focus both stay on the button until the next tap somewhere else, so every
|
|
311
|
+
* IconButton's label (the top-bar icons, the sync indicator, a row's actions) stayed up
|
|
312
|
+
* over whatever the tap had just opened. Now a touch or pen press is told apart from the
|
|
313
|
+
* mouse: the emulated `mouseenter` that follows a tap is ignored, the press itself closes
|
|
314
|
+
* a bubble that was up, and a focus that arrives while the page's last input was a finger
|
|
315
|
+
* or a pen — the tap's own, or one a closing dialog RETURNS to the icon it was opened
|
|
316
|
+
* from — shows nothing. The next key press (a Bluetooth keyboard on a tablet) or mouse
|
|
317
|
+
* press makes focus count again, and a mouse entering the trigger hovers as ever, so a
|
|
318
|
+
* hybrid laptop gets both.
|
|
319
|
+
*
|
|
320
|
+
* Focus otherwise shows the bubble, as it always did — the keyboard, a script, a test's
|
|
321
|
+
* `.focus()` — with one more exception: the focus a mouse press on the trigger gives it.
|
|
322
|
+
* There it follows `:focus-visible`, so a text field still shows its bubble while you type
|
|
323
|
+
* and a clicked button does not hold it: a desktop click behaves as it always did while
|
|
324
|
+
* the pointer is over the button (the hover shows it) and lets it go when the pointer
|
|
325
|
+
* leaves, where the in-place bubble used to linger until the button lost focus — the same
|
|
326
|
+
* left-behind bubble on a smaller scale, and what the portalled bubble always did. A
|
|
327
|
+
* keyboard user's bubble, in turn, now stays up when a mouse passes over and leaves (the
|
|
328
|
+
* portalled one used to close). Any `pointerdown` outside the trigger closes an open
|
|
329
|
+
* bubble too — the Safari case, where a tap elsewhere moves no focus.
|
|
330
|
+
*
|
|
331
|
+
* What a tap DOES show is {@link TooltipTap} (`tap`). By default a tap that activates
|
|
332
|
+
* something shows nothing — the action is the answer — while a tap that activates nothing
|
|
333
|
+
* toggles the bubble, because there the bubble is the only answer: a write-locked or
|
|
334
|
+
* disabled control, whose bubble is the reason it did not respond, and a trigger with no
|
|
335
|
+
* control at all, a truncated payee or a badge, whose bubble is the rest of the text. A
|
|
336
|
+
* second tap, a tap anywhere else, Escape or the focus leaving closes it. "Activates" is
|
|
337
|
+
* read off the DOM — a link, a button, a field, a label, an element with a widget role
|
|
338
|
+
* (`button`, `link`, `checkbox`, `option`, …), or a DataTable row (`tr[tabindex]`) — from
|
|
339
|
+
* the tapped element outwards, past the tooltip, so a glyph inside a clickable row or a
|
|
340
|
+
* button counts as that row or button. What it cannot read is a click handler on an
|
|
341
|
+
* element with no role (give it `role="button"`, which it needs anyway, or pass
|
|
342
|
+
* `tap="ignore"`), and a button that exists only to explain, whose click does nothing —
|
|
343
|
+
* FieldHint's "?" — which says so with `tap="toggle"`.
|
|
344
|
+
*
|
|
345
|
+
* Why not show the label on a long press, as Android does for its own icons? Because the
|
|
346
|
+
* web gives no reliable long press on a control: browsers disagree on whether the release
|
|
347
|
+
* that ends one still clicks the button, and the button's own long press — the context
|
|
348
|
+
* menu, text selection, a row's drag — claims the gesture first on others. A gesture meant
|
|
349
|
+
* to ask "what does this do" must not risk doing it. Screen readers do not need it (the
|
|
350
|
+
* bubble is in `aria-describedby`, and an IconButton's label is its name), and a tap on
|
|
351
|
+
* anything that does nothing already shows it. No scroll listener either: every touch scroll begins with a `pointerdown`, which
|
|
352
|
+
* already closes the bubble, and closing on scroll would close a keyboard user's bubble
|
|
353
|
+
* the moment Tab scrolled its control into view.
|
|
257
354
|
*/
|
|
258
355
|
export function Tooltip({
|
|
259
356
|
label,
|
|
@@ -262,6 +359,7 @@ export function Tooltip({
|
|
|
262
359
|
portal,
|
|
263
360
|
lazy = false,
|
|
264
361
|
redact = false,
|
|
362
|
+
tap = "auto",
|
|
265
363
|
children,
|
|
266
364
|
...rest
|
|
267
365
|
}: TooltipProps) {
|
|
@@ -277,6 +375,7 @@ export function Tooltip({
|
|
|
277
375
|
side={side}
|
|
278
376
|
className={className}
|
|
279
377
|
redact={redact}
|
|
378
|
+
tap={tap}
|
|
280
379
|
{...rest}
|
|
281
380
|
>
|
|
282
381
|
{children}
|
|
@@ -294,6 +393,7 @@ export function Tooltip({
|
|
|
294
393
|
redact={redact}
|
|
295
394
|
detect={portal === undefined}
|
|
296
395
|
lazy={lazy}
|
|
396
|
+
tap={tap}
|
|
297
397
|
{...rest}
|
|
298
398
|
>
|
|
299
399
|
{children}
|
|
@@ -302,13 +402,241 @@ export function Tooltip({
|
|
|
302
402
|
}
|
|
303
403
|
|
|
304
404
|
|
|
405
|
+
/** The pointer handlers a caller may hand {@link Tooltip} that the trigger also needs:
|
|
406
|
+
* both run, the trigger's first. (The mouse and focus handlers are the trigger's alone,
|
|
407
|
+
* as they have always been.) */
|
|
408
|
+
type PointerHandlers = Pick<
|
|
409
|
+
ComponentPropsWithoutRef<"span">,
|
|
410
|
+
"onPointerEnter" | "onPointerDown" | "onPointerUp" | "onPointerCancel"
|
|
411
|
+
>;
|
|
412
|
+
|
|
413
|
+
/** A press that is not the mouse's: a finger, or a pen on the glass. */
|
|
414
|
+
function isTap(pointerType: string): boolean {
|
|
415
|
+
return pointerType === "touch" || pointerType === "pen";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* What a tap ACTIVATES, for `tap="auto"`: the tapped element or the nearest ancestor —
|
|
420
|
+
* inside the tooltip or round it — that a tap would do something with. The DataTable's
|
|
421
|
+
* own list of row-owned controls (`OWN_CONTROL`), plus `tr[tabindex]` — a clickable
|
|
422
|
+
* DataTable row is a roving tab stop, so all but one of its rows say `-1` — and the other
|
|
423
|
+
* widget roles a tap selects or toggles.
|
|
424
|
+
*
|
|
425
|
+
* Two things on the DataTable's list are left off on purpose. A bare `tabindex`: a roving
|
|
426
|
+
* tab stop is `0` on one element of a widget and `-1` on the rest, so it would make one
|
|
427
|
+
* cell of a display-only grid "activate" and its neighbours not — and dialogs, panels and
|
|
428
|
+
* `<main>` carry `-1` only to be focusable by script. And `gridcell` / `row`: the
|
|
429
|
+
* CalendarHeatmap's days are display-only grid cells whose tooltip is the day's value,
|
|
430
|
+
* and a tap is the only way to read it on a phone; a heatmap day that does select is a
|
|
431
|
+
* `<button>`, caught above.
|
|
432
|
+
*/
|
|
433
|
+
const ACTIVATES = [
|
|
434
|
+
"a[href]",
|
|
435
|
+
"button",
|
|
436
|
+
"input",
|
|
437
|
+
"select",
|
|
438
|
+
"textarea",
|
|
439
|
+
"label",
|
|
440
|
+
"summary",
|
|
441
|
+
'[contenteditable=""]',
|
|
442
|
+
'[contenteditable="true"]',
|
|
443
|
+
"tr[tabindex]",
|
|
444
|
+
...[
|
|
445
|
+
"button",
|
|
446
|
+
"link",
|
|
447
|
+
"checkbox",
|
|
448
|
+
"radio",
|
|
449
|
+
"switch",
|
|
450
|
+
"tab",
|
|
451
|
+
"menuitem",
|
|
452
|
+
"menuitemcheckbox",
|
|
453
|
+
"menuitemradio",
|
|
454
|
+
"option",
|
|
455
|
+
"treeitem",
|
|
456
|
+
"slider",
|
|
457
|
+
"spinbutton",
|
|
458
|
+
"combobox",
|
|
459
|
+
].map((role) => `[role="${role}"]`),
|
|
460
|
+
].join(",");
|
|
461
|
+
|
|
462
|
+
/** Whether a tap on `target` should toggle the bubble — see {@link TooltipTap}. Under
|
|
463
|
+
* `auto`: when the tap activates nothing, either because there is no control under it
|
|
464
|
+
* or because the control is disabled (natively, or `aria-disabled` — a write lock). */
|
|
465
|
+
function tapShows(tap: TooltipTap, target: EventTarget | null): boolean {
|
|
466
|
+
if (tap !== "auto") return tap === "toggle";
|
|
467
|
+
if (!(target instanceof Element)) return true;
|
|
468
|
+
const control = target.closest(ACTIVATES);
|
|
469
|
+
return control === null || control.matches(":disabled") || control.closest('[aria-disabled="true"]') !== null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The last kind of input the document saw: a `pointerdown`'s `pointerType` ("mouse",
|
|
474
|
+
* "touch", "pen"), "keyboard" after a key press, `null` before either. ONE pair of
|
|
475
|
+
* capturing listeners for the whole page, installed by the first trigger that mounts —
|
|
476
|
+
* not a pair per tooltip, which a table of forty would multiply.
|
|
477
|
+
*
|
|
478
|
+
* Why a page-wide note and not only the trigger's own: the focus that brings a label back
|
|
479
|
+
* after a tap is not always the tap's. A tap on a top-bar icon opens a dialog; closing it
|
|
480
|
+
* (another tap) RETURNS the focus to the icon by script, and that focus arrives with no
|
|
481
|
+
* press on the icon at all. On a phone it would put the label up over the page the dialog
|
|
482
|
+
* just left, and keep it there. Keys an on-screen keyboard sends while typing
|
|
483
|
+
* (`Unidentified`, a composition) and shortcut chords do not count as the keyboard.
|
|
484
|
+
*/
|
|
485
|
+
let lastInput: string | null = null;
|
|
486
|
+
let trackingInput = false;
|
|
487
|
+
|
|
488
|
+
function trackInput(): void {
|
|
489
|
+
if (trackingInput || typeof document === "undefined") return;
|
|
490
|
+
trackingInput = true;
|
|
491
|
+
document.addEventListener(
|
|
492
|
+
"pointerdown",
|
|
493
|
+
(e) => {
|
|
494
|
+
lastInput = e.pointerType;
|
|
495
|
+
},
|
|
496
|
+
{ capture: true, passive: true },
|
|
497
|
+
);
|
|
498
|
+
document.addEventListener(
|
|
499
|
+
"keydown",
|
|
500
|
+
(e) => {
|
|
501
|
+
if (e.key === "Unidentified" || e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
502
|
+
lastInput = "keyboard";
|
|
503
|
+
},
|
|
504
|
+
{ capture: true, passive: true },
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Whether a focus should show the bubble. Not after a tap — the last input was a finger
|
|
510
|
+
* or a pen, whoever moved the focus. Not after a mouse press on this very trigger either,
|
|
511
|
+
* unless the browser would draw a focus ring there (`:focus-visible`: a text field, not a
|
|
512
|
+
* button) — the hover already shows it while the pointer is there. Every other focus
|
|
513
|
+
* shows it as every focus did before: the keyboard, a script, and a test's `.focus()` or
|
|
514
|
+
* `fireEvent.focus` (jsdom's own `:focus-visible` guesses from whatever events the test
|
|
515
|
+
* file fired before, so it is consulted only where a press makes the answer certain).
|
|
516
|
+
*/
|
|
517
|
+
function focusShows(target: EventTarget, pressedByMouse: boolean): boolean {
|
|
518
|
+
if (lastInput !== null && isTap(lastInput)) return false;
|
|
519
|
+
if (!pressedByMouse || !(target instanceof Element)) return true;
|
|
520
|
+
try {
|
|
521
|
+
return target.matches(":focus-visible");
|
|
522
|
+
} catch {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Whether the bubble is up, from what the trigger hears — shared by both variants so the
|
|
529
|
+
* in-place and the portalled bubble cannot disagree about it. See "A tap is not a hover"
|
|
530
|
+
* on {@link Tooltip} for the rules; this is their bookkeeping.
|
|
531
|
+
*
|
|
532
|
+
* Three ways up — hovered by a mouse (or a hovering pen), focused (see {@link focusShows}),
|
|
533
|
+
* and tapped (`tap`) — and Escape over all three (`dismissed`), re-armed by the next
|
|
534
|
+
* request rather than by an effect watching the flags: coming back to a trigger is a
|
|
535
|
+
* fresh request for its label, and an effect would also re-show the bubble under a
|
|
536
|
+
* pointer that never left. `onArm` runs with each request, for the variant's own per-open
|
|
537
|
+
* work (the reading direction, the clipping check).
|
|
538
|
+
*
|
|
539
|
+
* The refs change nothing on screen, only how the next event is read. `pressedByTouch`
|
|
540
|
+
* is set by a touch or pen arriving or pressing — which precedes the emulated
|
|
541
|
+
* `mouseenter` a tap produces (pointer events first, then the compatibility mouse
|
|
542
|
+
* events) — and cleared by a mouse, or a pen that is not pressing, entering again, so a
|
|
543
|
+
* hybrid laptop's mouse still hovers. `pressedByMouse` spans one mouse press, the window
|
|
544
|
+
* in which that press's own focus arrives.
|
|
545
|
+
*/
|
|
546
|
+
function useTooltipTrigger(
|
|
547
|
+
triggerRef: RefObject<HTMLSpanElement | null>,
|
|
548
|
+
tap: TooltipTap,
|
|
549
|
+
passed: PointerHandlers,
|
|
550
|
+
onArm: (el: HTMLElement) => void,
|
|
551
|
+
) {
|
|
552
|
+
const [hovered, setHovered] = useState(false);
|
|
553
|
+
const [focused, setFocused] = useState(false);
|
|
554
|
+
const [tapped, setTapped] = useState(false);
|
|
555
|
+
const [dismissed, setDismissed] = useState(false);
|
|
556
|
+
const pressedByTouch = useRef(false);
|
|
557
|
+
const pressedByMouse = useRef(false);
|
|
558
|
+
// The tap in progress, and whether a bubble was up when it began: a tap on an open
|
|
559
|
+
// bubble closes it (at pointerdown) and must not open it again at pointerup.
|
|
560
|
+
const press = useRef<{ wasOpen: boolean } | null>(null);
|
|
561
|
+
useEffect(trackInput, []);
|
|
562
|
+
const open = (hovered || focused || tapped) && !dismissed;
|
|
563
|
+
const close = () => {
|
|
564
|
+
setHovered(false);
|
|
565
|
+
setFocused(false);
|
|
566
|
+
setTapped(false);
|
|
567
|
+
};
|
|
568
|
+
useEscapeKey(() => setDismissed(true), open);
|
|
569
|
+
// A press anywhere else closes it — the case a blur does not cover: Safari moves no
|
|
570
|
+
// focus on a tap, and a tap on plain text moves it nowhere.
|
|
571
|
+
useOutsideClick(triggerRef, close, open);
|
|
572
|
+
const arm = (el: HTMLElement) => {
|
|
573
|
+
setDismissed(false);
|
|
574
|
+
onArm(el);
|
|
575
|
+
};
|
|
576
|
+
const handlers = {
|
|
577
|
+
onPointerEnter: (e: PointerEvent<HTMLSpanElement>) => {
|
|
578
|
+
if (e.pointerType === "touch") pressedByTouch.current = true;
|
|
579
|
+
else if (e.buttons === 0) pressedByTouch.current = false;
|
|
580
|
+
passed.onPointerEnter?.(e);
|
|
581
|
+
},
|
|
582
|
+
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
|
|
583
|
+
if (isTap(e.pointerType)) {
|
|
584
|
+
pressedByTouch.current = true;
|
|
585
|
+
press.current = { wasOpen: open };
|
|
586
|
+
close();
|
|
587
|
+
} else {
|
|
588
|
+
pressedByTouch.current = false;
|
|
589
|
+
pressedByMouse.current = true;
|
|
590
|
+
}
|
|
591
|
+
passed.onPointerDown?.(e);
|
|
592
|
+
},
|
|
593
|
+
onPointerUp: (e: PointerEvent<HTMLSpanElement>) => {
|
|
594
|
+
pressedByMouse.current = false;
|
|
595
|
+
const began = press.current;
|
|
596
|
+
press.current = null;
|
|
597
|
+
if (began && !began.wasOpen && isTap(e.pointerType) && tapShows(tap, e.target)) {
|
|
598
|
+
setTapped(true);
|
|
599
|
+
arm(e.currentTarget);
|
|
600
|
+
}
|
|
601
|
+
passed.onPointerUp?.(e);
|
|
602
|
+
},
|
|
603
|
+
// The browser took the gesture over — a scroll, a pinch: not a tap.
|
|
604
|
+
onPointerCancel: (e: PointerEvent<HTMLSpanElement>) => {
|
|
605
|
+
pressedByMouse.current = false;
|
|
606
|
+
press.current = null;
|
|
607
|
+
passed.onPointerCancel?.(e);
|
|
608
|
+
},
|
|
609
|
+
onMouseEnter: (e: MouseEvent<HTMLSpanElement>) => {
|
|
610
|
+
if (pressedByTouch.current) return;
|
|
611
|
+
setHovered(true);
|
|
612
|
+
arm(e.currentTarget);
|
|
613
|
+
},
|
|
614
|
+
onMouseLeave: () => setHovered(false),
|
|
615
|
+
onFocus: (e: FocusEvent<HTMLSpanElement>) => {
|
|
616
|
+
if (!focusShows(e.target, pressedByMouse.current)) return;
|
|
617
|
+
setFocused(true);
|
|
618
|
+
arm(e.currentTarget);
|
|
619
|
+
},
|
|
620
|
+
onBlur: (e: FocusEvent<HTMLSpanElement>) => {
|
|
621
|
+
setFocused(false);
|
|
622
|
+
if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;
|
|
623
|
+
setTapped(false);
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
return { open, dismissed, handlers };
|
|
627
|
+
}
|
|
628
|
+
|
|
305
629
|
/** The variant that lives next to its trigger, and — when `detect` is on, which is the
|
|
306
630
|
* default — moves its bubble to `<body>` when that turns out to be inside a clipping
|
|
307
631
|
* container (see "Inside a scroll container" on {@link Tooltip}).
|
|
308
632
|
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
633
|
+
* Whether the bubble is up is {@link useTooltipTrigger}'s, shared with the portalled
|
|
634
|
+
* variant. Until 0.25 the always-mounted bubble showed itself with CSS alone —
|
|
635
|
+
* `group-hover` / `group-focus-within` — which is exactly what a tap on a phone could not
|
|
636
|
+
* get rid of: the tapped button keeps the focus, and `:focus-within` cannot be told that
|
|
637
|
+
* the focus came from a finger. It is shown from the same state as every other bubble
|
|
638
|
+
* now, and is `display: none` while closed (see "never widens the page" on
|
|
639
|
+
* {@link Tooltip}).
|
|
312
640
|
*
|
|
313
641
|
* ONE component for both placements rather than a switch between the two variants: a
|
|
314
642
|
* switch would be a different component at the same place in the tree, and React
|
|
@@ -322,60 +650,48 @@ function InPlaceTooltip({
|
|
|
322
650
|
redact,
|
|
323
651
|
detect,
|
|
324
652
|
lazy,
|
|
653
|
+
tap,
|
|
325
654
|
children,
|
|
326
655
|
...rest
|
|
327
656
|
}: TooltipVariantProps & { detect: boolean; lazy: boolean }) {
|
|
328
657
|
const id = useId();
|
|
329
658
|
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
|
330
659
|
const [clipped, setClipped] = useState(false);
|
|
331
|
-
const [hovered, setHovered] = useState(false);
|
|
332
|
-
const [focused, setFocused] = useState(false);
|
|
333
|
-
const [dismissed, setDismissed] = useState(false);
|
|
334
660
|
const [dir, setDir] = useState<Direction>("ltr");
|
|
335
|
-
|
|
336
|
-
|
|
661
|
+
// The reading direction on every request (the clamp keeps the start edge of a bubble
|
|
662
|
+
// too wide for the room); the clipping check too, for a container that began to scroll
|
|
663
|
+
// after mount.
|
|
664
|
+
const { open, dismissed, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => {
|
|
665
|
+
setDir(dirOf(el));
|
|
666
|
+
if (detect) setClipped(hasClippingAncestor(el));
|
|
667
|
+
});
|
|
337
668
|
// keksdose G7: slide an open in-place bubble back onto the screen. See "The in-place
|
|
338
669
|
// bubble is clamped" on {@link Tooltip}.
|
|
339
670
|
const bubbleRef = useRef<HTMLSpanElement | null>(null);
|
|
340
|
-
const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);
|
|
671
|
+
const { shift, measuring } = useViewportClamp(bubbleRef, open && !clipped, side, label, dir);
|
|
341
672
|
// At mount, before the first paint: the in-place bubble inside a scroller is the
|
|
342
673
|
// phantom-scroll bug whether or not anyone opens it.
|
|
343
674
|
useLayoutEffect(() => {
|
|
344
675
|
if (detect) setClipped(hasClippingAncestor(triggerRef.current));
|
|
345
676
|
}, [detect]);
|
|
346
|
-
// Re-armed by the next hover or focus rather than by an effect watching those flags:
|
|
347
|
-
// coming back to a trigger is a fresh request for its label, and an effect would also
|
|
348
|
-
// re-show the bubble under a pointer that never left. The clipping check is repeated
|
|
349
|
-
// here for a container that began to scroll after mount.
|
|
350
|
-
const arm = (el: Element) => {
|
|
351
|
-
setDismissed(false);
|
|
352
|
-
if (!detect) return;
|
|
353
|
-
setDir(dirOf(el));
|
|
354
|
-
setClipped(hasClippingAncestor(el));
|
|
355
|
-
};
|
|
356
677
|
return (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
678
|
+
// No role for this span: its handlers track whether the bubble is up and activate
|
|
679
|
+
// nothing; the caller's child is the interactive element, keeps its own handlers,
|
|
680
|
+
// and its focus shows the bubble too.
|
|
360
681
|
<span
|
|
361
|
-
// `...rest` first: the
|
|
362
|
-
//
|
|
682
|
+
// `...rest` first: the handlers below are what decides whether a bubble is up, and
|
|
683
|
+
// a caller passing an `onFocus` of its own must not replace them (its pointer
|
|
684
|
+
// handlers are called from them).
|
|
363
685
|
{...rest}
|
|
364
686
|
ref={triggerRef}
|
|
687
|
+
// Clipped for the one render in which the bubble is measured (see useViewportClamp):
|
|
688
|
+
// that layout must not reach the page's width.
|
|
689
|
+
style={measuring ? { ...rest.style, overflow: "clip" } : rest.style}
|
|
365
690
|
className={cn("relative inline-flex", !clipped && "group/tooltip", className)}
|
|
366
|
-
// These
|
|
367
|
-
//
|
|
691
|
+
// These track WHETHER A BUBBLE IS UP. They activate nothing — the only thing here
|
|
692
|
+
// that can be activated is the caller's child, which keeps every handler it
|
|
368
693
|
// arrived with — so this wrapper needs no role and no key handling of its own.
|
|
369
|
-
|
|
370
|
-
setHovered(true);
|
|
371
|
-
arm(e.currentTarget);
|
|
372
|
-
}}
|
|
373
|
-
onMouseLeave={() => setHovered(false)}
|
|
374
|
-
onFocus={(e) => {
|
|
375
|
-
setFocused(true);
|
|
376
|
-
arm(e.currentTarget);
|
|
377
|
-
}}
|
|
378
|
-
onBlur={() => setFocused(false)}
|
|
694
|
+
{...handlers}
|
|
379
695
|
>
|
|
380
696
|
{/* In place the bubble is always there to point at; portalled or lazy, only while up. */}
|
|
381
697
|
{describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}
|
|
@@ -385,10 +701,7 @@ function InPlaceTooltip({
|
|
|
385
701
|
)
|
|
386
702
|
) : lazy ? (
|
|
387
703
|
// keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only
|
|
388
|
-
// while up, so it is visible whenever it exists
|
|
389
|
-
// than the `group-hover` switch, which would also be right but would make the
|
|
390
|
-
// bubble's visibility depend on two sources (the state that mounted it and the
|
|
391
|
-
// CSS that shows it) that can disagree for a frame after Escape.
|
|
704
|
+
// while up, so it is visible whenever it exists.
|
|
392
705
|
open && (
|
|
393
706
|
<span
|
|
394
707
|
ref={bubbleRef}
|
|
@@ -407,14 +720,19 @@ function InPlaceTooltip({
|
|
|
407
720
|
id={id}
|
|
408
721
|
role="tooltip"
|
|
409
722
|
style={shiftStyle(shift)}
|
|
410
|
-
// The `hidden` ATTRIBUTE, not
|
|
411
|
-
//
|
|
412
|
-
//
|
|
723
|
+
// The `hidden` ATTRIBUTE, not a class: dismissing has to take the bubble out of
|
|
724
|
+
// the accessibility tree as well as off the screen, or a screen reader still
|
|
725
|
+
// reads out the description of a bubble the user just closed. Merely CLOSED it
|
|
726
|
+
// is the `hidden` CLASS — `display: none` in the browser, so it takes no room
|
|
727
|
+
// and widens nothing (live #381), and nothing at all under jsdom, where tests
|
|
728
|
+
// have always found this bubble without a hover. `aria-describedby` reads a
|
|
729
|
+
// `display: none` node all the same.
|
|
413
730
|
hidden={dismissed || undefined}
|
|
414
731
|
data-private={redact ? "" : undefined}
|
|
415
732
|
className={cn(
|
|
416
733
|
TOOLTIP_SURFACE,
|
|
417
|
-
"pointer-events-none absolute z-50
|
|
734
|
+
"pointer-events-none absolute z-50",
|
|
735
|
+
open ? "opacity-100" : "hidden",
|
|
418
736
|
sidePositionClass[side],
|
|
419
737
|
)}
|
|
420
738
|
>
|
|
@@ -463,24 +781,67 @@ interface Shift {
|
|
|
463
781
|
|
|
464
782
|
const NO_SHIFT: Shift = { x: 0, y: 0 };
|
|
465
783
|
|
|
784
|
+
/** An in-place bubble's slide and the label, side and direction it was measured for. */
|
|
785
|
+
interface Placement {
|
|
786
|
+
shift: Shift;
|
|
787
|
+
for: { side: TooltipSide; label: ReactNode; dir: Direction } | null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const UNPLACED: Placement = { shift: NO_SHIFT, for: null };
|
|
791
|
+
|
|
466
792
|
/** How far to slide the span `[low, high]` so it sits inside `[TOOLTIP_MARGIN,
|
|
467
|
-
* extent - TOOLTIP_MARGIN]`. Zero when it already does.
|
|
468
|
-
*
|
|
469
|
-
* worth keeping
|
|
470
|
-
*
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
793
|
+
* extent - TOOLTIP_MARGIN]`. Zero when it already does. When the span is wider than the
|
|
794
|
+
* room, the edge it keeps is its START — as in {@link clamp}, the start of a label is the
|
|
795
|
+
* half worth keeping — which is the low edge unless `keepHigh` says the start is the
|
|
796
|
+
* high one (an RTL label, along x). The width cap means that only happens on a viewport
|
|
797
|
+
* barely wider than the margins, or one whose scrollbar the cap's `100vw` counts. */
|
|
798
|
+
function slideInto(low: number, high: number, extent: number, keepHigh = false): number {
|
|
799
|
+
const min = TOOLTIP_MARGIN;
|
|
800
|
+
const max = extent - TOOLTIP_MARGIN;
|
|
801
|
+
if (high - low > max - min) return keepHigh ? max - high : min - low;
|
|
802
|
+
if (low < min) return min - low;
|
|
803
|
+
if (high > max) return max - high;
|
|
474
804
|
return 0;
|
|
475
805
|
}
|
|
476
806
|
|
|
807
|
+
/**
|
|
808
|
+
* The glass a bubble has to stay on, in CSS pixels — `window.innerWidth` is the wrong
|
|
809
|
+
* width on a phone (keksdose run 72, live #381). There it is the LAYOUT viewport, and
|
|
810
|
+
* that grows to the page's width as soon as anything sticks out past the screen: 430 on
|
|
811
|
+
* a 406px screen in Chromium's mobile emulation, with `position: fixed` layers laid out
|
|
812
|
+
* against the 430. A bubble that had widened the page then measured itself as inside it.
|
|
813
|
+
* The root element's client width is the initial containing block instead — the screen,
|
|
814
|
+
* the width `100vw` and the media queries see — whatever overflows, and on a desktop it
|
|
815
|
+
* also leaves a classic scrollbar out. It is 0 where nothing is laid out (jsdom), and the
|
|
816
|
+
* window is the answer there. The height stays the window's: nothing measured grows it,
|
|
817
|
+
* and a phone's collapsing toolbar makes the window the truer of the two.
|
|
818
|
+
*/
|
|
819
|
+
function viewportSize(): TooltipViewport {
|
|
820
|
+
return {
|
|
821
|
+
width: document.documentElement.clientWidth || window.innerWidth,
|
|
822
|
+
height: window.innerHeight,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
477
826
|
/**
|
|
478
827
|
* The in-place bubble's viewport clamp (keksdose G7): while `active`, measure the bubble
|
|
479
828
|
* in a LAYOUT effect — before paint, so there is no frame with the label off the screen —
|
|
480
|
-
* and return the cross-axis slide that brings it inside the viewport
|
|
481
|
-
* `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`
|
|
829
|
+
* and return the cross-axis slide that brings it inside the viewport ({@link viewportSize})
|
|
830
|
+
* minus `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`
|
|
482
831
|
* along y. {@link NO_SHIFT} while closed, so the next open measures the bubble where the
|
|
483
|
-
* CSS alone puts it.
|
|
832
|
+
* CSS alone puts it. `dir` only decides which edge survives a bubble wider than the room.
|
|
833
|
+
*
|
|
834
|
+
* `measuring` is true for the one render in which the bubble is measured — on open, and
|
|
835
|
+
* again when the label, side or direction changes while open — and the caller clips its
|
|
836
|
+
* wrapper (`overflow: clip`) for that render. Measuring means laying the bubble out where
|
|
837
|
+
* the CSS alone puts it, which is past the edge whenever there is something to slide, and
|
|
838
|
+
* that layout counts towards the page's scrollable overflow even though it is never
|
|
839
|
+
* painted. Chrome on a phone grows the layout viewport to it and does not always give the
|
|
840
|
+
* width back: in an RTL page, where overflow on the left is scrollable, a bubble measured
|
|
841
|
+
* at −24 left the page 426 wide and scrolled by −20 after the slide had already brought it
|
|
842
|
+
* to 4 (Chromium mobile emulation, 406px). A clipped wrapper keeps the measurement out of
|
|
843
|
+
* every ancestor's overflow — `clip` and not `hidden`, so the wrapper never becomes a
|
|
844
|
+
* scroll container — and it is unclipped, with the slide, in the same task, before paint.
|
|
484
845
|
*
|
|
485
846
|
* The rect it reads already includes the slide it applied last time (a label that
|
|
486
847
|
* changed while open), so that slide is taken back out before deciding the new one.
|
|
@@ -491,33 +852,62 @@ function useViewportClamp(
|
|
|
491
852
|
active: boolean,
|
|
492
853
|
side: TooltipSide,
|
|
493
854
|
label: ReactNode,
|
|
494
|
-
|
|
495
|
-
|
|
855
|
+
dir: Direction,
|
|
856
|
+
): { shift: Shift; measuring: boolean } {
|
|
857
|
+
// The slide, and what it was measured for: anything else on screen — a fresh open, a
|
|
858
|
+
// new label, side or direction — is not measured yet, and renders clipped until it is.
|
|
859
|
+
const [placement, setPlacement] = useState<Placement>(UNPLACED);
|
|
860
|
+
const measured =
|
|
861
|
+
placement.for !== null &&
|
|
862
|
+
placement.for.side === side &&
|
|
863
|
+
placement.for.label === label &&
|
|
864
|
+
placement.for.dir === dir;
|
|
496
865
|
useLayoutEffect(() => {
|
|
497
866
|
const el = bubbleRef.current;
|
|
498
867
|
if (!active || !el) {
|
|
499
|
-
|
|
868
|
+
setPlacement(UNPLACED);
|
|
500
869
|
return;
|
|
501
870
|
}
|
|
871
|
+
if (measured) return;
|
|
502
872
|
const r = el.getBoundingClientRect();
|
|
503
|
-
|
|
873
|
+
const unlaid = r.width === 0 && r.height === 0;
|
|
504
874
|
const horizontal = side === "top" || side === "bottom";
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
875
|
+
const viewport = viewportSize();
|
|
876
|
+
setPlacement(({ shift: previous }) => {
|
|
877
|
+
const next = unlaid
|
|
878
|
+
? previous
|
|
879
|
+
: horizontal
|
|
880
|
+
? { x: slideInto(r.left - previous.x, r.right - previous.x, viewport.width, dir === "rtl"), y: 0 }
|
|
881
|
+
: { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, viewport.height) };
|
|
882
|
+
const shift = next.x === previous.x && next.y === previous.y ? previous : next;
|
|
883
|
+
return { shift, for: { side, label, dir } };
|
|
510
884
|
});
|
|
511
|
-
}, [bubbleRef, active, side, label]);
|
|
512
|
-
return shift;
|
|
885
|
+
}, [bubbleRef, active, measured, side, label, dir]);
|
|
886
|
+
return { shift: placement.shift, measuring: active && !measured };
|
|
513
887
|
}
|
|
514
888
|
|
|
515
|
-
/**
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
889
|
+
/**
|
|
890
|
+
* The inline style for a slide: none at all when there is nothing to slide, so a bubble
|
|
891
|
+
* that fits renders exactly as it did before G7.
|
|
892
|
+
*
|
|
893
|
+
* A MARGIN, not a `transform` (0.14–0.24 used `transform: translate(…)`), because of what
|
|
894
|
+
* Blink does with the page's width (keksdose run 72, live #381). The slide is decided after
|
|
895
|
+
* a layout that placed the bubble where the CSS alone puts it — past the edge — and that
|
|
896
|
+
* layout already counted it into the page's scrollable overflow. A later change to
|
|
897
|
+
* `transform` alone re-paints but does not re-lay-out, so that overflow was never taken
|
|
898
|
+
* back: measured in Chromium's mobile emulation, a bubble shown at 108–428 on a 406px
|
|
899
|
+
* screen and then slid to 400 by a transform left the page 428 wide, the layout viewport
|
|
900
|
+
* with it (`position: fixed` layers laid out against 428), until the bubble closed. The
|
|
901
|
+
* same slide as a margin is a layout change; the page went back to 406 in the same frame.
|
|
902
|
+
*
|
|
903
|
+
* Physical margins, matching the physical maths: `margin-left` moves a `top` / `bottom`
|
|
904
|
+
* bubble, whose placement is the physical `left: 50%`; `margin-top` moves the four side
|
|
905
|
+
* placements, placed by `top: 50%`. Neither is a margin the placement classes set — they
|
|
906
|
+
* keep their gap on the main axis (`mb-1`, `ms-1`, …).
|
|
907
|
+
*/
|
|
519
908
|
function shiftStyle(shift: Shift) {
|
|
520
|
-
|
|
909
|
+
if (shift.x === 0 && shift.y === 0) return undefined;
|
|
910
|
+
return shift.x !== 0 ? { marginLeft: shift.x } : { marginTop: shift.y };
|
|
521
911
|
}
|
|
522
912
|
|
|
523
913
|
const portalTransformBySide: Record<PhysicalSide, string> = {
|
|
@@ -660,46 +1050,38 @@ function PortalTooltip({
|
|
|
660
1050
|
side,
|
|
661
1051
|
className,
|
|
662
1052
|
redact,
|
|
1053
|
+
tap,
|
|
663
1054
|
children,
|
|
664
1055
|
...rest
|
|
665
1056
|
}: TooltipVariantProps) {
|
|
666
1057
|
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
|
667
|
-
const [visible, setVisible] = useState(false);
|
|
668
1058
|
// The trigger's reading direction, read when the bubble is asked for (an event, not a
|
|
669
1059
|
// render): it resolves `start` / `end`, and the portalled bubble — which has left the
|
|
670
1060
|
// subtree it would have inherited `dir` from — carries it too.
|
|
671
1061
|
const [dir, setDir] = useState<Direction>("ltr");
|
|
672
|
-
const show = (el: Element) => {
|
|
673
|
-
setDir(dirOf(el));
|
|
674
|
-
setVisible(true);
|
|
675
|
-
};
|
|
676
1062
|
const id = useId();
|
|
677
1063
|
// Escape closes it outright, since this variant's bubble only exists while it is
|
|
678
1064
|
// shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
|
|
679
1065
|
// 1.4.13 asks for: dismissible now, still available when you ask again.
|
|
680
|
-
|
|
1066
|
+
const { open, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => setDir(dirOf(el)));
|
|
681
1067
|
|
|
682
1068
|
return (
|
|
683
1069
|
<>
|
|
684
|
-
{/*
|
|
685
|
-
|
|
686
|
-
is the interactive element, and focusing it shows the bubble. */}
|
|
1070
|
+
{/* As in `InPlaceTooltip`, no role: the handlers only show and hide the bubble; the
|
|
1071
|
+
caller's child is the interactive element, and focusing it shows the bubble. */}
|
|
687
1072
|
<span
|
|
688
|
-
// As in `InPlaceTooltip`: the caller's attributes first, the
|
|
689
|
-
//
|
|
1073
|
+
// As in `InPlaceTooltip`: the caller's attributes first, the handlers that run
|
|
1074
|
+
// this component after them. The BUBBLE is deliberately not given them — it
|
|
690
1075
|
// is portalled to `<body>`, and an id or a tour anchor duplicated onto a node
|
|
691
1076
|
// that only exists while hovered would match twice or match nothing.
|
|
692
1077
|
{...rest}
|
|
693
1078
|
ref={triggerRef}
|
|
694
1079
|
className={cn("relative inline-flex", className)}
|
|
695
|
-
|
|
696
|
-
onMouseLeave={() => setVisible(false)}
|
|
697
|
-
onFocus={(e) => show(e.currentTarget)}
|
|
698
|
-
onBlur={() => setVisible(false)}
|
|
1080
|
+
{...handlers}
|
|
699
1081
|
>
|
|
700
|
-
{describedBy(children,
|
|
1082
|
+
{describedBy(children, open ? id : undefined)}
|
|
701
1083
|
</span>
|
|
702
|
-
{
|
|
1084
|
+
{open && (
|
|
703
1085
|
<PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
|
|
704
1086
|
)}
|
|
705
1087
|
</>
|
|
@@ -740,7 +1122,8 @@ function PortalBubble({
|
|
|
740
1122
|
if (!measured) return null;
|
|
741
1123
|
const next = {
|
|
742
1124
|
size: { width: measured.width, height: measured.height },
|
|
743
|
-
|
|
1125
|
+
// The glass, not the window: see viewportSize (live #381).
|
|
1126
|
+
viewport: viewportSize(),
|
|
744
1127
|
};
|
|
745
1128
|
// Only publish what actually CHANGED: every re-measure allocates a fresh
|
|
746
1129
|
// object, and a new object on every scroll event would re-render the
|