closed
- MOUSE_ENTER
- → opening · start_open_timer(openDelay)
- FOCUS_IN
- → open · show_immediately
Component contract
WAI-ARIA Tooltip pattern (scratch build) — wraps the consumer's first Element child as the trigger, opens on hover (with openDelay) and focus (immediate), dismisses on Escape while keeping the trigger focused, and closes on blur or genuine mouseleave. shadow:false so aria-describedby on the trigger can reference the tooltip's id.
| Name | Type | Values | Default |
|---|---|---|---|
| label | string | — | — |
| openDelay | number | — | 250 |
| closeDelay | number | — | 100 |
| placement | enum | top, bottom | bottom |
Initial state: closed
{
"name": "tooltip",
"version": "1.0.0",
"props": {
"label": {
"type": "string"
},
"openDelay": {
"type": "number",
"default": 250
},
"closeDelay": {
"type": "number",
"default": 100
},
"placement": {
"type": "enum",
"values": [
"top",
"bottom"
],
"default": "bottom"
}
},
"slot": {
"name": "default",
"purpose": "the trigger — the first Element child inside ds-tooltip is treated as the trigger and MUST be focusable (native button/link/form control, or an element with tabindex=0). The tooltip is rendered as a sibling <span role=\"tooltip\"> at the end of the host's children."
},
"state_machine": {
"initial": "closed",
"states": {
"closed": {
"on": {
"MOUSE_ENTER": {
"target": "opening",
"actions": [
"start_open_timer(openDelay)"
]
},
"FOCUS_IN": {
"target": "open",
"actions": [
"show_immediately"
]
}
}
},
"opening": {
"on": {
"TIMER_ELAPSED": {
"target": "open",
"actions": [
"show"
]
},
"MOUSE_LEAVE": {
"target": "closed",
"actions": [
"cancel_open_timer"
]
}
}
},
"open": {
"on": {
"MOUSE_LEAVE": {
"target": "closing",
"actions": [
"start_close_timer(closeDelay)"
]
},
"FOCUS_OUT": {
"target": "closed",
"actions": [
"hide_immediately"
]
},
"ESCAPE": {
"target": "closed",
"actions": [
"hide_immediately",
"keep_focus_on_trigger"
]
}
}
},
"closing": {
"on": {
"TIMER_ELAPSED": {
"target": "closed",
"actions": [
"hide"
]
},
"MOUSE_ENTER": {
"target": "open",
"actions": [
"cancel_close_timer"
]
}
}
}
}
},
"tokens_used": {
"spacing": [
"xs",
"sm"
],
"radius": [
"sm"
],
"font": [
"family",
"size-md"
]
},
"a11y": {
"native_element": "span[role=tooltip] alongside the consumer trigger (scratch-built)",
"apg_pattern": "https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/",
"keyboard": [
"Focus (Tab into trigger) → tooltip opens immediately (no openDelay for keyboard)",
"Escape (while open) → dismisses without moving focus; trigger remains focused (WCAG 1.4.13 dismissable)",
"Blur (Tab out of trigger) → tooltip closes immediately",
"Enter / Space / arrow / printable → forwarded to trigger untouched"
],
"pointer": [
"mouseenter host → open after openDelay (ergonomic against twitchy tooltips)",
"mouseleave host → close after closeDelay",
"re-entering during closeDelay cancels the hide (hoverable — WCAG 1.4.13)"
],
"aria": {
"tooltip_element": "role=tooltip, unique id per instance (auto-generated to avoid collisions on pages with multiple tooltips)",
"trigger": "aria-describedby=<tooltipId> set imperatively on the first Element child in componentDidLoad"
},
"wcag_1_4_13_content_on_hover_or_focus": {
"dismissable": "Escape hides the tooltip without moving focus",
"hoverable": "closeDelay + cancel-on-re-enter allow the pointer to move onto the tooltip surface without dismissing it",
"persistent": "tooltip stays visible while trigger is focused or hovered and while the pointer is over the tooltip surface"
},
"consumer_contract": [
"The first Element child inside <ds-tooltip> MUST be focusable — otherwise keyboard users cannot summon the tooltip and the pattern fails WCAG 1.4.13. The component does not force focusability onto arbitrary children.",
"`label` is REQUIRED — a tooltip with no accessible text is inert.",
"The tooltip is describedby, NOT labelledby — it supplements the trigger's own accessible name (which comes from the trigger's text or aria-label)."
],
"notes": "Scratch-built per the a11y spec §D ceremony because there is no native tooltip element. shadow:false is intentional: aria-describedby needs to reference the tooltip's id from the light-DOM trigger, and shadow-scoped ids are unreachable.",
"pending_manual_verification": [
"VoiceOver on macOS: confirm the tooltip text is announced as a description of the trigger when the trigger gains focus",
"VoiceOver: confirm Escape dismisses the tooltip and leaves the trigger focused; re-focusing brings it back",
"Chromium reduced-motion: confirm no residual animation is added by the component"
]
}
}