@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 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type ReactElement,\n type ReactNode,\n type RefObject,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { hasClippingAncestor } from \"../lib/clipping\";\n\n/** The attribute that marks an element as a clipping container for {@link Tooltip}'s\n * auto-portal, whatever its computed `overflow` — `<div data-clips>` or\n * `<div {...{ [CLIPS_ATTRIBUTE]: \"\" }}>`. Put it on an app's own scroller so a test\n * environment without stylesheets (jsdom) portals the same tooltips the browser\n * does. DataTable's body and Table's wrapper already carry it. */\nexport { CLIPS_ATTRIBUTE } from \"../lib/clipping\";\n\n/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the\n * right in LTR and the left in RTL — and are what a layout that mirrors should ask\n * for. `left` / `right` stay physical, for a bubble tied to something that does not\n * mirror (a chart axis, a map). */\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\" | \"start\" | \"end\";\n\n/** The placement the maths works in: a logical side resolved against the trigger. */\ntype PhysicalSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nfunction physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {\n if (side === \"start\") return dir === \"rtl\" ? \"right\" : \"left\";\n if (side === \"end\") return dir === \"rtl\" ? \"left\" : \"right\";\n return side;\n}\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-placed one never turns round, so there\n * `side` is still the whole of which side it is on — but since 0.14 it is slid back\n * along the cross axis when it would cross the viewport edge (keksdose G7), which the\n * viewport term in this cap is what makes possible: a bubble never wider than the glass\n * minus the margins always fits once slid. */\nconst TOOLTIP_SURFACE =\n \"w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n // Logical insets, so CSS resolves the side from the inherited direction with no\n // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.\n start: \"end-full top-1/2 -translate-y-1/2 me-1\",\n end: \"start-full top-1/2 -translate-y-1/2 ms-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n /**\n * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for\n * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls\n * (`overflow` other than `visible`, or the {@link CLIPS_ATTRIBUTE} marker), in which\n * case it is portalled — see \"Inside a scroll container\" below. `true` always portals, `false` never does; both are exactly\n * what they were before the default existed.\n */\n portal?: boolean;\n /**\n * Mount the IN-PLACE bubble only while it is up (hovered or focused, not dismissed),\n * instead of always. It still sits next to its trigger and is still placed by CSS\n * alone; what changes is that, while closed, it is not in the DOM — so it is not in\n * `getAllByRole(\"tooltip\")`, not in an ancestor's `textContent` and not in an\n * ancestor's accessible name. The trigger is described (`aria-describedby`) while\n * the bubble is up, exactly as a portalled one is. See \"Lazy in place\" on\n * {@link Tooltip} for why this exists (keksdose F6) and why it is not the default.\n *\n * Has no effect on a bubble that is portalled (`portal`, or auto-portalled inside a\n * clipping container) — that one is already mounted only while up.\n */\n lazy?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side`, and every span attribute the\n * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\" | \"lazy\"> & { side: TooltipSide };\n\n/**\n * Hover/focus label for a control.\n *\n * Two placements, and the choice matters more than it looks. In place, the bubble is\n * always mounted next to the trigger and fades in on `:hover`, which costs no state and\n * works in a plain render test. Portalled, the bubble is mounted in `document.body`\n * only while it is up, positioned by measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the in-place bubble is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, the bubble has to be portalled — and by default it\n * now is.** An always-mounted bubble is absolutely positioned, but an absolutely\n * positioned descendant still counts towards its scroll-container ancestor's\n * scrollable overflow — so an invisible bubble on a control near the right edge makes\n * the container scroll sideways with nothing to reveal. That is what Keksdose feedback\n * dev#488 reported on the admin roster: 66px of horizontal scroll on a table that fit,\n * 44px of it owed to tooltips nobody could see. And a visible one is clipped by the\n * container's edge. The portalled bubble is `position: fixed` and absent until hovered,\n * so it adds no width and cannot be clipped.\n *\n * Until 0.10.0 the cure was `portal` at the call site, and kastlan asked for it to stop\n * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,\n * and the ones that forgot were only found by someone scrolling sideways. So with\n * `portal` left out, the tooltip looks for a clipping ancestor itself — any element\n * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`,\n * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the\n * answer the same under test: jsdom computes no Tailwind, so there every scroller\n * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted\n * bubble then repeated the label in the cell's accessible name and `textContent`\n * (\"CheckingChecking\"), and apps pinned `portal` to stop it. The kit's own scrollers\n * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it\n * does in the browser.\n * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble\n * nobody opened, so a check that waited for the hover would find the damage already\n * done. It looks again on every open, for a container that started scrolling after\n * the tooltip mounted (a table that grew). Only the bubble changes place; the trigger\n * and the caller's child stay mounted, so a switch never costs a focused button its\n * focus. Outside any such container the in-place bubble is kept, which is still the\n * cheaper one and the one a plain render test can find without a hover.\n *\n * Why not simply portal everything? Because the in-place bubble is the one existing\n * app tests rely on (it is in the DOM without a hover), and because it follows its\n * trigger through a scroll or an animation for free — the portalled one re-measures.\n *\n * ⚠️ **Lazy in place: `lazy` keeps the bubble next to its trigger but out of the DOM\n * until it is up (keksdose F6).** `data-clips` cured the doubled text for tooltips\n * inside a table; it did nothing for the ones outside any clipping container, where the\n * always-mounted bubble is still a real `role=\"tooltip\"` node full of text. About seven\n * keksdose sites — column-header tooltips, a direction toggle, an fx-estimate row, a\n * FlagBadge, a toast inside a `<button>` — still pinned `portal` for that alone: their\n * tests found two tooltips with `getAllByRole(\"tooltip\")`, read the label twice in a\n * `textContent`, or got a button whose accessible name had the bubble's sentence glued\n * on. `portal` fixed the test and cost the browser the measured, re-positioning bubble\n * for no reason. `lazy` is the in-place bubble with the portalled one's lifetime: it is\n * rendered only while hovered or focused (and not dismissed), in the same slot, with the\n * same classes, so it looks and sits exactly as the default one does when it is up.\n *\n * Why unmount rather than hide? Hiding was the other option — keep the bubble mounted\n * with `hidden` (or `aria-hidden` plus `display: none` until `:hover` / `:focus-within`)\n * and let `aria-describedby` go on pointing at it, which the accname algorithm allows:\n * a node referenced directly by `aria-describedby` contributes its text even while\n * hidden. That would take the bubble out of the role queries and out of an ancestor's\n * accessible name — but NOT out of `textContent`, which is plain DOM and counts hidden\n * text too, and `textContent` is one of the three things F6 lists. Only a bubble that\n * is not there solves all three. The cost is the portalled variant's: the trigger is\n * described while the bubble is up rather than always. Focusing the trigger IS what\n * puts it up, and React commits the `aria-describedby` in the same task as that focus\n * event, so a screen reader landing on the control still hears it; what goes is the description\n * of a trigger that is read in browse mode without ever being focused.\n *\n * Escape still dismisses it — here by unmounting it, which takes it off the screen and\n * out of the accessibility tree at once — and the next hover or focus brings it back.\n * There is no fade to lose: neither in-place bubble has ever had a transition, only the\n * `opacity` switch, so appearing on a state change looks the same as appearing on\n * `:hover`.\n *\n * Why opt-in rather than the new default? Because the always-mounted bubble is the one\n * existing app tests find without a hover (see above): flipping it would turn every\n * `getByRole(\"tooltip\")` written against 0.12 into a failure in all three apps at once.\n * Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.\n *\n * ⚠️ **The in-place bubble is clamped to the viewport when it opens (keksdose G7).** The\n * portalled bubble has always measured itself and been pushed back onto the glass\n * ({@link placeTooltip}); the in-place one never learnt its own size, so `side` was the\n * whole of its placement and a `top` / `bottom` bubble sat centred on its trigger\n * whatever that cost. The cost shows on a phone: long labels live at a row's START edge\n * — a gcloud command in keksdose's jobs panel, a canned reply in a support thread — and\n * a 20rem bubble centred on a trigger 16px from the edge hangs half its text off the\n * screen. keksdose pinned `portal` on those sites for that alone, which gave up `lazy`'s\n * point (the cheap, in-place bubble) to buy a placement.\n *\n * So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures\n * it and, if it crosses the viewport edge minus the same margin the portalled bubble\n * keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or\n * down for the four side placements — with an inline `transform`, which composes with the\n * placement classes' own `translate`. The main axis is left alone on purpose: sliding a\n * `start` bubble along the main axis would slide it over its own trigger, and turning it\n * round is a measured-placement decision this CSS-placed bubble does not make (pass\n * `portal` for that). The width is already capped to the viewport (see\n * `TOOLTIP_SURFACE`), so a bubble can always fit once slid, and a long label wraps\n * instead of growing past the glass.\n *\n * Why on by default, with no prop? Because it is a no-op for every bubble that already\n * fits — the shift is zero unless the bubble would overflow — so the only placements it\n * changes are ones that were broken. The maths is in physical viewport pixels, so it\n * needs no `dir`: an RTL row puts its long label at the RIGHT edge and gets slid left by\n * the same code. Under jsdom there is no layout — every rect is zero-sized — and a\n * zero-sized bubble is taken to be unmeasured, so tests see no transform at all. It is\n * measured on open (and when the label or side changes while open), not on every scroll:\n * an in-place bubble follows its trigger for free, and a page that scrolls sideways under\n * an open tooltip is not a case worth a listener per tooltip.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal,\n lazy = false,\n redact = false,\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal === true) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <InPlaceTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n detect={portal === undefined}\n lazy={lazy}\n {...rest}\n >\n {children}\n </InPlaceTooltip>\n );\n}\n\n\n/** The variant that lives next to its trigger, and — when `detect` is on, which is the\n * default — moves its bubble to `<body>` when that turns out to be inside a clipping\n * container (see \"Inside a scroll container\" on {@link Tooltip}).\n *\n * In place it holds the little state it does for the two things CSS cannot express —\n * which element to point `aria-describedby` at, and Escape — and not for the fade,\n * which is still `group-hover`/`group-focus-within` and still costs a render nothing.\n *\n * ONE component for both placements rather than a switch between the two variants: a\n * switch would be a different component at the same place in the tree, and React\n * would remount the caller's child with it — a button that loses focus the moment\n * its own tooltip decided where to go. Here the wrapper and the child stay put and\n * only the bubble's slot changes. */\nfunction InPlaceTooltip({\n label,\n side,\n className,\n redact,\n detect,\n lazy,\n children,\n ...rest\n}: TooltipVariantProps & { detect: boolean; lazy: boolean }) {\n const id = useId();\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const [clipped, setClipped] = useState(false);\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const open = (hovered || focused) && !dismissed;\n useEscapeKey(() => setDismissed(true), open);\n // keksdose G7: slide an open in-place bubble back onto the screen. See \"The in-place\n // bubble is clamped\" on {@link Tooltip}.\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n const shift = useViewportClamp(bubbleRef, open && !clipped, side, label);\n // At mount, before the first paint: the in-place bubble inside a scroller is the\n // phantom-scroll bug whether or not anyone opens it.\n useLayoutEffect(() => {\n if (detect) setClipped(hasClippingAncestor(triggerRef.current));\n }, [detect]);\n // Re-armed by the next hover or focus rather than by an effect watching those flags:\n // coming back to a trigger is a fresh request for its label, and an effect would also\n // re-show the bubble under a pointer that never left. The clipping check is repeated\n // here for a container that began to scroll after mount.\n const arm = (el: Element) => {\n setDismissed(false);\n if (!detect) return;\n setDir(dirOf(el));\n setClipped(hasClippingAncestor(el));\n };\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers track\n whether the bubble is up and activate nothing; the caller's child is the\n interactive element, keeps its own handlers, and its focus shows the bubble too. */\n <span\n // `...rest` first: the four handlers below are what decides whether a bubble is\n // up, and a caller passing an `onFocus` of its own must not replace them.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", !clipped && \"group/tooltip\", className)}\n // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing\n // here that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n onMouseEnter={(e) => {\n setHovered(true);\n arm(e.currentTarget);\n }}\n onMouseLeave={() => setHovered(false)}\n onFocus={(e) => {\n setFocused(true);\n arm(e.currentTarget);\n }}\n onBlur={() => setFocused(false)}\n >\n {/* In place the bubble is always there to point at; portalled or lazy, only while up. */}\n {describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}\n {clipped ? (\n open && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )\n ) : lazy ? (\n // keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only\n // while up, so it is visible whenever it exists — `opacity-100` outright rather\n // than the `group-hover` switch, which would also be right but would make the\n // bubble's visibility depend on two sources (the state that mounted it and the\n // CSS that shows it) that can disagree for a frame after Escape.\n open && (\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n data-private={redact ? \"\" : undefined}\n style={shiftStyle(shift)}\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none absolute z-50 opacity-100\", sidePositionClass[side])}\n >\n {label}\n </span>\n )\n ) : (\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n style={shiftStyle(shift)}\n // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the\n // bubble out of the accessibility tree as well as off the screen, or a screen\n // reader still reads out the description of a bubble the user just closed.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n )}\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\n/** A cross-axis slide, in viewport pixels, for an in-place bubble. */\ninterface Shift {\n x: number;\n y: number;\n}\n\nconst NO_SHIFT: Shift = { x: 0, y: 0 };\n\n/** How far to slide the span `[low, high]` so it sits inside `[TOOLTIP_MARGIN,\n * extent - TOOLTIP_MARGIN]`. Zero when it already does. The start edge wins when the\n * span is wider than the room — as in {@link clamp}, the start of a label is the half\n * worth keeping (and the width cap means that only happens on a viewport narrower than\n * twice the margin). */\nfunction slideInto(low: number, high: number, extent: number): number {\n if (low < TOOLTIP_MARGIN) return TOOLTIP_MARGIN - low;\n if (high > extent - TOOLTIP_MARGIN) return Math.max(extent - TOOLTIP_MARGIN - high, TOOLTIP_MARGIN - low);\n return 0;\n}\n\n/**\n * The in-place bubble's viewport clamp (keksdose G7): while `active`, measure the bubble\n * in a LAYOUT effect — before paint, so there is no frame with the label off the screen —\n * and return the cross-axis slide that brings it inside the viewport minus\n * `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`\n * along y. {@link NO_SHIFT} while closed, so the next open measures the bubble where the\n * CSS alone puts it.\n *\n * The rect it reads already includes the slide it applied last time (a label that\n * changed while open), so that slide is taken back out before deciding the new one.\n * A zero-sized rect means no layout (jsdom, `display: none`) and leaves the bubble put.\n */\nfunction useViewportClamp(\n bubbleRef: RefObject<HTMLSpanElement | null>,\n active: boolean,\n side: TooltipSide,\n label: ReactNode,\n): Shift {\n const [shift, setShift] = useState<Shift>(NO_SHIFT);\n useLayoutEffect(() => {\n const el = bubbleRef.current;\n if (!active || !el) {\n setShift(NO_SHIFT);\n return;\n }\n const r = el.getBoundingClientRect();\n if (r.width === 0 && r.height === 0) return;\n const horizontal = side === \"top\" || side === \"bottom\";\n setShift((previous) => {\n const next = horizontal\n ? { x: slideInto(r.left - previous.x, r.right - previous.x, window.innerWidth), y: 0 }\n : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, window.innerHeight) };\n return next.x === previous.x && next.y === previous.y ? previous : next;\n });\n }, [bubbleRef, active, side, label]);\n return shift;\n}\n\n/** The inline style for a slide: none at all when there is nothing to slide, so a bubble\n * that fits renders exactly as it did before G7. `transform` rather than `translate`\n * because the placement classes own the `translate` property (Tailwind v4's\n * `-translate-x-1/2`); the two compose instead of one replacing the other. */\nfunction shiftStyle(shift: Shift) {\n return shift.x === 0 && shift.y === 0 ? undefined : { transform: `translate(${shift.x}px, ${shift.y}px)` };\n}\n\nconst portalTransformBySide: Record<PhysicalSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: PhysicalSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: PhysicalSide;\n}\n\nconst opposite: Record<PhysicalSide, PhysicalSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const [visible, setVisible] = useState(false);\n // The trigger's reading direction, read when the bubble is asked for (an event, not a\n // render): it resolves `start` / `end`, and the portalled bubble — which has left the\n // subtree it would have inherited `dir` from — carries it too.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const show = (el: Element) => {\n setDir(dirOf(el));\n setVisible(true);\n };\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n useEscapeKey(() => setVisible(false), visible);\n\n return (\n <>\n {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- as in\n `InPlaceTooltip`: the handlers only show and hide the bubble; the caller's child\n is the interactive element, and focusing it shows the bubble. */}\n <span\n // As in `InPlaceTooltip`: the caller's attributes first, the four handlers that\n // run this component after them. The BUBBLE is deliberately not given them — it\n // is portalled to `<body>`, and an id or a tour anchor duplicated onto a node\n // that only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n onMouseEnter={(e) => show(e.currentTarget)}\n onMouseLeave={() => setVisible(false)}\n onFocus={(e) => show(e.currentTarget)}\n onBlur={() => setVisible(false)}\n >\n {describedBy(children, visible ? id : undefined)}\n </span>\n {visible && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )}\n </>\n );\n}\n\n/** The measured, `position: fixed` bubble on `<body>`, mounted only while it is up.\n * Shared by {@link PortalTooltip} and the in-place variant's clipped mode, so the two\n * cannot place a bubble differently. */\nfunction PortalBubble({\n triggerRef,\n id,\n label,\n side,\n dir,\n redact,\n}: {\n triggerRef: RefObject<HTMLSpanElement | null>;\n id: string;\n label: ReactNode;\n side: TooltipSide;\n dir: Direction;\n redact: boolean | undefined;\n}) {\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, true);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = bubbleRef.current?.getBoundingClientRect();\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [rect, label]);\n\n const physical = physicalSide(side, dir);\n const point = rect ? tooltipAnchor(rect, physical) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;\n\n if (!point || typeof document === \"undefined\") return null;\n return createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n dir={dir}\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[physical],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useEffect,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type FocusEvent,\n type MouseEvent,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n type RefObject,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { hasClippingAncestor } from \"../lib/clipping\";\n\n/** The attribute that marks an element as a clipping container for {@link Tooltip}'s\n * auto-portal, whatever its computed `overflow` — `<div data-clips>` or\n * `<div {...{ [CLIPS_ATTRIBUTE]: \"\" }}>`. Put it on an app's own scroller so a test\n * environment without stylesheets (jsdom) portals the same tooltips the browser\n * does. DataTable's body and Table's wrapper already carry it. */\nexport { CLIPS_ATTRIBUTE } from \"../lib/clipping\";\n\n/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the\n * right in LTR and the left in RTL — and are what a layout that mirrors should ask\n * for. `left` / `right` stay physical, for a bubble tied to something that does not\n * mirror (a chart axis, a map). */\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\" | \"start\" | \"end\";\n\n/** What a TAP — a touch or a pen press, never the mouse — on the trigger does to the\n * bubble. See \"A tap is not a hover\" on {@link Tooltip}.\n *\n * - `\"auto\"` (the default): a tap that activates something shows nothing; a tap that\n * activates nothing — on a disabled or `aria-disabled` control (a write lock's\n * reason), or on a trigger with no control at all (a truncated name, a badge) —\n * toggles the bubble, since showing it is then the tap's only answer.\n * - `\"toggle\"`: every tap toggles it — for a control that exists only to explain, such\n * as a \"?\" whose click does nothing.\n * - `\"ignore\"`: a tap never shows it. */\nexport type TooltipTap = \"auto\" | \"toggle\" | \"ignore\";\n\n/** The placement the maths works in: a logical side resolved against the trigger. */\ntype PhysicalSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nfunction physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {\n if (side === \"start\") return dir === \"rtl\" ? \"right\" : \"left\";\n if (side === \"end\") return dir === \"rtl\" ? \"left\" : \"right\";\n return side;\n}\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-placed one never turns round, so there\n * `side` is still the whole of which side it is on — but since 0.14 it is slid back\n * along the cross axis when it would cross the viewport edge (keksdose G7), which the\n * viewport term in this cap is what makes possible: a bubble never wider than the glass\n * minus the margins always fits once slid. */\nconst TOOLTIP_SURFACE =\n \"w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n // Logical insets, so CSS resolves the side from the inherited direction with no\n // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.\n start: \"end-full top-1/2 -translate-y-1/2 me-1\",\n end: \"start-full top-1/2 -translate-y-1/2 ms-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n /**\n * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for\n * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls\n * (`overflow` other than `visible`, or the {@link CLIPS_ATTRIBUTE} marker), in which\n * case it is portalled — see \"Inside a scroll container\" below. `true` always portals, `false` never does; both are exactly\n * what they were before the default existed.\n */\n portal?: boolean;\n /**\n * Mount the IN-PLACE bubble only while it is up (hovered or focused, not dismissed),\n * instead of always. It still sits next to its trigger and is still placed by CSS\n * alone; what changes is that, while closed, it is not in the DOM — so it is not in\n * `getAllByRole(\"tooltip\")`, not in an ancestor's `textContent` and not in an\n * ancestor's accessible name. The trigger is described (`aria-describedby`) while\n * the bubble is up, exactly as a portalled one is. See \"Lazy in place\" on\n * {@link Tooltip} for why this exists (keksdose F6) and why it is not the default.\n *\n * Has no effect on a bubble that is portalled (`portal`, or auto-portalled inside a\n * clipping container) — that one is already mounted only while up.\n */\n lazy?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n /**\n * What a tap (touch or pen) on the trigger does — see {@link TooltipTap} and \"A tap is\n * not a hover\" below. Default `\"auto\"`: a tap that activates the control shows nothing;\n * one that activates nothing (a disabled control, plain text) toggles the bubble.\n */\n tap?: TooltipTap;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side` and `tap`, and every span attribute\n * the caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\" | \"lazy\" | \"tap\"> & {\n side: TooltipSide;\n tap: TooltipTap;\n};\n\n/**\n * Hover/focus label for a control.\n *\n * Two placements, and the choice matters more than it looks. In place, the bubble is\n * always mounted next to the trigger — so a plain render test finds it without a hover —\n * and shown while it is up (`display: none` otherwise, see \"never widens the page\"\n * below). Portalled, the bubble is mounted in `document.body` only while it is up,\n * positioned by measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the in-place bubble is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, the bubble has to be portalled — and by default it\n * now is.** An always-mounted bubble is absolutely positioned, but an absolutely\n * positioned descendant still counts towards its scroll-container ancestor's\n * scrollable overflow — so an invisible bubble on a control near the right edge makes\n * the container scroll sideways with nothing to reveal. That is what Keksdose feedback\n * dev#488 reported on the admin roster: 66px of horizontal scroll on a table that fit,\n * 44px of it owed to tooltips nobody could see. And a visible one is clipped by the\n * container's edge. The portalled bubble is `position: fixed` and absent until hovered,\n * so it adds no width and cannot be clipped.\n *\n * Until 0.10.0 the cure was `portal` at the call site, and kastlan asked for it to stop\n * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,\n * and the ones that forgot were only found by someone scrolling sideways. So with\n * `portal` left out, the tooltip looks for a clipping ancestor itself — any element\n * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`,\n * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the\n * answer the same under test: jsdom computes no Tailwind, so there every scroller\n * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted\n * bubble then repeated the label in the cell's accessible name and `textContent`\n * (\"CheckingChecking\"), and apps pinned `portal` to stop it. The kit's own scrollers\n * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it\n * does in the browser.\n * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble\n * nobody opened, so a check that waited for the hover would find the damage already\n * done. It looks again on every open, for a container that started scrolling after\n * the tooltip mounted (a table that grew). Only the bubble changes place; the trigger\n * and the caller's child stay mounted, so a switch never costs a focused button its\n * focus. Outside any such container the in-place bubble is kept, which is still the\n * cheaper one and the one a plain render test can find without a hover.\n *\n * Why not simply portal everything? Because the in-place bubble is the one existing\n * app tests rely on (it is in the DOM without a hover), and because it follows its\n * trigger through a scroll or an animation for free — the portalled one re-measures.\n *\n * ⚠️ **Lazy in place: `lazy` keeps the bubble next to its trigger but out of the DOM\n * until it is up (keksdose F6).** `data-clips` cured the doubled text for tooltips\n * inside a table; it did nothing for the ones outside any clipping container, where the\n * always-mounted bubble is still a real `role=\"tooltip\"` node full of text. About seven\n * keksdose sites — column-header tooltips, a direction toggle, an fx-estimate row, a\n * FlagBadge, a toast inside a `<button>` — still pinned `portal` for that alone: their\n * tests found two tooltips with `getAllByRole(\"tooltip\")`, read the label twice in a\n * `textContent`, or got a button whose accessible name had the bubble's sentence glued\n * on. `portal` fixed the test and cost the browser the measured, re-positioning bubble\n * for no reason. `lazy` is the in-place bubble with the portalled one's lifetime: it is\n * rendered only while hovered or focused (and not dismissed), in the same slot, with the\n * same classes, so it looks and sits exactly as the default one does when it is up.\n *\n * Why unmount rather than hide? Hiding was the other option — keep the bubble mounted\n * with `hidden` (or `aria-hidden` plus `display: none` until `:hover` / `:focus-within`)\n * and let `aria-describedby` go on pointing at it, which the accname algorithm allows:\n * a node referenced directly by `aria-describedby` contributes its text even while\n * hidden. That would take the bubble out of the role queries and out of an ancestor's\n * accessible name — but NOT out of `textContent`, which is plain DOM and counts hidden\n * text too, and `textContent` is one of the three things F6 lists. Only a bubble that\n * is not there solves all three. The cost is the portalled variant's: the trigger is\n * described while the bubble is up rather than always. Focusing the trigger IS what\n * puts it up, and React commits the `aria-describedby` in the same task as that focus\n * event, so a screen reader landing on the control still hears it; what goes is the description\n * of a trigger that is read in browse mode without ever being focused.\n *\n * Escape still dismisses it — here by unmounting it, which takes it off the screen and\n * out of the accessibility tree at once — and the next hover or focus brings it back.\n * There is no fade to lose: neither in-place bubble has ever had a transition, only the\n * `opacity` switch, so appearing on a state change looks the same as appearing on\n * `:hover`.\n *\n * Why opt-in rather than the new default? Because the always-mounted bubble is the one\n * existing app tests find without a hover (see above): flipping it would turn every\n * `getByRole(\"tooltip\")` written against 0.12 into a failure in all three apps at once.\n * Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.\n *\n * ⚠️ **The in-place bubble is clamped to the viewport when it opens (keksdose G7).** The\n * portalled bubble has always measured itself and been pushed back onto the glass\n * ({@link placeTooltip}); the in-place one never learnt its own size, so `side` was the\n * whole of its placement and a `top` / `bottom` bubble sat centred on its trigger\n * whatever that cost. The cost shows on a phone: long labels live at a row's START edge\n * — a gcloud command in keksdose's jobs panel, a canned reply in a support thread — and\n * a 20rem bubble centred on a trigger 16px from the edge hangs half its text off the\n * screen. keksdose pinned `portal` on those sites for that alone, which gave up `lazy`'s\n * point (the cheap, in-place bubble) to buy a placement.\n *\n * So now, when an in-place bubble goes up (default or `lazy`), a layout effect measures\n * it and, if it crosses the viewport edge minus the same margin the portalled bubble\n * keeps, slides it back along its CROSS axis only — sideways for `top` / `bottom`, up or\n * down for the four side placements — with an inline margin (a `transform` until 0.25; see\n * {@link shiftStyle} for why that widened the page). The main axis is left alone on purpose: sliding a\n * `start` bubble along the main axis would slide it over its own trigger, and turning it\n * round is a measured-placement decision this CSS-placed bubble does not make (pass\n * `portal` for that). The width is already capped to the viewport (see\n * `TOOLTIP_SURFACE`), so a bubble can always fit once slid, and a long label wraps\n * instead of growing past the glass.\n *\n * Why on by default, with no prop? Because it is a no-op for every bubble that already\n * fits — the shift is zero unless the bubble would overflow — so the only placements it\n * changes are ones that were broken. The maths is in physical viewport pixels: an RTL\n * row puts its long label at the RIGHT edge and gets slid left by the same code, and the\n * reading direction only decides which edge is kept when a bubble is wider than the room\n * (its start, as {@link placeTooltip} keeps). Under jsdom there is no layout — every rect\n * is zero-sized — and a zero-sized bubble is taken to be unmeasured, so tests see no\n * transform at all. It is measured on open (and when the label or side changes while\n * open), not on every scroll: an in-place bubble follows its trigger for free, and a page\n * that scrolls sideways under an open tooltip is not a case worth a listener per tooltip.\n *\n * ⚠️ **An in-place bubble never widens the page (keksdose run 72, live #381).** Two\n * holes let it, and a page wider than the screen is worse than a bubble cut off: on a\n * phone Chrome then lays every `position: fixed` layer out against the WIDER page while\n * the glass still shows the old width. Measured on a 406px phone: the sync icon at x=254,\n * its `bottom` bubble at 102–422, the page 426px wide, and a FloatingPanel 17px off the\n * glass. First, the clamp compared against `window.innerWidth` — and on a phone that is\n * the layout viewport, which GROWS to the page's width once something sticks out (430 on\n * a 406px screen, measured in Chromium's mobile emulation), so a bubble already past the\n * edge found itself \"inside\" and stayed there. It now measures against the root's client\n * width, which on a phone is the screen (the initial containing block, the width `100vw`\n * and media queries see) whatever overflows, and on a desktop also leaves out a classic\n * scrollbar; the window is the fallback where there is no layout (jsdom). The portalled\n * bubble takes the same width, since a `fixed` bubble placed against a grown layout\n * viewport is off the glass for the same reason. Second, the always-mounted bubble was\n * `opacity: 0` while closed — invisible, but still laid out where the CSS puts it, so a\n * bubble nobody opened, centred on a trigger near the end edge, widened the page all by\n * itself (dev#488's phantom scroll, at the scale of the page instead of a scroller, where\n * the auto-portal cannot help: the page is not an ancestor it can portal out of). It is\n * now `display: none` until it is up, so a closed bubble takes no room at all, and an open\n * one is slid onto the glass in the same layout pass that shows it, before paint. jsdom\n * computes no Tailwind, so tests still find the closed bubble without a hover.\n *\n * ⚠️ **A tap is not a hover (keksdose run 72, live #379).** The bubble used to show on\n * `mouseenter` and on `focus`, and hide only on `mouseleave` and `blur`. A tap on a phone\n * fires an emulated `mouseenter` and, on a button, focuses it — and the virtual mouse and\n * the focus both stay on the button until the next tap somewhere else, so every\n * IconButton's label (the top-bar icons, the sync indicator, a row's actions) stayed up\n * over whatever the tap had just opened. Now a touch or pen press is told apart from the\n * mouse: the emulated `mouseenter` that follows a tap is ignored, the press itself closes\n * a bubble that was up, and a focus that arrives while the page's last input was a finger\n * or a pen — the tap's own, or one a closing dialog RETURNS to the icon it was opened\n * from — shows nothing. The next key press (a Bluetooth keyboard on a tablet) or mouse\n * press makes focus count again, and a mouse entering the trigger hovers as ever, so a\n * hybrid laptop gets both.\n *\n * Focus otherwise shows the bubble, as it always did — the keyboard, a script, a test's\n * `.focus()` — with one more exception: the focus a mouse press on the trigger gives it.\n * There it follows `:focus-visible`, so a text field still shows its bubble while you type\n * and a clicked button does not hold it: a desktop click behaves as it always did while\n * the pointer is over the button (the hover shows it) and lets it go when the pointer\n * leaves, where the in-place bubble used to linger until the button lost focus — the same\n * left-behind bubble on a smaller scale, and what the portalled bubble always did. A\n * keyboard user's bubble, in turn, now stays up when a mouse passes over and leaves (the\n * portalled one used to close). Any `pointerdown` outside the trigger closes an open\n * bubble too — the Safari case, where a tap elsewhere moves no focus.\n *\n * What a tap DOES show is {@link TooltipTap} (`tap`). By default a tap that activates\n * something shows nothing — the action is the answer — while a tap that activates nothing\n * toggles the bubble, because there the bubble is the only answer: a write-locked or\n * disabled control, whose bubble is the reason it did not respond, and a trigger with no\n * control at all, a truncated payee or a badge, whose bubble is the rest of the text. A\n * second tap, a tap anywhere else, Escape or the focus leaving closes it. \"Activates\" is\n * read off the DOM — a link, a button, a field, a label, an element with a widget role\n * (`button`, `link`, `checkbox`, `option`, …), or a DataTable row (`tr[tabindex]`) — from\n * the tapped element outwards, past the tooltip, so a glyph inside a clickable row or a\n * button counts as that row or button. What it cannot read is a click handler on an\n * element with no role (give it `role=\"button\"`, which it needs anyway, or pass\n * `tap=\"ignore\"`), and a button that exists only to explain, whose click does nothing —\n * FieldHint's \"?\" — which says so with `tap=\"toggle\"`.\n *\n * Why not show the label on a long press, as Android does for its own icons? Because the\n * web gives no reliable long press on a control: browsers disagree on whether the release\n * that ends one still clicks the button, and the button's own long press — the context\n * menu, text selection, a row's drag — claims the gesture first on others. A gesture meant\n * to ask \"what does this do\" must not risk doing it. Screen readers do not need it (the\n * bubble is in `aria-describedby`, and an IconButton's label is its name), and a tap on\n * anything that does nothing already shows it. No scroll listener either: every touch scroll begins with a `pointerdown`, which\n * already closes the bubble, and closing on scroll would close a keyboard user's bubble\n * the moment Tab scrolled its control into view.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal,\n lazy = false,\n redact = false,\n tap = \"auto\",\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal === true) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n tap={tap}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <InPlaceTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n detect={portal === undefined}\n lazy={lazy}\n tap={tap}\n {...rest}\n >\n {children}\n </InPlaceTooltip>\n );\n}\n\n\n/** The pointer handlers a caller may hand {@link Tooltip} that the trigger also needs:\n * both run, the trigger's first. (The mouse and focus handlers are the trigger's alone,\n * as they have always been.) */\ntype PointerHandlers = Pick<\n ComponentPropsWithoutRef<\"span\">,\n \"onPointerEnter\" | \"onPointerDown\" | \"onPointerUp\" | \"onPointerCancel\"\n>;\n\n/** A press that is not the mouse's: a finger, or a pen on the glass. */\nfunction isTap(pointerType: string): boolean {\n return pointerType === \"touch\" || pointerType === \"pen\";\n}\n\n/**\n * What a tap ACTIVATES, for `tap=\"auto\"`: the tapped element or the nearest ancestor —\n * inside the tooltip or round it — that a tap would do something with. The DataTable's\n * own list of row-owned controls (`OWN_CONTROL`), plus `tr[tabindex]` — a clickable\n * DataTable row is a roving tab stop, so all but one of its rows say `-1` — and the other\n * widget roles a tap selects or toggles.\n *\n * Two things on the DataTable's list are left off on purpose. A bare `tabindex`: a roving\n * tab stop is `0` on one element of a widget and `-1` on the rest, so it would make one\n * cell of a display-only grid \"activate\" and its neighbours not — and dialogs, panels and\n * `<main>` carry `-1` only to be focusable by script. And `gridcell` / `row`: the\n * CalendarHeatmap's days are display-only grid cells whose tooltip is the day's value,\n * and a tap is the only way to read it on a phone; a heatmap day that does select is a\n * `<button>`, caught above.\n */\nconst ACTIVATES = [\n \"a[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"label\",\n \"summary\",\n '[contenteditable=\"\"]',\n '[contenteditable=\"true\"]',\n \"tr[tabindex]\",\n ...[\n \"button\",\n \"link\",\n \"checkbox\",\n \"radio\",\n \"switch\",\n \"tab\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"option\",\n \"treeitem\",\n \"slider\",\n \"spinbutton\",\n \"combobox\",\n ].map((role) => `[role=\"${role}\"]`),\n].join(\",\");\n\n/** Whether a tap on `target` should toggle the bubble — see {@link TooltipTap}. Under\n * `auto`: when the tap activates nothing, either because there is no control under it\n * or because the control is disabled (natively, or `aria-disabled` — a write lock). */\nfunction tapShows(tap: TooltipTap, target: EventTarget | null): boolean {\n if (tap !== \"auto\") return tap === \"toggle\";\n if (!(target instanceof Element)) return true;\n const control = target.closest(ACTIVATES);\n return control === null || control.matches(\":disabled\") || control.closest('[aria-disabled=\"true\"]') !== null;\n}\n\n/**\n * The last kind of input the document saw: a `pointerdown`'s `pointerType` (\"mouse\",\n * \"touch\", \"pen\"), \"keyboard\" after a key press, `null` before either. ONE pair of\n * capturing listeners for the whole page, installed by the first trigger that mounts —\n * not a pair per tooltip, which a table of forty would multiply.\n *\n * Why a page-wide note and not only the trigger's own: the focus that brings a label back\n * after a tap is not always the tap's. A tap on a top-bar icon opens a dialog; closing it\n * (another tap) RETURNS the focus to the icon by script, and that focus arrives with no\n * press on the icon at all. On a phone it would put the label up over the page the dialog\n * just left, and keep it there. Keys an on-screen keyboard sends while typing\n * (`Unidentified`, a composition) and shortcut chords do not count as the keyboard.\n */\nlet lastInput: string | null = null;\nlet trackingInput = false;\n\nfunction trackInput(): void {\n if (trackingInput || typeof document === \"undefined\") return;\n trackingInput = true;\n document.addEventListener(\n \"pointerdown\",\n (e) => {\n lastInput = e.pointerType;\n },\n { capture: true, passive: true },\n );\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.key === \"Unidentified\" || e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return;\n lastInput = \"keyboard\";\n },\n { capture: true, passive: true },\n );\n}\n\n/**\n * Whether a focus should show the bubble. Not after a tap — the last input was a finger\n * or a pen, whoever moved the focus. Not after a mouse press on this very trigger either,\n * unless the browser would draw a focus ring there (`:focus-visible`: a text field, not a\n * button) — the hover already shows it while the pointer is there. Every other focus\n * shows it as every focus did before: the keyboard, a script, and a test's `.focus()` or\n * `fireEvent.focus` (jsdom's own `:focus-visible` guesses from whatever events the test\n * file fired before, so it is consulted only where a press makes the answer certain).\n */\nfunction focusShows(target: EventTarget, pressedByMouse: boolean): boolean {\n if (lastInput !== null && isTap(lastInput)) return false;\n if (!pressedByMouse || !(target instanceof Element)) return true;\n try {\n return target.matches(\":focus-visible\");\n } catch {\n return true;\n }\n}\n\n/**\n * Whether the bubble is up, from what the trigger hears — shared by both variants so the\n * in-place and the portalled bubble cannot disagree about it. See \"A tap is not a hover\"\n * on {@link Tooltip} for the rules; this is their bookkeeping.\n *\n * Three ways up — hovered by a mouse (or a hovering pen), focused (see {@link focusShows}),\n * and tapped (`tap`) — and Escape over all three (`dismissed`), re-armed by the next\n * request rather than by an effect watching the flags: coming back to a trigger is a\n * fresh request for its label, and an effect would also re-show the bubble under a\n * pointer that never left. `onArm` runs with each request, for the variant's own per-open\n * work (the reading direction, the clipping check).\n *\n * The refs change nothing on screen, only how the next event is read. `pressedByTouch`\n * is set by a touch or pen arriving or pressing — which precedes the emulated\n * `mouseenter` a tap produces (pointer events first, then the compatibility mouse\n * events) — and cleared by a mouse, or a pen that is not pressing, entering again, so a\n * hybrid laptop's mouse still hovers. `pressedByMouse` spans one mouse press, the window\n * in which that press's own focus arrives.\n */\nfunction useTooltipTrigger(\n triggerRef: RefObject<HTMLSpanElement | null>,\n tap: TooltipTap,\n passed: PointerHandlers,\n onArm: (el: HTMLElement) => void,\n) {\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [tapped, setTapped] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n const pressedByTouch = useRef(false);\n const pressedByMouse = useRef(false);\n // The tap in progress, and whether a bubble was up when it began: a tap on an open\n // bubble closes it (at pointerdown) and must not open it again at pointerup.\n const press = useRef<{ wasOpen: boolean } | null>(null);\n useEffect(trackInput, []);\n const open = (hovered || focused || tapped) && !dismissed;\n const close = () => {\n setHovered(false);\n setFocused(false);\n setTapped(false);\n };\n useEscapeKey(() => setDismissed(true), open);\n // A press anywhere else closes it — the case a blur does not cover: Safari moves no\n // focus on a tap, and a tap on plain text moves it nowhere.\n useOutsideClick(triggerRef, close, open);\n const arm = (el: HTMLElement) => {\n setDismissed(false);\n onArm(el);\n };\n const handlers = {\n onPointerEnter: (e: PointerEvent<HTMLSpanElement>) => {\n if (e.pointerType === \"touch\") pressedByTouch.current = true;\n else if (e.buttons === 0) pressedByTouch.current = false;\n passed.onPointerEnter?.(e);\n },\n onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {\n if (isTap(e.pointerType)) {\n pressedByTouch.current = true;\n press.current = { wasOpen: open };\n close();\n } else {\n pressedByTouch.current = false;\n pressedByMouse.current = true;\n }\n passed.onPointerDown?.(e);\n },\n onPointerUp: (e: PointerEvent<HTMLSpanElement>) => {\n pressedByMouse.current = false;\n const began = press.current;\n press.current = null;\n if (began && !began.wasOpen && isTap(e.pointerType) && tapShows(tap, e.target)) {\n setTapped(true);\n arm(e.currentTarget);\n }\n passed.onPointerUp?.(e);\n },\n // The browser took the gesture over — a scroll, a pinch: not a tap.\n onPointerCancel: (e: PointerEvent<HTMLSpanElement>) => {\n pressedByMouse.current = false;\n press.current = null;\n passed.onPointerCancel?.(e);\n },\n onMouseEnter: (e: MouseEvent<HTMLSpanElement>) => {\n if (pressedByTouch.current) return;\n setHovered(true);\n arm(e.currentTarget);\n },\n onMouseLeave: () => setHovered(false),\n onFocus: (e: FocusEvent<HTMLSpanElement>) => {\n if (!focusShows(e.target, pressedByMouse.current)) return;\n setFocused(true);\n arm(e.currentTarget);\n },\n onBlur: (e: FocusEvent<HTMLSpanElement>) => {\n setFocused(false);\n if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;\n setTapped(false);\n },\n };\n return { open, dismissed, handlers };\n}\n\n/** The variant that lives next to its trigger, and — when `detect` is on, which is the\n * default — moves its bubble to `<body>` when that turns out to be inside a clipping\n * container (see \"Inside a scroll container\" on {@link Tooltip}).\n *\n * Whether the bubble is up is {@link useTooltipTrigger}'s, shared with the portalled\n * variant. Until 0.25 the always-mounted bubble showed itself with CSS alone —\n * `group-hover` / `group-focus-within` — which is exactly what a tap on a phone could not\n * get rid of: the tapped button keeps the focus, and `:focus-within` cannot be told that\n * the focus came from a finger. It is shown from the same state as every other bubble\n * now, and is `display: none` while closed (see \"never widens the page\" on\n * {@link Tooltip}).\n *\n * ONE component for both placements rather than a switch between the two variants: a\n * switch would be a different component at the same place in the tree, and React\n * would remount the caller's child with it — a button that loses focus the moment\n * its own tooltip decided where to go. Here the wrapper and the child stay put and\n * only the bubble's slot changes. */\nfunction InPlaceTooltip({\n label,\n side,\n className,\n redact,\n detect,\n lazy,\n tap,\n children,\n ...rest\n}: TooltipVariantProps & { detect: boolean; lazy: boolean }) {\n const id = useId();\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const [clipped, setClipped] = useState(false);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n // The reading direction on every request (the clamp keeps the start edge of a bubble\n // too wide for the room); the clipping check too, for a container that began to scroll\n // after mount.\n const { open, dismissed, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => {\n setDir(dirOf(el));\n if (detect) setClipped(hasClippingAncestor(el));\n });\n // keksdose G7: slide an open in-place bubble back onto the screen. See \"The in-place\n // bubble is clamped\" on {@link Tooltip}.\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n const { shift, measuring } = useViewportClamp(bubbleRef, open && !clipped, side, label, dir);\n // At mount, before the first paint: the in-place bubble inside a scroller is the\n // phantom-scroll bug whether or not anyone opens it.\n useLayoutEffect(() => {\n if (detect) setClipped(hasClippingAncestor(triggerRef.current));\n }, [detect]);\n return (\n // No role for this span: its handlers track whether the bubble is up and activate\n // nothing; the caller's child is the interactive element, keeps its own handlers,\n // and its focus shows the bubble too.\n <span\n // `...rest` first: the handlers below are what decides whether a bubble is up, and\n // a caller passing an `onFocus` of its own must not replace them (its pointer\n // handlers are called from them).\n {...rest}\n ref={triggerRef}\n // Clipped for the one render in which the bubble is measured (see useViewportClamp):\n // that layout must not reach the page's width.\n style={measuring ? { ...rest.style, overflow: \"clip\" } : rest.style}\n className={cn(\"relative inline-flex\", !clipped && \"group/tooltip\", className)}\n // These track WHETHER A BUBBLE IS UP. They activate nothing — the only thing here\n // that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n {...handlers}\n >\n {/* In place the bubble is always there to point at; portalled or lazy, only while up. */}\n {describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}\n {clipped ? (\n open && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )\n ) : lazy ? (\n // keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only\n // while up, so it is visible whenever it exists.\n open && (\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n data-private={redact ? \"\" : undefined}\n style={shiftStyle(shift)}\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none absolute z-50 opacity-100\", sidePositionClass[side])}\n >\n {label}\n </span>\n )\n ) : (\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n style={shiftStyle(shift)}\n // The `hidden` ATTRIBUTE, not a class: dismissing has to take the bubble out of\n // the accessibility tree as well as off the screen, or a screen reader still\n // reads out the description of a bubble the user just closed. Merely CLOSED it\n // is the `hidden` CLASS — `display: none` in the browser, so it takes no room\n // and widens nothing (live #381), and nothing at all under jsdom, where tests\n // have always found this bubble without a hover. `aria-describedby` reads a\n // `display: none` node all the same.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50\",\n open ? \"opacity-100\" : \"hidden\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n )}\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\n/** A cross-axis slide, in viewport pixels, for an in-place bubble. */\ninterface Shift {\n x: number;\n y: number;\n}\n\nconst NO_SHIFT: Shift = { x: 0, y: 0 };\n\n/** An in-place bubble's slide and the label, side and direction it was measured for. */\ninterface Placement {\n shift: Shift;\n for: { side: TooltipSide; label: ReactNode; dir: Direction } | null;\n}\n\nconst UNPLACED: Placement = { shift: NO_SHIFT, for: null };\n\n/** How far to slide the span `[low, high]` so it sits inside `[TOOLTIP_MARGIN,\n * extent - TOOLTIP_MARGIN]`. Zero when it already does. When the span is wider than the\n * room, the edge it keeps is its START — as in {@link clamp}, the start of a label is the\n * half worth keeping — which is the low edge unless `keepHigh` says the start is the\n * high one (an RTL label, along x). The width cap means that only happens on a viewport\n * barely wider than the margins, or one whose scrollbar the cap's `100vw` counts. */\nfunction slideInto(low: number, high: number, extent: number, keepHigh = false): number {\n const min = TOOLTIP_MARGIN;\n const max = extent - TOOLTIP_MARGIN;\n if (high - low > max - min) return keepHigh ? max - high : min - low;\n if (low < min) return min - low;\n if (high > max) return max - high;\n return 0;\n}\n\n/**\n * The glass a bubble has to stay on, in CSS pixels — `window.innerWidth` is the wrong\n * width on a phone (keksdose run 72, live #381). There it is the LAYOUT viewport, and\n * that grows to the page's width as soon as anything sticks out past the screen: 430 on\n * a 406px screen in Chromium's mobile emulation, with `position: fixed` layers laid out\n * against the 430. A bubble that had widened the page then measured itself as inside it.\n * The root element's client width is the initial containing block instead — the screen,\n * the width `100vw` and the media queries see — whatever overflows, and on a desktop it\n * also leaves a classic scrollbar out. It is 0 where nothing is laid out (jsdom), and the\n * window is the answer there. The height stays the window's: nothing measured grows it,\n * and a phone's collapsing toolbar makes the window the truer of the two.\n */\nfunction viewportSize(): TooltipViewport {\n return {\n width: document.documentElement.clientWidth || window.innerWidth,\n height: window.innerHeight,\n };\n}\n\n/**\n * The in-place bubble's viewport clamp (keksdose G7): while `active`, measure the bubble\n * in a LAYOUT effect — before paint, so there is no frame with the label off the screen —\n * and return the cross-axis slide that brings it inside the viewport ({@link viewportSize})\n * minus `TOOLTIP_MARGIN`. `top` / `bottom` slide along x; `left` / `right` / `start` / `end`\n * along y. {@link NO_SHIFT} while closed, so the next open measures the bubble where the\n * CSS alone puts it. `dir` only decides which edge survives a bubble wider than the room.\n *\n * `measuring` is true for the one render in which the bubble is measured — on open, and\n * again when the label, side or direction changes while open — and the caller clips its\n * wrapper (`overflow: clip`) for that render. Measuring means laying the bubble out where\n * the CSS alone puts it, which is past the edge whenever there is something to slide, and\n * that layout counts towards the page's scrollable overflow even though it is never\n * painted. Chrome on a phone grows the layout viewport to it and does not always give the\n * width back: in an RTL page, where overflow on the left is scrollable, a bubble measured\n * at −24 left the page 426 wide and scrolled by −20 after the slide had already brought it\n * to 4 (Chromium mobile emulation, 406px). A clipped wrapper keeps the measurement out of\n * every ancestor's overflow — `clip` and not `hidden`, so the wrapper never becomes a\n * scroll container — and it is unclipped, with the slide, in the same task, before paint.\n *\n * The rect it reads already includes the slide it applied last time (a label that\n * changed while open), so that slide is taken back out before deciding the new one.\n * A zero-sized rect means no layout (jsdom, `display: none`) and leaves the bubble put.\n */\nfunction useViewportClamp(\n bubbleRef: RefObject<HTMLSpanElement | null>,\n active: boolean,\n side: TooltipSide,\n label: ReactNode,\n dir: Direction,\n): { shift: Shift; measuring: boolean } {\n // The slide, and what it was measured for: anything else on screen — a fresh open, a\n // new label, side or direction — is not measured yet, and renders clipped until it is.\n const [placement, setPlacement] = useState<Placement>(UNPLACED);\n const measured =\n placement.for !== null &&\n placement.for.side === side &&\n placement.for.label === label &&\n placement.for.dir === dir;\n useLayoutEffect(() => {\n const el = bubbleRef.current;\n if (!active || !el) {\n setPlacement(UNPLACED);\n return;\n }\n if (measured) return;\n const r = el.getBoundingClientRect();\n const unlaid = r.width === 0 && r.height === 0;\n const horizontal = side === \"top\" || side === \"bottom\";\n const viewport = viewportSize();\n setPlacement(({ shift: previous }) => {\n const next = unlaid\n ? previous\n : horizontal\n ? { x: slideInto(r.left - previous.x, r.right - previous.x, viewport.width, dir === \"rtl\"), y: 0 }\n : { x: 0, y: slideInto(r.top - previous.y, r.bottom - previous.y, viewport.height) };\n const shift = next.x === previous.x && next.y === previous.y ? previous : next;\n return { shift, for: { side, label, dir } };\n });\n }, [bubbleRef, active, measured, side, label, dir]);\n return { shift: placement.shift, measuring: active && !measured };\n}\n\n/**\n * The inline style for a slide: none at all when there is nothing to slide, so a bubble\n * that fits renders exactly as it did before G7.\n *\n * A MARGIN, not a `transform` (0.14–0.24 used `transform: translate(…)`), because of what\n * Blink does with the page's width (keksdose run 72, live #381). The slide is decided after\n * a layout that placed the bubble where the CSS alone puts it — past the edge — and that\n * layout already counted it into the page's scrollable overflow. A later change to\n * `transform` alone re-paints but does not re-lay-out, so that overflow was never taken\n * back: measured in Chromium's mobile emulation, a bubble shown at 108–428 on a 406px\n * screen and then slid to 400 by a transform left the page 428 wide, the layout viewport\n * with it (`position: fixed` layers laid out against 428), until the bubble closed. The\n * same slide as a margin is a layout change; the page went back to 406 in the same frame.\n *\n * Physical margins, matching the physical maths: `margin-left` moves a `top` / `bottom`\n * bubble, whose placement is the physical `left: 50%`; `margin-top` moves the four side\n * placements, placed by `top: 50%`. Neither is a margin the placement classes set — they\n * keep their gap on the main axis (`mb-1`, `ms-1`, …).\n */\nfunction shiftStyle(shift: Shift) {\n if (shift.x === 0 && shift.y === 0) return undefined;\n return shift.x !== 0 ? { marginLeft: shift.x } : { marginTop: shift.y };\n}\n\nconst portalTransformBySide: Record<PhysicalSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: PhysicalSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: PhysicalSide;\n}\n\nconst opposite: Record<PhysicalSide, PhysicalSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n tap,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n // The trigger's reading direction, read when the bubble is asked for (an event, not a\n // render): it resolves `start` / `end`, and the portalled bubble — which has left the\n // subtree it would have inherited `dir` from — carries it too.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n const { open, handlers } = useTooltipTrigger(triggerRef, tap, rest, (el) => setDir(dirOf(el)));\n\n return (\n <>\n {/* As in `InPlaceTooltip`, no role: the handlers only show and hide the bubble; the\n caller's child is the interactive element, and focusing it shows the bubble. */}\n <span\n // As in `InPlaceTooltip`: the caller's attributes first, the handlers that run\n // this component after them. The BUBBLE is deliberately not given them — it\n // is portalled to `<body>`, and an id or a tour anchor duplicated onto a node\n // that only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n {...handlers}\n >\n {describedBy(children, open ? id : undefined)}\n </span>\n {open && (\n <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />\n )}\n </>\n );\n}\n\n/** The measured, `position: fixed` bubble on `<body>`, mounted only while it is up.\n * Shared by {@link PortalTooltip} and the in-place variant's clipped mode, so the two\n * cannot place a bubble differently. */\nfunction PortalBubble({\n triggerRef,\n id,\n label,\n side,\n dir,\n redact,\n}: {\n triggerRef: RefObject<HTMLSpanElement | null>;\n id: string;\n label: ReactNode;\n side: TooltipSide;\n dir: Direction;\n redact: boolean | undefined;\n}) {\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, true);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = bubbleRef.current?.getBoundingClientRect();\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n // The glass, not the window: see viewportSize (live #381).\n viewport: viewportSize(),\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [rect, label]);\n\n const physical = physicalSide(side, dir);\n const point = rect ? tooltipAnchor(rect, physical) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;\n\n if (!point || typeof document === \"undefined\") return null;\n return createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n dir={dir}\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[physical],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -23,6 +23,14 @@ interface TranslationReviewEditorProps {
|
|
|
23
23
|
onClose?: () => void;
|
|
24
24
|
/** Look, do not touch: a locale the viewer may read but not review. */
|
|
25
25
|
readOnly?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* 0.25: put the cursor in the wording field when the editor opens, at the end of the
|
|
28
|
+
* text. The panel sets it when a phone card is swiped toward "Needs a change" (keksdose
|
|
29
|
+
* live #377): that swipe opens the editor to be written in, and a reviewer who has just
|
|
30
|
+
* said "this is wrong" should not have to find the field first. Not for an editor
|
|
31
|
+
* opened by a tap — that one may only be opened to read.
|
|
32
|
+
*/
|
|
33
|
+
focusWording?: boolean;
|
|
26
34
|
/** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the
|
|
27
35
|
* provider's locale. */
|
|
28
36
|
formatDate?: (iso: string) => string;
|
|
@@ -51,6 +59,6 @@ interface TranslationReviewEditorProps {
|
|
|
51
59
|
* resolution closes the editor through `onClose`, and a rejection keeps it open with what
|
|
52
60
|
* was typed and the error under the buttons.
|
|
53
61
|
*/
|
|
54
|
-
declare function TranslationReviewEditor({ row, referenceLabel, localeLabel, onSave, onClear, onClose, readOnly, formatDate: formatDateProp, formatError, className, labels: labelsProp, }: TranslationReviewEditorProps): react.JSX.Element;
|
|
62
|
+
declare function TranslationReviewEditor({ row, referenceLabel, localeLabel, onSave, onClear, onClose, readOnly, focusWording, formatDate: formatDateProp, formatError, className, labels: labelsProp, }: TranslationReviewEditorProps): react.JSX.Element;
|
|
55
63
|
|
|
56
64
|
export { TranslationReviewEditor, type TranslationReviewEditorProps };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
6
|
import { useKitFormat } from "../lib/format.js";
|
|
@@ -23,6 +23,7 @@ function TranslationReviewEditor({
|
|
|
23
23
|
onClear,
|
|
24
24
|
onClose,
|
|
25
25
|
readOnly = false,
|
|
26
|
+
focusWording = false,
|
|
26
27
|
formatDate: formatDateProp,
|
|
27
28
|
formatError,
|
|
28
29
|
className,
|
|
@@ -37,6 +38,14 @@ function TranslationReviewEditor({
|
|
|
37
38
|
const [note, setNote] = useState(review?.note ?? "");
|
|
38
39
|
const [busy, setBusy] = useState(null);
|
|
39
40
|
const [failure, setFailure] = useState(null);
|
|
41
|
+
const wordingRef = useRef(null);
|
|
42
|
+
const focusOnOpen = useRef(focusWording && editable);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const field = wordingRef.current;
|
|
45
|
+
if (!focusOnOpen.current || !field) return;
|
|
46
|
+
field.focus();
|
|
47
|
+
field.setSelectionRange(field.value.length, field.value.length);
|
|
48
|
+
}, []);
|
|
40
49
|
const canFlag = note.trim() !== "" || suggestion.trim() !== row.text.trim();
|
|
41
50
|
const changed = row.status === "changed" ? review : null;
|
|
42
51
|
const date = (iso) => formatDateProp ? formatDateProp(iso) : formatDate(iso, "medium");
|
|
@@ -94,6 +103,7 @@ function TranslationReviewEditor({
|
|
|
94
103
|
/* @__PURE__ */ jsx(
|
|
95
104
|
Textarea,
|
|
96
105
|
{
|
|
106
|
+
ref: wordingRef,
|
|
97
107
|
label: missing ? labels.translation : labels.suggestion,
|
|
98
108
|
lang: row.locale,
|
|
99
109
|
rows: Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80))),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/translation-review-editor.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { placeholderTokens, reviewWrite } from \"../lib/translation-review\";\nimport type {\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationVerdict,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { Caption, SectionLabel } from \"./text\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { Button, Textarea } from \"./ui\";\n\n/** A callback may return a promise: resolve and the editor closes, reject and it stays\n * open with the error. */\ntype MaybePromise = void | Promise<unknown>;\n\nexport interface TranslationReviewEditorProps {\n row: TranslationRow;\n /** The reference language's own name (\"English\", \"Deutsch\") — the app's choice of\n * reference, so the app's word for it. */\n referenceLabel: ReactNode;\n /** The reviewed language's own name (\"Français\"). */\n localeLabel: ReactNode;\n /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last\n * verdict, with no fields and no actions. */\n onSave?: (write: TranslationReviewWrite) => MaybePromise;\n /** Clear the verdict — back to unreviewed. Left out: no reset button. */\n onClear?: (key: TranslationReviewKey) => MaybePromise;\n /** The Cancel button — and called after a save or a reset resolves, so the panel\n * that opened the editor closes it. */\n onClose?: () => void;\n /** Look, do not touch: a locale the viewer may read but not review. */\n readOnly?: boolean;\n /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the\n * provider's locale. */\n formatDate?: (iso: string) => string;\n /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wording a verdict was given on, under the line that says it changed. */\nfunction Then({ label, text }: { label: string; text: string }) {\n return (\n <div className=\"mt-1.5 min-w-0\">\n <span className=\"text-xs font-medium\">{label}</span>\n <p className=\"whitespace-pre-line break-words text-[var(--text-secondary)]\">{text}</p>\n </div>\n );\n}\n\n/**\n * One string, opened: the reference beside the translation, what changed since the last\n * verdict, what a machine can see is wrong (the placeholders), and the two things a\n * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.\n *\n * \"Better wording\" opens on the CURRENT text rather than empty, because the usual\n * correction is a word or an ending, and retyping a sentence to change one word is how\n * new mistakes get in. It is sent only with \"Needs a change\" — approving says the text is\n * fine as it stands. A \"Needs a change\" with neither a note nor a different wording tells\n * whoever fixes it nothing, so it takes one of the two.\n *\n * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is\n * nothing to approve. Its field starts empty and is the translation to add; the verdict\n * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to\n * add.\n *\n * Every action waits for its callback: the buttons hold while the promise is out, a\n * resolution closes the editor through `onClose`, and a rejection keeps it open with what\n * was typed and the error under the buttons.\n */\nexport function TranslationReviewEditor({\n row,\n referenceLabel,\n localeLabel,\n onSave,\n onClear,\n onClose,\n readOnly = false,\n formatDate: formatDateProp,\n formatError,\n className,\n labels: labelsProp,\n}: TranslationReviewEditorProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const review = row.review;\n const missing = row.text === \"\";\n const editable = !readOnly && onSave !== undefined;\n const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);\n const [note, setNote] = useState(review?.note ?? \"\");\n const [busy, setBusy] = useState<\"approve\" | \"flag\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n\n const canFlag = note.trim() !== \"\" || suggestion.trim() !== row.text.trim();\n const changed = row.status === \"changed\" ? review : null;\n const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"medium\"));\n\n const run = async (kind: \"approve\" | \"flag\" | \"clear\", action: () => MaybePromise) => {\n setBusy(kind);\n setFailure(null);\n try {\n await action();\n onClose?.();\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n } finally {\n setBusy(null);\n }\n };\n\n const save = (verdict: TranslationVerdict) => {\n if (!onSave) return;\n const trimmedNote = note.trim() || null;\n // Approving says the text is right as it stands: there is no wording to suggest.\n const sent = verdict === \"APPROVED\" ? null : suggestion.trim() || null;\n void run(verdict === \"APPROVED\" ? \"approve\" : \"flag\", () =>\n onSave(reviewWrite(row, verdict, trimmedNote, sent)),\n );\n };\n\n return (\n <div className={cn(\"min-w-0 space-y-3 text-sm\", className)}>\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{row.key}</code>\n\n <div className=\"grid gap-3 md:grid-cols-2\">\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {referenceLabel}\n </SectionLabel>\n <p className=\"mt-1 whitespace-pre-line break-words text-[var(--text-secondary)]\">{row.reference}</p>\n </div>\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {localeLabel}\n </SectionLabel>\n {missing ? (\n <p className=\"mt-1 italic text-[var(--text-muted)]\">{labels.missingText}</p>\n ) : (\n <p lang={row.locale} className=\"mt-1 whitespace-pre-line break-words\">\n {row.text}\n </p>\n )}\n </div>\n </div>\n\n {changed && (\n <AlertBanner tone=\"warning\" variant=\"inline\" size=\"sm\" block live={false}>\n <div className=\"min-w-0\">\n <p>{labels.changedSince}</p>\n {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || \"—\"} />}\n {changed.referenceText != null && changed.referenceText !== row.reference && (\n <Then label={labels.reviewedReference} text={changed.referenceText} />\n )}\n </div>\n </AlertBanner>\n )}\n\n {row.placeholderMismatch && (\n <AlertBanner tone=\"danger\" variant=\"inline\" size=\"sm\" block live={false}>\n <span className=\"min-w-0 break-words\">\n {labels.placeholderMismatch(\n placeholderTokens(row.reference).join(\" \") || \"—\",\n placeholderTokens(row.text).join(\" \") || \"—\",\n )}\n </span>\n </AlertBanner>\n )}\n\n {review && (\n <Caption>\n {(review.verdict === \"APPROVED\" ? labels.lastApproved : labels.lastFlagged)(\n review.reviewerName ?? labels.erasedReviewer,\n date(review.reviewedAt),\n )}\n </Caption>\n )}\n\n {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}\n {!editable && review?.verdict === \"NEEDS_CHANGE\" && (review.suggestion || review.note) && (\n <div className=\"space-y-2\">\n {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}\n {review.note && <Then label={labels.note} text={review.note} />}\n </div>\n )}\n\n {editable && (\n <>\n <Textarea\n label={missing ? labels.translation : labels.suggestion}\n lang={row.locale}\n rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}\n value={suggestion}\n disabled={busy !== null}\n onChange={(e) => setSuggestion(e.target.value)}\n />\n <Textarea\n label={labels.note}\n rows={2}\n value={note}\n placeholder={labels.notePlaceholder}\n disabled={busy !== null}\n onChange={(e) => setNote(e.target.value)}\n />\n <div className=\"flex flex-wrap gap-2\">\n {!missing && (\n <Button\n variant=\"primary\"\n commit\n pending={busy === \"approve\"}\n disabled={busy !== null}\n onClick={() => save(\"APPROVED\")}\n >\n {labels.approve}\n </Button>\n )}\n <Button\n variant={missing ? \"primary\" : \"danger\"}\n commit\n pending={busy === \"flag\"}\n disabled={busy !== null || !canFlag}\n onClick={() => save(\"NEEDS_CHANGE\")}\n >\n {missing ? labels.suggest : labels.flag}\n </Button>\n {review && onClear && (\n <Button\n variant=\"ghost\"\n commit\n pending={busy === \"clear\"}\n disabled={busy !== null}\n onClick={() => void run(\"clear\", () => onClear({ locale: row.locale, key: row.key }))}\n >\n {labels.reset}\n </Button>\n )}\n {onClose && (\n <Button variant=\"ghost\" disabled={busy !== null} onClick={onClose}>\n {labels.cancel}\n </Button>\n )}\n </div>\n </>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/translation-review-editor.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { placeholderTokens, reviewWrite } from \"../lib/translation-review\";\nimport type {\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationVerdict,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { Caption, SectionLabel } from \"./text\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { Button, Textarea } from \"./ui\";\n\n/** A callback may return a promise: resolve and the editor closes, reject and it stays\n * open with the error. */\ntype MaybePromise = void | Promise<unknown>;\n\nexport interface TranslationReviewEditorProps {\n row: TranslationRow;\n /** The reference language's own name (\"English\", \"Deutsch\") — the app's choice of\n * reference, so the app's word for it. */\n referenceLabel: ReactNode;\n /** The reviewed language's own name (\"Français\"). */\n localeLabel: ReactNode;\n /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last\n * verdict, with no fields and no actions. */\n onSave?: (write: TranslationReviewWrite) => MaybePromise;\n /** Clear the verdict — back to unreviewed. Left out: no reset button. */\n onClear?: (key: TranslationReviewKey) => MaybePromise;\n /** The Cancel button — and called after a save or a reset resolves, so the panel\n * that opened the editor closes it. */\n onClose?: () => void;\n /** Look, do not touch: a locale the viewer may read but not review. */\n readOnly?: boolean;\n /**\n * 0.25: put the cursor in the wording field when the editor opens, at the end of the\n * text. The panel sets it when a phone card is swiped toward \"Needs a change\" (keksdose\n * live #377): that swipe opens the editor to be written in, and a reviewer who has just\n * said \"this is wrong\" should not have to find the field first. Not for an editor\n * opened by a tap — that one may only be opened to read.\n */\n focusWording?: boolean;\n /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the\n * provider's locale. */\n formatDate?: (iso: string) => string;\n /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wording a verdict was given on, under the line that says it changed. */\nfunction Then({ label, text }: { label: string; text: string }) {\n return (\n <div className=\"mt-1.5 min-w-0\">\n <span className=\"text-xs font-medium\">{label}</span>\n <p className=\"whitespace-pre-line break-words text-[var(--text-secondary)]\">{text}</p>\n </div>\n );\n}\n\n/**\n * One string, opened: the reference beside the translation, what changed since the last\n * verdict, what a machine can see is wrong (the placeholders), and the two things a\n * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.\n *\n * \"Better wording\" opens on the CURRENT text rather than empty, because the usual\n * correction is a word or an ending, and retyping a sentence to change one word is how\n * new mistakes get in. It is sent only with \"Needs a change\" — approving says the text is\n * fine as it stands. A \"Needs a change\" with neither a note nor a different wording tells\n * whoever fixes it nothing, so it takes one of the two.\n *\n * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is\n * nothing to approve. Its field starts empty and is the translation to add; the verdict\n * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to\n * add.\n *\n * Every action waits for its callback: the buttons hold while the promise is out, a\n * resolution closes the editor through `onClose`, and a rejection keeps it open with what\n * was typed and the error under the buttons.\n */\nexport function TranslationReviewEditor({\n row,\n referenceLabel,\n localeLabel,\n onSave,\n onClear,\n onClose,\n readOnly = false,\n focusWording = false,\n formatDate: formatDateProp,\n formatError,\n className,\n labels: labelsProp,\n}: TranslationReviewEditorProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const review = row.review;\n const missing = row.text === \"\";\n const editable = !readOnly && onSave !== undefined;\n const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);\n const [note, setNote] = useState(review?.note ?? \"\");\n const [busy, setBusy] = useState<\"approve\" | \"flag\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const wordingRef = useRef<HTMLTextAreaElement>(null);\n // On mount only: the prop says how the editor was OPENED, not where focus belongs\n // for the rest of its life.\n const focusOnOpen = useRef(focusWording && editable);\n useEffect(() => {\n const field = wordingRef.current;\n if (!focusOnOpen.current || !field) return;\n field.focus();\n field.setSelectionRange(field.value.length, field.value.length);\n }, []);\n\n const canFlag = note.trim() !== \"\" || suggestion.trim() !== row.text.trim();\n const changed = row.status === \"changed\" ? review : null;\n const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"medium\"));\n\n const run = async (kind: \"approve\" | \"flag\" | \"clear\", action: () => MaybePromise) => {\n setBusy(kind);\n setFailure(null);\n try {\n await action();\n onClose?.();\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n } finally {\n setBusy(null);\n }\n };\n\n const save = (verdict: TranslationVerdict) => {\n if (!onSave) return;\n const trimmedNote = note.trim() || null;\n // Approving says the text is right as it stands: there is no wording to suggest.\n const sent = verdict === \"APPROVED\" ? null : suggestion.trim() || null;\n void run(verdict === \"APPROVED\" ? \"approve\" : \"flag\", () =>\n onSave(reviewWrite(row, verdict, trimmedNote, sent)),\n );\n };\n\n return (\n <div className={cn(\"min-w-0 space-y-3 text-sm\", className)}>\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{row.key}</code>\n\n <div className=\"grid gap-3 md:grid-cols-2\">\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {referenceLabel}\n </SectionLabel>\n <p className=\"mt-1 whitespace-pre-line break-words text-[var(--text-secondary)]\">{row.reference}</p>\n </div>\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {localeLabel}\n </SectionLabel>\n {missing ? (\n <p className=\"mt-1 italic text-[var(--text-muted)]\">{labels.missingText}</p>\n ) : (\n <p lang={row.locale} className=\"mt-1 whitespace-pre-line break-words\">\n {row.text}\n </p>\n )}\n </div>\n </div>\n\n {changed && (\n <AlertBanner tone=\"warning\" variant=\"inline\" size=\"sm\" block live={false}>\n <div className=\"min-w-0\">\n <p>{labels.changedSince}</p>\n {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || \"—\"} />}\n {changed.referenceText != null && changed.referenceText !== row.reference && (\n <Then label={labels.reviewedReference} text={changed.referenceText} />\n )}\n </div>\n </AlertBanner>\n )}\n\n {row.placeholderMismatch && (\n <AlertBanner tone=\"danger\" variant=\"inline\" size=\"sm\" block live={false}>\n <span className=\"min-w-0 break-words\">\n {labels.placeholderMismatch(\n placeholderTokens(row.reference).join(\" \") || \"—\",\n placeholderTokens(row.text).join(\" \") || \"—\",\n )}\n </span>\n </AlertBanner>\n )}\n\n {review && (\n <Caption>\n {(review.verdict === \"APPROVED\" ? labels.lastApproved : labels.lastFlagged)(\n review.reviewerName ?? labels.erasedReviewer,\n date(review.reviewedAt),\n )}\n </Caption>\n )}\n\n {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}\n {!editable && review?.verdict === \"NEEDS_CHANGE\" && (review.suggestion || review.note) && (\n <div className=\"space-y-2\">\n {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}\n {review.note && <Then label={labels.note} text={review.note} />}\n </div>\n )}\n\n {editable && (\n <>\n <Textarea\n ref={wordingRef}\n label={missing ? labels.translation : labels.suggestion}\n lang={row.locale}\n rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}\n value={suggestion}\n disabled={busy !== null}\n onChange={(e) => setSuggestion(e.target.value)}\n />\n <Textarea\n label={labels.note}\n rows={2}\n value={note}\n placeholder={labels.notePlaceholder}\n disabled={busy !== null}\n onChange={(e) => setNote(e.target.value)}\n />\n <div className=\"flex flex-wrap gap-2\">\n {!missing && (\n <Button\n variant=\"primary\"\n commit\n pending={busy === \"approve\"}\n disabled={busy !== null}\n onClick={() => save(\"APPROVED\")}\n >\n {labels.approve}\n </Button>\n )}\n <Button\n variant={missing ? \"primary\" : \"danger\"}\n commit\n pending={busy === \"flag\"}\n disabled={busy !== null || !canFlag}\n onClick={() => save(\"NEEDS_CHANGE\")}\n >\n {missing ? labels.suggest : labels.flag}\n </Button>\n {review && onClear && (\n <Button\n variant=\"ghost\"\n commit\n pending={busy === \"clear\"}\n disabled={busy !== null}\n onClick={() => void run(\"clear\", () => onClear({ locale: row.locale, key: row.key }))}\n >\n {labels.reset}\n </Button>\n )}\n {onClose && (\n <Button variant=\"ghost\" disabled={busy !== null} onClick={onClose}>\n {labels.cancel}\n </Button>\n )}\n </div>\n </>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -84,6 +84,16 @@ interface TranslationReviewLabels {
|
|
|
84
84
|
exportCorrections: (count: number) => string;
|
|
85
85
|
/** A callback rejected and no `formatError` says better. */
|
|
86
86
|
failed: string;
|
|
87
|
+
/** 0.25: the Undo toast after an approval (`undo`) — one string, or a batch of them. */
|
|
88
|
+
approvedToast: (count: number) => string;
|
|
89
|
+
/** 0.25: a group's header under `groupBy` — how many of its rows nobody has read yet,
|
|
90
|
+
* of how many the filters show in it. */
|
|
91
|
+
groupCount: (unreviewed: number, total: number) => string;
|
|
92
|
+
/** 0.25: a group's bulk action — approve its unreviewed rows. */
|
|
93
|
+
approveGroup: (count: number) => string;
|
|
94
|
+
/** 0.25: asked first when a group's bulk approve is larger than a page — more than the
|
|
95
|
+
* reviewer can have had on screen. `group` is the group's name. */
|
|
96
|
+
confirmGroup: (count: number, group: string) => string;
|
|
87
97
|
}
|
|
88
98
|
declare const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels;
|
|
89
99
|
/** A status's word, from the namespace's five. */
|
|
@@ -44,7 +44,11 @@ const DEFAULT_TRANSLATION_REVIEW_LABELS = {
|
|
|
44
44
|
readOnly: "You can read this language but not review it.",
|
|
45
45
|
scope: (areas) => `Your review is limited to: ${areas}.`,
|
|
46
46
|
exportCorrections: (count) => `Export corrections (${count})`,
|
|
47
|
-
failed: "That did not work. Please try again."
|
|
47
|
+
failed: "That did not work. Please try again.",
|
|
48
|
+
approvedToast: (count) => count === 1 ? "String approved" : `${count} strings approved`,
|
|
49
|
+
groupCount: (unreviewed, total) => `${unreviewed} unreviewed / ${total}`,
|
|
50
|
+
approveGroup: (count) => `Approve unreviewed (${count})`,
|
|
51
|
+
confirmGroup: (count, group) => count === 1 ? `Approve the unreviewed string in ${group}?` : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`
|
|
48
52
|
};
|
|
49
53
|
function reviewStatusLabel(labels, status) {
|
|
50
54
|
switch (status) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/translation-review-labels.ts"],"sourcesContent":["import type { ReviewStatus } from \"../lib/translation-review\";\n\n/**\n * The `translationReview` namespace: every word of the translation-review parts\n * (`ReviewStatusChip`, `TranslationProgress`, `TranslationLocaleTabs`,\n * `TranslationExportButton`, `TranslationReviewEditor`, `TranslationReviewPanel`).\n *\n * Its own module, like `data-table-labels.ts`: `kit-labels.tsx` and `defaults.ts` import\n * it, and neither has to load the components to get the words. The English is keksdose's\n * and kastlan's own (`translation_review.*`, `translations:*`), British.\n */\nexport interface TranslationReviewLabels {\n /** The five statuses — the chip, the filter, the progress legend. */\n statusMissing: string;\n statusUnreviewed: string;\n statusChanged: string;\n statusNeedsChange: string;\n statusApproved: string;\n /** Accessible name of the status filter. */\n statusFilter: string;\n /** The status filter's first option. */\n all: string;\n /** A status filter option with its count: \"Unreviewed · 12\". */\n filterCount: (label: string, count: number) => string;\n /** The source filter's label — shown only when the rows come from several sources\n * (kastlan's screen texts and documents). */\n source: string;\n allSources: string;\n /** The namespace filter's label and its \"every one\" option. */\n namespace: string;\n allNamespaces: string;\n search: string;\n /** The placeholder filter, with how many rows it would show. */\n placeholdersOnly: (count: number) => string;\n /** The chip on a row whose placeholders differ from the reference's. */\n placeholderChip: string;\n /** The key column, and the key's caption in the editor. */\n key: string;\n statusColumn: string;\n /** No row matches the filters. */\n empty: string;\n /** In place of the text of a string the locale lacks. */\n missingText: string;\n /** The progress bar's label. */\n progress: (approved: number, total: number) => string;\n /** A locale tab's badge. */\n localeProgress: (approved: number, total: number) => string;\n /** Accessible name of the locale tabs. */\n locales: string;\n approve: string;\n /** Send a string back: \"Needs a change\". */\n flag: string;\n /** \"Needs a change\" for a MISSING string: the suggestion is the translation to add. */\n suggest: string;\n /** Clear a verdict: back to unreviewed. */\n reset: string;\n /** The editor's way out without saving. */\n cancel: string;\n approveSelected: string;\n resetSelected: string;\n /** Selects every row the filters show — the phone's way into the bulk actions, where\n * the table has no checkboxes. */\n selectShown: string;\n /** Over the old wording of a row that changed since its verdict. */\n changedSince: string;\n reviewedWording: string;\n reviewedReference: string;\n /** The placeholders differ: the reference's and the text's, each a list. */\n placeholderMismatch: (reference: string, text: string) => string;\n lastApproved: (name: string, date: string) => string;\n lastFlagged: (name: string, date: string) => string;\n /** The reviewer's name once the account is gone — what `name` above becomes. */\n erasedReviewer: string;\n /** The editor's wording field, opened on the current text. */\n suggestion: string;\n /** The same field for a missing string. */\n translation: string;\n note: string;\n notePlaceholder: string;\n /** The viewer may read this locale but not review it. */\n readOnly: string;\n /** The reviewer is limited to some areas — `areas` is their names, joined. */\n scope: (areas: string) => string;\n exportCorrections: (count: number) => string;\n /** A callback rejected and no `formatError` says better. */\n failed: string;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels = {\n statusMissing: \"Missing\",\n statusUnreviewed: \"Unreviewed\",\n statusChanged: \"Changed since review\",\n statusNeedsChange: \"Needs a change\",\n statusApproved: \"Approved\",\n statusFilter: \"Status\",\n all: \"All\",\n filterCount: (label, count) => `${label} · ${count}`,\n source: \"Texts\",\n allSources: \"All texts\",\n namespace: \"Area\",\n allNamespaces: \"All areas\",\n search: \"Search keys and text\",\n placeholdersOnly: (count) => `Only placeholder problems (${count})`,\n placeholderChip: \"Placeholders\",\n key: \"Key\",\n statusColumn: \"Status\",\n empty: \"No strings match these filters.\",\n missingText: \"Missing in this language\",\n progress: (approved, total) => `${approved} of ${total} approved`,\n localeProgress: (approved, total) => `${approved}/${total}`,\n locales: \"Languages\",\n approve: \"Approve\",\n flag: \"Needs a change\",\n suggest: \"Suggest translation\",\n reset: \"Mark unreviewed\",\n cancel: \"Cancel\",\n approveSelected: \"Approve selected\",\n resetSelected: \"Mark selected unreviewed\",\n selectShown: \"Select all shown\",\n changedSince: \"This string changed after it was reviewed. Please read it again.\",\n reviewedWording: \"Wording when reviewed\",\n reviewedReference: \"Reference when reviewed\",\n placeholderMismatch: (reference, text) =>\n `The placeholders differ from the reference — reference: ${reference}; this text: ${text}. The app fills these in, so they must stay exactly as they are.`,\n lastApproved: (name, date) => `Approved by ${name} on ${date}`,\n lastFlagged: (name, date) => `Marked as needing a change by ${name} on ${date}`,\n erasedReviewer: \"a deleted account\",\n suggestion: \"Better wording\",\n translation: \"Translation\",\n note: \"Note (optional)\",\n notePlaceholder: \"What is wrong, or what to keep in mind\",\n readOnly: \"You can read this language but not review it.\",\n scope: (areas) => `Your review is limited to: ${areas}.`,\n exportCorrections: (count) => `Export corrections (${count})`,\n failed: \"That did not work. Please try again.\",\n};\n\n/** A status's word, from the namespace's five. */\nexport function reviewStatusLabel(labels: TranslationReviewLabels, status: ReviewStatus): string {\n switch (status) {\n case \"missing\":\n return labels.statusMissing;\n case \"unreviewed\":\n return labels.statusUnreviewed;\n case \"changed\":\n return labels.statusChanged;\n case \"needs_change\":\n return labels.statusNeedsChange;\n case \"approved\":\n return labels.statusApproved;\n }\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/components/translation-review-labels.ts"],"sourcesContent":["import type { ReviewStatus } from \"../lib/translation-review\";\n\n/**\n * The `translationReview` namespace: every word of the translation-review parts\n * (`ReviewStatusChip`, `TranslationProgress`, `TranslationLocaleTabs`,\n * `TranslationExportButton`, `TranslationReviewEditor`, `TranslationReviewPanel`).\n *\n * Its own module, like `data-table-labels.ts`: `kit-labels.tsx` and `defaults.ts` import\n * it, and neither has to load the components to get the words. The English is keksdose's\n * and kastlan's own (`translation_review.*`, `translations:*`), British.\n */\nexport interface TranslationReviewLabels {\n /** The five statuses — the chip, the filter, the progress legend. */\n statusMissing: string;\n statusUnreviewed: string;\n statusChanged: string;\n statusNeedsChange: string;\n statusApproved: string;\n /** Accessible name of the status filter. */\n statusFilter: string;\n /** The status filter's first option. */\n all: string;\n /** A status filter option with its count: \"Unreviewed · 12\". */\n filterCount: (label: string, count: number) => string;\n /** The source filter's label — shown only when the rows come from several sources\n * (kastlan's screen texts and documents). */\n source: string;\n allSources: string;\n /** The namespace filter's label and its \"every one\" option. */\n namespace: string;\n allNamespaces: string;\n search: string;\n /** The placeholder filter, with how many rows it would show. */\n placeholdersOnly: (count: number) => string;\n /** The chip on a row whose placeholders differ from the reference's. */\n placeholderChip: string;\n /** The key column, and the key's caption in the editor. */\n key: string;\n statusColumn: string;\n /** No row matches the filters. */\n empty: string;\n /** In place of the text of a string the locale lacks. */\n missingText: string;\n /** The progress bar's label. */\n progress: (approved: number, total: number) => string;\n /** A locale tab's badge. */\n localeProgress: (approved: number, total: number) => string;\n /** Accessible name of the locale tabs. */\n locales: string;\n approve: string;\n /** Send a string back: \"Needs a change\". */\n flag: string;\n /** \"Needs a change\" for a MISSING string: the suggestion is the translation to add. */\n suggest: string;\n /** Clear a verdict: back to unreviewed. */\n reset: string;\n /** The editor's way out without saving. */\n cancel: string;\n approveSelected: string;\n resetSelected: string;\n /** Selects every row the filters show — the phone's way into the bulk actions, where\n * the table has no checkboxes. */\n selectShown: string;\n /** Over the old wording of a row that changed since its verdict. */\n changedSince: string;\n reviewedWording: string;\n reviewedReference: string;\n /** The placeholders differ: the reference's and the text's, each a list. */\n placeholderMismatch: (reference: string, text: string) => string;\n lastApproved: (name: string, date: string) => string;\n lastFlagged: (name: string, date: string) => string;\n /** The reviewer's name once the account is gone — what `name` above becomes. */\n erasedReviewer: string;\n /** The editor's wording field, opened on the current text. */\n suggestion: string;\n /** The same field for a missing string. */\n translation: string;\n note: string;\n notePlaceholder: string;\n /** The viewer may read this locale but not review it. */\n readOnly: string;\n /** The reviewer is limited to some areas — `areas` is their names, joined. */\n scope: (areas: string) => string;\n exportCorrections: (count: number) => string;\n /** A callback rejected and no `formatError` says better. */\n failed: string;\n /** 0.25: the Undo toast after an approval (`undo`) — one string, or a batch of them. */\n approvedToast: (count: number) => string;\n /** 0.25: a group's header under `groupBy` — how many of its rows nobody has read yet,\n * of how many the filters show in it. */\n groupCount: (unreviewed: number, total: number) => string;\n /** 0.25: a group's bulk action — approve its unreviewed rows. */\n approveGroup: (count: number) => string;\n /** 0.25: asked first when a group's bulk approve is larger than a page — more than the\n * reviewer can have had on screen. `group` is the group's name. */\n confirmGroup: (count: number, group: string) => string;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels = {\n statusMissing: \"Missing\",\n statusUnreviewed: \"Unreviewed\",\n statusChanged: \"Changed since review\",\n statusNeedsChange: \"Needs a change\",\n statusApproved: \"Approved\",\n statusFilter: \"Status\",\n all: \"All\",\n filterCount: (label, count) => `${label} · ${count}`,\n source: \"Texts\",\n allSources: \"All texts\",\n namespace: \"Area\",\n allNamespaces: \"All areas\",\n search: \"Search keys and text\",\n placeholdersOnly: (count) => `Only placeholder problems (${count})`,\n placeholderChip: \"Placeholders\",\n key: \"Key\",\n statusColumn: \"Status\",\n empty: \"No strings match these filters.\",\n missingText: \"Missing in this language\",\n progress: (approved, total) => `${approved} of ${total} approved`,\n localeProgress: (approved, total) => `${approved}/${total}`,\n locales: \"Languages\",\n approve: \"Approve\",\n flag: \"Needs a change\",\n suggest: \"Suggest translation\",\n reset: \"Mark unreviewed\",\n cancel: \"Cancel\",\n approveSelected: \"Approve selected\",\n resetSelected: \"Mark selected unreviewed\",\n selectShown: \"Select all shown\",\n changedSince: \"This string changed after it was reviewed. Please read it again.\",\n reviewedWording: \"Wording when reviewed\",\n reviewedReference: \"Reference when reviewed\",\n placeholderMismatch: (reference, text) =>\n `The placeholders differ from the reference — reference: ${reference}; this text: ${text}. The app fills these in, so they must stay exactly as they are.`,\n lastApproved: (name, date) => `Approved by ${name} on ${date}`,\n lastFlagged: (name, date) => `Marked as needing a change by ${name} on ${date}`,\n erasedReviewer: \"a deleted account\",\n suggestion: \"Better wording\",\n translation: \"Translation\",\n note: \"Note (optional)\",\n notePlaceholder: \"What is wrong, or what to keep in mind\",\n readOnly: \"You can read this language but not review it.\",\n scope: (areas) => `Your review is limited to: ${areas}.`,\n exportCorrections: (count) => `Export corrections (${count})`,\n failed: \"That did not work. Please try again.\",\n approvedToast: (count) => (count === 1 ? \"String approved\" : `${count} strings approved`),\n groupCount: (unreviewed, total) => `${unreviewed} unreviewed / ${total}`,\n approveGroup: (count) => `Approve unreviewed (${count})`,\n confirmGroup: (count, group) =>\n count === 1\n ? `Approve the unreviewed string in ${group}?`\n : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`,\n};\n\n/** A status's word, from the namespace's five. */\nexport function reviewStatusLabel(labels: TranslationReviewLabels, status: ReviewStatus): string {\n switch (status) {\n case \"missing\":\n return labels.statusMissing;\n case \"unreviewed\":\n return labels.statusUnreviewed;\n case \"changed\":\n return labels.statusChanged;\n case \"needs_change\":\n return labels.statusNeedsChange;\n case \"approved\":\n return labels.statusApproved;\n }\n}\n"],"mappings":";AAkGO,MAAM,oCAA6D;AAAA,EACxE,eAAe;AAAA,EACf,kBAAkB;AAAA,EAClB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,KAAK;AAAA,EACL,aAAa,CAAC,OAAO,UAAU,GAAG,KAAK,SAAM,KAAK;AAAA,EAClD,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,kBAAkB,CAAC,UAAU,8BAA8B,KAAK;AAAA,EAChE,iBAAiB;AAAA,EACjB,KAAK;AAAA,EACL,cAAc;AAAA,EACd,OAAO;AAAA,EACP,aAAa;AAAA,EACb,UAAU,CAAC,UAAU,UAAU,GAAG,QAAQ,OAAO,KAAK;AAAA,EACtD,gBAAgB,CAAC,UAAU,UAAU,GAAG,QAAQ,IAAI,KAAK;AAAA,EACzD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,aAAa;AAAA,EACb,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,qBAAqB,CAAC,WAAW,SAC/B,gEAA2D,SAAS,gBAAgB,IAAI;AAAA,EAC1F,cAAc,CAAC,MAAM,SAAS,eAAe,IAAI,OAAO,IAAI;AAAA,EAC5D,aAAa,CAAC,MAAM,SAAS,iCAAiC,IAAI,OAAO,IAAI;AAAA,EAC7E,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,MAAM;AAAA,EACN,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,OAAO,CAAC,UAAU,8BAA8B,KAAK;AAAA,EACrD,mBAAmB,CAAC,UAAU,uBAAuB,KAAK;AAAA,EAC1D,QAAQ;AAAA,EACR,eAAe,CAAC,UAAW,UAAU,IAAI,oBAAoB,GAAG,KAAK;AAAA,EACrE,YAAY,CAAC,YAAY,UAAU,GAAG,UAAU,iBAAiB,KAAK;AAAA,EACtE,cAAc,CAAC,UAAU,uBAAuB,KAAK;AAAA,EACrD,cAAc,CAAC,OAAO,UACpB,UAAU,IACN,oCAAoC,KAAK,MACzC,eAAe,KAAK,0BAA0B,KAAK;AAC3D;AAGO,SAAS,kBAAkB,QAAiC,QAA8B;AAC/F,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,EAClB;AACF;","names":[]}
|