From b160835fb340544febc5a9b9a8341410703b53af Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 21 Oct 2025 16:38:46 -0700 Subject: [PATCH 1/4] fix: focus on hover for popover --- packages/react/react/src/components/Popover.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/react/src/components/Popover.tsx b/packages/react/react/src/components/Popover.tsx index 7885cff23..4ec11b0d1 100644 --- a/packages/react/react/src/components/Popover.tsx +++ b/packages/react/react/src/components/Popover.tsx @@ -102,6 +102,7 @@ function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children const hover = useHover(context, { enabled: openOnHover, mouseOnly: true, + move: false, delay: { open: delay, close: closeDelay, @@ -161,7 +162,7 @@ function PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPo } function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element { - const { getFloatingProps, context, openReason, transitionStatus } = usePopoverContext(); + const { getFloatingProps, context, transitionStatus } = usePopoverContext(); const { refs, placement } = context; const triggerElement = refs.reference.current as HTMLElement | null; @@ -176,10 +177,10 @@ function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element { return ( } + initialFocus={-1} + returnFocus={false} >
Date: Tue, 21 Oct 2025 17:36:36 -0700 Subject: [PATCH 2/4] feat: add keyboard support to sliders --- packages/core/core/src/components/slider.ts | 52 +++++++++++++++++++ .../core/core/src/components/time-slider.ts | 14 ++++- .../core/core/src/components/volume-slider.ts | 17 +++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/core/core/src/components/slider.ts b/packages/core/core/src/components/slider.ts index 610458bbf..48ecedc54 100644 --- a/packages/core/core/src/components/slider.ts +++ b/packages/core/core/src/components/slider.ts @@ -7,8 +7,10 @@ export interface SliderState { _pointerRatio: number; _hovering: boolean; _dragging: boolean; + _keying: boolean; _fillWidth: number; _pointerWidth: number; + _stepSize: number; } export class Slider { @@ -19,8 +21,10 @@ export class Slider { _pointerRatio: 0, _hovering: false, _dragging: false, + _keying: false, _fillWidth: 0, _pointerWidth: 0, + _stepSize: 0.01, }); attach(target: HTMLElement): void { @@ -34,6 +38,8 @@ export class Slider { this.#element.addEventListener('pointerup', this, { signal }); this.#element.addEventListener('pointerenter', this, { signal }); this.#element.addEventListener('pointerleave', this, { signal }); + this.#element.addEventListener('keydown', this, { signal }); + this.#element.addEventListener('keyup', this, { signal }); } detach(): void { @@ -80,6 +86,12 @@ export class Slider { case 'pointerleave': this.#handlePointerLeave(event as PointerEvent); break; + case 'keydown': + this.#handleKeyDown(event as KeyboardEvent); + break; + case 'keyup': + this.#handleKeyUp(event as KeyboardEvent); + break; } } @@ -120,6 +132,46 @@ export class Slider { #handlePointerLeave(_event: PointerEvent) { this.setState({ _hovering: false }); } + + #handleKeyDown(event: KeyboardEvent) { + const { key } = event; + const { _pointerRatio, _stepSize } = this.#state.get(); + + let newRatio = _pointerRatio; + + switch (key) { + case 'ArrowLeft': + case 'ArrowDown': + event.preventDefault(); + newRatio = Math.max(0, _pointerRatio - _stepSize); + break; + case 'ArrowRight': + case 'ArrowUp': + event.preventDefault(); + newRatio = Math.min(1, _pointerRatio + _stepSize); + break; + case 'Home': + event.preventDefault(); + newRatio = 0; + break; + case 'End': + event.preventDefault(); + newRatio = 1; + break; + default: + return; // Don't update state for other keys + } + + this.setState({ _pointerRatio: newRatio, _keying: true }); + } + + #handleKeyUp(_event: KeyboardEvent) { + this.setState({ _keying: false }); + } + + setStepSize(stepSize: number): void { + this.setState({ _stepSize: Math.max(0, Math.min(1, stepSize)) }); + } } export interface Point { diff --git a/packages/core/core/src/components/time-slider.ts b/packages/core/core/src/components/time-slider.ts index a83e6a151..8a90126db 100644 --- a/packages/core/core/src/components/time-slider.ts +++ b/packages/core/core/src/components/time-slider.ts @@ -22,7 +22,7 @@ export class TimeSlider extends Slider { // While seeking, use seeking time so it doesn't jump back to the current time; // Otherwise, use current time; let _fillWidth = 0; - if (state._dragging) { + if (state._dragging || state._keying) { _fillWidth = state._pointerRatio * 100; } else if (state.duration > 0) { if (this.#seekingTime !== null && this.#oldCurrentTime === state.currentTime) { @@ -53,6 +53,9 @@ export class TimeSlider extends Slider { case 'pointerup': this.#handlePointerUp(event as PointerEvent); break; + case 'keydown': + this.#handleKeyDown(event as KeyboardEvent); + break; default: super.handleEvent(event); break; @@ -92,6 +95,15 @@ export class TimeSlider extends Slider { super.handleEvent(event); } + + #handleKeyDown(event: KeyboardEvent) { + super.handleEvent(event); + + const { _pointerRatio, duration, requestSeek } = super.getState() as TimeSliderState; + + this.#seekingTime = _pointerRatio * duration; + requestSeek(this.#seekingTime); + } } function formatTime(time: number): string { diff --git a/packages/core/core/src/components/volume-slider.ts b/packages/core/core/src/components/volume-slider.ts index aadac9694..8dde0fd3b 100644 --- a/packages/core/core/src/components/volume-slider.ts +++ b/packages/core/core/src/components/volume-slider.ts @@ -11,13 +11,18 @@ export interface VolumeSliderState extends SliderState { } export class VolumeSlider extends Slider { + constructor() { + super(); + this.setStepSize(0.1); + } + getState(): VolumeSliderState { const state = super.getState() as VolumeSliderState; // When dragging, use pointer position for immediate feedback; // Otherwise, use current volume; let _fillWidth = 0; - if (state._dragging) { + if (state._dragging || state._keying) { _fillWidth = state._pointerRatio * 100; } else { _fillWidth = state.muted ? 0 : (state.volume || 0) * 100; @@ -40,6 +45,9 @@ export class VolumeSlider extends Slider { case 'pointerup': this.#handlePointerUp(event as PointerEvent); break; + case 'keydown': + this.#handleKeyDown(event as KeyboardEvent); + break; default: super.handleEvent(event); break; @@ -72,6 +80,13 @@ export class VolumeSlider extends Slider { super.handleEvent(event); } + + #handleKeyDown(event: KeyboardEvent) { + super.handleEvent(event); + + const { _pointerRatio, requestVolumeChange } = super.getState() as VolumeSliderState; + requestVolumeChange(_pointerRatio); + } } function formatVolume(volume: number): string { From 14832ff7e4a42d6f54964dcace890e0ce121326f Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 22 Oct 2025 11:48:31 -0700 Subject: [PATCH 3/4] fix: html popover mouseleave --- packages/core/media-store/src/factory.ts | 7 +-- .../src/state-mediators/audible.ts | 18 +++---- packages/core/media-store/src/types.ts | 4 ++ .../html/html/src/components/media-popover.ts | 47 +++++++++++++++---- 4 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 packages/core/media-store/src/types.ts diff --git a/packages/core/media-store/src/factory.ts b/packages/core/media-store/src/factory.ts index 68909d566..2d1c77521 100644 --- a/packages/core/media-store/src/factory.ts +++ b/packages/core/media-store/src/factory.ts @@ -1,9 +1,6 @@ -import { getKey, map, subscribeKeys } from 'nanostores'; +import type { StateOwners } from './types'; -export interface StateOwners { - media?: any; - container?: any; -} +import { getKey, map, subscribeKeys } from 'nanostores'; export interface EventOrAction { type: string; diff --git a/packages/core/media-store/src/state-mediators/audible.ts b/packages/core/media-store/src/state-mediators/audible.ts index 05a61f8f8..ff6fcb47e 100644 --- a/packages/core/media-store/src/state-mediators/audible.ts +++ b/packages/core/media-store/src/state-mediators/audible.ts @@ -1,10 +1,12 @@ +import type { StateOwners } from '../types'; + export const audible = { muted: { - get(stateOwners: any): boolean { + get(stateOwners: StateOwners): boolean { const { media } = stateOwners; return media?.muted ?? false; }, - set(value: boolean, stateOwners: any): void { + set(value: boolean, stateOwners: StateOwners): void { const { media } = stateOwners; if (!media) return; media.muted = value; @@ -13,7 +15,7 @@ export const audible = { } }, stateOwnersUpdateHandlers: [ - (handler: (value?: boolean) => void, stateOwners: any): (() => void) | void => { + (handler: (value?: boolean) => void, stateOwners: StateOwners): (() => void) | void => { const { media } = stateOwners; if (!media) return; @@ -30,22 +32,22 @@ export const audible = { }, }, volume: { - get(stateOwners: any): number { + get(stateOwners: StateOwners): number { const { media } = stateOwners; return media?.volume ?? 1.0; }, - set(value: number, stateOwners: any): void { + set(value: number, stateOwners: StateOwners): void { const { media } = stateOwners; if (!media) return; const numericValue = +value; if (!Number.isFinite(numericValue)) return; media.volume = numericValue; if (numericValue > 0) { - media.mute = false; + media.muted = false; } }, stateOwnersUpdateHandlers: [ - (handler: (value?: number) => void, stateOwners: any): (() => void) | void => { + (handler: (value?: number) => void, stateOwners: StateOwners): (() => void) | void => { const { media } = stateOwners; if (!media) return; @@ -62,7 +64,7 @@ export const audible = { }, // NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP) volumeLevel: { - get(stateOwners: any): 'high' | 'medium' | 'low' | 'off' { + get(stateOwners: StateOwners): 'high' | 'medium' | 'low' | 'off' { const { media } = stateOwners; if (typeof media?.volume == 'undefined') return 'high'; if (media.muted || media.volume === 0) return 'off'; diff --git a/packages/core/media-store/src/types.ts b/packages/core/media-store/src/types.ts new file mode 100644 index 000000000..9fec8287a --- /dev/null +++ b/packages/core/media-store/src/types.ts @@ -0,0 +1,4 @@ +export interface StateOwners { + media?: HTMLMediaElement; + container?: HTMLElement; +} diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts index 1e7a3e2f5..fb0ada608 100644 --- a/packages/html/html/src/components/media-popover.ts +++ b/packages/html/html/src/components/media-popover.ts @@ -1,24 +1,27 @@ import type { Placement } from '@floating-ui/dom'; import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; -import { getNextTabbable, getPreviousTabbable, isOutsideEvent, uniqueId } from '../utils/element-utils'; +import { getDocument, getNextTabbable, getPreviousTabbable, isOutsideEvent, uniqueId } from '../utils/element-utils'; export class MediaPopoverRoot extends HTMLElement { #open = false; #hoverTimeout: ReturnType | null = null; #cleanup: (() => void) | null = null; #transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial'; - - constructor() { - super(); - this.addEventListener('mouseenter', this.#handleMouseEnter.bind(this)); - this.addEventListener('mouseleave', this.#handleMouseLeave.bind(this)); - this.addEventListener('focusin', this.#handleFocusIn.bind(this)); - this.addEventListener('focusout', this.#handleFocusOut.bind(this)); - } + #abortController: AbortController | null = null; connectedCallback(): void { this.#updateVisibility(); + + this.#abortController ??= new AbortController(); + const { signal } = this.#abortController; + + this.addEventListener('mouseenter', this, { signal }); + this.addEventListener('mouseleave', this, { signal }); + this.addEventListener('focusin', this, { signal }); + this.addEventListener('focusout', this, { signal }); + + getDocument(this).documentElement.addEventListener('mouseleave', this, { signal }); } disconnectedCallback(): void { @@ -27,6 +30,28 @@ export class MediaPopoverRoot extends HTMLElement { this.#transitionStatus = 'unmounted'; this.#updateVisibility(); + + this.#abortController?.abort(); + this.#abortController = null; + } + + handleEvent(event: Event): void { + switch (event.type) { + case 'mouseenter': + this.#handleMouseEnter(); + break; + case 'mouseleave': + this.#handleMouseLeave(event as MouseEvent); + break; + case 'focusin': + this.#handleFocusIn(event as FocusEvent); + break; + case 'focusout': + this.#handleFocusOut(event as FocusEvent); + break; + default: + break; + } } static get observedAttributes(): string[] { @@ -99,6 +124,10 @@ export class MediaPopoverRoot extends HTMLElement { this.#popupElement.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open'); this.#popupElement.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted'); this.#popupElement.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted'); + + this.#abortController ??= new AbortController(); + const { signal } = this.#abortController; + this.#popupElement.addEventListener('mouseleave', this, { signal }); } const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement; From 9900ea1615057534172d7f6dd22ed2eac151f2a8 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 22 Oct 2025 15:09:33 -0700 Subject: [PATCH 4/4] fix: slider pointer ratio bug --- packages/core/core/src/components/slider.ts | 2 +- packages/core/core/src/components/time-slider.ts | 11 ++++++++++- packages/core/core/src/components/volume-slider.ts | 11 ++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/core/core/src/components/slider.ts b/packages/core/core/src/components/slider.ts index 48ecedc54..e7b8ec870 100644 --- a/packages/core/core/src/components/slider.ts +++ b/packages/core/core/src/components/slider.ts @@ -170,7 +170,7 @@ export class Slider { } setStepSize(stepSize: number): void { - this.setState({ _stepSize: Math.max(0, Math.min(1, stepSize)) }); + this.setState({ _stepSize: Math.max(0.001, Math.min(1, stepSize)) }); } } diff --git a/packages/core/core/src/components/time-slider.ts b/packages/core/core/src/components/time-slider.ts index 8a90126db..8b82b7c89 100644 --- a/packages/core/core/src/components/time-slider.ts +++ b/packages/core/core/src/components/time-slider.ts @@ -18,7 +18,7 @@ export class TimeSlider extends Slider { getState(): TimeSliderState { const state = super.getState() as TimeSliderState; - // When dragging, use pointer position for immediate feedback; + // When dragging or keying, use pointer position for immediate feedback; // While seeking, use seeking time so it doesn't jump back to the current time; // Otherwise, use current time; let _fillWidth = 0; @@ -41,6 +41,15 @@ export class TimeSlider extends Slider { return { ...state, _fillWidth, _currentTimeText, _durationText }; } + setState(state: Partial): void { + // When not dragging or keying, set pointer ratio to current time / duration. + if (!state._dragging && !state._keying && state.currentTime && state.duration) { + super.setState({ ...state, _pointerRatio: state.currentTime / state.duration }); + return; + } + super.setState(state); + } + handleEvent(event: Event): void { const { type } = event; switch (type) { diff --git a/packages/core/core/src/components/volume-slider.ts b/packages/core/core/src/components/volume-slider.ts index 8dde0fd3b..ebc1a764b 100644 --- a/packages/core/core/src/components/volume-slider.ts +++ b/packages/core/core/src/components/volume-slider.ts @@ -19,7 +19,7 @@ export class VolumeSlider extends Slider { getState(): VolumeSliderState { const state = super.getState() as VolumeSliderState; - // When dragging, use pointer position for immediate feedback; + // When dragging or keying, use pointer position for immediate feedback; // Otherwise, use current volume; let _fillWidth = 0; if (state._dragging || state._keying) { @@ -33,6 +33,15 @@ export class VolumeSlider extends Slider { return { ...state, _fillWidth, _volumeText }; } + setState(state: Partial): void { + // When not dragging or keying, set pointer ratio to current volume. + if (!state._dragging && !state._keying && state.volume) { + super.setState({ ...state, _pointerRatio: state.volume }); + return; + } + super.setState(state); + } + handleEvent(event: Event): void { const { type } = event; switch (type) {