/** * TBD * * ## Create a multi thumb slider * * You can create a slider with two thumbs by adding the {@link UwcInputElement.multiple | `[multiple]`} attribute * * @module */ import { create, createEventRegistrar, dispatch, } from "@hesxenon/prelude/Dom.js"; import { flow, pipe } from "@hesxenon/prelude/Function.js"; import * as Option from "@hesxenon/prelude/Option.js"; import { Events } from "../events"; import * as Root from "../parts/root"; import { Connect, SupportedIdlType } from "../uwc-input"; const asNumber = (value: SupportedIdlType) => { const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined; return num == null || Number.isNaN(num) ? undefined : num; }; const syncFormData: Connect = (input) => { dispatch(input, { type: Events.formValuesChanged, detail: input.value != null ? [String(input.value as number)] : input.start != null && input.end != null ? [`${input.start},${input.end}`] : [], }); }; const validate: Connect = () => {}; /** * @internal */ export const connectRange: Connect = (input, opts) => { const initial = { value: input.value, start: input.start, end: input.end, } satisfies Partial; //#region utils /** * return the event offset in percent from the left edge of the track */ const getEventPosition = (e: MouseEvent): number | undefined => { if (e.screenX === 0) { // for some reason mouseup triggers a drag event with screenX 0 return; } const trackBox = track.getBoundingClientRect(); return ((e.clientX - trackBox.x) / trackBox.width) * 100; }; const getStepValue = (e: KeyboardEvent) => { const stepValue = asNumber(input.step) ?? 0.01; switch (e.key) { case "ArrowLeft": case "ArrowUp": return stepValue * -1; case "ArrowRight": case "ArrowDown": return stepValue; } }; const getMin = () => asNumber(input.min) ?? 0; const getMax = () => asNumber(input.max) ?? 100; const roundValue = (value: number) => { const step = asNumber(input.step); return step == null ? value : Math.round(value / step) * step; }; const clampStart = (value: unknown) => Math.max( getMin(), Math.min( Math.min(getMax(), asNumber(input.end) ?? Number.POSITIVE_INFINITY), typeof value === "number" ? value : Number.POSITIVE_INFINITY, ), ); const clampEnd = (value: unknown) => Math.min( getMax(), Math.max( Math.max(getMin(), asNumber(input.start) ?? Number.NEGATIVE_INFINITY), typeof value === "number" ? value : Number.NEGATIVE_INFINITY, ), ); const setStart = (start: number, opts: { dispatchEvent: boolean }) => { const next = clampStart(roundValue(start)); if (input.start != null) { const before = input.start; if (next !== before) { input.start = next; if (opts.dispatchEvent && input.start !== before) { dispatch(input, { type: "change" }); } } } else { const before = input.value; thumbEnd.hidden = true; if (next !== before) { input.value = next; if (opts.dispatchEvent && input.value !== before) { dispatch(input, { type: "change" }); } } } thumbStart.style.left = `${next}%`; thumbStart.ariaValueNow = `${next}`; cover.style.left = `${0}%`; cover.style.right = `${100 - next}%`; }; const setEnd: typeof setStart = (end, opts) => { const next = clampEnd(roundValue(end)); const before = input.end; if (next !== before) { input.end = next; if (opts.dispatchEvent && input.end !== before) { dispatch(input, { type: "change" }); } } thumbEnd.hidden = false; thumbEnd.style.left = `${next}%`; thumbEnd.ariaValueNow = `${next}`; cover.style.left = `${next}%`; cover.style.right = `${100 - next}%`; }; //#endregion //#region parts const cover = create("div", { part: "cover" }); const track = create( "div", { part: "track", onclick: (e) => { if (e.target !== e.currentTarget) { return; } pipe( getEventPosition(e), Option.fromNullable, Option.map((value) => { const distanceToStart = Math.abs( value - (asNumber(input.start) ?? Number.NEGATIVE_INFINITY), ); const distanceToEnd = Math.abs( value - (asNumber(input.end) ?? Number.POSITIVE_INFINITY), ); if (thumbEnd.hidden || distanceToStart <= distanceToEnd) { setStart(value, { dispatchEvent: true }); thumbStart.focus(); } else { setEnd(value, { dispatchEvent: true }); thumbEnd.focus(); } }), ); }, }, [cover], ); const thumbStart = create("div", { draggable: true, className: "thumb", part: "thumb-start", role: "slider", tabIndex: 0, onkeydown: flow( getStepValue, Option.fromNullable, Option.flatMap((increment) => pipe( Option.fromNullable(asNumber(input.value) ?? asNumber(input.start)), Option.map((current) => current + increment), ), ), Option.map((start) => setStart(start, { dispatchEvent: true })), ), ondragstart: (e) => { e.dataTransfer?.setDragImage(create("div"), 0, 0); }, ondrag: flow( getEventPosition, Option.fromNullable, Option.map((start) => setStart(start, { dispatchEvent: true })), ), }); const thumbEnd = create("div", { hidden: !input.multiple, draggable: true, className: "thumb", part: "thumb-end", role: "slider", tabIndex: 0, onkeydown: flow( getStepValue, Option.fromNullable, Option.flatMap((increment) => pipe( Option.fromNullable(asNumber(input.value) ?? asNumber(input.end)), Option.map((current) => current + increment), ), ), Option.map((end) => setEnd(end, { dispatchEvent: true })), ), ondragstart: (e) => { e.dataTransfer?.setDragImage(create("div"), 0, 0); }, ondrag: flow( getEventPosition, Option.fromNullable, Option.map((end) => setEnd(end, { dispatchEvent: true })), ), }); //#endregion //#region event listeners const on = createEventRegistrar(input, opts.disconnect); on( [ Events.init, Events.valueChanged, Events.startChanged, Events.minChanged, Events.endChanged, Events.maxChanged, ], (e) => { if ( e.type === Events.init && input.value == null && input.start == null && input.end == null ) { input.value = 50; } if ( (e.type === Events.valueChanged || e.type === Events.startChanged || e.type === Events.endChanged) && (input.readonly || input.disabled) ) { e.preventDefault(); return; } const value = asNumber(input.value); const start = asNumber(input.start); const end = asNumber(input.end); if (value == null && end != null && start != null) { setStart(start, { dispatchEvent: false }); setEnd(end, { dispatchEvent: false }); } else if (value != null && start == null && end == null) { setStart(value, { dispatchEvent: false }); } else { console.warn( "Invalid range value: either set value or both, start AND end", ); } input.ariaValueMin = String(getMin()); input.ariaValueMax = String(getMax()); }, ); on( [Events.init, Events.valueChanged, Events.startChanged, Events.endChanged], () => syncFormData(input, opts), ); on([Events.init, Events.formValuesChanged], () => validate(input, opts)); on([Events.formReset], () => Object.assign(input, initial)); on([Events.clear], () => { input.value = undefined; input.start = undefined; input.end = undefined; }); //#endregion //#region render Root.replaceChildren(input, [track, thumbStart, thumbEnd]); //#endregion //#region init dispatch(input, { type: Events.init, bubbles: false }); };