Add the range slider design to the NumberInput widget (#839)

* Add range slider to NumberInput

* Cleanup

* Fix event ordering causing bug in Firefox

* Polish the code

* Switch number input modes to range in relevant places
This commit is contained in:
Keavon Chambers 2022-11-05 22:57:19 -07:00
parent 18507b78ac
commit 782f528279
9 changed files with 435 additions and 120 deletions

View file

@ -18,8 +18,8 @@
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-hue-picker>
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div>
</LayoutCol>
<LayoutCol class="opacity-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-opacity-picker>
<div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }" v-if="!isNone"></div>
<LayoutCol class="alpha-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-alpha-picker>
<div class="selection-pincers" :style="{ top: `${(1 - alpha) * 100}%` }" v-if="!isNone"></div>
</LayoutCol>
<LayoutCol class="details">
<LayoutRow
@ -43,7 +43,7 @@
:value="newColor.toHexOptionalAlpha() || '-'"
@commitText="(value: string) => setColorCode(value)"
:centered="true"
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with opacity.\nAccepts input of CSS color values including named colors.'"
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors.'"
/>
</LayoutRow>
</LayoutRow>
@ -98,12 +98,13 @@
</LayoutRow>
</LayoutRow>
<NumberInput
:label="'Opacity'"
:value="!isNone ? opacity * 100 : undefined"
@update:value="(value: number) => setColorOpacityPercent(value)"
:label="'Alpha'"
:value="!isNone ? alpha * 100 : undefined"
@update:value="(value: number) => setColorAlphaPercent(value)"
:min="0"
:max="100"
:unit="'%'"
:mode="'Range'"
:tooltip="`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`"
/>
<LayoutRow class="leftover-space"></LayoutRow>
@ -141,14 +142,14 @@
.saturation-value-picker,
.hue-picker,
.opacity-picker {
.alpha-picker {
height: 256px;
position: relative;
overflow: hidden;
}
.hue-picker,
.opacity-picker {
.alpha-picker {
width: 24px;
margin-left: 8px;
position: relative;
@ -161,7 +162,7 @@
--selection-pincers-color: var(--hue-color-contrasting);
}
.opacity-picker {
.alpha-picker {
background: linear-gradient(to bottom, var(--opaque-color), transparent);
&::before {
@ -403,12 +404,12 @@ export default defineComponent({
hue: hsva.h,
saturation: hsva.s,
value: hsva.v,
opacity: hsva.a,
alpha: hsva.a,
isNone: hsvaOrNone === undefined,
initialHue: hsva.h,
initialSaturation: hsva.s,
initialValue: hsva.v,
initialOpacity: hsva.a,
initialAlpha: hsva.a,
initialIsNone: hsvaOrNone === undefined,
draggingPickerTrack: undefined as HTMLDivElement | undefined,
colorSpaceChoices: COLOR_SPACE_CHOICES,
@ -421,11 +422,11 @@ export default defineComponent({
},
newColor(): Color {
if (this.isNone) return new Color("none");
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
},
initialColor(): Color {
if (this.initialIsNone) return new Color("none");
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialOpacity });
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialAlpha });
},
black(): Color {
return new Color(0, 0, 0, 1);
@ -434,7 +435,7 @@ export default defineComponent({
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(isOpen: boolean) {
if (isOpen) this.setInitialHsvAndOpacity(this.hue, this.saturation, this.value, this.opacity, this.isNone);
if (isOpen) this.setInitialHSVA(this.hue, this.saturation, this.value, this.alpha, this.isNone);
},
// Called only when `color` is changed from outside this component (with v-model)
color(color: Color) {
@ -451,19 +452,19 @@ export default defineComponent({
if (hsva.v !== 0) this.saturation = hsva.s;
// Update the value
this.value = hsva.v;
// Update the opacity
this.opacity = hsva.a;
// Update the alpha
this.alpha = hsva.a;
// Update the status of this not being a color
this.isNone = false;
} else {
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
this.setNewHSVA(0, 0, 0, 1, true);
}
},
},
methods: {
onPointerDown(e: PointerEvent) {
const target = (e.target || undefined) as HTMLElement | undefined;
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-opacity-picker]") || undefined;
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
this.addEvents();
@ -484,14 +485,14 @@ export default defineComponent({
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.strayCloses = false;
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
} else if (this.draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
this.strayCloses = false;
}
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
this.setColor(color);
},
onPointerUp() {
@ -512,7 +513,7 @@ export default defineComponent({
this.$emit("update:open", isOpen);
},
setColor(color?: Color) {
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
this.$emit("update:color", colorToEmit);
},
swapNewWithInitial() {
@ -521,11 +522,11 @@ export default defineComponent({
const tempHue = this.hue;
const tempSaturation = this.saturation;
const tempValue = this.value;
const tempOpacity = this.opacity;
const tempAlpha = this.alpha;
const tempIsNone = this.isNone;
this.setNewHsvAndOpacity(this.initialHue, this.initialSaturation, this.initialValue, this.initialOpacity, this.initialIsNone);
this.setInitialHsvAndOpacity(tempHue, tempSaturation, tempValue, tempOpacity, tempIsNone);
this.setNewHSVA(this.initialHue, this.initialSaturation, this.initialValue, this.initialAlpha, this.initialIsNone);
this.setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
this.setColor(initial);
},
@ -545,8 +546,8 @@ export default defineComponent({
this.setColor();
},
setColorOpacityPercent(opacity: number) {
this.opacity = opacity / 100;
setColorAlphaPercent(alpha: number) {
this.alpha = alpha / 100;
this.setColor();
},
setColorPresetSubtile(e: MouseEvent) {
@ -557,7 +558,7 @@ export default defineComponent({
},
setColorPreset(preset: PresetColors) {
if (preset === "none") {
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
this.setNewHSVA(0, 0, 0, 1, true);
this.setColor(new Color("none"));
return;
}
@ -565,21 +566,21 @@ export default defineComponent({
const presetColor = new Color(...PURE_COLORS[preset], 1);
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
this.setNewHsvAndOpacity(hsva.h, hsva.s, hsva.v, hsva.a, false);
this.setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
this.setColor(presetColor);
},
setNewHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
setNewHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
this.hue = hue;
this.saturation = saturation;
this.value = value;
this.opacity = opacity;
this.alpha = alpha;
this.isNone = isNone;
},
setInitialHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
this.initialHue = hue;
this.initialSaturation = saturation;
this.initialValue = value;
this.initialOpacity = opacity;
this.initialAlpha = alpha;
this.initialIsNone = isNone;
},
async activateEyedropperSample() {

View file

@ -2,11 +2,11 @@
<template>
<LayoutRow class="field-input" :class="{ disabled, 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
<input
type="text"
v-if="!textarea"
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
ref="input"
type="text"
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
@ -15,6 +15,7 @@
@change="() => $emit('textChanged')"
@keydown.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
data-input-element
/>
<textarea
v-else
@ -49,10 +50,11 @@
flex-direction: row-reverse;
label {
flex: 1 1 100%;
flex: 0 0 auto;
line-height: 18px;
margin-left: 8px;
padding: 3px 0;
padding-right: 4px;
margin-left: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -1,11 +1,12 @@
<template>
<FieldInput
class="number-input"
:class="mode.toLocaleLowerCase()"
v-model:value="text"
:label="label"
:spellcheck="false"
:disabled="disabled"
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined, '--progress-factor': (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }"
:tooltip="tooltip"
:sharpRightCorners="sharpRightCorners"
@textFocused="() => onTextFocused()"
@ -13,104 +14,279 @@
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
>
<button v-if="value !== undefined" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button>
<button v-if="value !== undefined" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button>
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button>
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button>
<input
type="range"
class="slider"
:class="{ hidden: rangeSliderClickDragState === 'mousedown' }"
v-if="mode === 'Range' && value !== undefined"
v-model="rangeSliderValue"
:min="rangeMin"
:max="rangeMax"
:step="sliderStepValue"
:disabled="disabled"
@input="() => sliderInput()"
@pointerdown="() => sliderPointerDown()"
@pointerup="() => sliderPointerUp()"
tabindex="-1"
/>
<div v-if="value !== undefined && rangeSliderClickDragState === 'mousedown'" class="fake-slider-thumb"></div>
<div v-if="value !== undefined" class="slider-progress"></div>
</FieldInput>
</template>
<style lang="scss">
.number-input {
input:focus ~ .arrow {
display: none;
}
&.increment {
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
label {
margin-left: 16px;
}
&:not(:hover) .arrow {
display: none;
}
input[type="text"]:not(:focus).has-label {
margin-right: 16px;
}
.arrow {
position: absolute;
top: 0;
padding: 9px 0;
border: none;
background: rgba(var(--color-1-nearblack-rgb), 0.75);
// Hide the increment arrows when entering text, disabled, or not hovered
input[type="text"]:focus ~ .arrow,
&.disabled .arrow,
&:not(:hover) .arrow {
display: none;
}
&:hover {
background: var(--color-6-lowergray);
// Style the increment arrows
.arrow {
position: absolute;
top: 0;
padding: 9px 0;
border: none;
background: rgba(var(--color-1-nearblack-rgb), 0.75);
&.right::before {
border-color: transparent transparent transparent var(--color-f-white);
&:hover {
background: var(--color-6-lowergray);
&.right::before {
border-color: transparent transparent transparent var(--color-f-white);
}
&.left::after {
border-color: transparent var(--color-f-white) transparent transparent;
}
}
&.left::after {
border-color: transparent var(--color-f-white) transparent transparent;
&.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;
}
}
}
}
&.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; // TODO: Prefix necessary? Test on Safari
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;
}
// Chromium and Safari
&::-webkit-slider-thumb {
-webkit-appearance: none; // TODO: Prefix necessary? Test on Safari
appearance: none;
border-radius: 2px;
width: 4px;
height: 24px;
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 {
mix-blend-mode: normal;
z-index: 0;
&::-webkit-slider-thumb {
background: var(--color-4-dimgray);
}
}
// Firefox
&::-moz-range-thumb {
border: none;
border-radius: 2px;
width: 4px;
height: 24px;
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
}
&:hover ~ .slider-progress::before {
background: var(--color-3-darkgray);
}
&::-moz-range-track {
height: 0;
}
}
&.right {
right: 0;
padding-left: 7px;
padding-right: 6px;
// 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: "";
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 3px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
display: block;
position: absolute;
border-radius: 2px;
margin-left: -2px;
left: calc(var(--progress-factor) * 100%);
width: 4px;
height: 24px;
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
}
}
&.left {
left: 0;
padding-left: 6px;
padding-right: 7px;
.slider-progress {
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
right: 2px;
pointer-events: none;
&::after {
&::before {
content: "";
width: 0;
height: 0;
border-style: solid;
border-width: 3px 3px 3px 0;
border-color: transparent var(--color-e-nearwhite) transparent transparent;
display: block;
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;
}
}
}
&.disabled .arrow {
display: none;
}
}
</style>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type IncrementBehavior } from "@/wasm-communication/messages";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
export type IncrementDirection = "Decrease" | "Increase";
export default defineComponent({
emits: ["update:value"],
props: {
// Label
label: { type: String as PropType<string>, required: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Disabled
disabled: { type: Boolean as PropType<boolean>, default: false },
// Value
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
min: { type: Number as PropType<number>, required: false },
max: { type: Number as PropType<number>, required: false },
isInteger: { type: Boolean as PropType<boolean>, default: false },
// Number presentation
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
unit: { type: String as PropType<string>, default: "" },
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
incrementFactor: { type: Number as PropType<number>, default: 1 },
disabled: { type: Boolean as PropType<boolean>, default: false },
// Mode behavior
// "Increment" shows arrows and allows dragging left/right to change the value.
// "Range" shows a range slider between some minimum and maximum value.
mode: { type: String as PropType<NumberInputMode>, default: "Increment" },
// When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`.
// When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`.
step: { type: Number as PropType<number>, default: 1 },
// `incrementBehavior` is only applicable with a `mode` of "Increment".
// "Add"/"Multiply": The value is added or multiplied by `step`.
// "None": the increment arrows are not shown.
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
incrementBehavior: { type: String as PropType<NumberInputIncrementBehavior>, default: "Add" },
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
// They set the lower and upper values of the slider to drag between.
rangeMin: { type: Number as PropType<number>, default: 0 },
rangeMax: { type: Number as PropType<number>, default: 1 },
// Styling
minWidth: { type: Number as PropType<number>, default: 0 },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
// Callbacks
@ -121,9 +297,76 @@ export default defineComponent({
return {
text: this.displayText(this.value),
editing: false,
// Stays in sync with a binding to the actual input range slider element.
rangeSliderValue: this.value !== undefined ? this.value : 0,
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
rangeSliderValueAsRendered: this.value !== undefined ? this.value : 0,
// "default": no interaction is happening.
// "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
// "dragging": the user is dragging the slider left/right.
rangeSliderClickDragState: "default" as "default" | "mousedown" | "dragging",
};
},
computed: {
sliderStepValue() {
const step = this.step === undefined ? 1 : this.step;
return this.isInteger ? step : "any";
},
},
methods: {
sliderInput() {
// Keep only 4 digits after the decimal point
const ROUNDING_EXPONENT = 4;
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
const roundedValue = Math.round(this.rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
if (this.value !== undefined && Math.abs(this.value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
return;
}
// The first event upon mousedown means we transition to a "mousedown" state
if (this.rangeSliderClickDragState === "default") {
this.rangeSliderClickDragState = "mousedown";
// Exit early because we don't want to use the value set by where on the track the user pressed
return;
}
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
if (this.rangeSliderClickDragState === "mousedown") {
this.rangeSliderClickDragState = "dragging";
}
// If we're in a dragging state, we want to use the new slider value
this.rangeSliderValueAsRendered = roundedValue;
this.updateValue(roundedValue);
},
sliderPointerDown() {
// We want to render the fake slider thumb at the old position, which is still the number held by `value`
this.rangeSliderValueAsRendered = this.value || 0;
// Because an `input` event is fired right before or after this (depending on browser), that first
// invocation will transition the state machine to `mousedown`. That's why we don't do it here.
},
sliderPointerUp() {
// User clicked but didn't drag, so we focus the text input element
if (this.rangeSliderClickDragState === "mousedown") {
const fieldInput = this.$refs.fieldInput as typeof FieldInput | undefined;
const inputElement = fieldInput?.$el.querySelector("[data-input-element]") as HTMLInputElement | undefined;
if (!inputElement) return;
// Set the slider position back to the original position to undo the user moving it
this.rangeSliderValue = this.rangeSliderValueAsRendered;
// Begin editing the number text field
inputElement.focus();
}
// Releasing the mouse means we can reset the state machine
this.rangeSliderClickDragState = "default";
},
onTextFocused() {
if (this.value === undefined) this.text = "";
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
@ -155,16 +398,16 @@ export default defineComponent({
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
},
onIncrement(direction: IncrementDirection) {
onIncrement(direction: "Decrease" | "Increase") {
if (this.value === undefined) return;
const actions = {
Add: (): void => {
const directionAddend = direction === "Increase" ? this.incrementFactor : -this.incrementFactor;
const directionAddend = direction === "Increase" ? this.step : -this.step;
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined);
},
Multiply: (): void => {
const directionMultiplier = direction === "Increase" ? this.incrementFactor : 1 / this.incrementFactor;
const directionMultiplier = direction === "Increase" ? this.step : 1 / this.step;
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined);
},
Callback: (): void => {
@ -207,11 +450,16 @@ export default defineComponent({
watch: {
// Called only when `value` is changed from outside this component (with v-model)
value(newValue: number | undefined) {
// Draw a dash if the value is undefined
if (newValue === undefined) {
this.text = "-";
return;
}
// Update the range slider with the new value
this.rangeSliderValue = newValue;
this.rangeSliderValueAsRendered = newValue;
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
let sanitized = newValue;
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);

View file

@ -23,7 +23,7 @@
}
&.centered {
input {
input:not(:focus) {
text-align: center;
}
}
@ -38,12 +38,19 @@ import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
value: { type: String as PropType<string>, required: true },
// Label
label: { type: String as PropType<string>, required: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Disabled
disabled: { type: Boolean as PropType<boolean>, default: false },
// Value
value: { type: String as PropType<string>, required: true },
// Styling
centered: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {

View file

@ -816,11 +816,23 @@ export class IconLabel extends WidgetProps {
tooltip!: string | undefined;
}
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
export type NumberInputIncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
export type NumberInputMode = "Increment" | "Range";
export class NumberInput extends WidgetProps {
// Label
label!: string | undefined;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
// Disabled
disabled!: boolean;
// Value
value!: number | undefined;
min!: number | undefined;
@ -829,22 +841,29 @@ export class NumberInput extends WidgetProps {
isInteger!: boolean;
// Number presentation
displayDecimalPlaces!: number;
unit!: string;
unitIsHiddenWhenEditing!: boolean;
incrementBehavior!: IncrementBehavior;
// Mode behavior
incrementFactor!: number;
mode!: NumberInputMode;
disabled!: boolean;
incrementBehavior!: NumberInputIncrementBehavior;
step!: number;
rangeMin!: number | undefined;
rangeMax!: number | undefined;
// Styling
minWidth!: number;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class OptionalInput extends WidgetProps {