-
Notifications
You must be signed in to change notification settings - Fork 249
feat(core): add HoverController #6358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
cd95a75
feat(core): add HoverController
5t3ph da204a2
feat(core): add HoverController usage and API stories
5t3ph 20539ad
Merge branch 'main' into seckles/hover-controller
5t3ph 2136d43
fix(sb): resolve merge error
5t3ph 4e7585a
fix(sb): resolve API table display for controllers
5t3ph 6694b0a
fix(core): add independent closeDelay, address PR feedback
5t3ph 7b86e73
Merge branch 'main' of https://github.com/adobe/spectrum-web-componen…
5t3ph 19daa62
docs(core): update HoverController to MDX docs
5t3ph ebb3861
chore(core): add changeset for HoverController
5t3ph 253392d
feat(core): add test for custom close delay
5t3ph File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
2nd-gen/packages/core/controllers/hover-controller/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
352 changes: 352 additions & 0 deletions
352
2nd-gen/packages/core/controllers/hover-controller/src/hover-controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| /** | ||
| * 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 type { ReactiveController, ReactiveElement } from 'lit'; | ||
|
|
||
| // ───────────────────────── | ||
| // TYPES | ||
| // ───────────────────────── | ||
|
|
||
| /** Minimum interface required from any element that hosts a {@link HoverController}. */ | ||
| export interface HoverControllerHost extends ReactiveElement { | ||
| /** Warm-up and cooldown duration in milliseconds. `0` means immediate open and close. */ | ||
| readonly delay: number; | ||
| /** When `true`, the controller skips all event wiring. */ | ||
| readonly manual: boolean; | ||
| /** When `true`, the controller skips all event wiring. */ | ||
| readonly disabled: boolean; | ||
| // Re-declared from HTMLElement to support TS lib targets that predate the Popover API types. | ||
| showPopover(): void; | ||
| hidePopover(): void; | ||
| } | ||
|
|
||
| /** Configuration options for {@link HoverController}. */ | ||
| export interface HoverControllerOptions { | ||
| /** | ||
| * Per-component-type key used to namespace shared warm state on `document`. | ||
| * Use the element tag name (e.g. `'swc-tooltip'`). Must be static; must not | ||
| * vary per instance. | ||
| */ | ||
| warmStateKey: string; | ||
| } | ||
|
|
||
| /** @internal */ | ||
| type WarmState = { | ||
| isWarm: boolean; | ||
| cooldownTimer: ReturnType<typeof setTimeout> | null; | ||
| }; | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // WARM STATE HELPER | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| // Keyed on `document` (not `window`) so each iframe has independent state. | ||
| // Symbol.for() deduplicates across bundle chunks in the same JS realm. | ||
| function getWarmState(doc: Document, key: symbol): WarmState { | ||
| const d = doc as Document & { [key: symbol]: WarmState | undefined }; | ||
| if (!d[key]) { | ||
| d[key] = { isWarm: false, cooldownTimer: null }; | ||
| } | ||
| return d[key]!; | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────────────────────────── | ||
| // CONTROLLER | ||
| // ───────────────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * A Lit {@link ReactiveController} that manages hover and keyboard-focus event | ||
| * wiring for components that use the native Popover API. | ||
| * | ||
| * See the Storybook stories for full usage documentation and interactive demos. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * class SwcTooltip extends LitElement implements HoverControllerHost { | ||
| * @property({ type: Number }) delay = 1500; | ||
| * @property({ type: Boolean }) manual = false; | ||
| * @property({ type: Boolean }) disabled = false; | ||
| * | ||
| * private 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); | ||
| * } | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| export class HoverController implements ReactiveController { | ||
| private readonly host: HoverControllerHost; | ||
| private readonly warmStateKey: symbol; | ||
| private target: HTMLElement | null = null; | ||
| private warmupTimer: ReturnType<typeof setTimeout> | null = null; | ||
| private isBridgeWired = false; | ||
|
|
||
| /** True while the trigger has keyboard focus; pointer-driven timers are suppressed. */ | ||
| private hasFocusOpen = false; | ||
|
|
||
| /** | ||
| * Set by `pointerdown` on the trigger and cleared asynchronously after `focusin` | ||
| * fires. The async reset ensures the flag is still `true` when `focusin` arrives | ||
| * synchronously later in the same click event sequence. | ||
| */ | ||
| private hadPointerdown = false; | ||
|
|
||
| private readonly boundPointerDownTrigger = | ||
| this.handlePointerDownTrigger.bind(this); | ||
| private readonly boundPointerEnterTrigger = | ||
| this.handlePointerEnterTrigger.bind(this); | ||
| private readonly boundPointerLeaveTrigger = | ||
| this.handlePointerLeaveTrigger.bind(this); | ||
| private readonly boundFocusin = this.handleFocusin.bind(this); | ||
| private readonly boundFocusout = this.handleFocusout.bind(this); | ||
| private readonly boundPointerEnterHost = | ||
| this.handlePointerEnterHost.bind(this); | ||
| private readonly boundPointerLeaveHost = | ||
| this.handlePointerLeaveHost.bind(this); | ||
|
|
||
| constructor(host: HoverControllerHost, options: HoverControllerOptions) { | ||
| this.host = host; | ||
| this.warmStateKey = Symbol.for(`swc-hover-state:${options.warmStateKey}`); | ||
| host.addController(this); | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // PUBLIC API | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * Sets the element that receives pointer and focus listeners. Call this whenever | ||
| * the resolved trigger changes (e.g. in `updated()` after a `for` attribute change). | ||
| * Passing `null` detaches all listeners from the previous target. | ||
| */ | ||
| public setTarget(trigger: HTMLElement | null): void { | ||
| this.unwireTarget(); | ||
| this.clearWarmupTimer(); | ||
| this.target = trigger; | ||
| this.wireTarget(); | ||
| } | ||
|
|
||
| public hostConnected(): void { | ||
| this.wireTarget(); | ||
| } | ||
|
|
||
| public hostDisconnected(): void { | ||
| this.unwireTarget(); | ||
| this.unwireBridge(); | ||
| this.clearWarmupTimer(); | ||
| this.clearCooldownTimer(); | ||
| this.hasFocusOpen = false; | ||
| this.hadPointerdown = false; | ||
| } | ||
|
|
||
| /** Re-evaluates `disabled` and `manual` guards whenever the host updates. */ | ||
| public hostUpdated(): void { | ||
| if (this.host.disabled || this.host.manual) { | ||
| this.clearWarmupTimer(); | ||
| this.hasFocusOpen = false; | ||
| this.hadPointerdown = false; | ||
| } | ||
| this.unwireTarget(); | ||
| this.wireTarget(); | ||
|
5t3ph marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // WIRING | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| private wireTarget(): void { | ||
| if (!this.target || this.host.disabled || this.host.manual) { | ||
| return; | ||
| } | ||
| this.target.addEventListener('pointerdown', this.boundPointerDownTrigger); | ||
| this.target.addEventListener('pointerenter', this.boundPointerEnterTrigger); | ||
| this.target.addEventListener('pointerleave', this.boundPointerLeaveTrigger); | ||
| this.target.addEventListener('focusin', this.boundFocusin); | ||
| this.target.addEventListener('focusout', this.boundFocusout); | ||
| } | ||
|
|
||
| private unwireTarget(): void { | ||
| if (!this.target) { | ||
| return; | ||
| } | ||
| this.target.removeEventListener( | ||
| 'pointerdown', | ||
| this.boundPointerDownTrigger | ||
| ); | ||
| this.target.removeEventListener( | ||
| 'pointerenter', | ||
| this.boundPointerEnterTrigger | ||
| ); | ||
| this.target.removeEventListener( | ||
| 'pointerleave', | ||
| this.boundPointerLeaveTrigger | ||
| ); | ||
| this.target.removeEventListener('focusin', this.boundFocusin); | ||
| this.target.removeEventListener('focusout', this.boundFocusout); | ||
| } | ||
|
|
||
| private wireBridge(): void { | ||
| if (this.isBridgeWired) { | ||
| return; | ||
| } | ||
| this.isBridgeWired = true; | ||
| this.host.addEventListener('pointerenter', this.boundPointerEnterHost); | ||
| this.host.addEventListener('pointerleave', this.boundPointerLeaveHost); | ||
| } | ||
|
|
||
| private unwireBridge(): void { | ||
| if (!this.isBridgeWired) { | ||
| return; | ||
| } | ||
| this.isBridgeWired = false; | ||
| this.host.removeEventListener('pointerenter', this.boundPointerEnterHost); | ||
| this.host.removeEventListener('pointerleave', this.boundPointerLeaveHost); | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // SHOW / HIDE | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| private showWithBridge(): void { | ||
| // Guard: showPopover() throws a DOMException if the popover is already open. | ||
| if (!this.host.matches(':popover-open')) { | ||
| this.host.showPopover(); | ||
| } | ||
| this.wireBridge(); | ||
| } | ||
|
|
||
| private callHidePopover(): void { | ||
| // Guard: hidePopover() throws a DOMException if the popover is already closed. | ||
| if (this.host.matches(':popover-open')) { | ||
| this.host.hidePopover(); | ||
| } | ||
| this.unwireBridge(); | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // TIMER HELPERS | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| private clearWarmupTimer(): void { | ||
| if (this.warmupTimer !== null) { | ||
| clearTimeout(this.warmupTimer); | ||
| this.warmupTimer = null; | ||
| } | ||
| } | ||
|
|
||
| private clearCooldownTimer(): void { | ||
| const warmState = getWarmState(this.host.ownerDocument, this.warmStateKey); | ||
| if (warmState.cooldownTimer !== null) { | ||
| clearTimeout(warmState.cooldownTimer); | ||
| warmState.cooldownTimer = null; | ||
| } | ||
| } | ||
|
|
||
| private startCooldown(): void { | ||
| const warmState = getWarmState(this.host.ownerDocument, this.warmStateKey); | ||
|
|
||
| if (this.host.delay > 0) { | ||
| warmState.cooldownTimer = setTimeout(() => { | ||
| warmState.cooldownTimer = null; | ||
| warmState.isWarm = false; | ||
| this.callHidePopover(); | ||
| }, this.host.delay); | ||
| } else { | ||
| warmState.isWarm = false; | ||
| this.callHidePopover(); | ||
| } | ||
|
5t3ph marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // EVENT HANDLERS — TRIGGER ELEMENT | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| private handlePointerDownTrigger(): void { | ||
| this.hadPointerdown = true; | ||
| // Cleared asynchronously so the flag is still true when focusin fires | ||
| // synchronously later in the same click event sequence. | ||
| setTimeout(() => { | ||
| this.hadPointerdown = false; | ||
| }, 0); | ||
| } | ||
|
|
||
| private handlePointerEnterTrigger(): void { | ||
| if (this.hasFocusOpen) { | ||
| return; | ||
| } | ||
| const warmState = getWarmState(this.host.ownerDocument, this.warmStateKey); | ||
|
|
||
| // Cancel any in-flight cooldown; the pointer is back in the hover zone. | ||
| this.clearCooldownTimer(); | ||
|
|
||
| if (this.host.delay === 0 || warmState.isWarm) { | ||
| this.showWithBridge(); | ||
| } else { | ||
| this.warmupTimer = setTimeout(() => { | ||
| this.warmupTimer = null; | ||
| warmState.isWarm = true; | ||
| this.showWithBridge(); | ||
| }, this.host.delay); | ||
|
5t3ph marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| private handlePointerLeaveTrigger(): void { | ||
| // Always clear warmup on leave — focus may have arrived mid-warmup and | ||
| // clearWarmupTimer() is a no-op if no timer is running. | ||
| this.clearWarmupTimer(); | ||
| if (this.hasFocusOpen) { | ||
| return; | ||
| } | ||
| this.startCooldown(); | ||
| } | ||
|
|
||
| private handleFocusin(): void { | ||
| // hadPointerdown is true when focus arrived via a pointer click; skip the | ||
| // open to avoid the flash caused by popover="auto" light dismiss on pointerdown. | ||
| if (this.hadPointerdown) { | ||
| return; | ||
| } | ||
| this.hasFocusOpen = true; | ||
| this.clearWarmupTimer(); | ||
| this.clearCooldownTimer(); | ||
| this.showWithBridge(); | ||
| } | ||
|
|
||
| private handleFocusout(): void { | ||
| this.hasFocusOpen = false; | ||
| // Clear any warmup that a pointer click may have started before focus left. | ||
| this.clearWarmupTimer(); | ||
| this.callHidePopover(); | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────────── | ||
| // EVENT HANDLERS — HOST ELEMENT (WCAG BRIDGE) | ||
| // ───────────────────────────────────────────────── | ||
|
|
||
| private handlePointerEnterHost(): void { | ||
| if (this.hasFocusOpen) { | ||
| return; | ||
| } | ||
| this.clearCooldownTimer(); | ||
| } | ||
|
|
||
| private handlePointerLeaveHost(): void { | ||
| if (this.hasFocusOpen) { | ||
| return; | ||
| } | ||
| this.startCooldown(); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.