diff options
feat: initial commit
Diffstat (limited to 'src/lib/vendor')
-rw-r--r-- | src/lib/vendor/svelte-range-slider/README | 1 | ||||
-rw-r--r-- | src/lib/vendor/svelte-range-slider/range-pips.svelte | 303 | ||||
-rw-r--r-- | src/lib/vendor/svelte-range-slider/range-slider.svelte | 1026 |
3 files changed, 1330 insertions, 0 deletions
diff --git a/src/lib/vendor/svelte-range-slider/README b/src/lib/vendor/svelte-range-slider/README new file mode 100644 index 0000000..b17797b --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/README @@ -0,0 +1 @@ +https://github.com/roycrippen4/svelte-range-slider/tree/master diff --git a/src/lib/vendor/svelte-range-slider/range-pips.svelte b/src/lib/vendor/svelte-range-slider/range-pips.svelte new file mode 100644 index 0000000..418fc7e --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/range-pips.svelte @@ -0,0 +1,303 @@ +<script lang="ts" module> + export interface PipsProps { + min?: number; + max?: number; + step?: number; + values?: number[]; + vertical?: boolean; + reversed?: boolean; + hoverable?: boolean; + disabled?: boolean; + pipstep?: number; + prefix?: string; + suffix?: string; + focus?: boolean; + range?: undefined | boolean | 'min' | 'max'; + all?: undefined | boolean | 'pip' | 'label'; + first?: boolean | 'pip' | 'label'; + last?: boolean | 'pip' | 'label'; + rest?: boolean | 'pip' | 'label'; + percentOf: (v: number) => number; + fixFloat: (v: number) => number; + orientationStart?: 'top' | 'bottom' | 'left' | 'right'; + orientationEnd?: 'top' | 'bottom' | 'left' | 'right'; + formatter?: (v: number, i: number, p: number) => string; + moveHandle: undefined | ((index: number | undefined, value: number) => number); + normalisedClient: (e: MouseEvent | TouchEvent) => { x: number; y: number }; + } +</script> + +<script lang="ts"> + let { + range = false, + min = 0, + max = 100, + step = 1, + values = [(max + min) / 2], + vertical = false, + reversed = false, + hoverable = true, + disabled = false, + pipstep, + all = true, + first, + last, + rest, + prefix = '', + suffix = '', + focus, + orientationStart, + // eslint-disable-next-line no-unused-vars + formatter = (v, i, p) => v.toString(), + percentOf, + moveHandle, + fixFloat, + normalisedClient + }: PipsProps = $props(); + + let clientStart = $state({ x: 0, y: 0 }); + let pipStep = $derived( + pipstep || + ((max - min) / step >= (vertical ? 50 : 100) ? (max - min) / (vertical ? 10 : 20) : 1) + ); + let pipCount = $derived(parseInt(((max - min) / (step * pipStep)).toString(), 10)); + let pipVal = $derived((val: number) => fixFloat(min + val * step * pipStep)); + let isSelected = $derived((val: number) => values.some((v) => fixFloat(v) === fixFloat(val))); + let inRange = $derived((val: number) => { + if (range === 'min') { + return values[0] > val; + } + if (range === 'max') { + return values[0] < val; + } + if (range) { + return values[0] < val && values[1] > val; + } + }); + + /** + * function to run when the user clicks on a label + * we store the original client position so we can check if the user has moved the mouse/finger + * @param {MouseEvent} e the event from browser + **/ + const labelDown = (e: MouseEvent) => { + clientStart = { x: e.clientX, y: e.clientY }; + }; + + /** + * function to run when the user releases the mouse/finger + * we check if the user has moved the mouse/finger, if not we "click" the label + * and move the handle it to the label position + * @param {number} val the value of the label + * @param {MouseEvent|TouchEvent} e the event from browser + */ + function labelUp(val: number, e: MouseEvent | TouchEvent) { + if (disabled) { + return; + } + + const clientPos = normalisedClient(e); + const distanceMoved = Math.sqrt( + Math.pow(clientStart.x - clientPos.x, 2) + Math.pow(clientStart.y - clientPos.y, 2) + ); + + if (clientStart && distanceMoved <= 5) { + moveHandle?.(undefined, val); + } + } +</script> + +<div + class="rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6" + class:disabled + class:hoverable + class:vertical + class:reversed + class:focus +> + {#if (all && first !== false) || first} + <span + class="pip-680f0f01-664b-43b5-9e1c-789449c63c62 first" + class:selected={isSelected(min)} + class:in-range={inRange(min)} + style="{orientationStart}: 0%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(min, e)} + > + {#if all === 'label' || first === 'label'} + <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b"> + {prefix}{formatter(fixFloat(min), 0, 0)}{suffix} + </span> + {/if} + </span> + {/if} + + {#if (all && rest !== false) || rest} + <!-- eslint-disable-next-line no-unused-vars --> + {#each Array(pipCount + 1) as _, i} + {#if pipVal(i) !== min && pipVal(i) !== max} + <span + class="pip-680f0f01-664b-43b5-9e1c-789449c63c62" + class:selected={isSelected(pipVal(i))} + class:in-range={inRange(pipVal(i))} + style="{orientationStart}: {percentOf(pipVal(i))}%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(pipVal(i), e)} + > + {#if all === 'label' || rest === 'label'} + <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b"> + {prefix}{formatter(pipVal(i), i, percentOf(pipVal(i)))}{suffix} + </span> + {/if} + </span> + {/if} + {/each} + {/if} + + {#if (all && last !== false) || last} + <span + class="pip last" + class:selected={isSelected(max)} + class:in-range={inRange(max)} + style="{orientationStart}: 100%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(max, e)} + > + {#if all === 'label' || last === 'label'} + <span class="pipVal"> + {prefix}{formatter(fixFloat(max), pipCount, 100)}{suffix} + </span> + {/if} + </span> + {/if} +</div> + +<style> + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) { + --pip: var(--range-pip, lightslategray); + --pip-text: var(--range-pip-text, var(--pip)); + --pip-active: var(--range-pip-active, darkslategrey); + --pip-active-text: var(--range-pip-active-text, var(--pip-active)); + --pip-hover: var(--range-pip-hover, darkslategrey); + --pip-hover-text: var(--range-pip-hover-text, var(--pip-hover)); + --pip-in-range: var(--range-pip-in-range, var(--pip-active)); + --pip-in-range-text: var(--range-pip-in-range-text, var(--pip-active-text)); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6) { + position: absolute; + height: 1em; + left: 0; + right: 0; + bottom: -1em; + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical) { + height: auto; + width: 1em; + left: 100%; + right: auto; + top: 0; + bottom: 0; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + height: 0.4em; + position: absolute; + top: 0.25em; + width: 1px; + white-space: nowrap; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + height: 1px; + width: 0.4em; + left: 0.25em; + top: auto; + bottom: auto; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + position: absolute; + top: 0.4em; + transform: translate(-50%, 25%); + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + position: absolute; + top: 0; + left: 0.4em; + transform: translate(25%, -50%); + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + transition: all 0.15s ease; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + transition: + all 0.15s ease, + font-weight 0s linear; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + color: var(--pip-text, lightslategray); + background-color: var(--pip, lightslategray); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) { + color: var(--pip-active-text, darkslategrey); + background-color: var(--pip-active, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) .pip:hover) { + color: var(--pip-hover-text, darkslategrey); + background-color: var(--pip-hover, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.in-range) { + color: var(--pip-in-range-text, darkslategrey); + background-color: var(--pip-in-range, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) { + height: 0.75em; + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical .pip.selected) { + height: 1px; + width: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 + .pip.selected + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + font-weight: bold; + top: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pip.selected + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + top: 0; + left: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) + .pip:not(.selected):hover + ) { + transition: none; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) + .pip:not(.selected):hover + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + transition: none; + font-weight: bold; + } +</style> diff --git a/src/lib/vendor/svelte-range-slider/range-slider.svelte b/src/lib/vendor/svelte-range-slider/range-slider.svelte new file mode 100644 index 0000000..7f522e2 --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/range-slider.svelte @@ -0,0 +1,1026 @@ +<script lang="ts" module> + export type ChangeEvent = { + activeHandle: number; + startValue: number; + previousValue: number; + value: number; + values: number[]; + }; + + export type StartEvent = { activeHandle: number; value: number; values: number[] }; + + export type StopEvent = { + activeHandle: number; + startValue: number; + value: number; + values: number[]; + }; + + export interface RangeSliderProps { + range?: boolean | 'min' | 'max'; + onchange?: (event: ChangeEvent) => void; + onstart?: (event: StartEvent) => void; + onstop?: (event: StopEvent) => void; + pushy?: boolean; + min?: number; + max?: number; + ariaLabels?: string[]; + precision?: number; + springOptions?: { stiffness: number; damping: number }; + id?: string; + prefix?: string; + suffix?: string; + pips?: boolean; + pipstep?: number; + all?: boolean | 'pip' | 'label'; + first?: boolean | 'pip' | 'label'; + last?: boolean | 'pip' | 'label'; + rest?: boolean | 'pip' | 'label'; + step?: number; + value?: number; + values?: number[]; + vertical?: boolean; + float?: boolean; + reversed?: boolean; + hoverable?: boolean; + disabled?: boolean; + formatter?: (value: number, index: number, percent: number) => string; + handleFormatter?: (value: number, index: number, percent: number) => string; + } +</script> + +<script lang="ts"> + import { spring } from 'svelte/motion'; + import RangePips from './range-pips.svelte'; + + let { + range = false, + pushy = false, + min = 0, + max = 100, + ariaLabels = [], + precision = 2, + springOptions = { stiffness: 0.15, damping: 0.4 }, + id = '', + prefix = '', + suffix = '', + pips = false, + pipstep, + all, + first, + last, + rest, + step = 1, + value = $bindable(0), + values = $bindable([(max + min) / 2]), + vertical = false, + float = false, + reversed = false, + hoverable = true, + disabled = false, + onchange, + onstart, + onstop, + formatter = (value: { toString: () => string }) => value.toString(), + handleFormatter = formatter + }: RangeSliderProps = $props(); + + if (value) { + values = [value]; + } + + let slider: Element | undefined = $state(undefined); + let valueLength = $state(0); + let focus = $state(false); + let handleActivated = $state(false); + let handlePressed = $state(false); + let keyboardActive = $state(false); + let activeHandle = $state(values.length - 1); + + let startValue: number | undefined = $state(); + + let previousValue: number | undefined = $state(); + + /** + * make sure the value is coerced to a float value + * @param {number} v the value to fix + * @return {number} a float version of the input + **/ + const fixFloat = (v: number): number => parseFloat((+v).toFixed(precision)); + + $effect(() => { + // check that "values" is an array, or set it as array to prevent any errors in springs, or range trimming + if (!Array.isArray(values)) { + values = [(max + min) / 2]; + console.error( + "'values' prop should be an Array (https://github.com/simeydotme/svelte-range-slider-pips#slider-props)" + ); + } + + // trim the range so it remains as a min/max (only 2 handles) + // and also align the handles to the steps + const trimmedAlignedValues = trimRange(values.map((v) => alignValueToStep(v))); + if ( + !(values.length === trimmedAlignedValues.length) || + !values.every((element, index) => fixFloat(element) === trimmedAlignedValues[index]) + ) { + values = trimmedAlignedValues; + } + + // check if the valueLength (length of values[]) has changed, + // because if so we need to re-seed the spring function with the new values array. + if (valueLength !== values.length) { + // set the initial spring values when the slider initialises, or when values array length has changed + springPositions = spring( + values.map((v) => percentOf(v)), + springOptions + ); + } else { + // update the value of the spring function for animated handles whenever the values has updated + springPositions.set(values.map((v) => percentOf(v))); + } + // set the valueLength for the next check + valueLength = values.length; + + if (values.length > 1 && !Array.isArray(ariaLabels)) { + console.warn( + `'ariaLabels' prop should be an Array (https://github.com/simeydotme/svelte-range-slider-pips#slider-props)` + ); + } + }); + + /** + * take in a value, and then calculate that value's percentage + * of the overall range (min-max); + * @param {number} val the value we're getting percent for + * @return {number} the percentage value + **/ + const percentOf = (/** @type {number} */ val: number): number => { + let percent = ((val - min) / (max - min)) * 100; + + if (isNaN(percent) || percent <= 0) { + return 0; + } + + if (percent >= 100) { + return 100; + } + + return fixFloat(percent); + }; + + /** + * clamp a value from the range so that it always + * falls within the min/max values + * @param {number} val the value to clamp + * @return {number} the value after it's been clamped + **/ + const clampValue = (/** @type {number} */ val: number): number => { + // return the min/max if outside of that range + return val <= min ? min : val >= max ? max : val; + }; + + /** + * align the value with the steps so that it + * always sits on the closest (above/below) step + * @param {number} val the value to align + * @return {number} the value after it's been aligned + **/ + const alignValueToStep = (/** @type {number} */ val: number): number => { + // sanity check for performance + if (val <= min) { + return fixFloat(min); + } + + if (val >= max) { + return fixFloat(max); + } + + val = fixFloat(val); + + // find the middle-point between steps and see if the value is closer to the next step, or previous step + let remainder = (val - min) % step; + let aligned = val - remainder; + + if (Math.abs(remainder) * 2 >= step) { + aligned += remainder > 0 ? step : -step; + } + + aligned = clampValue(aligned); // make sure the value is within acceptable limits + + // make sure the returned value is set to the precision desired + // this is also because javascript often returns weird floats + // when dealing with odd numbers and percentages + return fixFloat(aligned); + }; + + /** + * the orientation of the handles/pips based on the + * input values of vertical and reversed + * @type {"top"|"bottom"|"left"|"right"} orientationStart + **/ + let orientationStart: 'top' | 'bottom' | 'left' | 'right' = $derived( + vertical ? (reversed ? 'top' : 'bottom') : reversed ? 'right' : 'left' + ); + let orientationEnd = $derived( + vertical ? (reversed ? 'bottom' : 'top') : reversed ? 'left' : 'right' + ); + + /** + * helper function to get the index of an element in it's DOM container + * @param {Element|null} el dom object reference we want the index of + * @returns {number} the index of the input element + **/ + function index(el: Element | null): number { + if (!el) { + return -1; + } + + let i = 0; + while ((el = el.previousElementSibling)) { + i++; + } + return i; + } + + /** + * normalise a mouse or touch event to return the + * client (x/y) object for that event + * @param {MouseEvent|TouchEvent} e a mouse/touch event to normalise + * @returns {{ x: number, y: number }} normalised event client object (x,y) + **/ + function normalisedClient(e: MouseEvent | TouchEvent): { x: number; y: number } { + if (e.type.includes('touch')) { + const touchEvent = e as TouchEvent; + const touch = touchEvent.touches[0] || touchEvent.changedTouches[0]; + return { x: touch.clientX, y: touch.clientY }; + } else { + const mouseEvent = e as MouseEvent; + return { x: mouseEvent.clientX, y: mouseEvent.clientY }; + } + } + + /** + * check if an element is a handle on the slider + * @param {Element} el dom object reference we want to check + * @returns {boolean} + **/ + function targetIsHandle(el: Element): boolean { + if (!slider) return false; + const handles = [...slider.querySelectorAll('.handle')]; + const isHandle = handles.includes(el); + const isChild = handles.some((handle) => handle.contains(el)); + return isHandle || isChild; + } + + /** + * trim the values array based on whether the property + * for 'range' is 'min', 'max', or truthy. This is because we + * do not want more than one handle for a min/max range, and we do + * not want more than two handles for a true range. + * @param {number[]} values the input values for the rangeSlider + * @return {number[]} the range array for creating a rangeSlider + **/ + function trimRange(values: number[]): number[] { + if (range === 'min' || range === 'max') { + return values.slice(0, 1); + } + if (range) { + return values.slice(0, 2); + } + + return values; + } + + /** + * helper to return the slider dimensions for finding + * the closest handle to user interaction + * @return {DOMRect} the range slider DOM client rect + **/ + function getSliderDimensions(): DOMRect | undefined { + return slider?.getBoundingClientRect(); + } + + /** + * helper to return closest handle to user interaction + * @param {{ x: number, y: number }} clientPos the client{x,y} positions to check against + * @return {number} the index of the closest handle to clientPos + **/ + function getClosestHandle(clientPos: { x: number; y: number }): number { + // first make sure we have the latest dimensions + // of the slider, as it may have changed size + const dims = getSliderDimensions(); + if (!dims) throw new Error('No Slider Dimensions yet.'); + // calculate the interaction position, percent and value + let handlePos = 0; + let handlePercent = 0; + let handleVal = 0; + if (vertical) { + handlePos = clientPos.y - dims.top; + handlePercent = (handlePos / dims.height) * 100; + handlePercent = reversed ? handlePercent : 100 - handlePercent; + } else { + handlePos = clientPos.x - dims.left; + handlePercent = (handlePos / dims.width) * 100; + handlePercent = reversed ? 100 - handlePercent : handlePercent; + } + handleVal = ((max - min) / 100) * handlePercent + min; + + // if we have a range, and the handles are at the same + // position, we want a simple check if the interaction + // value is greater than return the second handle + if (range === true && values[0] === values[1]) { + if (handleVal > values[1]) { + return 1; + } + + return 0; + + // if there are multiple handles, and not a range, then + // we sort the handles values, and return the first one closest + // to the interaction value + } + + return values.indexOf( + [...values].sort((a, b) => Math.abs(handleVal - a) - Math.abs(handleVal - b))[0] + ); + } + + /** + * take the interaction position on the slider, convert + * it to a value on the range, and then send that value + * through to the moveHandle() method to set the active + * handle's position + * @param {{ x: number, y: number }} clientPos the client{x,y} of the interaction + **/ + function handleInteract(clientPos: { x: number; y: number }) { + // first make sure we have the latest dimensions + // of the slider, as it may have changed size + const dims = getSliderDimensions(); + if (!dims) throw new Error('No Slider Dimensions yet.'); + // calculate the interaction position, percent and value + let handlePos = 0; + let handlePercent = 0; + let handleVal = 0; + if (vertical) { + handlePos = clientPos.y - dims.top; + handlePercent = (handlePos / dims.height) * 100; + handlePercent = reversed ? handlePercent : 100 - handlePercent; + } else { + handlePos = clientPos.x - dims.left; + handlePercent = (handlePos / dims.width) * 100; + handlePercent = reversed ? 100 - handlePercent : handlePercent; + } + handleVal = ((max - min) / 100) * handlePercent + min; + // move handle to the value + moveHandle(activeHandle, handleVal); + } + + let lastSetValue = NaN; + /** + * move a handle to a specific value, respecting the clamp/align rules + * @param {number} index the index of the handle we want to move + * @param {number} handleValue the value to move the handle to + * @return {number} the value that was moved to (after alignment/clamping) + **/ + function moveHandle(index: number | undefined, handleValue: number): number { + // align & clamp the value so we're not doing extra + // calculation on an out-of-range value down below + handleValue = alignValueToStep(handleValue); + // use the active handle if handle index is not provided + if (typeof index === 'undefined') { + index = activeHandle; + } + // if this is a range slider perform special checks + if (range) { + // restrict the handles of a range-slider from + // going past one-another unless "pushy" is true + if (index === 0 && handleValue > values[1]) { + if (pushy) { + values[1] = handleValue; + } else { + handleValue = values[1]; + } + } else if (index === 1 && handleValue < values[0]) { + if (pushy) { + values[0] = handleValue; + } else { + handleValue = values[0]; + } + } + } + + // if the value has changed, update it + if (values[index] !== handleValue) { + values[index] = handleValue; + } + + // fire the change event when the handle moves, + // and store the previous value for the next time + if (previousValue !== handleValue) { + handleOnChange(); + previousValue = handleValue; + } + lastSetValue = handleValue; + value = handleValue; + return handleValue; + } + $effect(() => { + if (value !== lastSetValue) moveHandle(undefined, value); + }); + + /** + * helper to find the beginning range value for use with css style + * @param {number[]} values the input values for the rangeSlider + * @return {number} the beginning of the range + **/ + function rangeStart(values: number[]): number { + if (range === 'min') { + return 0; + } + + return values[0]; + } + + /** + * helper to find the ending range value for use with css style + * @param {array} values the input values for the rangeSlider + * @return {number} the end of the range + **/ + function rangeEnd(values: Array<any>): number { + if (range === 'max') { + return 0; + } + + if (range === 'min') { + return 100 - values[0]; + } + + return 100 - values[1]; + } + + /** + * helper to take a string of html and return only the text + * @param {string} possibleHtml the string that may contain html + * @return {string} the text from the input + */ + function pureText(possibleHtml: string): string { + return `${possibleHtml}`.replace(/<[^>]*>/g, ''); + } + + /** + * when the user has unfocussed (blurred) from the + * slider, deactivate all handles + **/ + function sliderBlurHandle() { + if (!keyboardActive) { + return; + } + + focus = false; + handleActivated = false; + handlePressed = false; + } + + /** + * when the user focusses the handle of a slider + * set it to be active + * @param {Event} e the event from browser + **/ + function sliderFocusHandle(e: Event) { + if (disabled) { + return; + } + + const target = e.target as HTMLElement; + activeHandle = index(target); + focus = true; + } + + /** + * handle the keyboard accessible features by checking the + * input type, and modfier key then moving handle by appropriate amount + * @param {KeyboardEvent} e the event from browser + **/ + function sliderKeydown(e: KeyboardEvent) { + if (disabled) { + return; + } + + const target = e.target as HTMLElement; + const handle = index(target); + let jump = e.ctrlKey || e.metaKey || e.shiftKey ? step * 10 : step; + let prevent = false; + + if (e.key === 'PageDown' || e.key === 'PageUp') { + jump *= 10; + } + + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + moveHandle(handle, values[handle] + jump); + prevent = true; + } + + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + moveHandle(handle, values[handle] - jump); + prevent = true; + } + + if (e.key === 'Home') { + moveHandle(handle, min); + prevent = true; + } + + if (e.key === 'End') { + moveHandle(handle, max); + prevent = true; + } + + if (prevent) { + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * function to run when the user touches the slider element anywhere + * @param {MouseEvent|TouchEvent} e the event from browser + **/ + function sliderInteractStart(e: MouseEvent | TouchEvent) { + if (disabled) { + return; + } + + const element = e.target as HTMLElement; + const clientPos = normalisedClient(e); + // set the closest handle as active + focus = true; + handleActivated = true; + handlePressed = true; + activeHandle = getClosestHandle(clientPos); + + // fire the start event + startValue = previousValue = alignValueToStep(values[activeHandle]); + handleOnStart(); + + // for touch devices we want the handle to instantly + // move to the position touched for more responsive feeling + if (e.type === 'touchstart' && !element.matches('.pipVal')) { + handleInteract(clientPos); + } + } + + /** + * function to run when the user stops touching + * down on the slider element anywhere + * @param {Event} e the event from browser + **/ + function sliderInteractEnd(e: Event) { + // fire the stop event for touch devices + if (e.type === 'touchend') { + handleOnStop(); + } + handlePressed = false; + } + + /** + * unfocus the slider if the user clicked off of + * it, somewhere else on the screen + * @param {MouseEvent|TouchEvent} e the event from browser + **/ + function bodyInteractStart(e: MouseEvent | TouchEvent) { + keyboardActive = false; + const target = e.target as HTMLElement; + if (slider && focus && e.target !== slider && !slider.contains(target)) { + focus = false; + } + } + + /** + * send the clientX through to handle the interaction + * whenever the user moves across screen while active + * @param {MouseEvent|TouchEvent} e the event from browser + **/ + function bodyInteract(e: MouseEvent | TouchEvent) { + if (!disabled) { + if (handleActivated) { + handleInteract(normalisedClient(e)); + } + } + } + + /** + * if user triggers mouseup on the body while + * a handle is active (without moving) then we + * trigger an interact event there + * @param {MouseEvent|TouchEvent} e the event from browser + **/ + function bodyMouseUp(e: MouseEvent | TouchEvent) { + if (!disabled) { + const el = e.target as HTMLElement; + // this only works if a handle is active, which can + // only happen if there was sliderInteractStart triggered + // on the slider, already + if (handleActivated) { + if (slider && (el === slider || slider.contains(el))) { + focus = true; + // don't trigger interact if the target is a handle (no need) or + // if the target is a label (we want to move to that value from rangePips) + if (!targetIsHandle(el) && !el.matches('.pipVal')) { + handleInteract(normalisedClient(e)); + } + } + // fire the stop event for mouse device + // when the body is triggered with an active handle + handleOnStop(); + } + } + handleActivated = false; + handlePressed = false; + } + + /** + * @param {KeyboardEvent} e + */ + function bodyKeyDown(e: KeyboardEvent) { + if (disabled) { + return; + } + + const target = e.target as HTMLElement; + + if (slider && (e.target === slider || slider.contains(target))) { + keyboardActive = true; + } + } + + function handleOnStop() { + if (disabled || !onstop || typeof onstop !== 'function') { + return; + } + + onstop({ + activeHandle, + startValue: startValue ?? 0, + value: values[activeHandle], + values: values.map((v) => alignValueToStep(v)) + }); + } + + function handleOnStart() { + if (disabled || !onstart || typeof onstart !== 'function') { + return; + } + + onstart({ + activeHandle, + value: startValue ?? 0, + values: values.map((v) => alignValueToStep(v)) + }); + } + + function handleOnChange() { + if (disabled || !onchange || typeof onchange !== 'function') { + return; + } + + onchange({ + activeHandle, + startValue: startValue ?? 0, + previousValue: typeof previousValue === 'undefined' ? (startValue ?? 0) : previousValue, + value: values[activeHandle], + values: values.map((v) => alignValueToStep(v)) + }); + } + + /** @type {import('svelte/motion').Spring<number[]>} */ + let springPositions: import('svelte/motion').Spring<number[]> = spring( + values.map((v) => percentOf(v)), + springOptions + ); +</script> + +<div + {id} + bind:this={slider} + role="none" + class="_rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28" + class:range + class:disabled + class:hoverable + class:vertical + class:reversed + class:focus + class:min={range === 'min'} + class:max={range === 'max'} + class:pips + class:pip-labels={all === 'label' || first === 'label' || last === 'label' || rest === 'label'} + onmousedown={sliderInteractStart} + onmouseup={sliderInteractEnd} +> + {#each values as value, index} + <span + role="slider" + class="rangeHandle" + class:active={focus && activeHandle === index} + class:press={handlePressed && activeHandle === index} + data-handle={index} + onblur={sliderBlurHandle} + onfocus={sliderFocusHandle} + onkeydown={sliderKeydown} + style="{orientationStart}: {$springPositions[index]}%; z-index: {activeHandle === index + ? 3 + : 2};" + aria-label={ariaLabels[index]} + aria-valuemin={range === true && index === 1 ? values[0] : min} + aria-valuemax={range === true && index === 0 ? values[1] : max} + aria-valuenow={value} + aria-valuetext="{prefix}{pureText(handleFormatter(value, index, percentOf(value)))}{suffix}" + aria-orientation={vertical ? 'vertical' : 'horizontal'} + aria-disabled={disabled} + tabindex={disabled ? -1 : 0} + > + <span class="rangeNub"></span> + {#if float} + <span class="rangeFloat"> + {prefix}{handleFormatter(value, index, percentOf(value))}{suffix} + </span> + {/if} + </span> + {/each} + + {#if range} + <span + class="rangeBar" + style="{orientationStart}: {rangeStart($springPositions)}%; + {orientationEnd}: {rangeEnd($springPositions)}%;" + ></span> + {/if} + + {#if pips} + <RangePips + {values} + {min} + {max} + {step} + {range} + {vertical} + {reversed} + {orientationStart} + {hoverable} + {disabled} + {all} + {first} + {last} + {rest} + {pipstep} + {prefix} + {suffix} + {formatter} + {focus} + {percentOf} + {moveHandle} + {fixFloat} + {normalisedClient} + /> + {/if} +</div> + +<svelte:window + onmousedown={bodyInteractStart} + onmousemove={bodyInteract} + onmouseup={bodyMouseUp} + onkeydown={bodyKeyDown} +/> + +<style> + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) { + --slider: var(--range-slider, #d7dada); + --handle-inactive: var(--range-handle-inactive, #99a2a2); + --handle: var(--range-handle, #838de7); + --handle-focus: var(--range-handle-focus, #4a40d4); + --handle-border: var(--range-handle-border, var(--handle)); + --range-inactive: var(--range-range-inactive, var(--handle-inactive)); + --range: var(--range-range, var(--handle-focus)); + --float-inactive: var(--range-float-inactive, var(--handle-inactive)); + --float: var(--range-float, var(--handle-focus)); + --float-text: var(--range-float-text, white); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) { + position: relative; + border-radius: 100px; + height: 0.5em; + margin: 1em; + transition: opacity 0.2s ease; + user-select: none; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 *) { + user-select: none; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.pips) { + margin-bottom: 1.8em; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.pip-labels) { + margin-bottom: 2.8em; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical) { + display: inline-block; + border-radius: 100px; + width: 0.5em; + min-height: 200px; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.pips) { + margin-right: 1.8em; + margin-bottom: 1em; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.pip-labels) { + margin-right: 2.8em; + margin-bottom: 1em; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle) { + position: absolute; + display: block; + height: 1.4em; + width: 1.4em; + top: 0.25em; + bottom: auto; + transform: translateY(-50%) translateX(-50%); + z-index: 2; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.reversed .rangeHandle) { + transform: translateY(-50%) translateX(50%); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical .rangeHandle) { + left: 0.25em; + top: auto; + transform: translateY(50%) translateX(-50%); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.reversed .rangeHandle) { + transform: translateY(-50%) translateX(-50%); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeNub), + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle:before) { + position: absolute; + left: 0; + top: 0; + display: block; + border-radius: 10em; + height: 100%; + width: 100%; + transition: box-shadow 0.2s ease; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle:before) { + content: ''; + left: 1px; + top: 1px; + bottom: 1px; + right: 1px; + height: auto; + width: auto; + box-shadow: 0 0 0 0px var(--handle-border); + opacity: 0; + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled) + .rangeHandle:hover:before + ) { + box-shadow: 0 0 0 8px var(--handle-border); + opacity: 0.2; + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled) + .rangeHandle.press:before + ), + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled) + .rangeHandle.press:hover:before + ) { + box-shadow: 0 0 0 12px var(--handle-border); + opacity: 0.4; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range:not(.min):not(.max) .rangeNub) { + border-radius: 10em 10em 10em 1.6em; + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range .rangeHandle:nth-of-type(1) .rangeNub + ) { + transform: rotate(-135deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range .rangeHandle:nth-of-type(2) .rangeNub + ) { + transform: rotate(45deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.reversed + .rangeHandle:nth-of-type(1) + .rangeNub + ) { + transform: rotate(45deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.reversed + .rangeHandle:nth-of-type(2) + .rangeNub + ) { + transform: rotate(-135deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical + .rangeHandle:nth-of-type(1) + .rangeNub + ) { + transform: rotate(135deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical + .rangeHandle:nth-of-type(2) + .rangeNub + ) { + transform: rotate(-45deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical.reversed + .rangeHandle:nth-of-type(1) + .rangeNub + ) { + transform: rotate(-45deg); + } + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical.reversed + .rangeHandle:nth-of-type(2) + .rangeNub + ) { + transform: rotate(135deg); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeFloat) { + display: block; + position: absolute; + left: 50%; + top: -0.5em; + transform: translate(-50%, -100%); + font-size: 1em; + text-align: center; + opacity: 0; + pointer-events: none; + white-space: nowrap; + transition: all 0.2s ease; + font-size: 0.9em; + padding: 0.2em 0.4em; + border-radius: 0.2em; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle.active .rangeFloat), + :global( + ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable .rangeHandle:hover .rangeFloat + ) { + opacity: 1; + top: -0.2em; + transform: translate(-50%, -100%); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeBar) { + position: absolute; + display: block; + transition: background 0.2s ease; + border-radius: 1em; + height: 0.5em; + top: 0; + user-select: none; + z-index: 1; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical .rangeBar) { + width: 0.5em; + height: auto; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) { + background-color: var(--slider, #d7dada); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeBar) { + background-color: var(--range-inactive, #99a2a2); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeBar) { + background-color: var(--range, #838de7); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeNub) { + background-color: var(--handle-inactive, #99a2a2); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeNub) { + background-color: var(--handle, #838de7); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle.active .rangeNub) { + background-color: var(--handle-focus, #4a40d4); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeFloat) { + color: white; + color: var(--float-text); + background-color: var(--float-inactive, #99a2a2); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeFloat) { + background-color: var(--float, #4a40d4); + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.disabled) { + opacity: 0.5; + } + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.disabled .rangeNub) { + background-color: var(--slider, #d7dada); + } +</style> |