Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| LayerProvider | 62.6 kB (+1.8 kB) · 10.5 kB CSS (0) | — | ✓ · ✓ · ✗ |
| lab/Schedule | 🔴 61.2 kB (+3.4 kB) · 3.9 kB CSS (0) | — | — · — · — |
| Carousel | 🔴 50.6 kB (+3.3 kB) · 10.5 kB CSS (0) | — | — · — · — |
111 more components
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| lab/TourStep | 🔴 55.1 kB (+3.3 kB) · 590 B CSS (0) | — | — · — · — |
| Popover | 🔴 49.7 kB (+3.2 kB) · 12.4 kB CSS (0) | — | — · — · — |
| DrawerHeader | 🔴 48.4 kB (+2.7 kB) · 11.6 kB CSS (0) | — | — · — · — |
| TableSelectionToolbar | 🔴 52.1 kB (+2.7 kB) · 11.5 kB CSS (0) | — | — · — · — |
| DialogHeader | 🔴 48.3 kB (+2.7 kB) · 10.2 kB CSS (0) | — | — · — · — |
| lab/InfoTip | 🔴 26.1 kB (+2.7 kB) · 458 B CSS (0) | — | — · — · — |
| Tooltip · Storybook | 🔴 20.9 kB (+2.7 kB) · 5.0 kB CSS (0) | 644 (+40) | ✓ · ✓ · ✓ |
| StatusDot | 🔴 23.1 kB (+2.7 kB) · 6.3 kB CSS (0) | — | — · — · — |
| ChatLayout | 🔴 48.3 kB (+2.7 kB) · 10.3 kB CSS (0) | — | — · — · — |
| Stepper | 🔴 46.8 kB (+2.7 kB) · 9.9 kB CSS (0) | — | — · — · — |
| lab/DiffViewer | 🔴 70.6 kB (+2.7 kB) · 2.1 kB CSS (0) | — | — · — · — |
| TopNavMenu | 🔴 58.0 kB (+2.6 kB) · 10.5 kB CSS (0) | — | — · — · — |
| TopNavMegaMenu | 🔴 54.1 kB (+2.6 kB) · 11.2 kB CSS (0) | — | — · — · — |
| ChatDictationButton | 🔴 45.9 kB (+2.6 kB) · 9.8 kB CSS (0) | — | — · — · — |
| MobileNavToggle | 🔴 45.6 kB (+2.6 kB) · 9.7 kB CSS (0) | — | — · — · — |
| FieldLabel | 🔴 36.0 kB (+2.6 kB) · 5.9 kB CSS (0) | — | — · — · — |
| ToggleButton | 🔴 43.1 kB (+2.6 kB) · 9.6 kB CSS (0) | — | — · — · — |
| ChatSendButton | 🔴 45.0 kB (+2.6 kB) · 9.5 kB CSS (0) | — | — · — · — |
| SideNavCollapseButton | 🔴 46.0 kB (+2.6 kB) · 9.8 kB CSS (0) | — | — · — · — |
| Banner | 🔴 47.2 kB (+2.6 kB) · 10.3 kB CSS (0) | — | — · — · — |
| Toast | 🔴 59.3 kB (+2.6 kB) · 10.1 kB CSS (0) | — | — · — · — |
| IconButton | 🔴 42.2 kB (+2.6 kB) · 9.5 kB CSS (0) | — | — · — · — |
| TabMenu | 🔴 55.1 kB (+2.6 kB) · 10.4 kB CSS (0) | — | — · — · — |
| Link | 🔴 42.8 kB (+2.6 kB) · 7.9 kB CSS (0) | — | — · — · — |
| ChatLayoutScrollButton | 🔴 45.6 kB (+2.6 kB) · 9.9 kB CSS (0) | — | — · — · — |
| Button | 🔴 42.2 kB (+2.6 kB) · 9.5 kB CSS (0) | — | — · — · — |
| TopNavHeading | 🔴 57.9 kB (+2.5 kB) · 10.4 kB CSS (0) | — | — · — · — |
| SideNavHeading | 🔴 58.5 kB (+2.5 kB) · 10.5 kB CSS (0) | — | — · — · — |
| lab/CommentPin | 🔴 47.0 kB (+2.5 kB) · 1.2 kB CSS (0) | — | — · — · — |
| SideNavItem | 🔴 54.4 kB (+2.5 kB) · 10.4 kB CSS (0) | — | — · — · — |
| Avatar | 🔴 34.9 kB (+2.5 kB) · 6.5 kB CSS (0) | — | — · — · — |
| SegmentedControl | 🔴 36.7 kB (+2.5 kB) · 6.5 kB CSS (0) | — | — · — · — |
| AvatarGroup | 🔴 37.6 kB (+2.5 kB) · 6.6 kB CSS (0) | — | — · — · — |
| AvatarGroupOverflow | 🔴 38.8 kB (+2.5 kB) · 6.5 kB CSS (0) | — | — · — · — |
| AppShell | 🔴 68.2 kB (+2.4 kB) · 12.1 kB CSS (0) | — | — · — · — |
| AlertDialog | 🔴 56.0 kB (+2.2 kB) · 12.7 kB CSS (0) | — | — · — · — |
| SideNav | 🔴 65.6 kB (+2.2 kB) · 10.9 kB CSS (0) | — | — · — · — |
| TopNav | 🔴 51.7 kB (+2.1 kB) · 10.7 kB CSS (0) | — | — · — · — |
| MobileNav | 🔴 50.1 kB (+2.1 kB) · 10.4 kB CSS (0) | — | — · — · — |
| Lightbox | 🔴 50.3 kB (+2.0 kB) · 10.1 kB CSS (0) | — | — · — · — |
| ChatComposerInput | 55.8 kB (+2.0 kB) · 10.4 kB CSS (0) | — | — · — · — |
| ChatComposerTokenElement | 55.8 kB (+2.0 kB) · 10.4 kB CSS (0) | — | — · — · — |
| TimeInput | 88.5 kB (+2.0 kB) · 14.3 kB CSS (0) | — | — · — · — |
| Calendar | 54.1 kB (+1.9 kB) · 10.3 kB CSS (0) | — | — · — · — |
| CodeBlock | 59.0 kB (+1.9 kB) · 11.1 kB CSS (0) | — | — · — · — |
| DateInput | 101.1 kB (+1.9 kB) · 15.3 kB CSS (0) | — | — · — · — |
| charts/ChartTooltip | 52.2 kB (+1.9 kB) · 20 B CSS (0) | — | — · — · — |
| Thumbnail | 49.4 kB (+1.9 kB) · 11.3 kB CSS (0) | — | — · — · — |
| DateTimeInput | 113.0 kB (+1.9 kB) · 15.8 kB CSS (0) | — | — · — · — |
| BreadcrumbItem | 66.9 kB (+1.9 kB) · 11.4 kB CSS (0) | — | — · — · — |
| TableCell | 76.1 kB (+1.8 kB) · 15.8 kB CSS (0) | — | — · — · — |
| DropdownMenu | 72.6 kB (+1.8 kB) · 15.4 kB CSS (0) | — | — · — · — |
| richtext/RichTextView | 256.7 kB (+1.8 kB) · 2.3 kB CSS (0) | — | — · — · — |
| charts/Chart | 62.7 kB (+1.8 kB) · 20 B CSS (0) | — | — · — · — |
| lab/ChatReactionBar | 65.3 kB (+1.8 kB) · 1.1 kB CSS (0) | — | — · — · — |
| lab/ChatEmojiPicker | 64.8 kB (+1.8 kB) · 773 B CSS (0) | — | — · — · — |
| charts/ChartLegend | 52.2 kB (+1.8 kB) · 20 B CSS (0) | — | — · — · — |
| ContextMenu | 74.9 kB (+1.8 kB) · 15.3 kB CSS (0) | — | — · — · — |
| MoreMenu | 72.9 kB (+1.8 kB) · 15.4 kB CSS (0) | — | — · — · — |
| FileInput | 53.6 kB (+1.8 kB) · 10.7 kB CSS (0) | — | — · — · — |
| TableHeaderCell | 75.7 kB (+1.8 kB) · 15.5 kB CSS (0) | — | — · — · — |
| TextInput | 52.7 kB (+1.8 kB) · 10.9 kB CSS (0) | — | — · — · — |
| ComplexSelector | 55.3 kB (+1.8 kB) · 11.1 kB CSS (0) | — | — · — · — |
| TextArea | 47.1 kB (+1.8 kB) · 9.8 kB CSS (0) | — | — · — · — |
| DateRangeInput | 69.4 kB (+1.8 kB) · 11.7 kB CSS (0) | — | — · — · — |
| Switch | 47.0 kB (+1.8 kB) · 9.7 kB CSS (0) | — | — · — · — |
| NumberInput | 54.9 kB (+1.8 kB) · 11.2 kB CSS (0) | — | — · — · — |
| CheckboxList | 43.3 kB (+1.8 kB) · 8.6 kB CSS (0) | — | — · — · — |
| lab/EmojiPicker | 70.1 kB (+1.8 kB) · 722 B CSS (0) | — | — · — · — |
| richtext/RichTextEditor | 276.1 kB (+1.8 kB) · 2.6 kB CSS (0) | — | — · — · — |
| lab/TransferListSelector | 80.5 kB (+1.8 kB) · 1.9 kB CSS (0) | — | — · — · — |
| Field | 41.4 kB (+1.8 kB) · 8.3 kB CSS (0) | — | — · — · — |
| CheckboxInput | 48.6 kB (+1.8 kB) · 9.7 kB CSS (0) | — | — · — · — |
| InputGroup | 42.0 kB (+1.8 kB) · 8.4 kB CSS (0) | — | — · — · — |
| lab/TransferList | 74.2 kB (+1.7 kB) · 1.7 kB CSS (0) | — | — · — · — |
| RadioList | 42.8 kB (+1.7 kB) · 8.3 kB CSS (0) | — | — · — · — |
| RadioListItem | 51.2 kB (+1.7 kB) · 10.1 kB CSS (0) | — | — · — · — |
| Slider | 46.1 kB (+1.7 kB) · 8.9 kB CSS (0) | — | — · — · — |
| CheckboxListItem | 53.3 kB (+1.7 kB) · 11.0 kB CSS (0) | — | — · — · — |
| ListItem | 52.6 kB (+1.7 kB) · 11.0 kB CSS (0) | — | — · — · — |
| lab/MobilePowerSearch | 166.6 kB (+1.6 kB) · 1.2 kB CSS (0) | — | — · — · — |
| lab/MobileTokenizer | 100.7 kB (+1.5 kB) · 880 B CSS (0) | — | — · — · — |
| ChatComposer | 60.7 kB (+1.5 kB) · 11.2 kB CSS (0) | — | — · — · — |
| richtext/RichTextEditorToolbar | 221.3 kB (+1.5 kB) · 454 B CSS (0) | — | — · — · — |
| lab/CommentComposer | 102.0 kB (+1.5 kB) · 1.9 kB CSS (0) | — | — · — · — |
| lab/Comments | 231.7 kB (+1.5 kB) · 2.5 kB CSS (0) | — | — · — · — |
| BaseTypeahead | 54.7 kB (+1.4 kB) · 10.2 kB CSS (0) | — | — · — · — |
| lab/CommentThread | 226.4 kB (+1.4 kB) · 2.4 kB CSS (0) | — | — · — · — |
| Tokenizer | 69.7 kB (+1.4 kB) · 17.1 kB CSS (0) | — | — · — · — |
| lab/ListInput | 81.4 kB (+1.4 kB) · 1.6 kB CSS (0) | — | — · — · — |
| PowerSearch | 182.3 kB (+1.3 kB) · 20.5 kB CSS (0) | — | — · — · — |
| MultiSelector | 72.3 kB (+1.3 kB) · 15.2 kB CSS (0) | — | — · — · — |
| Selector | 76.5 kB (+1.3 kB) · 15.6 kB CSS (0) | — | — · — · — |
| Typeahead | 62.3 kB (+1.3 kB) · 11.8 kB CSS (0) | — | — · — · — |
| Markdown | 144.9 kB (+1.3 kB) · 18.3 kB CSS (0) | — | — · — · — |
| Pagination | 85.4 kB (+1.2 kB) · 15.9 kB CSS (0) | — | — · — · — |
| Table | 84.2 kB (+1.2 kB) · 16.0 kB CSS (0) | — | — · — · — |
| CommandPalette | 48.4 kB (+175 B) · 12.2 kB CSS (0) | — | — · — · — |
| ProgressBar | 8.1 kB (+132 B) · 6.9 kB CSS (0) | — | — · — · — |
| ChatMessageList | 22.8 kB (+97 B) · 7.9 kB CSS (0) | — | — · — · — |
| CommandPaletteInput | 26.7 kB (+80 B) · 8.5 kB CSS (0) | — | — · — · — |
| HoverCard | 18.5 kB (+54 B) · 5.0 kB CSS (0) | — | — · — · — |
| Spinner | 18.5 kB (+41 B) · 6.7 kB CSS (0) | — | — · — · — |
| Timestamp | 21.3 kB (+39 B) · 9.9 kB CSS (0) | — | — · — · — |
| Timer | 9.6 kB (+35 B) · 7.0 kB CSS (0) | — | — · — · — |
| ChatToolCalls | 25.8 kB (+32 B) · 9.3 kB CSS (0) | — | — · — · — |
| Heading | 7.9 kB (+19 B) · 6.0 kB CSS (0) | — | — · — · — |
| Text | 7.4 kB (+14 B) · 6.0 kB CSS (0) | — | — · — · — |
| DropdownMenuSubMenu | 55.9 kB (−31 B) · 9.9 kB CSS (0) | — | — · — · — |
| layer | — | — | — · ✓ · — |
| Layer | — | — | ✓ · ✓ · — |
Package size: @astryxdesign/core 5.83 MB packed (+13.5 kB); no new dependencies.
API changes: core +4 · ~0 · −0
Committed API report behind this build: core 18 rows; run pnpm api:report.
4 API changes (advisory)
| Package | Where | Signature | Usage | |
|---|---|---|---|---|
| + | core |
. · Components · component Tooltip |
prop triggerBehavior?: TooltipTriggerBehavior = 'adaptive' |
<Tooltip triggerBehavior="adaptive" /> |
| + | core |
. · Types · interface TooltipOptions |
triggerBehavior?: TooltipTriggerBehavior = 'adaptive' |
import type {TooltipOptions} from '@astryxdesign/core'; const x: TooltipOptions = {triggerBehavior: …} |
| + | core |
. · Types · type TooltipTriggerBehavior |
type TooltipTriggerBehavior = 'adaptive' | 'hover' | 'toggle' |
import type {TooltipTriggerBehavior} from '@astryxdesign/core' |
| + | core |
./Tooltip · Also exported from an earlier entry point · TooltipTriggerBehavior |
TooltipTriggerBehavior (.) |
import {TooltipTriggerBehavior} from '@astryxdesign/core/Tooltip' |
Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.
Generated by PR Enrichment workflow | View full report
There was a problem hiding this comment.
Changes requested: the toggle behaves as component:Tooltip FR9 describes for button triggers, but a few gaps need fixing first.
- A pin inside a modal
Dialogis announced withannounceMessageinto the page-body live region, which the modal hides, so announce throughuseAnnounceoruseAnnounceRenderedTextto getspec:AST-086FR14 routing. - A labelled HoverCard on a non-button trigger loses
aria-controls/aria-expandedunderpressTrigger="toggle"(HoverCard.tsx:380), which also makes the codemod's span rewrite change behavior; hand that state to the pin only when it wires the trigger, and test it. - The outside-press Effect in
usePressPinduplicatesuseTouchTrigger's outside dismissal; please extend that owner instead. - The hook docs promise the press stops at the trigger (it does not without the component wrapper),
touchTrigger="tap"has no@deprecatedtag, andTooltipPressTrigger/HoverCardPressTriggerare missing from the entry-point exports. - At 2,307 authored lines, please split the DEP-0016 codemod and deprecation into a follow-up in the same release.
[Automated review]
| announcedRef.current = true; | ||
| const text = optionsRef.current.announcement()?.trim(); | ||
| if (text != null && text !== '') { | ||
| announceMessage(text, 'polite'); |
There was a problem hiding this comment.
announceMessage always writes to the page-body live region. Inside an open modal Dialog, that region is inert, so the pin is never heard; useAnnounce() routes to the modal's own region (spec:AST-086 FR14). The shared useAnnounceRenderedText already reads a layer's rendered text and announces it through useAnnounce, so gating it on the pin would replace this Effect.
|
|
||
| // Under the toggle, the press pin owns aria-expanded (true only while | ||
| // pinned) and aria-controls (FR10); the popup role stays here. | ||
| const ownsToggleState = pressTrigger !== 'toggle'; |
There was a problem hiding this comment.
This hands aria-controls/aria-expanded to the press pin for every trigger, but usePressPin wires only button triggers. A labelled HoverCard on a span or link trigger therefore loses HoverCard FR3's popup state, and the codemod rewrites such spans. Something like pressTrigger === 'toggle' && isPressPinTrigger(firstChild) keeps FR3 for the rest.
| // While pinned, a press or focus outside the trigger and the layer closes it. | ||
| useEffect(() => { | ||
| if (!isToggleMode || trigger == null || !isExpanded) { | ||
| return; | ||
| } | ||
| const ownerDocument = trigger.ownerDocument; | ||
| const isInside = (target: EventTarget | null) => { | ||
| if (!(target instanceof Node)) { | ||
| return false; | ||
| } | ||
| const surface = ownerDocument.getElementById(layerId); | ||
| return ( | ||
| trigger.contains(target) || | ||
| (surface != null && surface.contains(target)) | ||
| ); | ||
| }; | ||
| const onPointerDown = (event: PointerEvent) => { | ||
| if (!isInside(event.target)) { | ||
| close(); | ||
| } | ||
| }; | ||
| const onFocusIn = (event: FocusEvent) => { | ||
| if (!isInside(event.target)) { | ||
| close(); | ||
| } | ||
| }; | ||
| ownerDocument.addEventListener('pointerdown', onPointerDown, true); | ||
| ownerDocument.addEventListener('focusin', onFocusIn, true); | ||
| return () => { | ||
| ownerDocument.removeEventListener('pointerdown', onPointerDown, true); | ||
| ownerDocument.removeEventListener('focusin', onFocusIn, true); | ||
| }; | ||
| }, [isToggleMode, trigger, isExpanded, layerId, close]); |
There was a problem hiding this comment.
useTouchTrigger (armOutsideDismiss), which both hooks already call, owns outside dismissal for hover layers, armed from the opening handler. This Effect adds a second path with its own containment test. Could the pin arm that owner from toggle, and could focus leaving close a pinned layer through the existing focus-leave path?
| {name: 'focusTrigger', type: "'auto' | 'always' | 'never'", description: 'When focus should open the tooltip. auto only attaches focus listeners to naturally focusable elements.', default: "'auto'"}, | ||
| {name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own; tap always opens (an info icon rendered as a button); none never opens on touch.', default: "'auto'"}, | ||
| {name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own; tap always opens (deprecated, DEP-0016: use pressTrigger="toggle"); none never opens on touch. Ignored under pressTrigger="toggle".', default: "'auto'"}, | ||
| {name: 'pressTrigger', type: "'none' | 'toggle'", description: 'Whether a press on the trigger pins the tooltip open. none leaves taps to touchTrigger; toggle, on a button trigger, pins on a click, a tap, Enter, or Space and closes on the same press, Escape, a press outside, or focus leaving. The trigger carries aria-expanded and aria-controls, the tooltip is announced once when it pins, and the press stops at the trigger.', default: "'none'"}, |
There was a problem hiding this comment.
"The press stops at the trigger" holds only for the Tooltip/HoverCard components, whose wrapper calls stopPropagation. With the hook alone, a button inside <div onClick> pins the tooltip and also fires the row's handler. The same wording is in useHoverCard.doc.mjs and both hooks' JSDoc.
| * as a button wants: it looks like an action to the DOM, but revealing the | ||
| * tooltip is the only thing it does | ||
| * - `tap`: tap always opens the tooltip, even on a trigger that acts. | ||
| * Deprecated (DEP-0016): pass `pressTrigger="toggle"` instead; `astryx |
There was a problem hiding this comment.
This is prose only. DEP-0016's warning field and spec:AST-017 FR29a ask for static @deprecated metadata on tap, and the description says it is present, but no @deprecated tag exists for it.
| export type {TooltipFocusTrigger, TooltipTouchTrigger} from './useTooltip'; | ||
| export type { | ||
| TooltipFocusTrigger, | ||
| TooltipPressTrigger, |
There was a problem hiding this comment.
TooltipPressTrigger (and HoverCardPressTrigger) is not added to Tooltip/index.ts, HoverCard/index.ts or Layer/index.ts. Those files export the FocusTrigger/TouchTrigger siblings, so the new types are unreachable from @astryxdesign/core (architecture:public-component-api INV1).
a5250c6 to
c543d57
Compare
|
Per the owner's ruling, DEP-0016 (the |
There was a problem hiding this comment.
Changes requested: the toggle now pins and closes as component:Tooltip FR9 describes and the earlier findings are fixed, but a few gaps remain.
- In Chromium, a press on a pinned HoverCard's (or Tooltip's) own text closes it, because the focus-leave check treats focus moving to the page body as leaving (
usePressPin.ts:353-359), while HoverCard FR9 says such a press keeps the pin. - The pin reads
textContentand announces it from its own Effect, butarchitecture:react-component-runtimeINV10 puts that inuseAnnounceRenderedText, which also keeps an unlabelled card's paragraphs apart (today it says "Ada LovelaceAnalyst, Payments"). - The HoverCard verification row cites Enter, Space, press-outside, focus-leave, pointer-leave and unlabelled cases that no HoverCard test covers yet.
- Smaller: the
touchDismissRefbridge Effect and the trigger element kept in state inusePressPin, the duplicatedstopPressPinClick, and the changeset's forward-looking DEP-0016 sentence.
[Automated review]
| if ( | ||
| next instanceof Node && | ||
| (trigger?.contains(next) === true || surface?.contains(next) === true) | ||
| ) { | ||
| return true; | ||
| } | ||
| close(); |
There was a problem hiding this comment.
When the person presses non-focusable text inside the pinned layer, Chromium moves focus from the trigger to the body, so relatedTarget is null and this closes the pin. A pinned HoverCard then closes on a press on its own content, against HoverCard FR9 ("a press on its own content keeps it pinned"), and a pinned Tooltip closes the same way. Could a focus move caused by a press inside the trigger or the layer be left to useTouchTrigger's outside dismissal, with this closing only when focus lands outside both?
| useEffect(() => { | ||
| if (!isToggleMode || trigger == null || !isExpanded || !wiresState) { | ||
| announcedRef.current = false; | ||
| return; | ||
| } | ||
| if (announcedRef.current) { | ||
| return; | ||
| } | ||
| announcedRef.current = true; | ||
| const text = optionsRef.current.announcement()?.trim(); | ||
| if (text != null && text !== '') { | ||
| announce(text, 'polite'); | ||
| } | ||
| }, [isToggleMode, trigger, isExpanded, wiresState, announce]); |
There was a problem hiding this comment.
This Effect announces text the hosts read with textContent. architecture:react-component-runtime INV10 keeps reading and announcing a component's rendered text in hooks/useAnnounceRenderedText.ts, which skips aria-hidden content and keeps blocks apart; an unlabelled HoverCard with two paragraphs is announced here as "Ada LovelaceAnalyst, Payments".
| }, []); | ||
|
|
||
| // The wired trigger, as state: its ARIA follows the pin through effects. | ||
| const [trigger, setTrigger] = useState<HTMLElement | null>(null); |
There was a problem hiding this comment.
architecture:react-component-runtime INV1: DOM nodes must not be stored in React state. The trigger can live in triggerRef alone, with attach/detach setting and restoring its aria-expanded/aria-controls, which would also replace the restore Effect below.
| useEffect(() => { | ||
| touchDismissRef.current = {arm: armOutsideDismiss, disarm: clearTapOpen}; | ||
| }, [armOutsideDismiss, clearTapOpen]); |
There was a problem hiding this comment.
This Effect only copies useTouchTrigger's callbacks into a ref, so it synchronizes no external system (INV3), and the same bridge and hideFromTouch are copied in useHoverCard.tsx:450-452. Letting usePressPin own the touch-trigger wiring (or useTouchTrigger take the pin's close) would leave each hook one call.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] Tooltip and HoverCard (and `useTooltip`, `useHoverCard`) take `pressTrigger` (types `TooltipPressTrigger`, `HoverCardPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the layer open and the same press closes it, while hover and focus still preview it. A pinned layer stays until Escape, a second press, a press outside, or focus leaving, and it takes Escape before the layer beneath it. The press stops at its trigger, so a clickable row, card, or label around it does nothing, and the trigger's own handlers still run. The trigger reports `aria-expanded` and `aria-controls`, and the layer is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10, `component:HoverCard` FR9 and FR10). A trigger that is not a button gets a development warning and keeps tap-only opening; beside `pressTrigger="toggle"`, `touchTrigger` is ignored. `touchTrigger="tap"` is deprecated in favor of `pressTrigger="toggle"` in the next patch (DEP-0016). |
There was a problem hiding this comment.
The last sentence announces the DEP-0016 deprecation, which now ships in the stacked follow-up. Could it move to that follow-up's changeset so this release note describes only what this change does?
c543d57 to
90ae917
Compare
|
Fixed in 90ae917, per the spec owners' ruling:
Kept, per the spec owners' ruling: the changeset says |
There was a problem hiding this comment.
Changes requested: the toggle now behaves as component:Tooltip and component:HoverCard FR9 and FR10 describe, and the earlier findings are fixed, but three items remain.
- The touch-trigger bridge (
touchDismissRef,hideFromTouch, theusePressPincall and thetouchTriggeroverride) is copied line for line intouseTooltip.tsx:375-439anduseHoverCard.tsx:384-448; moving it intousePressPin, so each hook makes one call, keeps the two layers from drifting apart. - At 2,218 authored lines the change is over the 2,000-line threshold; the HoverCard wiring (about 600 lines) could land as a follow-up, or the description could show why the two are inseparable.
- The changeset's last sentence announces DEP-0016, which ships in #9246, so it belongs in that pull request's changeset.
[Automated review]
| // A press pins the card under `pressTrigger="toggle"` (FR9, FR10). The pin | ||
| // arms and drops the touch trigger's outside dismissal, the one owner of a | ||
| // hover layer's dismissal by a press outside. That hook takes the pin's | ||
| // close as part of its hide, so the pin reaches it through a ref, assigned | ||
| // during render below and read only from handlers. | ||
| const touchDismissRef = useRef<{arm: () => void; disarm: () => void} | null>( | ||
| null, | ||
| ); | ||
| const armTouchDismiss = useCallback(() => touchDismissRef.current?.arm(), []); | ||
| const disarmTouchDismiss = useCallback( | ||
| () => touchDismissRef.current?.disarm(), | ||
| [], | ||
| ); | ||
|
|
||
| const { | ||
| isToggleTrigger, | ||
| isPinnedRef: isPressPinnedRef, | ||
| attach: attachPressPin, | ||
| unpin: unpinPress, | ||
| closeIfPinned: closePressPin, | ||
| handleFocusLeave: handlePressFocusLeave, | ||
| isPinShown, | ||
| } = usePressPin({ | ||
| pressTrigger, | ||
| isEnabled, | ||
| isOpen, | ||
| isLayerOpen, | ||
| layerId, | ||
| component: 'HoverCard', | ||
| show: showNow, | ||
| hide: hideNow, | ||
| report: next => (next ? onShow?.() : onHide?.()), | ||
| label, | ||
| armOutsideDismiss: armTouchDismiss, | ||
| disarmOutsideDismiss: disarmTouchDismiss, | ||
| }); | ||
|
|
||
| // An outside press or scroll closes a pinned layer through the pin, so the | ||
| // pin drops with it; otherwise it hides as a tap-open does. | ||
| const hideFromTouch = useCallback(() => { | ||
| if (closePressPin()) { | ||
| return; | ||
| } | ||
| hideNow(); | ||
| }, [closePressPin, hideNow]); | ||
|
|
||
| const { | ||
| isTouchPointerRef, | ||
| isTouchInteraction, | ||
| handlePointerEnter, | ||
| handlePointerDown: handleTouchPointerDown, | ||
| clearTapOpen, | ||
| } = useTouchTrigger({ | ||
| touchTrigger, | ||
| armOutsideDismiss, | ||
| } = useTouchTriggerInternal({ | ||
| // Under the toggle, a trigger no press can pin keeps tap-only opening. | ||
| touchTrigger: pressTrigger === 'toggle' ? 'tap' : touchTrigger, | ||
| isEnabled, | ||
| isControlled: isOpen !== undefined, | ||
| isOpen: isLayerOpen, | ||
| layerId: layerId, | ||
| triggerRef, | ||
| show: showNow, | ||
| hide: hideNow, | ||
| hide: hideFromTouch, | ||
| }); | ||
| touchDismissRef.current = {arm: armOutsideDismiss, disarm: clearTapOpen}; |
There was a problem hiding this comment.
This block is the same as useTooltip.tsx:375-439 apart from the component name and one option: the touchDismissRef bridge, hideFromTouch, the usePressPin call and the touchTrigger override. A fix to how a pin closes on an outside press would then have to land twice. Could usePressPin (or one Layer hook) call useTouchTriggerInternal itself and hand back the touch handles, so each hook makes one call?
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] Tooltip and HoverCard (and `useTooltip`, `useHoverCard`) take `pressTrigger` (types `TooltipPressTrigger`, `HoverCardPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the layer open and the same press closes it, while hover and focus still preview it. A pinned layer stays until Escape, a second press, a press outside, or focus leaving, and it takes Escape before the layer beneath it. The press stops at its trigger, so a clickable row, card, or label around it does nothing, and the trigger's own handlers still run. The trigger reports `aria-expanded` and `aria-controls`, and the layer is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10, `component:HoverCard` FR9 and FR10). A trigger that is not a button gets a development warning and keeps tap-only opening; beside `pressTrigger="toggle"`, `touchTrigger` is ignored. `touchTrigger="tap"` is deprecated in favor of `pressTrigger="toggle"` in the next patch (DEP-0016). |
There was a problem hiding this comment.
Approval needs an engineering owner's spec decision first. The new anchorRef sentence in component:Tooltip FR9 lets a plain onClick container receive the pinning press, and the current FR9 says the press stops at its trigger. An engineering owner records approval by commenting /approve-spec 26d7af948c0d9d8fa2fee0c4d123c93adf9945ea; the author may do this, as an engineering owner. Please also say whether bare useTooltip triggers fall under the same exception. Two changes are needed either way:
- Replace the module-level
warnedTriggersset inusePressPin.tswithuseDevWarning, which already owns once-per-mount development warnings. - Bring the 2,074 authored lines to 2,000 or under, for example by moving the caller-less
repeatsTriggerTextoption to the pull request that first uses it.
[Automated review]
| | 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 `pressTrigger="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 `pressTrigger="toggle"` and a trigger whose role is `button` (a `<button>` or `role="button"`), a click, a completed tap, Enter, or Space on the trigger pins the tooltip open, and the same press on a pinned tooltip closes it. Hover and keyboard focus still preview it per FR3 and FR4; on touch no preview opens and a tap pins it, while a touch that becomes a scroll pins nothing. A pinned tooltip stays open when the pointer leaves the trigger and the surface; Escape (FR6), a second press, a press outside the trigger and the surface, or focus moving outside them closes it. In a controlled tooltip a press reports the toggled value through `onOpenChange` and does not change the state itself (FR7). A press that pins or closes the layer stops at its trigger: it does not reach a clickable row, card, or container around it, or the control a label names. With `anchorRef`, the caller's own handlers still receive the press: the anchor's `onClick` runs, and a plain `onClick` element around the anchor receives it unless the anchor's `onClick` stops it; Astryx clickable rows, cards, and containers ignore it. A trigger whose role is not `button` gets a development warning and keeps tap-only opening: a tap opens the tooltip as `touchTrigger="tap"` does, and no press pins it. A trigger whose tooltip repeats its own visible text, such as a truncated Text that is its own tab stop, may take `pressTrigger="toggle"` without being a button: a press pins it as on a button trigger, and it gets no development warning, no `aria-expanded` or `aria-controls`, and no announcement, since its text is already its content. Implementation gap: no component reaches the own-text clause yet (Current-state impact). | DEC-1; WCAG 1.4.13, 2.1.1 | |
There was a problem hiding this comment.
The new anchorRef sentence ("a plain onClick element around the anchor receives it unless the anchor's onClick stops it") narrows this row's own stop: "it does not reach a clickable row, card, or container around it". As a requirement change it needs an engineering owner's /approve-spec on this head. Bare useTooltip triggers behave the same way today, and FR9 does not mention them.
| export type LayerPressTrigger = 'none' | 'toggle'; | ||
|
|
||
| /** Triggers already warned that a press cannot pin them: one warning each. */ | ||
| const warnedTriggers = new WeakSet<Element>(); |
There was a problem hiding this comment.
Core already owns once-per-mount development warnings in useDevWarning (hooks/useDevWarning.ts). Since offeredKind is render state, useDevWarning(options.component, message, isToggleMode && offeredKind === 1 && wiresState) could replace this set and the warn branch in wire, and the "warns once per trigger" test still holds.
|
/approve-spec 26d7af9 |
26d7af9 to
38e6fa2
Compare
|
Fixed in 38e6fa2:
|
There was a problem hiding this comment.
Approval needs an engineering owner's spec decision first. This revision's component:Tooltip FR9 lets a plain onClick container receive the pinning press from an anchorRef or bare useTooltip trigger, while the current FR9 says the press stops at its trigger, and the earlier /approve-spec covered only the previous revision's anchorRef wording. An engineering owner records approval of this wording by commenting /approve-spec 38e6fa2bd8c82e9404b4739a64d0506c7961f503; the author may do this as an engineering owner. One change is needed either way:
- The changeset says the press stops before a clickable row for both
TooltipanduseTooltip, but a bareuseTooltiptrigger inside a plainonClickrow still runs it, so nameuseTooltipbesideanchorRefin that sentence.
[Automated review]
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] Tooltip (and `useTooltip`) take `pressTrigger` (type `TooltipPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the tooltip open and the same press closes it, while hover and focus still preview it. A pinned tooltip stays until Escape, a second press, a press outside, or focus leaving, and it takes Escape before the layer beneath it. The press stops at its trigger, so a clickable row, card, or label around it does nothing, and the trigger's own handlers still run. With `anchorRef`, Tooltip renders nothing around the trigger to stop it: the anchor's own `onClick` runs, Astryx clickable rows, cards, and containers ignore the press, and a plain `onClick` element around the anchor receives it unless the anchor's `onClick` stops it. The trigger reports `aria-expanded` and `aria-controls`, and the tooltip's text is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10). A trigger that is not a button gets a development warning and keeps tap-only opening; beside `pressTrigger="toggle"`, `touchTrigger` is ignored. |
There was a problem hiding this comment.
This names useTooltip beside Tooltip, then says the press stops before a clickable row with only anchorRef as the exception. A bare useTooltip trigger has no wrapper to stop it, so a plain onClick row around it still runs on the pinning press (the hook docs say so). Could the anchorRef sentence also name useTooltip on a caller's own trigger?
| | 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 `pressTrigger="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 `pressTrigger="toggle"` and a trigger whose role is `button` (a `<button>` or `role="button"`), a click, a completed tap, Enter, or Space on the trigger pins the tooltip open, and the same press on a pinned tooltip closes it. Hover and keyboard focus still preview it per FR3 and FR4; on touch no preview opens and a tap pins it, while a touch that becomes a scroll pins nothing. A pinned tooltip stays open when the pointer leaves the trigger and the surface; Escape (FR6), a second press, a press outside the trigger and the surface, or focus moving outside them closes it. In a controlled tooltip a press reports the toggled value through `onOpenChange` and does not change the state itself (FR7). A press that pins or closes the layer stops at its trigger: it does not reach a clickable row, card, or container around it, or the control a label names. With `anchorRef`, or `useTooltip` with a caller's trigger, the caller's own handlers still receive the press: the anchor's `onClick` runs, and a plain `onClick` element around the anchor receives it unless the anchor's `onClick` stops it; Astryx clickable rows, cards, and containers ignore it. A trigger whose role is not `button` gets a development warning and keeps tap-only opening: a tap opens the tooltip as `touchTrigger="tap"` does, and no press pins it. A trigger whose tooltip repeats its own visible text, such as a truncated Text that is its own tab stop, may take `pressTrigger="toggle"` without being a button: a press pins it as on a button trigger, and it gets no development warning, no `aria-expanded` or `aria-controls`, and no announcement, since its text is already its content. Implementation gap: the own-text clause is not built in Core yet (Current-state impact). | DEC-1; WCAG 1.4.13, 2.1.1 | |
There was a problem hiding this comment.
The new sentence ("With anchorRef, or useTooltip with a caller's trigger, ... a plain onClick element around the anchor receives it") narrows this row's own stop at the trigger, and this revision adds the useTooltip case to the wording approved earlier. It needs an engineering owner's /approve-spec for this revision.
|
/approve-spec 38e6fa2 |
38e6fa2 to
3870f5e
Compare
|
Updated in 3870f5e, on main with #9379 merged:
|
|
/approve-spec 3870f5e |
There was a problem hiding this comment.
Changes requested: triggerBehavior behaves as component:Tooltip FR9 and FR10 describe, and FR9's anchorRef and useTooltip exception is approved for this revision, but the record's evidence for the new values is left stale. Please:
- Restate the
FR5, FR9 (triggerBehavior)verification row (Tooltip.spec.md:308), which still says the evidence is a gap until built while line 140 records the behavior built, as a named gap for the cases it lists and for FR9'suseTooltipclause, or add those tests here. - Rebase onto
main: the branch conflicts inuseTooltip.tsx,useTooltip.doc.mjsandTooltip.spec.md.
[Automated review]
| | FR9, FR10, AR6 | Evidence gap until built: a press-toggle suite in `Tooltip.test.tsx` and `useTooltip.test.tsx`, and a real-touch and keyboard case in `TouchTrigger.a11y.chromium.spec.ts` | Click, Enter, Space, and tap pin; second press, press outside, focus leaving, and Escape close; pointer leave keeps the pin; controlled; non-button trigger; a truncated Text trigger whose tooltip repeats its text; a toggle trigger inside a ClickableCard, an Item row, and a `<label>` | A press that does not pin or close as stated, a pin lost on pointer leave, a missing or stale `aria-expanded` or `aria-controls`, a second announcement, a non-button trigger without the warning or its tap fallback, an own-text trigger that warns, gets `aria-expanded`, or is announced, or a pinning press that also activates the card, row, or labelled control around the trigger, fails. | `audit:Tooltip/behavior` | | ||
| | FR9, FR10, AR6 | `usePressPin.test.tsx` Tooltip suites; `TouchTrigger.a11y.chromium.spec.ts` toggle-behavior and mouse cases; `layerDismissalFamilies.test.tsx` pinned Tooltip over a Dialog; own text: evidence gap until built | Click, Enter, Space, and tap pin; second press, press outside, focus leaving, and Escape close; pointer leave keeps the pin; controlled; non-button trigger; a truncated Text trigger whose tooltip repeats its text; a toggle trigger inside a ClickableCard, an Item row, and a `<label>` | A press that does not pin or close as stated, a pin lost on pointer leave, a missing or stale `aria-expanded` or `aria-controls`, a second announcement, a non-button trigger without the warning or its tap fallback, an own-text trigger that warns, gets `aria-expanded`, or is announced, or a pinning press that also activates the card, row, or labelled control around a child trigger, fails. | `audit:Tooltip/behavior` | | ||
| | FR9 (`anchorRef`) | `usePressPin.test.tsx` `anchorRef` suite; `TouchTrigger.a11y.chromium.spec.ts` anchored case | An anchor with its own `onClick`, inside a ClickableCard, an Item row, a Table row that expands on click, and a plain `onClick` row | An anchor's own `onClick` that does not run, or an Astryx card, row, or Table row around the anchor that activates, fails. | `audit:Tooltip/behavior` | | ||
| | FR5, FR9 (`triggerBehavior`) | Evidence gap until built: a trigger-behavior suite in `Tooltip.test.tsx` and `useTooltip.test.tsx`, and a real-touch and pen case in `TouchTrigger.a11y.chromium.spec.ts` | `adaptive` by default and explicitly, `hover`, and `toggle`, each with mouse hover, keyboard focus, touch, and pen; `touchTrigger` beside each value | Under any value, a lost hover or keyboard-focus preview; under `adaptive`, an info-only tap that leaves the tooltip shut or a press that pins it; under `hover`, a touch or pen tap that opens it or a press that pins it; under `toggle`, a click, tap, Enter, or Space that does not pin or close it as FR9 states; or a `touchTrigger` that changes `hover` or `toggle`, fails. | `audit:Tooltip/behavior` | |
There was a problem hiding this comment.
This row still says the evidence is a gap until built, while line 140 now records triggerBehavior as built. The cases it names (a pen under each value, adaptive set explicitly, keyboard preview under hover and toggle, touchTrigger beside each value) have no tests in this pull request, and neither does FR9's approved useTooltip clause, which the FR9 (anchorRef) row above does not name. Could the Evidence cell name those as a remaining gap, or could the tests come here?
`component:Tooltip` FR9 and FR10, `spec:AST-003` FR4: - `triggerBehavior` on Tooltip and useTooltip, with the type TooltipTriggerBehavior: `adaptive`, the default and the released behavior (a tap opens the tooltip unless the trigger acts; an explicit `touchTrigger` still decides taps under it until the next minor); `hover` (touch never opens it); or `toggle`. Hover and focus preview it under every value. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the tooltip and the same press closes it. On a `role="button"` element, Enter acts on keydown and Space on keyup, as on a native button. - A pinned tooltip stays until Escape, a second press, a press or scroll outside (the touch trigger's outside dismissal, which the pin arms: the pin owns the layer's touch trigger, so useTooltip makes one usePressPin call), or focus moving onto an element outside the trigger and the tooltip (the pin listens for it on the trigger, whatever `focusTrigger` is, in the trigger's own document); a press on the tooltip's own text keeps it. It takes Escape before the layer beneath it. A `triggerBehavior` that changes after mount wires or unwires the trigger, and a tooltip whose `triggerBehavior` turns to `adaptive` while pinned closes on leave again. - The press stops at its trigger: its default action is prevented, and the trigger's own handlers still run. With `anchorRef`, the caller's own handlers still receive the press: the anchor's `onClick` runs, and a plain `onClick` element around the anchor receives it unless the anchor's `onClick` stops it; Astryx clickable rows, cards, and containers ignore it (FR9). The trigger carries aria-expanded and aria-controls on a button trigger, and the tooltip's rendered text is announced once when it pins, through useAnnounceRenderedText (`spec:AST-086` FR12). - A non-button trigger gets one development warning and keeps tap-only opening (`Layer/usePressPin.ts`, the hover layers' shared press pin). - `TooltipTriggerBehavior` is a closed union; the API report lists it. - LayerTriggerBehavior and the touch trigger's armOutsideDismiss (useTouchTriggerInternal) stay internal: neither is exported from the Layer entry point, and the public useTouchTrigger return is unchanged.
|
Updated in ff2c7a3.
Also in this head: |
3870f5e to
ff2c7a3
Compare
|
/approve-spec ff2c7a3 |
There was a problem hiding this comment.
Changes requested: triggerBehavior behaves as component:Tooltip FR9 and FR10 describe, and an engineering owner approved the record change for this revision, but the pull request now counts 2,151 authored lines, over the 2,000-line limit. Please move at least 152 separable lines into one of the stacked pull requests (the role="button" Space-on-keyup timing and its tests are one candidate), or explain in the description why no remaining part can stack. An optional cleanup is noted inline.
[Automated review]
| // What the host offered last: 0 is nothing yet, 1 a trigger that is not a | ||
| // button, 2 a button. It is a primitive, so a host that attaches the same | ||
| // trigger on every commit renders nothing. | ||
| const [offeredKind, setOfferedKind] = useState<0 | 1 | 2>(0); |
There was a problem hiding this comment.
Optional (non-blocking): a named union would make this and the isWired line explain themselves, so both comments can go:
const [offeredKind, setOfferedKind] = useState<'none' | 'other' | 'button'>('none');
// …
const isWired = isToggleMode && offeredKind === 'button';
Status: every gate under Testing passed at this head, ff2c7a3, on main 032c4a1. It merges cleanly with main 6a591d9. The record changed, so it needs
/approve-specat this head.User need
Some tooltips are the only place their information lives, such as an info icon or a "What is this?" button. Touch, keyboard, and screen-reader users can't keep those tooltips open. Hover opens one, and leaving closes it.
touchTrigger="tap"opens it on a tap, but a mouse click, Enter, or Space doesn't keep it open, and nothing pins it. That makes the information hover-only, whicharchitecture:interaction-modalityrules out for content that exists nowhere else.Problem and solution fit
triggerBehavior="toggle"turns the trigger into a toggle. On a button trigger, a click, a tap, Enter, or Space pins the tooltip, and the same press closes it. Hover and focus still preview it, so every input has one way to keep the tooltip.spec:AST-003FR4, DEC-6).Current specification
component:TooltipFR9, FR10, AR6 and DEC-1, plusspec:AST-003FR4 and DEC-6, all current at 1d8696a (#9175). The prop's name and values,triggerBehavior: 'adaptive' | 'hover' | 'toggle'with default'adaptive', and FR5's rules for them come from #9379, merged as a68e0e3. The record lines this pull request changes use #9379's wording.Public delta and caller burden
triggerBehavior: 'adaptive' | 'hover' | 'toggle'onTooltipanduseTooltip, with the closed-union typeTooltipTriggerBehavior. Hover and keyboard focus preview the tooltip under every value.adaptive, the default, changes nothing. It is the released touch behavior: a tap opens the tooltip unless the trigger performs an action of its own. An explicittouchTriggerstill decides taps underadaptive, until CLN-0024 removestouchTriggerin 0.8.0.hover: no touch or pen tap opens the tooltip, and no press pins it. This is the releasedtouchTrigger="none".toggle, on a button trigger (a<button>orrole="button"): a click, a tap, Enter, or Space pins the tooltip, and the same press closes it. On arole="button"element, Enter acts on keydown and Space on keyup, as on a native button.focusTriggeris, in the trigger's own document.onOpenChangeand doesn't change itself.aria-expanded(true only while pinned) and, while the tooltip shows,aria-controls. When the tooltip pins, the shared rendered-text announcer reads its text once (spec:AST-086FR12), throughuseAnnounce. Inside a modal, that announcement plays in the modal's own region (FR14).<label>around it doesn't operate its control. WithanchorRef, oruseTooltipwith a caller's trigger, the caller's own handlers still receive the press. A plainonClickelement around the trigger receives the press unless the trigger'sonClickstops it. Astryx clickable rows, cards, and containers ignore the press.hoverortoggle,touchTriggeris ignored.Before and after:
Not in this pull request (each stacked above, in this order; all ship in the same release):
triggerBehaviormatrix evidence (FR5 and FR9'striggerBehaviorverification row): test(Tooltip): triggerBehavior on every input #9406. Here that row stays a named evidence gap.triggerBehavioron the same press pin: feat(HoverCard): triggerBehavior="toggle" pins the card on a press #9291.touchTrigger="tap", with its codemod: feat(Tooltip, HoverCard, cli): deprecate touchTrigger="tap" (DEP-0016), with its upgrade codemod #9246.Supersedes: nothing. This pull request carries the toggle itself.
API changes
These are added public rows, decided by
component:TooltipFR9 and FR10 (#9175). They go to human review underarchitecture:public-component-apiINV25.packages/core/api-report.mdlists exactly these rows.@astryxdesign/coreTooltipProps.triggerBehavior?: TooltipTriggerBehavior(default'adaptive')<Tooltip content="…" triggerBehavior="toggle"><Button label="What is this?">?</Button></Tooltip>@astryxdesign/coreTooltipOptions.triggerBehavior?: TooltipTriggerBehavior(default'adaptive')useTooltip({triggerBehavior: 'toggle'})@astryxdesign/coretype TooltipTriggerBehavior = 'adaptive' | 'hover' | 'toggle'const mode: TooltipTriggerBehavior = 'toggle';Design and alternatives
Layer/usePressPin.ts. It owns the layer's touch trigger (useTouchTriggerInternal), souseTooltipmakes oneusePressPincall (architecture:react-component-runtimeINV16). The touch trigger getstapundertoggle(the pin decides),noneunderhover, and the caller'stouchTriggerunderadaptive.LayerTriggerBehaviorand the touch trigger'sarmOutsideDismissstay off theLayerentry point. The publicuseTouchTriggerreturn is unchanged.aria-expandedandaria-controlswhile it wires a button trigger, and restores the trigger's own values when it unwires.Evidence
layerDismissalFamilies.test.tsxfails.useAnnounce(inside a modal too), a scroll outside, a press on the tooltip's own text, atriggerBehaviorchange after mount, the singleusePressPincall, thefocusTrigger="never"close, and thehovermapping.role="button"trigger pins on keydown, and arole="button"trigger that clicks itself on Space keyup pins and then unpins.Layer/usePressPin.test.tsx(36 tests): click, tap, Enter, and Space pin. Space acts on keyup, and a trigger that clicks itself on Space keydown or keyup pins once. Pointer leave and a press on the tooltip's own text keep the pin. Escape, a second press, a press outside, a scroll starting outside, and focus leaving close it, including in another document and underfocusTrigger="never". The file also covers the controlled case, the non-button warning,triggerBehaviorchanges, the trigger state, one announcement (inside a modal Dialog too), the stop inside a clickable row, a ClickableCard, an Item row, and a<label>, and theanchorRefcases.layerDismissalFamilies.test.tsx: a pinned Tooltip over a Dialog takes Escape first, and the Dialog stays open.Layer/usePressPin.calls.test.tsx: the host makes oneusePressPincall per render, and every touch-trigger call comes from inside it.hooks/useAnnounceRenderedText.adopters.test.tsx: a pinned Tooltip hands its text to the shared announcer and announces none of it itself (architecture:react-component-runtimeINV10).TouchTrigger.a11y.chromium.spec.ts, on the newa11y/Tooltip toggle behaviorstories: a real tap pins and the next tap closes, a tap outside closes, Enter pins and Space closes, and inside a ClickableCard the tap pins and the card stays shut. Under a mouse, a click on the tooltip's own text keeps the pin, an anchored trigger runs its own click handler and pins, and withfocusTrigger="never", Tab to another control closes a pin.Tooltip.spec.md):anchorRefand foruseTooltipwith a caller's trigger, with aFR9 (anchorRef)verification row.triggerBehaviorverification row names its evidence gap.system_specsaddsspec:AST-086/FR12and/FR14, and apath-applicabilityblock linksLayer/usePressPin.ts. Those links replace the citations that its source comments dropped.Tooltip.tsx, and fix(HoverCard): focusTrigger="auto" decides when focus arrives, through Tooltip's shared check #9597 movesuseTooltip'sisFocusForTriggerintoutils/triggerFocus.ts. Whichever lands second rebases.Split
Four pieces stack above this one, by the maintainers' rulings, and all ship in the same release: #9406, #9361, #9291, and #9246 (see above). This one is 17 files, +2,083/−68 (2,151 lines): the shared press pin and touch-trigger wiring, Tooltip's wiring, its tests and stories, its record lines and docs, its API report rows, and its changeset. It is over 2,000 lines because the press pin, its touch-trigger wiring, and its first adopter only work together. Tests and stories are 59% of it. Rebased onto main 032c4a1, past #9366 (focus counting), #9417, #9398, #9531, #9516 (comment provenance), and #9589 (API reports).
Scope
Writing rubric results
The rubric in
docs/contributing/spec-writing.md, over the changed parts ofTooltip.spec.md:Testing
Local gates at this head, ff2c7a3 (on main 032c4a1):
pnpm check:knowledge,pnpm check:changesets,pnpm check:i18n-runtime, coretsc(0 errors),ASTRYX_STRICT_LINT=1 pnpm lint(0 errors,check:repoincluded), Prettier on the touched files, the root build, and the Storybook build. The two touched.doc.mjsfiles stay unformatted, as on main.--check: this pull request's entry is exact. Only main's own ChatComposer, Item, layerSurfacePress, and Token entries are stale, as on main.pnpm api:report --package corewrites exactly this pull request's four rows. Main's own TextInput and TextArea drift stays out.pnpm test:ui: 730 files, 18,150 tests pass.pnpm test:node: 16,011 pass, with 2 expected failures.TouchTrigger.a11y.chromium.spec.tsin Chromium: 19 of 19, twice..a11y.browser.spec.tsspecs pass ten times each: 220 of 220. They ran in the CI profile: the Playwright 1.61.1 image, 4 CPUs,CI=1,LP_NUM_THREADS=4, and one worker.TouchTrigger.a11y.chromium.spec.tsruns in Chromium only, by the config's globs.