From ed6c6cfce93457f76965b02b5a96a96f5b810650 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 17:04:03 -0800 Subject: [PATCH] feat(Tooltip): a trigger whose tooltip repeats its own text pins without toggle state `component:Tooltip` FR9's own-text clause, on the shared press pin (`Layer/usePressPin.ts`): - usePressPin's internal `repeatsTriggerText` option, passed through useTooltipInternal (useTooltip with Core's internal options), pins a trigger whose tooltip repeats its own visible text, such as a truncated Text that is its own tab stop, without a button trigger: no development warning, no aria-expanded or aria-controls, and no announcement. The option joins the settle key and the re-wire Effect, so turning it on after mount wires the trigger. - Astryx clickable containers and rows ignore a press-pin click: the mark lives in `Layer/pressPinClick.ts`, which useClickableContainer, Item rows, TreeList rows, and the Table row-expansion and tree-data row clicks read. So a trigger that is not a nested button, an own-text trigger or an anchored `role="button"` element, doesn't activate the card or row around it when no component wrapper stops the press. - No component passes the option yet; the record keeps that implementation gap. - With the gap closed, Tooltip's FR9 gap note, its current-state item, and the Item-row exception in the changeset, docs, and JSDoc go. --- .changeset/trigger-behavior-toggle.md | 2 +- docs/specs/components/Tooltip/Tooltip.spec.md | 41 ++- packages/core/src/Item/Item.tsx | 3 +- packages/core/src/Layer/pressPinClick.ts | 27 ++ packages/core/src/Layer/usePressPin.test.tsx | 237 +++++++++++++++++- packages/core/src/Layer/usePressPin.ts | 64 +++-- .../rowExpansion/useTableRowExpansion.tsx | 6 +- .../Table/plugins/tree/useTableTreeData.tsx | 6 +- packages/core/src/Tooltip/Tooltip.doc.mjs | 4 +- packages/core/src/Tooltip/Tooltip.tsx | 5 +- packages/core/src/Tooltip/useTooltip.doc.mjs | 4 +- packages/core/src/Tooltip/useTooltip.tsx | 30 ++- packages/core/src/TreeList/TreeListItem.tsx | 3 +- .../core/src/hooks/useClickableContainer.ts | 5 +- 14 files changed, 373 insertions(+), 64 deletions(-) create mode 100644 packages/core/src/Layer/pressPinClick.ts diff --git a/.changeset/trigger-behavior-toggle.md b/.changeset/trigger-behavior-toggle.md index 6eef27cfbf474..5b8eea01b74a9 100644 --- a/.changeset/trigger-behavior-toggle.md +++ b/.changeset/trigger-behavior-toggle.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] Tooltip and `useTooltip` take `triggerBehavior` (type `TooltipTriggerBehavior`). `adaptive`, the default, keeps the released behavior. `hover` previews the tooltip on hover and focus, and no touch or pen tap opens it. `toggle` pins the tooltip on a click, tap, Enter, or Space on a button trigger, and the same press closes it. Escape, a press outside, or focus leaving also closes it. The press stops at the trigger: its default action is prevented, the trigger's own handlers still run, and a clickable row, card, or label around it does nothing. With `anchorRef` or a caller's own trigger, a plain `onClick` element around the trigger still receives the press, and so does an Item row when that trigger is not a native button. The trigger reports `aria-expanded` and `aria-controls`, and the tooltip's text is announced once when it pins. A trigger that is not a button gets a development warning. `touchTrigger` now decides taps only under `adaptive`. +[feat] Tooltip and `useTooltip` take `triggerBehavior` (type `TooltipTriggerBehavior`). `adaptive`, the default, keeps the released behavior. `hover` previews the tooltip on hover and focus, and no touch or pen tap opens it. `toggle` pins the tooltip on a click, tap, Enter, or Space on a button trigger, and the same press closes it. Escape, a press outside, or focus leaving also closes it. The press stops at the trigger: its default action is prevented, the trigger's own handlers still run, and a clickable row, card, or label around it does nothing. With `anchorRef` or a caller's own trigger, a plain `onClick` element around the trigger still receives the press. The trigger reports `aria-expanded` and `aria-controls`, and the tooltip's text is announced once when it pins. A trigger that is not a button gets a development warning. `touchTrigger` now decides taps only under `adaptive`. @vjeux diff --git a/docs/specs/components/Tooltip/Tooltip.spec.md b/docs/specs/components/Tooltip/Tooltip.spec.md index fd6dabe1d6b1e..17d5ceae4d1a3 100644 --- a/docs/specs/components/Tooltip/Tooltip.spec.md +++ b/docs/specs/components/Tooltip/Tooltip.spec.md @@ -56,7 +56,7 @@ system_specs: [spec:AST-003, spec:AST-017, spec:AST-086/FR12, spec:AST-086/FR14] | Behavior | A `role="tooltip"` layer that describes its trigger. It opens on hover after `delay`, on keyboard focus, and on a qualifying tap; it stays open while the pointer moves onto it, and closes on leave, Escape, or pressing the trigger (a press that focuses a text field reopens it). With `triggerBehavior="hover"`, no touch or pen tap opens it. With `triggerBehavior="toggle"`, hover and focus preview it, and a click, tap, Enter, or Space on a button trigger pins it open until Escape, a second press, a press outside, or focus leaving. | | End-user impact | Mouse, keyboard, and touch users can each reveal the hint, move onto it, and dismiss it; screen-reader users hear it as the trigger's description. With the toggle, people on touch, keyboard, or a screen reader can keep it open to read it, and hear it once when it pins. | | Builder impact | A trigger whose information lives only in the tooltip passes `triggerBehavior="toggle"` and a button trigger (DEC-1). `touchTrigger="tap"` callers migrate by trigger kind: the deprecating release's codemod, an opt-in early migration when run explicitly, rewrites icon and span triggers and reports the rest (DEP-0016), and CLN-0024 names each remaining kind's replacement in 0.8.0, whose upgrade runs the codemod again. | -| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` is removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (CLN-0024). DEP-0016's deprecation metadata and codemod are not yet built; twelve further implementation gaps (see Current-state impact). | +| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` is removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (CLN-0024). DEP-0016's deprecation metadata and codemod are not yet built; eleven further implementation gaps (see Current-state impact). | | Review checks | Reject a tooltip that cannot be hovered or dismissed (WCAG 1.4.13), one that opens on a tap's focus or on pointer focus of a trigger that is not a text field, a lost `aria-describedby` link, a pinned tooltip that closes on pointer leave or lets Escape pass to the layer beneath, a toggle without `aria-expanded`, or a non-button toggle trigger without the development warning. | | Governing rules | WCAG 1.4.13, 2.1.1; `family:overlay-dismissal`; `spec:AST-003` FR4; `spec:AST-017` (deprecation); `architecture:public-component-api` INV9, INV21; `architecture:layer-runtime`; `architecture:interaction-modality`; `architecture:component-theming-surface`. | @@ -163,12 +163,11 @@ Consumer migration instructions belong in consumer docs and release notes. - **DEP-0016 is not built.** `touchTrigger="tap"` carries no `@deprecated` metadata yet, and the `astryx upgrade` codemod DEP-0016 names does not exist yet; `triggerBehavior` is built (FR9, FR10, and FR5's rules for its values). -- **FR9's own-text pin is not built.** A trigger whose tooltip repeats its own - visible text, such as a truncated Text, gets FR9's development warning under - `triggerBehavior="toggle"` and keeps tap-only opening. -- **An Item row runs on a press of an anchored button-role trigger.** Item's - row click skips only native controls. So an anchored or caller's trigger that - is not a native button pins the tooltip and also runs the row (FR9). +- **No trigger reaches FR9's own-text pin yet.** A trigger whose tooltip + repeats its own visible text pins without being a button only through an + internal option, and no component passes it yet, so a text trigger under + `triggerBehavior="toggle"` gets FR9's development warning and keeps tap-only + opening. - **The tooltip consumes Escape until AST-003 ships.** A visible, topmost tooltip handles Escape, as `family:overlay-dismissal` records, so the layer underneath it, such as a Dialog, stays open for that press. `spec:AST-003` @@ -227,19 +226,19 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1 | The rendered tooltip contains one owned surface, `role="tooltip"`, carrying the `tooltip` target, at most 300 px wide, with the supplied `content` inside; content has no separate target and there is no arrow. | Current source, docs, and tests | -| FR2 | Trigger wiring: a single string or number child is wrapped in a focusable inline span; element `children` attach to their first element; `anchorRef` attaches to that element. The trigger's `aria-describedby` gains the tooltip id, merged with and later restored to any existing value. Implementation gap: the wiring runs only when the trigger's listeners attach, so a trigger element replaced after mount, or a trigger whose own `aria-describedby` changes, loses it until a re-render re-attaches them (Current-state impact). | Current source and tests | -| FR3 | On a hover-capable pointer, entering the trigger opens after `delay` ms; leaving closes after `hideDelay` ms. With `hideDelay` 0 a short bridge lets the pointer move onto the surface without closing it, and the surface stays open while hovered. | WCAG 1.4.13; tests | -| FR4 | Focus that matches `:focus-visible` opens the tooltip per `focusTrigger`: keyboard focus, programmatic focus that follows keyboard input (AR4), pointer focus on a text-entry trigger, which browsers mark focus-visible, and programmatic focus on a text-entry trigger unless a finger or pen pressed that trigger and the last input was a pointer press, and on any other trigger unless the last input was a pointer (after keyboard input, as in AR4, or before any input). Implementation gap: script focus after a pointer press that focused nothing opens it, because the hook reads `:focus-visible` alone (Current-state impact). Focus a tap leaves behind, and other pointer focus, does not. Focus leaving the trigger schedules the hide as a pointer leave does. Under `focusTrigger="auto"`, focus opens the tooltip on any focusable trigger, judged when focus arrives. | Current source and tests | -| FR5 | On touch and pen, a completed tap opens per `triggerBehavior` when the finger lifts; a touch that becomes a scroll, or a press the browser does not count as a tap, does not open it. Under `adaptive`, a tap that already acts leaves it shut: walking out from the tapped element, the trigger included, the innermost button, link, label, form control, option, menu item, or row or card made clickable with `useClickableContainer` decides, and an `aria-disabled` control, or a label for a disabled control, is not an action, so a disabled control's reason opens on a tap even inside a clickable row or card (DEC-1). Until CLN-0024 removes `touchTrigger`, an explicit `touchTrigger` decides instead under `adaptive`: `tap` opens on every tap and `none` on no tap. Implementation gap: the walk ships with CLN-0024 in the next minor; until then `adaptive`, like the shipped `touchTrigger="auto"`, asks only whether the trigger itself performs an action, apart from a disabled control (Compatibility and migration). A second tap on the trigger, a tap outside, or a scroll starting outside closes it. Under `hover`, no tap opens it. Under `toggle`, FR9 decides what a tap does. `hover` and `toggle` ignore `touchTrigger`. | Current source and tests | -| FR6 | Escape closes a visible tooltip; Escape during text composition is ignored. Escape routing follows `family:overlay-dismissal`: a visible topmost tooltip in the page handles that Escape, so a layer underneath stays open for that press. Implementation gap: a tooltip rendered into another document, such as an iframe or a Picture-in-Picture window, with no layer beneath it is never closed by Escape (Current-state impact). `spec:AST-003` FR4, accepted and not yet implemented, makes an unpinned tooltip passive and gives a pinned one Escape first (Current-state impact). Outside `triggerBehavior="toggle"`, pressing an uncontrolled tooltip's trigger closes it; a press that focuses a text-entry trigger reopens it through FR4. | WCAG 1.4.13; tests | -| FR7 | `isOpen` forces the tooltip shown or hidden, and `isOpen={true}` suppresses hover and focus hiding; a controlled tooltip answers Escape by calling `onOpenChange(false)` without hiding, and is not closed by pressing the trigger. `isDefaultOpen` opens it on mount, still dismissible. Implementation gap: with `isOpen={false}`, any focus FR4 opens on, keyboard focus or a click into a text-field trigger, still opens the tooltip and reports `onOpenChange(true)`, and Escape then only reports `onOpenChange(false)`, so it stays until focus or the pointer leaves the trigger or the tooltip, or the caller re-renders with a new `onOpenChange` (Current-state impact). `isEnabled={false}` disables triggers and drops a pending open. | Current source and tests | -| FR8 | `onOpenChange` reports each show and hide. `hasHoverIndication` (`auto`: a single string or number child) adds a hover cue to the inline trigger. | Current source and tests | -| FR9 | With `triggerBehavior="toggle"` and a trigger whose role is `button` (a `