| <script lang="ts"> |
| import { createEventDispatcher, onMount, onDestroy } from "svelte"; |
|
|
| import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input"; |
| import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages"; |
| import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; |
|
|
| import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte"; |
| import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte"; |
|
|
| const BUTTONS_LEFT = 0b0000_0001; |
| const BUTTONS_RIGHT = 0b0000_0010; |
| const BUTTON_LEFT = 0; |
| const BUTTON_RIGHT = 2; |
|
|
| const dispatch = createEventDispatcher<{ value: number | undefined; startHistoryTransaction: undefined }>(); |
|
|
| |
| export let label: string | undefined = undefined; |
| export let tooltip: string | undefined = undefined; |
|
|
| |
| export let disabled = false; |
|
|
| |
| |
| export let value: number | undefined = undefined; |
| export let min: number | undefined = undefined; |
| export let max: number | undefined = undefined; |
| export let isInteger = false; |
|
|
| |
| export let displayDecimalPlaces = 2; |
| export let unit = ""; |
| export let unitIsHiddenWhenEditing = true; |
|
|
| |
| |
| |
| export let mode: NumberInputMode = "Increment"; |
| |
| |
| export let step = 1; |
| |
| |
| |
| |
| export let incrementBehavior: NumberInputIncrementBehavior = "Add"; |
| |
| |
| export let rangeMin = 0; |
| export let rangeMax = 1; |
|
|
| |
| export let minWidth = 0; |
| export let maxWidth = 0; |
|
|
| |
| export let incrementCallbackIncrease: (() => void) | undefined = undefined; |
| export let incrementCallbackDecrease: (() => void) | undefined = undefined; |
|
|
| let self: FieldInput | undefined; |
| let inputRangeElement: HTMLInputElement | undefined; |
| let text = displayText(value, unit); |
| let editing = false; |
| let isDragging = false; |
| let pressingArrow = false; |
| let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined; |
| |
| let rangeSliderValue = value !== undefined ? value : 0; |
| |
| |
| let rangeSliderValueAsRendered = value !== undefined ? value : 0; |
| |
| |
| |
| |
| |
| let rangeSliderClickDragState: "Ready" | "Deciding" | "Dragging" | "Aborted" = "Ready"; |
| |
| let initialValueBeforeDragging: number | undefined = undefined; |
| |
| let cumulativeDragDelta = 0; |
| |
| let ctrlKeyDown = false; |
|
|
| $: watchValue(value, unit); |
|
|
| $: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any"; |
| $: styles = { |
| ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}), |
| ...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}), |
| ...(mode === "Range" ? { "--progress-factor": Math.min(Math.max((rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin), 0), 1) } : {}), |
| }; |
|
|
| |
| const trackCtrl = (e: KeyboardEvent | MouseEvent) => (ctrlKeyDown = e.ctrlKey); |
| onMount(() => { |
| addEventListener("keydown", trackCtrl); |
| addEventListener("keyup", trackCtrl); |
| addEventListener("mousemove", trackCtrl); |
| }); |
| onDestroy(() => { |
| removeEventListener("keydown", trackCtrl); |
| removeEventListener("keyup", trackCtrl); |
| removeEventListener("mousemove", trackCtrl); |
| }); |
|
|
| |
| |
| |
|
|
| |
| function watchValue(value: number | undefined, unit: string) { |
| |
| if (rangeSliderClickDragState === "Dragging") return; |
|
|
| |
| if (value === undefined) { |
| text = "-"; |
| return; |
| } |
|
|
| |
| rangeSliderValue = value; |
| rangeSliderValueAsRendered = value; |
|
|
| |
| let sanitized = value; |
| if (typeof min === "number") sanitized = Math.max(sanitized, min); |
| if (typeof max === "number") sanitized = Math.min(sanitized, max); |
|
|
| text = displayText(sanitized, unit); |
| } |
|
|
| |
| |
| function updateValue(newValue: number | undefined): number | undefined { |
| |
| const oldValue = value !== undefined && isInteger ? Math.round(value) : value; |
| let newValueValidated = newValue !== undefined ? newValue : oldValue; |
|
|
| if (newValueValidated !== undefined) { |
| if (typeof min === "number" && !Number.isNaN(min)) newValueValidated = Math.max(newValueValidated, min); |
| if (typeof max === "number" && !Number.isNaN(max)) newValueValidated = Math.min(newValueValidated, max); |
|
|
| if (isInteger) newValueValidated = Math.round(newValueValidated); |
|
|
| rangeSliderValue = newValueValidated; |
| rangeSliderValueAsRendered = newValueValidated; |
| } |
|
|
| text = displayText(newValueValidated, unit); |
|
|
| if (newValue !== undefined) dispatch("value", newValueValidated); |
|
|
| |
| return newValueValidated; |
| } |
|
|
| |
| |
| |
|
|
| |
| function displayText(displayValue: number | undefined, unit: string): string { |
| if (displayValue === undefined) return "-"; |
|
|
| const roundingPower = 10 ** Math.max(displayDecimalPlaces, 0); |
|
|
| const unitlessDisplayValue = Math.round(displayValue * roundingPower) / roundingPower; |
| return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`; |
| } |
|
|
| |
| function unPluralize(unit: string, quantity: number): string { |
| if (quantity !== 1 || !unit.endsWith("s")) return unit; |
| return unit.slice(0, -1); |
| } |
|
|
| |
| |
| |
|
|
| function onTextFocused() { |
| |
| const MAX_PRECISION = 12; |
| const noFloatingImprecisionValue = value === undefined ? undefined : Number(value.toPrecision(MAX_PRECISION)); |
|
|
| if (value === undefined) text = ""; |
| else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`; |
| else text = `${noFloatingImprecisionValue}${unPluralize(unit, value)}`; |
|
|
| editing = true; |
|
|
| self?.selectAllText(text); |
| |
| if (isDragging) self?.unFocus(); |
| } |
|
|
| |
| |
| function onTextChanged() { |
| |
| if (!editing) return; |
|
|
| |
| const textWithLeadingZeroes = text.replaceAll(/(?<=^|[^0-9])\./g, "0."); |
|
|
| let newValue = evaluateMathExpression(textWithLeadingZeroes); |
| if (newValue !== undefined && isNaN(newValue)) newValue = undefined; |
|
|
| if (newValue !== undefined) { |
| const oldValue = value !== undefined && isInteger ? Math.round(value) : value; |
| if (newValue !== oldValue) dispatch("startHistoryTransaction"); |
| } |
| updateValue(newValue); |
|
|
| editing = false; |
| self?.unFocus(); |
| } |
|
|
| function onTextChangeCanceled() { |
| updateValue(undefined); |
|
|
| const valueOrZero = value !== undefined ? value : 0; |
| rangeSliderValue = valueOrZero; |
| rangeSliderValueAsRendered = valueOrZero; |
|
|
| editing = false; |
|
|
| self?.unFocus(); |
| } |
|
|
| |
| |
| |
|
|
| function onIncrementPointerDown(e: PointerEvent, direction: "Decrease" | "Increase") { |
| if (value === undefined || e.button !== BUTTON_LEFT) return; |
|
|
| const actions: Record<NumberInputIncrementBehavior, () => void> = { |
| Add: () => { |
| const directionAddend = direction === "Increase" ? step : -step; |
| const newValue = value !== undefined ? value + directionAddend : undefined; |
| updateValue(newValue); |
| }, |
| Multiply: () => { |
| const directionMultiplier = direction === "Increase" ? step : 1 / step; |
| const newValue = value !== undefined ? value * directionMultiplier : undefined; |
| updateValue(newValue); |
| }, |
| Callback: () => { |
| if (direction === "Increase") incrementCallbackIncrease?.(); |
| if (direction === "Decrease") incrementCallbackDecrease?.(); |
| }, |
| None: () => {}, |
| }; |
|
|
| const sendAction = () => { |
| if (!pressingArrow) return; |
|
|
| actions[incrementBehavior](); |
|
|
| if (afterInitialDelay) repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_INTERVAL_MS); |
| afterInitialDelay = true; |
| }; |
|
|
| pressingArrow = true; |
| initialValueBeforeDragging = value; |
| let afterInitialDelay = false; |
| sendAction(); |
| repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_DELAY_MS); |
| addEventListener("keydown", incrementPressAbort); |
| } |
|
|
| function onIncrementPointerUp() { |
| pressingArrow = false; |
| clearTimeout(repeatTimeout); |
| } |
|
|
| function incrementPressAbort(e: KeyboardEvent | MouseEvent) { |
| |
| if (e instanceof KeyboardEvent && e.key !== "Escape") return; |
| if (e instanceof MouseEvent && e.button !== BUTTON_RIGHT) return; |
|
|
| const element = self?.element() || undefined; |
| if (element) preventEscapeClosingParentFloatingMenu(element); |
|
|
| pressingArrow = false; |
| clearTimeout(repeatTimeout); |
| updateValue(initialValueBeforeDragging); |
| removeEventListener("keydown", onIncrementPointerUp); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| function onDragPointerDown(e: PointerEvent) { |
| |
| if (e.button !== BUTTON_LEFT || mode !== "Increment" || value === undefined || disabled || editing) return; |
|
|
| |
| if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); |
|
|
| |
| e.preventDefault(); |
|
|
| |
|
|
| |
| let alreadyActedGuard = false; |
|
|
| |
| const onMove = () => { |
| if (alreadyActedGuard) return; |
| alreadyActedGuard = true; |
| isDragging = true; |
| beginDrag(e); |
| removeEventListener("pointermove", onMove); |
| }; |
| |
| const onUp = () => { |
| if (alreadyActedGuard) return; |
| alreadyActedGuard = true; |
| isDragging = false; |
| self?.focus(); |
| removeEventListener("pointerup", onUp); |
| }; |
| addEventListener("pointermove", onMove); |
| addEventListener("pointerup", onUp); |
| } |
|
|
| function beginDrag(e: PointerEvent) { |
| |
| const target = e.target || undefined; |
| if (!(target instanceof HTMLElement)) return; |
|
|
| |
| target.requestPointerLock(); |
| initialValueBeforeDragging = value; |
| cumulativeDragDelta = 0; |
|
|
| |
| startDragging(); |
|
|
| |
| |
| |
| let ignoredFirstMovement = false; |
|
|
| const pointerUp = () => { |
| |
| |
| initialValueBeforeDragging = value; |
| cumulativeDragDelta = 0; |
|
|
| document.exitPointerLock(); |
| }; |
| const pointerMove = (e: PointerEvent) => { |
| |
| if (e.buttons & BUTTONS_RIGHT) { |
| document.exitPointerLock(); |
| return; |
| } |
|
|
| |
| |
| if (e.buttons === 0 && e.button !== -1) { |
| document.exitPointerLock(); |
| return; |
| } |
|
|
| |
| if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) { |
| const CHANGE_PER_DRAG_PX = 0.1; |
| const CHANGE_PER_DRAG_PX_SLOW = CHANGE_PER_DRAG_PX / 10; |
|
|
| const dragDelta = e.movementX * (e.shiftKey ? CHANGE_PER_DRAG_PX_SLOW : CHANGE_PER_DRAG_PX); |
| cumulativeDragDelta += dragDelta; |
|
|
| const combined = initialValueBeforeDragging + cumulativeDragDelta; |
| const combineSnapped = e.ctrlKey ? Math.round(combined) : combined; |
|
|
| const newValue = updateValue(combineSnapped); |
|
|
| |
| if (newValue !== undefined) cumulativeDragDelta -= combineSnapped - newValue; |
| } |
| ignoredFirstMovement = true; |
| }; |
| const pointerLockChange = () => { |
| |
| if (document.pointerLockElement) return; |
|
|
| |
| updateValue(initialValueBeforeDragging); |
| initialValueBeforeDragging = undefined; |
| cumulativeDragDelta = 0; |
|
|
| |
| removeEventListener("pointerup", pointerUp); |
| removeEventListener("pointermove", pointerMove); |
| document.removeEventListener("pointerlockchange", pointerLockChange); |
| }; |
|
|
| addEventListener("pointerup", pointerUp); |
| addEventListener("pointermove", pointerMove); |
| document.addEventListener("pointerlockchange", pointerLockChange); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function onSliderInput() { |
| |
| if (rangeSliderClickDragState === "Aborted") { |
| |
| |
| |
| |
| updateValue(rangeSliderValueAsRendered); |
|
|
| |
| return; |
| } |
|
|
| |
| const ROUNDING_EXPONENT = 4; |
| const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT; |
| const roundedValue = Math.round(rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE; |
|
|
| |
| if (value !== undefined && Math.abs(value - roundedValue) < 1 / ROUNDING_MAGNITUDE) { |
| return; |
| } |
|
|
| |
| const snappedValue = ctrlKeyDown || isInteger ? Math.round(roundedValue) : roundedValue; |
|
|
| |
| |
| if (rangeSliderClickDragState === "Ready") { |
| |
| rangeSliderClickDragState = "Deciding"; |
|
|
| |
| rangeSliderValueAsRendered = value || 0; |
|
|
| |
| initialValueBeforeDragging = value; |
|
|
| |
| addEventListener("mousedown", sliderAbortFromMousedown); |
| addEventListener("keydown", sliderAbortFromMousedown); |
|
|
| |
| return; |
| } |
|
|
| |
| |
| removeEventListener("mousedown", sliderAbortFromMousedown); |
| removeEventListener("keydown", sliderAbortFromMousedown); |
|
|
| |
| |
| if (rangeSliderClickDragState === "Deciding") { |
| |
| rangeSliderClickDragState = "Dragging"; |
|
|
| |
| startDragging(); |
|
|
| |
| addEventListener("pointermove", sliderAbortFromDragging); |
| addEventListener("keydown", sliderAbortFromDragging); |
|
|
| |
| } |
|
|
| |
| rangeSliderValueAsRendered = snappedValue; |
| updateValue(snappedValue); |
| } |
|
|
| |
| |
| |
| |
| function onSliderPointerUp() { |
| |
| if (rangeSliderClickDragState === "Deciding") { |
| const inputElement = self?.element(); |
| if (!inputElement) return; |
|
|
| |
| rangeSliderValue = rangeSliderValueAsRendered; |
|
|
| |
| inputElement.focus(); |
|
|
| |
| } |
|
|
| |
| |
| if (rangeSliderClickDragState !== "Aborted") { |
| rangeSliderClickDragState = "Ready"; |
| } |
|
|
| |
| removeEventListener("mousedown", sliderAbortFromMousedown); |
| removeEventListener("keydown", sliderAbortFromMousedown); |
| removeEventListener("pointermove", sliderAbortFromDragging); |
| removeEventListener("keydown", sliderAbortFromDragging); |
| } |
|
|
| function startDragging() { |
| |
| |
| dispatch("startHistoryTransaction"); |
| } |
|
|
| |
| |
| |
| function sliderAbortFromDragging(e: PointerEvent | KeyboardEvent) { |
| |
| if (e instanceof KeyboardEvent) { |
| |
| if (e.key === "Escape") sliderAbort(true); |
| } |
|
|
| |
| |
| |
| if (e instanceof PointerEvent && e.buttons & BUTTONS_RIGHT) { |
| |
| sliderAbort(false); |
| } |
|
|
| |
| |
| |
| if (e instanceof PointerEvent && !(e.target === inputRangeElement && e.buttons & BUTTONS_LEFT)) { |
| |
| rangeSliderClickDragState = "Ready"; |
|
|
| |
| |
| removeEventListener("pointermove", sliderAbortFromDragging); |
| removeEventListener("keydown", sliderAbortFromDragging); |
| } |
| } |
|
|
| |
| |
| function sliderAbortFromMousedown(e: MouseEvent | KeyboardEvent) { |
| |
| const abortWithEscape = e instanceof KeyboardEvent && e.key === "Escape"; |
| const abortWithRightClick = e instanceof MouseEvent && e.button === BUTTON_RIGHT; |
|
|
| |
| if (abortWithEscape || abortWithRightClick) sliderAbort(abortWithEscape); |
|
|
| |
| removeEventListener("mousedown", sliderAbortFromMousedown); |
| removeEventListener("keydown", sliderAbortFromMousedown); |
| } |
|
|
| |
| function sliderAbort(abortWithEscape: boolean) { |
| const element = self?.element() || undefined; |
| if (abortWithEscape && element) preventEscapeClosingParentFloatingMenu(element); |
|
|
| |
| if (inputRangeElement) inputRangeElement.disabled = true; |
| setTimeout(() => { |
| if (inputRangeElement) inputRangeElement.disabled = false; |
| }, 0); |
|
|
| |
| if (initialValueBeforeDragging !== undefined) { |
| rangeSliderValueAsRendered = initialValueBeforeDragging; |
| updateValue(initialValueBeforeDragging); |
| } |
|
|
| |
| rangeSliderClickDragState = "Aborted"; |
|
|
| |
| |
| const sliderResetAbort = () => { |
| |
| |
| |
| |
| |
| |
| |
| setTimeout(() => (rangeSliderClickDragState = "Ready"), 0); |
|
|
| |
| removeEventListener("pointerup", sliderResetAbort); |
| }; |
| addEventListener("pointerup", sliderResetAbort); |
|
|
| |
| removeEventListener("pointermove", sliderAbortFromDragging); |
| removeEventListener("keydown", sliderAbortFromDragging); |
| } |
| </script> |
|
|
| <FieldInput |
| class={"number-input"} |
| classes={{ |
| increment: mode === "Increment", |
| range: mode === "Range", |
| }} |
| value={text} |
| on:value={({ detail }) => (text = detail)} |
| on:textFocused={onTextFocused} |
| on:textChanged={onTextChanged} |
| on:textChangeCanceled={onTextChangeCanceled} |
| on:pointerdown={onDragPointerDown} |
| {label} |
| {disabled} |
| {tooltip} |
| {styles} |
| hideContextMenu={true} |
| spellcheck={false} |
| bind:this={self} |
| > |
| {#if value !== undefined} |
| {#if mode === "Increment" && incrementBehavior !== "None"} |
| <button |
| class="arrow left" |
| on:pointerdown={(e) => onIncrementPointerDown(e, "Decrease")} |
| on:mousedown={incrementPressAbort} |
| on:pointerup={onIncrementPointerUp} |
| on:pointerleave={onIncrementPointerUp} |
| tabindex="-1" |
| ></button> |
| <button |
| class="arrow right" |
| on:pointerdown={(e) => onIncrementPointerDown(e, "Increase")} |
| on:mousedown={incrementPressAbort} |
| on:pointerup={onIncrementPointerUp} |
| on:pointerleave={onIncrementPointerUp} |
| tabindex="-1" |
| ></button> |
| {/if} |
| {#if mode === "Range"} |
| <input |
| type="range" |
| tabindex="-1" |
| class="slider" |
| class:hidden={rangeSliderClickDragState === "Deciding"} |
| {disabled} |
| min={rangeMin} |
| max={rangeMax} |
| step={sliderStepValue} |
| bind:value={rangeSliderValue} |
| on:input={onSliderInput} |
| on:pointerup={onSliderPointerUp} |
| on:contextmenu|preventDefault |
| on:wheel={(e) => /* Stops slider eating the scroll event in Firefox */ e.target instanceof HTMLInputElement && e.target.blur()} |
| bind:this={inputRangeElement} |
| /> |
| {#if rangeSliderClickDragState === "Deciding"} |
| <div class="fake-slider-thumb" /> |
| {/if} |
| <div class="slider-progress" /> |
| {/if} |
| {/if} |
| </FieldInput> |
|
|
| <style lang="scss" global> |
| .number-input { |
| input { |
| text-align: center; |
| } |
| |
| &.increment { |
| // Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows |
| label { |
| margin-left: 8px; |
| } |
| |
| // Keep the right-aligned input element from overlapping the increment arrow on the right |
| input[type="text"]:not(:focus).has-label { |
| margin-right: 8px; |
| } |
| |
| // Hide the increment arrows when entering text, disabled, or not hovered |
| input[type="text"]:focus ~ .arrow, |
| &.disabled .arrow, |
| &:not(:hover) .arrow { |
| display: none; |
| } |
| |
| // Show the left-right arrow cursor when hovered over the draggable area |
| &:not(.disabled) input[type="text"]:not(:focus), |
| &:not(.disabled) label { |
| cursor: ew-resize; |
| } |
| |
| // Style the decrement/increment arrows |
| .arrow { |
| position: absolute; |
| top: 0; |
| margin: 0; |
| padding: 9px 0; |
| border: none; |
| border-radius: 2px; |
| background: rgba(var(--color-1-nearblack-rgb), 0.5); |
| // An outline can appear when pressing the arrow button with left click then hitting Escape, so this stops that from showing |
| outline: none; |
| // TODO: This is a quick, imperfect way to make the arrow buttons appear like they're behind the text (without messing with the element click targets if we used z-index). |
| // TODO: But it doesn't preserve the exact hover color due to the blending. Improve this by using a separate element for displaying the arrow and listening for pointer events. |
| mix-blend-mode: screen; |
| |
| &.right { |
| right: 0; |
| padding-left: 7px; |
| padding-right: 6px; |
| |
| &::before { |
| content: ""; |
| display: block; |
| width: 0; |
| height: 0; |
| border-style: solid; |
| border-width: 3px 0 3px 3px; |
| border-color: transparent transparent transparent var(--color-e-nearwhite); |
| } |
| } |
| |
| &.left { |
| left: 0; |
| padding-left: 6px; |
| padding-right: 7px; |
| |
| &::after { |
| content: ""; |
| display: block; |
| width: 0; |
| height: 0; |
| border-style: solid; |
| border-width: 3px 3px 3px 0; |
| border-color: transparent var(--color-e-nearwhite) transparent transparent; |
| } |
| } |
| |
| &:hover { |
| background: var(--color-4-dimgray); |
| |
| &::before { |
| border-color: transparent transparent transparent var(--color-f-white); |
| } |
| |
| &::after { |
| border-color: transparent var(--color-f-white) transparent transparent; |
| } |
| } |
| } |
| } |
| |
| &.range { |
| position: relative; |
| |
| input[type="text"], |
| label { |
| z-index: 1; |
| } |
| |
| input[type="text"]:focus ~ .slider, |
| input[type="text"]:focus ~ .fake-slider-thumb, |
| input[type="text"]:focus ~ .slider-progress { |
| display: none; |
| } |
| |
| .slider { |
| position: absolute; |
| left: 0; |
| top: 0; |
| width: 100%; |
| height: 100%; |
| padding: 0; |
| margin: 0; |
| -webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+) |
| appearance: none; |
| background: none; |
| cursor: default; |
| // Except when disabled, the range slider goes above the label and input so it's interactable. |
| // Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black. |
| // When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly. |
| z-index: 2; |
| mix-blend-mode: screen; |
| |
| &.hidden { |
| opacity: 0; |
| } |
| |
| &:disabled { |
| mix-blend-mode: normal; |
| z-index: 0; |
| } |
| |
| &:hover ~ .slider-progress::before { |
| background: var(--color-3-darkgray); |
| } |
| |
| // Chromium and Safari |
| &::-webkit-slider-thumb { |
| -webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+) |
| appearance: none; |
| border-radius: 2px; |
| width: 4px; |
| height: 22px; |
| background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background |
| } |
| |
| &:hover::-webkit-slider-thumb { |
| background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background |
| } |
| |
| &:disabled::-webkit-slider-thumb { |
| background: var(--color-4-dimgray); |
| } |
| |
| // Firefox |
| &::-moz-range-thumb { |
| border: none; |
| border-radius: 2px; |
| width: 4px; |
| height: 22px; |
| background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background |
| } |
| |
| &:hover::-moz-range-thumb { |
| background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background |
| } |
| |
| &:disabled::-moz-range-thumb { |
| background: var(--color-4-dimgray); |
| } |
| |
| &::-moz-range-track { |
| height: 0; |
| } |
| } |
| |
| // This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove. |
| // That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet. |
| // Instead, we want to wait until the user does something: |
| // - Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry. |
| // - Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider. |
| .fake-slider-thumb { |
| position: absolute; |
| left: 2px; |
| right: 2px; |
| top: 0; |
| bottom: 0; |
| z-index: 2; |
| mix-blend-mode: screen; |
| pointer-events: none; |
| |
| &::before { |
| content: ""; |
| position: absolute; |
| border-radius: 2px; |
| margin-left: -2px; |
| width: 4px; |
| height: 22px; |
| top: 1px; |
| left: calc(var(--progress-factor) * 100%); |
| background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background |
| } |
| } |
| |
| .slider-progress { |
| position: absolute; |
| top: 2px; |
| bottom: 2px; |
| left: 2px; |
| right: 2px; |
| pointer-events: none; |
| |
| &::before { |
| content: ""; |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: calc(var(--progress-factor) * 100% - 2px); |
| height: 100%; |
| background: var(--color-2-mildblack); |
| border-radius: 1px 0 0 1px; |
| } |
| } |
| } |
| } |
| </style> |
|
|