|
<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> |
|
|