mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add keyboard control to sliders (#115)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user