From 43605bfbe3f390e289015de82dbacb6445d5457a Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:36:14 +0300 Subject: [PATCH 1/2] feat(ui): ToggletipBadge block A Kumo Badge inside a button that opens its reason as a Tooltip on hover, keyboard focus and a press, so a tap explains it on touch screens where a tooltip alone never opens. The reason is repeated in the accessible name; every word is a prop. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- packages/ui/src/blocks/ToggletipBadge.tsx | 73 +++++++++++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/test/blocks.test.tsx | 36 ++++++++++- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/blocks/ToggletipBadge.tsx diff --git a/packages/ui/src/blocks/ToggletipBadge.tsx b/packages/ui/src/blocks/ToggletipBadge.tsx new file mode 100644 index 00000000..9a3a2b4c --- /dev/null +++ b/packages/ui/src/blocks/ToggletipBadge.tsx @@ -0,0 +1,73 @@ +import { Badge, type BadgeVariant, cn, Tooltip } from "@cloudflare/kumo"; +import { type ReactNode, useRef, useState } from "react"; + +export interface ToggletipBadgeProps { + /** The badge's word, from the screen's catalog. */ + label: string; + /** What the badge means, shown in the toggletip and read as part of the badge's name. */ + reason: string; + /** The badge's tone (Kumo `Badge` variant); `secondary` for quiet states. */ + variant?: BadgeVariant; + /** A 14 px Phosphor icon with `aria-hidden`; the word always says the state too. */ + icon?: ReactNode; + className?: string; +} + +/** How Kumo's `Tooltip` (Base UI) says why it wants to open or close. */ +type OpenChange = { reason?: string } | undefined; + +/** + * A badge that explains itself: a Kumo `Badge` inside a button whose `reason` opens in a Kumo + * `Tooltip` on hover, on keyboard focus and on a press — Base UI tooltips never open on touch, so + * the button toggles it (open, then closed by a second press, Escape or a press elsewhere; a + * pressed-open toggletip stays when the pointer leaves). The reason wraps at 18 rem, and is repeated + * for screen readers inside the badge, so it is part of the button's accessible name + * ("Ended (collection rule). This entry…"). Every word is a prop; there is no fallback. + */ +export function ToggletipBadge({ + label, + reason, + variant = "secondary", + icon, + className, +}: ToggletipBadgeProps) { + const [open, setOpen] = useState(false); + /** Opened by a press: hover and focus no longer close it, only a press, Escape or outside. */ + const pinned = useRef(false); + const onOpenChange = (next: boolean, details: OpenChange) => { + // The trigger's own press is handled by `onClick` (the tooltip would only ever close on it). + if (details?.reason === "trigger-press") return; + if (!next && pinned.current && details?.reason === "trigger-hover") return; + if (!next) pinned.current = false; + setOpen(next); + }; + return ( + {reason}} + // A render function, so our classes are merged with the tooltip trigger's, not replaced. + render={(props) => ( +