@eifi1/ui-kit 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/components/error-boundary.d.ts +8 -1
- package/dist/components/error-boundary.js +4 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/page-header.d.ts +55 -2
- package/dist/components/page-header.js +34 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/tooltip.d.ts +31 -0
- package/dist/components/tooltip.js +33 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +87 -6
- package/dist/feedback/feedback-thread.js +134 -58
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/i18n/locales/de.js +3 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.js +2 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/error-boundary.tsx +16 -2
- package/src/components/page-header.tsx +98 -3
- package/src/components/tooltip.tsx +109 -2
- package/src/feedback/feedback-thread.tsx +229 -37
- package/src/i18n/locales/de.ts +2 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -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-only one never learns its own size,\n * so there `side` is still the whole of the placement. */\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 */\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 // 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 id={id}\n role=\"tooltip\"\n data-private={redact ? \"\" : undefined}\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 id={id}\n role=\"tooltip\"\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\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 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":[]}
|
|
@@ -28,6 +28,13 @@ import 'lucide-react';
|
|
|
28
28
|
* **RTL.** Sides are logical — `justify-end` is the reading end, and the bubble's
|
|
29
29
|
* tucked corner is `rounded-ee` / `rounded-es` — and each body is `dir="auto"`, so a
|
|
30
30
|
* Hebrew reply in an English thread (or the reverse) lays out as its own language.
|
|
31
|
+
*
|
|
32
|
+
* **A chat, too** (keksdose G4/G5). keksdose's support chat is the same conversation
|
|
33
|
+
* read the chat way: status lines between the messages ("Closed automatically",
|
|
34
|
+
* "Reopened"), a day line where the date changes, clock times under it, Enter to send
|
|
35
|
+
* and several files uploaded ahead of the send. Each of those is an opt-in here
|
|
36
|
+
* (`kind: "event"`, `daySeparators`, `timeFormat="clock"`, `sendOn="enter"`,
|
|
37
|
+
* `attachmentSlot` + `canSend`), so the feedback thread's defaults stay as they were.
|
|
31
38
|
*/
|
|
32
39
|
/** The `feedbackThread` namespace of `<UiKitProvider labels>`. */
|
|
33
40
|
interface FeedbackThreadLabels {
|
|
@@ -52,6 +59,9 @@ interface FeedbackComposerLabels {
|
|
|
52
59
|
send: string;
|
|
53
60
|
/** The shortcut line, given the platform's modifier ("Ctrl" or "⌘"). */
|
|
54
61
|
sendHint: (modifier: string) => string;
|
|
62
|
+
/** The shortcut line under `sendOn="enter"` (keksdose G5): Enter sends, so say how a
|
|
63
|
+
* new line is made. */
|
|
64
|
+
sendHintEnter: string;
|
|
55
65
|
}
|
|
56
66
|
declare const DEFAULT_FEEDBACK_COMPOSER_LABELS: FeedbackComposerLabels;
|
|
57
67
|
interface FeedbackThreadAttachment {
|
|
@@ -65,6 +75,8 @@ interface FeedbackThreadAttachment {
|
|
|
65
75
|
size?: number;
|
|
66
76
|
}
|
|
67
77
|
interface FeedbackThreadMessage {
|
|
78
|
+
/** Leave out, or `"message"`. `"event"` is a {@link FeedbackThreadEvent}. */
|
|
79
|
+
kind?: "message";
|
|
68
80
|
id: string | number;
|
|
69
81
|
/** Who wrote it. Left out on an `own` message, it reads "You". */
|
|
70
82
|
author?: ReactNode;
|
|
@@ -77,8 +89,32 @@ interface FeedbackThreadMessage {
|
|
|
77
89
|
/** Written by the team rather than a user: gets the "Staff" badge. */
|
|
78
90
|
staff?: boolean;
|
|
79
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* A line in the thread that nobody wrote — "Closed automatically", "Marked as
|
|
94
|
+
* resolved", "Reopened" — drawn centred and muted with its time, no author, no side,
|
|
95
|
+
* no bubble.
|
|
96
|
+
*
|
|
97
|
+
* keksdose G4: its support chat interleaves these with the messages, and as a message
|
|
98
|
+
* they came out as a bubble from nobody at the start side, which reads as the member
|
|
99
|
+
* having said "resolved". Word it for the READER, as with `own`: the app knows whether
|
|
100
|
+
* "you reopened it" or "Anna reopened it" is true for the person looking.
|
|
101
|
+
*
|
|
102
|
+
* The text is the app's, not somebody's words about their data, so it is NOT tagged
|
|
103
|
+
* `data-private`; one that names a person can wrap the name in its own
|
|
104
|
+
* `<span data-private>` (`body` is a node for that reason).
|
|
105
|
+
*/
|
|
106
|
+
interface FeedbackThreadEvent {
|
|
107
|
+
kind: "event";
|
|
108
|
+
id: string | number;
|
|
109
|
+
/** A `Date`, an ISO string or epoch ms. */
|
|
110
|
+
createdAt: Date | string | number;
|
|
111
|
+
body: ReactNode;
|
|
112
|
+
}
|
|
113
|
+
/** One row of a {@link FeedbackThread}: a message, or an event line between them. */
|
|
114
|
+
type FeedbackThreadItem = FeedbackThreadMessage | FeedbackThreadEvent;
|
|
80
115
|
interface FeedbackThreadProps {
|
|
81
|
-
messages
|
|
116
|
+
/** Oldest first. Event lines ({@link FeedbackThreadEvent}) sit among the messages. */
|
|
117
|
+
messages: readonly FeedbackThreadItem[];
|
|
82
118
|
loading?: boolean;
|
|
83
119
|
/** Replaces the default "No comments yet". */
|
|
84
120
|
empty?: ReactNode;
|
|
@@ -90,14 +126,30 @@ interface FeedbackThreadProps {
|
|
|
90
126
|
renderAttachment?: (attachment: FeedbackThreadAttachment, message: FeedbackThreadMessage) => ReactNode;
|
|
91
127
|
/** Tag bodies, authors and attachments `data-private`. Default true. */
|
|
92
128
|
redact?: boolean;
|
|
93
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* How a row's time reads. `"relative"` (default): "5 minutes ago", a date past six
|
|
131
|
+
* days — right for a report's comments, read days apart. `"clock"`: the time of day
|
|
132
|
+
* in the locale ("14:05", "2:05 PM") — the chat look, keksdose G4; pair it with
|
|
133
|
+
* `daySeparators`, which say which day that clock time is on. Either way the full
|
|
134
|
+
* date and time is in the Tooltip.
|
|
135
|
+
*/
|
|
136
|
+
timeFormat?: "relative" | "clock";
|
|
137
|
+
/**
|
|
138
|
+
* A centred day line before the first row and wherever the next row is on another
|
|
139
|
+
* calendar day (the device's zone): "Today", "Yesterday", else the date — keksdose
|
|
140
|
+
* G4. "Today" / "Yesterday" are `Intl.RelativeTimeFormat`'s own words in the kit
|
|
141
|
+
* locale, so there is no label to translate. Default false.
|
|
142
|
+
*/
|
|
143
|
+
daySeparators?: boolean;
|
|
144
|
+
/** "Now" for the relative times and the day lines — pin it in tests and stories.
|
|
145
|
+
* Default: the clock. */
|
|
94
146
|
now?: Date | number;
|
|
95
147
|
/** Overrides the provider's locale for the times. */
|
|
96
148
|
locale?: string;
|
|
97
149
|
labels?: Partial<FeedbackThreadLabels>;
|
|
98
150
|
className?: string;
|
|
99
151
|
}
|
|
100
|
-
declare function FeedbackThread({ messages, loading, empty, renderAttachment, redact, now, locale: localeProp, labels: labelsProp, className, }: FeedbackThreadProps): react.JSX.Element;
|
|
152
|
+
declare function FeedbackThread({ messages, loading, empty, renderAttachment, redact, now, locale: localeProp, timeFormat, daySeparators, labels: labelsProp, className, }: FeedbackThreadProps): react.JSX.Element;
|
|
101
153
|
/** What the composer's attach control takes — {@link FeedbackNoteAttachment} with the
|
|
102
154
|
* labels optional, since the field reads `feedbackAttachment` from the provider. */
|
|
103
155
|
type FeedbackComposerAttachment = Omit<FeedbackNoteAttachment, "labels"> & {
|
|
@@ -108,6 +160,10 @@ interface FeedbackComposerProps {
|
|
|
108
160
|
* Send the comment. Return the promise: the box clears when it RESOLVES, and only of
|
|
109
161
|
* what was sent — text typed while it was in flight stays. A rejection keeps
|
|
110
162
|
* everything for a retry (surface the error yourself; the composer stays quiet).
|
|
163
|
+
*
|
|
164
|
+
* `attachment` is the built-in field's one picture. Files the host uploaded ahead
|
|
165
|
+
* (`attachmentSlot`) are the host's state, read by the host here — and `body` may be
|
|
166
|
+
* `""` when `canSend` let an attachments-only message go.
|
|
111
167
|
*/
|
|
112
168
|
onSend: (body: string, attachment: File | null) => void | Promise<unknown>;
|
|
113
169
|
/** The send is in flight: a spinner on the button and no second send. */
|
|
@@ -115,6 +171,31 @@ interface FeedbackComposerProps {
|
|
|
115
171
|
/** Offer one picture with the comment (file, paste or `onCaptureScreenshot`).
|
|
116
172
|
* `true` for the defaults. Left out, the composer is text only. */
|
|
117
173
|
attachment?: boolean | FeedbackComposerAttachment;
|
|
174
|
+
/**
|
|
175
|
+
* The host's own attach control, in the row with Send where the built-in field sits
|
|
176
|
+
* (after it, if both are on) — keksdose G5: its support chat uploads up to N files
|
|
177
|
+
* AHEAD of the send and sends their refs, which one `File` in `onSend` cannot carry.
|
|
178
|
+
* The slot is the host's picker plus the chips of what is uploaded; the upload, the
|
|
179
|
+
* refs and clearing them once `onSend` resolves stay with the host. Pair it with
|
|
180
|
+
* `canSend`.
|
|
181
|
+
*/
|
|
182
|
+
attachmentSlot?: ReactNode;
|
|
183
|
+
/**
|
|
184
|
+
* Whether Send is enabled, over the composer's own rule (some text in the box) —
|
|
185
|
+
* for state the composer cannot see, i.e. `attachmentSlot`'s (keksdose G5): `true`
|
|
186
|
+
* with files ready makes an empty draft sendable (`onSend("", null)`), `false` while
|
|
187
|
+
* an upload is still running holds even a written reply back. Left out, the text
|
|
188
|
+
* decides. `pending` blocks a send either way.
|
|
189
|
+
*/
|
|
190
|
+
canSend?: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* Which key sends. `"mod-enter"` (default): Ctrl/⌘+Enter, the report dialog's and
|
|
193
|
+
* note editor's shortcut, with plain Enter a new line — feedback is written in
|
|
194
|
+
* paragraphs. `"enter"`: the chat way (keksdose G5) — Enter sends, Shift+Enter is a
|
|
195
|
+
* new line, Ctrl/⌘+Enter still sends; an Enter that confirms an IME composition
|
|
196
|
+
* (Japanese, Chinese input) is the IME's, never a send.
|
|
197
|
+
*/
|
|
198
|
+
sendOn?: "mod-enter" | "enter";
|
|
118
199
|
/** Rendered INSTEAD of the composer — the thread is closed, or read-only for this
|
|
119
200
|
* reader. */
|
|
120
201
|
disabledReason?: ReactNode;
|
|
@@ -166,8 +247,8 @@ interface FeedbackComposerHandle {
|
|
|
166
247
|
* The answer box under a {@link FeedbackThread}: a textarea, an optional picture,
|
|
167
248
|
* and Send — Ctrl/⌘+Enter sends, the shortcut the report dialog and the note editor
|
|
168
249
|
* use, so the habit carries across the feature. Plain Enter is a new line: feedback is
|
|
169
|
-
* written in paragraphs, unlike a chat.
|
|
250
|
+
* written in paragraphs, unlike a chat — which `sendOn="enter"` is for (keksdose G5).
|
|
170
251
|
*/
|
|
171
|
-
declare function FeedbackComposer({ onSend, pending, attachment, disabledReason, rows, placeholder, value, onValueChange, ref, labels: labelsProp, className, }: FeedbackComposerProps): react.JSX.Element;
|
|
252
|
+
declare function FeedbackComposer({ onSend, pending, attachment, attachmentSlot, canSend: canSendProp, sendOn, disabledReason, rows, placeholder, value, onValueChange, ref, labels: labelsProp, className, }: FeedbackComposerProps): react.JSX.Element;
|
|
172
253
|
|
|
173
|
-
export { DEFAULT_FEEDBACK_COMPOSER_LABELS, DEFAULT_FEEDBACK_THREAD_LABELS, FeedbackComposer, type FeedbackComposerAttachment, type FeedbackComposerHandle, type FeedbackComposerLabels, type FeedbackComposerProps, FeedbackThread, type FeedbackThreadAttachment, type FeedbackThreadLabels, type FeedbackThreadMessage, type FeedbackThreadProps };
|
|
254
|
+
export { DEFAULT_FEEDBACK_COMPOSER_LABELS, DEFAULT_FEEDBACK_THREAD_LABELS, FeedbackComposer, type FeedbackComposerAttachment, type FeedbackComposerHandle, type FeedbackComposerLabels, type FeedbackComposerProps, FeedbackThread, type FeedbackThreadAttachment, type FeedbackThreadEvent, type FeedbackThreadItem, type FeedbackThreadLabels, type FeedbackThreadMessage, type FeedbackThreadProps };
|
|
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { Paperclip, Send } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
|
-
import { formatRelativeTime } from "../lib/format.js";
|
|
6
|
+
import { formatDate, formatRelativeTime } from "../lib/format.js";
|
|
7
7
|
import { Button, EmptyState, Spinner, Textarea } from "../components/ui.js";
|
|
8
8
|
import { Skeleton } from "../components/skeleton.js";
|
|
9
9
|
import { Tooltip } from "../components/tooltip.js";
|
|
@@ -21,7 +21,8 @@ const DEFAULT_FEEDBACK_COMPOSER_LABELS = {
|
|
|
21
21
|
field: "Write a comment",
|
|
22
22
|
placeholder: "Write a comment\u2026",
|
|
23
23
|
send: "Send",
|
|
24
|
-
sendHint: (modifier) => `${modifier} + Enter to send
|
|
24
|
+
sendHint: (modifier) => `${modifier} + Enter to send`,
|
|
25
|
+
sendHintEnter: "Enter to send, Shift + Enter for a new line"
|
|
25
26
|
};
|
|
26
27
|
function FeedbackThread({
|
|
27
28
|
messages,
|
|
@@ -31,6 +32,8 @@ function FeedbackThread({
|
|
|
31
32
|
redact = true,
|
|
32
33
|
now,
|
|
33
34
|
locale: localeProp,
|
|
35
|
+
timeFormat = "relative",
|
|
36
|
+
daySeparators = false,
|
|
34
37
|
labels: labelsProp,
|
|
35
38
|
className
|
|
36
39
|
}) {
|
|
@@ -54,52 +57,83 @@ function FeedbackThread({
|
|
|
54
57
|
}
|
|
55
58
|
);
|
|
56
59
|
}
|
|
57
|
-
|
|
60
|
+
const days = [];
|
|
61
|
+
for (const message of messages) {
|
|
58
62
|
const date = toDate(message.createdAt);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
days.push(date ? dayNumber(date) : days.at(-1) ?? null);
|
|
64
|
+
}
|
|
65
|
+
return /* @__PURE__ */ jsx("ol", { "aria-label": labels.thread, "aria-busy": loading || void 0, className: cn("space-y-3", className), children: messages.flatMap((message, index) => {
|
|
66
|
+
const date = toDate(message.createdAt);
|
|
67
|
+
const time = date && // The kit's Tooltip, not a native title: one tooltip look app-wide
|
|
68
|
+
// (keksdose F0 / dev#523).
|
|
69
|
+
/* @__PURE__ */ jsx(Tooltip, { label: new Intl.DateTimeFormat(locale, { dateStyle: "full", timeStyle: "short" }).format(date), children: /* @__PURE__ */ jsx("time", { dateTime: date.toISOString(), className: "tabular-nums", children: timeFormat === "clock" ? formatDate(date, "time", { locale }) : formatRelativeTime(date, {
|
|
70
|
+
now: reference,
|
|
71
|
+
locale,
|
|
72
|
+
// Past the kit's day steps it is a date: "2 weeks ago" makes
|
|
73
|
+
// the reader do arithmetic the date already did.
|
|
74
|
+
absoluteAfterDays: 6.5
|
|
75
|
+
}) }) });
|
|
76
|
+
const rows = [];
|
|
77
|
+
const day = date && dayNumber(date);
|
|
78
|
+
if (daySeparators && date && day !== days[index - 1]) {
|
|
79
|
+
rows.push(/* @__PURE__ */ jsx(DaySeparator, { date, now: reference, locale }, `day-${message.id}`));
|
|
80
|
+
}
|
|
81
|
+
if (message.kind === "event") {
|
|
82
|
+
rows.push(
|
|
83
|
+
/* @__PURE__ */ jsxs(
|
|
84
|
+
"li",
|
|
68
85
|
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
message.own ? "rounded-ee-sm bg-[var(--brand-bg)]" : "rounded-es-sm bg-[var(--bg-surface-2)]"
|
|
72
|
-
),
|
|
86
|
+
"data-event": "",
|
|
87
|
+
className: "flex flex-wrap items-baseline justify-center gap-x-2 px-4 text-center text-xs text-[var(--text-muted)]",
|
|
73
88
|
children: [
|
|
74
|
-
/* @__PURE__ */
|
|
75
|
-
|
|
76
|
-
message.staff && /* @__PURE__ */ jsx("span", { className: "rounded border border-[var(--border)] bg-[var(--bg-surface)] px-1 text-[10px] font-medium uppercase tracking-wide text-[var(--text-secondary)]", children: labels.staff }),
|
|
77
|
-
date && // The kit's Tooltip, not a native title: one tooltip look app-wide
|
|
78
|
-
// (keksdose F0 / dev#523).
|
|
79
|
-
/* @__PURE__ */ jsx(Tooltip, { label: new Intl.DateTimeFormat(locale, { dateStyle: "full", timeStyle: "short" }).format(date), children: /* @__PURE__ */ jsx("time", { dateTime: date.toISOString(), className: "tabular-nums", children: formatRelativeTime(date, {
|
|
80
|
-
now: reference,
|
|
81
|
-
locale,
|
|
82
|
-
// Past the kit's day steps it is a date: "2 weeks ago" makes
|
|
83
|
-
// the reader do arithmetic the date already did.
|
|
84
|
-
absoluteAfterDays: 6.5
|
|
85
|
-
}) }) })
|
|
86
|
-
] }),
|
|
87
|
-
message.body && /* @__PURE__ */ jsx(
|
|
88
|
-
"p",
|
|
89
|
-
{
|
|
90
|
-
"data-private": priv,
|
|
91
|
-
dir: "auto",
|
|
92
|
-
className: "whitespace-pre-wrap break-words text-start text-[var(--text-primary)]",
|
|
93
|
-
children: message.body
|
|
94
|
-
}
|
|
95
|
-
),
|
|
96
|
-
attachments.length > 0 && /* @__PURE__ */ jsx("ul", { "aria-label": labels.attachments, "data-private": priv, className: "mt-2 flex flex-wrap gap-2", children: attachments.map((attachment, index) => /* @__PURE__ */ jsx("li", { className: "min-w-0", children: renderAttachment ? renderAttachment(attachment, message) : /* @__PURE__ */ jsx(DefaultAttachment, { attachment, size: file.size }) }, attachment.id ?? `${index}-${attachment.name}`)) })
|
|
89
|
+
/* @__PURE__ */ jsx("span", { children: message.body }),
|
|
90
|
+
time && /* @__PURE__ */ jsx("span", { className: "text-[11px]", children: time })
|
|
97
91
|
]
|
|
98
|
-
}
|
|
92
|
+
},
|
|
93
|
+
message.id
|
|
99
94
|
)
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
);
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
const author = message.author ?? (message.own ? labels.you : null);
|
|
99
|
+
const attachments = message.attachments ?? [];
|
|
100
|
+
rows.push(
|
|
101
|
+
/* @__PURE__ */ jsx(
|
|
102
|
+
"li",
|
|
103
|
+
{
|
|
104
|
+
className: cn("flex", message.own ? "justify-end" : "justify-start"),
|
|
105
|
+
"data-own": message.own ? "" : void 0,
|
|
106
|
+
children: /* @__PURE__ */ jsxs(
|
|
107
|
+
"article",
|
|
108
|
+
{
|
|
109
|
+
className: cn(
|
|
110
|
+
"max-w-[85%] min-w-0 rounded-lg px-3 py-2 text-sm",
|
|
111
|
+
message.own ? "rounded-ee-sm bg-[var(--brand-bg)]" : "rounded-es-sm bg-[var(--bg-surface-2)]"
|
|
112
|
+
),
|
|
113
|
+
children: [
|
|
114
|
+
/* @__PURE__ */ jsxs("header", { className: "mb-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--text-muted)]", children: [
|
|
115
|
+
author != null && /* @__PURE__ */ jsx("span", { "data-private": priv, className: "font-medium text-[var(--text-secondary)]", children: author }),
|
|
116
|
+
message.staff && /* @__PURE__ */ jsx("span", { className: "rounded border border-[var(--border)] bg-[var(--bg-surface)] px-1 text-[10px] font-medium uppercase tracking-wide text-[var(--text-secondary)]", children: labels.staff }),
|
|
117
|
+
time
|
|
118
|
+
] }),
|
|
119
|
+
message.body && /* @__PURE__ */ jsx(
|
|
120
|
+
"p",
|
|
121
|
+
{
|
|
122
|
+
"data-private": priv,
|
|
123
|
+
dir: "auto",
|
|
124
|
+
className: "whitespace-pre-wrap break-words text-start text-[var(--text-primary)]",
|
|
125
|
+
children: message.body
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
attachments.length > 0 && /* @__PURE__ */ jsx("ul", { "aria-label": labels.attachments, "data-private": priv, className: "mt-2 flex flex-wrap gap-2", children: attachments.map((attachment, index2) => /* @__PURE__ */ jsx("li", { className: "min-w-0", children: renderAttachment ? renderAttachment(attachment, message) : /* @__PURE__ */ jsx(DefaultAttachment, { attachment, size: file.size }) }, attachment.id ?? `${index2}-${attachment.name}`)) })
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
},
|
|
133
|
+
message.id
|
|
134
|
+
)
|
|
102
135
|
);
|
|
136
|
+
return rows;
|
|
103
137
|
}) });
|
|
104
138
|
}
|
|
105
139
|
function DefaultAttachment({
|
|
@@ -135,6 +169,39 @@ function useMinuteClock(active) {
|
|
|
135
169
|
}, [active]);
|
|
136
170
|
return time;
|
|
137
171
|
}
|
|
172
|
+
function dayNumber(date) {
|
|
173
|
+
return date.getFullYear() * 1e4 + (date.getMonth() + 1) * 100 + date.getDate();
|
|
174
|
+
}
|
|
175
|
+
function DaySeparator({ date, now, locale }) {
|
|
176
|
+
const today = new Date(now);
|
|
177
|
+
const days = Math.round(
|
|
178
|
+
(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())) / 864e5
|
|
179
|
+
);
|
|
180
|
+
let label;
|
|
181
|
+
if (days === 0 || days === -1) {
|
|
182
|
+
const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days + 0, "day");
|
|
183
|
+
label = word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
|
184
|
+
} else {
|
|
185
|
+
label = formatDate(
|
|
186
|
+
date,
|
|
187
|
+
{
|
|
188
|
+
weekday: "long",
|
|
189
|
+
day: "numeric",
|
|
190
|
+
month: "long",
|
|
191
|
+
...date.getFullYear() === today.getFullYear() ? null : { year: "numeric" }
|
|
192
|
+
},
|
|
193
|
+
{ locale }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const y = String(date.getFullYear()).padStart(4, "0");
|
|
197
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
198
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
199
|
+
return /* @__PURE__ */ jsxs("li", { "data-day-separator": "", className: "flex items-center gap-3 py-1 text-xs text-[var(--text-muted)]", children: [
|
|
200
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "h-px flex-1 bg-[var(--border)]" }),
|
|
201
|
+
/* @__PURE__ */ jsx("time", { dateTime: `${y}-${m}-${d}`, className: "font-medium", children: label }),
|
|
202
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "h-px flex-1 bg-[var(--border)]" })
|
|
203
|
+
] });
|
|
204
|
+
}
|
|
138
205
|
function toDate(value) {
|
|
139
206
|
const date = value instanceof Date ? value : new Date(value);
|
|
140
207
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
@@ -146,6 +213,9 @@ function FeedbackComposer({
|
|
|
146
213
|
onSend,
|
|
147
214
|
pending = false,
|
|
148
215
|
attachment,
|
|
216
|
+
attachmentSlot,
|
|
217
|
+
canSend: canSendProp,
|
|
218
|
+
sendOn = "mod-enter",
|
|
149
219
|
disabledReason,
|
|
150
220
|
rows = 3,
|
|
151
221
|
placeholder,
|
|
@@ -200,7 +270,7 @@ function FeedbackComposer({
|
|
|
200
270
|
if (disabledReason != null && disabledReason !== false) {
|
|
201
271
|
return /* @__PURE__ */ jsx("p", { className: cn("text-xs text-[var(--text-muted)]", className), children: disabledReason });
|
|
202
272
|
}
|
|
203
|
-
const canSend = draft.trim().length > 0 && !pending;
|
|
273
|
+
const canSend = (canSendProp ?? draft.trim().length > 0) && !pending;
|
|
204
274
|
const send = () => {
|
|
205
275
|
if (!canSend) return;
|
|
206
276
|
const sentDraft = draft;
|
|
@@ -224,7 +294,10 @@ function FeedbackComposer({
|
|
|
224
294
|
value: draft,
|
|
225
295
|
onChange: (event) => setDraft(event.target.value),
|
|
226
296
|
onKeyDown: (event) => {
|
|
227
|
-
if (
|
|
297
|
+
if (event.key !== "Enter") return;
|
|
298
|
+
const mod = event.ctrlKey || event.metaKey;
|
|
299
|
+
const chat = sendOn === "enter" && !event.shiftKey && !event.altKey && !event.nativeEvent.isComposing && event.keyCode !== 229;
|
|
300
|
+
if (mod || chat) {
|
|
228
301
|
event.preventDefault();
|
|
229
302
|
send();
|
|
230
303
|
}
|
|
@@ -232,21 +305,24 @@ function FeedbackComposer({
|
|
|
232
305
|
}
|
|
233
306
|
),
|
|
234
307
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end justify-between gap-2", children: [
|
|
235
|
-
config ? /* @__PURE__ */
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
308
|
+
config || attachmentSlot != null ? /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-wrap items-end gap-2", children: [
|
|
309
|
+
config && /* @__PURE__ */ jsx(
|
|
310
|
+
FeedbackAttachmentField,
|
|
311
|
+
{
|
|
312
|
+
value: file,
|
|
313
|
+
onChange: setFile,
|
|
314
|
+
labels: config.labels,
|
|
315
|
+
accept: config.accept,
|
|
316
|
+
maxBytes: config.maxBytes,
|
|
317
|
+
onError: config.onError,
|
|
318
|
+
onCaptureScreenshot: config.onCaptureScreenshot,
|
|
319
|
+
pasteFrom: root
|
|
320
|
+
}
|
|
321
|
+
),
|
|
322
|
+
attachmentSlot
|
|
323
|
+
] }) : /* @__PURE__ */ jsx("span", {}),
|
|
248
324
|
/* @__PURE__ */ jsxs("div", { className: "ms-auto flex items-center gap-2", children: [
|
|
249
|
-
/* @__PURE__ */ jsx("span", { className: "text-xs text-[var(--text-placeholder)]", children: labels.sendHint(modifier) }),
|
|
325
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-[var(--text-placeholder)]", children: sendOn === "enter" ? labels.sendHintEnter : labels.sendHint(modifier) }),
|
|
250
326
|
/* @__PURE__ */ jsxs(Button, { onClick: send, disabled: !canSend, "aria-busy": pending || void 0, children: [
|
|
251
327
|
pending ? /* @__PURE__ */ jsx(Spinner, { label: null, className: "size-4" }) : /* @__PURE__ */ jsx(Send, { className: "size-4 rtl:-scale-x-100", "aria-hidden": true }),
|
|
252
328
|
labels.send
|