Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .ai/rules/stories-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,23 @@ export const StaticColors: Story = {

The decorator displays two background zones—dark gradient for `static-color="white"` content, light gradient for `static-color="black"` content.

## Story naming

When the camelCase export name does not produce a readable display name — for example, `TextWrapping` for a story that should appear as "Text wrapping" — set the display name via `storyName` assigned after the export:

```typescript
/**
* When the pointer moves from the trigger into the popover bubble, the popover stays
* open...
*/
export const TextWrapping: Story = {
tags: ['behaviors'],
};
TextWrapping.storyName = 'Text wrapping';
```

Do **not** use a `### Heading` at the top of a JSDoc comment as a proxy for the story's display name. JSDoc H3 headings are only appropriate for sub-sections within the documentation body (for example, `### Features` and `### Best practices` inside an Accessibility story).

## Story ordering

Section ordering is hand-authored in each component's per-component MDX file (`<component>.mdx` at the component root). Inside an MDX page, sections appear in the order they are written; story-level parameters do not control rendering order.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
{/* Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */}

import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks';

import * as Stories from './stories/hover-controller.stories';

<Meta of={Stories} />

<DocsHeader />

## Usage

`HoverController` handles hover and keyboard-focus events so consuming components don't duplicate this logic. It opens and closes a `popover="auto"` surface relative to a resolved trigger element, enforces warm-up and cooldown timing, and provides the WCAG 1.4.13 pointer bridge that keeps the popover open when the pointer moves into it.

### What it does

- **Warm-up / cooldown timing** — the popover opens only after the pointer has rested on the trigger for `delay` ms. Leaving the trigger or the popover starts a `closeDelay` cooldown before close.
- **Warm-state acceleration** — once any instance of a component type has opened, subsequent hovers on the same type open immediately without re-timing. Warm state resets after the `closeDelay` cooldown elapses.
- **WCAG 1.4.13 pointer bridge** — moving the pointer from the trigger into the popover cancels the cooldown, keeping the popover open so users can interact with its content.
- **Keyboard focus priority** — `Tab` focus opens immediately and suppresses all pointer-driven timers until `focusout`. Pointer-click focus is excluded; see the Behaviors section.

### Basic usage

1. Implement `HoverControllerHost` on your element (`delay`, `closeDelay`, `manual`, `disabled`, `showPopover`, `hidePopover`). The host must have `popover` set so it participates in the native Popover API.
2. Construct the controller in the host's `constructor`.
3. Resolve the trigger element and call `setTarget()` from `updated()` whenever it changes. The controller does not resolve trigger elements itself.
4. Wire ARIA relationships (`aria-describedby` / `aria-labelledby`) in the host on `open` change — the controller does not set ARIA attributes.

```typescript
import { LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import {
HoverController,
type HoverControllerHost,
} from '@spectrum-web-components/core/controllers/hover-controller.js';

class SwcTooltip extends LitElement implements HoverControllerHost {
@property({ type: Number }) delay = 1500;
@property({ type: Number }) closeDelay = 300;
@property({ type: Boolean }) manual = false;
@property({ type: Boolean }) disabled = false;

private triggerElement: HTMLElement | null = null;

private readonly hoverController = new HoverController(this, {
warmStateKey: 'swc-tooltip',
});

protected override updated(changes: PropertyValues): void {
super.updated(changes);
if (changes.has('triggerElement')) {
this.hoverController.setTarget(this.triggerElement ?? null);
}
}
}
```

## Options

### Multi-type warm-state isolation

Warm state is stored per component type using the `warmStateKey` constructor option. Two controllers with different keys are completely independent: warming one type does not accelerate warm-up for the other.

Hover trigger A until the tooltip opens (250 ms). Then hover trigger B; it still waits the full 250 ms because its warm state is independent.

<Canvas of={Stories.MultiTypeIsolation} />

## States

### Disabled

When `disabled` is set on the host, the controller skips all event wiring. Pointer and focus events on the trigger have no effect. Setting `disabled = false` at runtime re-enables wiring without a page reload.

When `disabled` transitions to `true` while the popover is open, the popover closes immediately.

<Canvas of={Stories.Disabled} />

### Manual

When `manual` is set on the host, the controller skips all event wiring. The consuming component is responsible for calling `showPopover()` / `hidePopover()` programmatically. Hover and focus events on the trigger have no effect.

Use `manual` mode when the component needs click-to-toggle behavior or programmatic control over open/close. The `for` attribute and ARIA wiring still function in manual mode.

<Canvas of={Stories.Manual} />

## Behaviors

### Warm-up and cooldown timing

When `delay > 0`, the popover opens only after the pointer has rested on the trigger for the full warm-up duration.

Once the popover has opened, warm state is set to `true` and shared across all instances of the same component type on the page. A second hover on any other trigger of the same type opens immediately without re-timing.

Warm state resets after the pointer leaves and the `closeDelay` cooldown timer elapses without the pointer re-entering a trigger or the popover bubble. `closeDelay` is independent of `delay` — the cooldown gives the pointer time to reach the bubble regardless of how quickly the popover opened.

<Canvas of={Stories.WarmUpAndCooldown} />

### WCAG 1.4.13 pointer bridge

When the pointer moves from the trigger into the popover bubble, the popover stays open. This satisfies the "hoverable" requirement of [WCAG 1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html).

The bridge works by cancelling the `closeDelay` cooldown timer when `pointerenter` fires on the host element. Because pointer leave and enter events for a single mouse gesture fire synchronously in the same browser task, the bridge always runs before the queued cooldown callback.

<Canvas of={Stories.PointerBridge} />

### Immediate open with delay="0"

When `delay` is `0`, the popover opens synchronously on `pointerenter`. The cooldown on `pointerleave` uses `closeDelay` (default 300 ms), independent of `delay`, so the WCAG 1.4.13 pointer bridge still applies regardless of how quickly the popover opened.

<Canvas of={Stories.ImmediateDelay} />

### Keyboard focus opens immediately

When the trigger receives keyboard focus (Tab / Shift+Tab), the popover opens immediately without waiting for the warm-up timer. The controller enters focus-priority mode: `pointerenter` and `pointerleave` events on both the trigger and the popover bubble are ignored until `focusout` fires. `focusout` closes immediately and restores normal pointer behaviour.

**Pointer-click focus is excluded** — a pointer click on the trigger does not open the popover. `pointerdown` on the trigger fires a `popover="auto"` light dismiss synchronously; opening on the subsequent `focusin` would immediately be reversed, producing a visible flash. The controller detects this sequence via the `hadPointerdown` flag and skips the open.

<Canvas of={Stories.KeyboardFocus} />

## Accessibility

### Features

`HoverController` implements two built-in accessibility behaviours:

1. **WCAG 1.4.13 pointer bridge** — the popover remains open when the pointer moves from the trigger into the popover bubble, satisfying the "hoverable" requirement of [WCAG 1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html). Users who need to interact with the popover content (for example to copy text or follow a link) can do so without the popover closing.

2. **Keyboard focus opens immediately** — Tab focus bypasses the warm-up timer and opens the popover at once. While the trigger is focused, pointer events on both the trigger and the popover are suppressed, preventing them from inadvertently starting cooldown timers.

### Best practices

- Pair with a `PlacementController` so the popover is always visible and positioned relative to the trigger, even near viewport edges.
- Set `warmStateKey` to the consuming element's tag name (e.g. `'swc-tooltip'`) to isolate warm state per component type.
- Use `manual` mode for programmatic control; the consuming component is then responsible for ARIA relationships and calling `showPopover()` / `hidePopover()` at the right times.
- Wire `aria-describedby` or `aria-labelledby` from the trigger to the popover in the host's `updated()` lifecycle — the controller does not set ARIA attributes.

<Canvas of={Stories.Accessibility} />

## API

### Methods

| Member | Description |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setTarget(trigger: HTMLElement \| null)` | Sets the element that receives pointer and focus listeners. Call from `updated()` whenever the resolved trigger changes. Passing `null` detaches all listeners from the previous target. |

### Constructor options (`HoverControllerOptions`)

| Option | Type | Description |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `warmStateKey` | `string` | Per-component-type key for shared warm state on `document`. Use the element tag name (e.g. `'swc-tooltip'`). Must be static; must not vary per instance. |

### Host interface (`HoverControllerHost`)

The host element must implement this interface. All members are read by the controller; none are written.

| Member | Type | Default | Description |
| --------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `delay` | `number` | — | Warm-up duration in ms before the popover opens on hover. `0` opens immediately. |
| `closeDelay` | `number` | `300` | Cooldown duration in ms after the pointer leaves the trigger or popover. Independent of `delay` so the WCAG 1.4.13 bridge always has time to cancel. |
| `manual` | `boolean` | — | When `true`, the controller skips all event wiring. |
| `disabled` | `boolean` | — | When `true`, the controller skips all event wiring. |
| `showPopover()` | method | — | Called by the controller to open the popover. |
| `hidePopover()` | method | — | Called by the controller to close the popover. |

<DocsFooter />
17 changes: 17 additions & 0 deletions 2nd-gen/packages/core/controllers/hover-controller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

export {
HoverController,
type HoverControllerHost,
type HoverControllerOptions,
} from './src/hover-controller.js';
Loading
Loading