feat: add keyboard control to sliders (#115)

This commit is contained in:
Wesley Luyten
2025-10-22 17:12:32 -05:00
committed by GitHub
parent 4502fe43ad
commit 0a4902623d
8 changed files with 159 additions and 29 deletions
@@ -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.001, Math.min(1, stepSize)) });
}
}
export interface Point {
@@ -18,11 +18,11 @@ 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;
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) {
@@ -41,6 +41,15 @@ export class TimeSlider extends Slider {
return { ...state, _fillWidth, _currentTimeText, _durationText };
}
setState(state: Partial<TimeSliderState>): 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) {
@@ -53,6 +62,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 +104,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 {
@@ -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;
// When dragging or keying, 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;
@@ -28,6 +33,15 @@ export class VolumeSlider extends Slider {
return { ...state, _fillWidth, _volumeText };
}
setState(state: Partial<VolumeSliderState>): 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) {
@@ -40,6 +54,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 +89,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 {
+2 -5
View File
@@ -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<D = undefined> {
type: string;
@@ -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';
+4
View File
@@ -0,0 +1,4 @@
export interface StateOwners {
media?: HTMLMediaElement;
container?: HTMLElement;
}
@@ -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<typeof setTimeout> | 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;
@@ -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 (
<FloatingFocusManager
disabled={openReason === 'hover'}
context={context}
modal={false}
initialFocus={context.refs.reference as MutableRefObject<HTMLElement>}
initialFocus={-1}
returnFocus={false}
>
<div
className={className}