feat: add min/max constraint to number + slider component
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { setInputCat as computeInputCat, type InputCat } from '$lib/utils'
|
||||
import { Button } from './common'
|
||||
import { Badge, Button } from './common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import FieldHeader from './FieldHeader.svelte'
|
||||
@@ -24,6 +24,8 @@
|
||||
import Password from './Password.svelte'
|
||||
import type VariableEditor from './VariableEditor.svelte'
|
||||
import type ItemPicker from './ItemPicker.svelte'
|
||||
import NumberTypeNarrowing from './NumberTypeNarrowing.svelte'
|
||||
import Range from './Range.svelte'
|
||||
|
||||
export let label: string = ''
|
||||
export let value: any
|
||||
@@ -53,6 +55,7 @@
|
||||
export let variableEditor: VariableEditor | undefined = undefined
|
||||
export let itemPicker: ItemPicker | undefined = undefined
|
||||
export let noMargin = false
|
||||
export let extra: Record<string, any> = {}
|
||||
|
||||
let seeEditable: boolean = enum_ != undefined || pattern != undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -185,6 +188,8 @@
|
||||
/>
|
||||
{#if type == 'string' && format != 'date-time'}
|
||||
<StringTypeNarrowing bind:format bind:pattern bind:enum_ bind:contentEncoding />
|
||||
{:else if type == 'number'}
|
||||
<NumberTypeNarrowing bind:min={extra['min']} bind:max={extra['max']} />
|
||||
{:else if type == 'object'}
|
||||
<ObjectTypeNarrowing bind:format />
|
||||
{:else if type == 'array'}
|
||||
@@ -212,18 +217,31 @@
|
||||
|
||||
<div class="flex space-x-1">
|
||||
{#if inputCat == 'number'}
|
||||
<input
|
||||
{autofocus}
|
||||
on:focus
|
||||
{disabled}
|
||||
type="number"
|
||||
class={valid
|
||||
? ''
|
||||
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
||||
placeholder={defaultValue ?? ''}
|
||||
bind:value
|
||||
on:input={() => dispatch('input', { value, isRaw: true })}
|
||||
/>
|
||||
{#if extra['min'] != undefined && extra['max'] != undefined}
|
||||
<div class="flex w-full gap-1">
|
||||
<span>{extra['min']}</span>
|
||||
<div class="grow">
|
||||
<Range bind:value min={extra['min']} max={extra['max']} />
|
||||
</div>
|
||||
<span>{extra['max']}</span>
|
||||
<span class="mx-2"><Badge large color="blue">{value}</Badge></span>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
{autofocus}
|
||||
on:focus
|
||||
{disabled}
|
||||
type="number"
|
||||
class={valid
|
||||
? ''
|
||||
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
||||
placeholder={defaultValue ?? ''}
|
||||
bind:value
|
||||
min={extra['min']}
|
||||
max={extra['max']}
|
||||
on:input={() => dispatch('input', { value, isRaw: true })}
|
||||
/>
|
||||
{/if}
|
||||
{:else if inputCat == 'boolean'}
|
||||
<Toggle
|
||||
{disabled}
|
||||
|
||||
22
frontend/src/lib/components/NumberTypeNarrowing.svelte
Normal file
22
frontend/src/lib/components/NumberTypeNarrowing.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Toggle from './Toggle.svelte'
|
||||
|
||||
export let min: number | undefined
|
||||
export let max: number | undefined
|
||||
|
||||
let minChecked: boolean = min != undefined
|
||||
let maxChecked: boolean = max != undefined
|
||||
</script>
|
||||
|
||||
<div class="my-2" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Toggle bind:checked={minChecked} options={{ right: 'min' }} />
|
||||
<input type="number" bind:value={min} disabled={!minChecked} />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Toggle bind:checked={maxChecked} options={{ right: 'max' }} />
|
||||
<input type="number" bind:value={max} disabled={!maxChecked} />
|
||||
</div>
|
||||
</div>
|
||||
303
frontend/src/lib/components/Range.svelte
Normal file
303
frontend/src/lib/components/Range.svelte
Normal file
@@ -0,0 +1,303 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { fly, fade } from 'svelte/transition'
|
||||
|
||||
// Props
|
||||
export let min = 0
|
||||
export let max = 100
|
||||
export let initialValue = 0
|
||||
export let id = null
|
||||
export let value = typeof initialValue === 'string' ? parseInt(initialValue) : initialValue
|
||||
|
||||
// Node Bindings
|
||||
let container = null
|
||||
let thumb = null
|
||||
let progressBar = null
|
||||
let element = null
|
||||
|
||||
// Internal State
|
||||
let elementX = null
|
||||
let currentThumb = null
|
||||
let holding = false
|
||||
let thumbHover = false
|
||||
let keydownAcceleration = 0
|
||||
let accelerationTimer = null
|
||||
|
||||
// Dispatch 'change' events
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Mouse shield used onMouseDown to prevent any mouse events penetrating other elements,
|
||||
// ie. hover events on other elements while dragging. Especially for Safari
|
||||
const mouseEventShield = document.createElement('div')
|
||||
mouseEventShield.setAttribute('class', 'mouse-over-shield')
|
||||
mouseEventShield.addEventListener('mouseover', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
function resizeWindow() {
|
||||
elementX = element.getBoundingClientRect().left
|
||||
}
|
||||
|
||||
// Allows both bind:value and on:change for parent value retrieval
|
||||
function setValue(val) {
|
||||
value = val
|
||||
dispatch('change', { value })
|
||||
}
|
||||
|
||||
function onTrackEvent(e) {
|
||||
// Update value immediately before beginning drag
|
||||
updateValueOnEvent(e)
|
||||
onDragStart(e)
|
||||
}
|
||||
|
||||
function onHover(e) {
|
||||
thumbHover = thumbHover ? false : true
|
||||
}
|
||||
|
||||
function onDragStart(e) {
|
||||
// If mouse event add a pointer events shield
|
||||
if (e.type === 'mousedown') document.body.append(mouseEventShield)
|
||||
currentThumb = thumb
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
// If using mouse - remove pointer event shield
|
||||
if (e.type === 'mouseup') {
|
||||
if (document.body.contains(mouseEventShield)) document.body.removeChild(mouseEventShield)
|
||||
// Needed to check whether thumb and mouse overlap after shield removed
|
||||
if (isMouseInElement(e, thumb)) thumbHover = true
|
||||
}
|
||||
currentThumb = null
|
||||
}
|
||||
|
||||
// Check if mouse event cords overlay with an element's area
|
||||
function isMouseInElement(event, element) {
|
||||
let rect = element.getBoundingClientRect()
|
||||
let { clientX: x, clientY: y } = event
|
||||
if (x < rect.left || x >= rect.right) return false
|
||||
if (y < rect.top || y >= rect.bottom) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Accessible keypress handling
|
||||
function onKeyPress(e) {
|
||||
// Max out at +/- 10 to value per event (50 events / 5)
|
||||
// 100 below is to increase the amount of events required to reach max velocity
|
||||
if (keydownAcceleration < 50) keydownAcceleration++
|
||||
let throttled = Math.ceil(keydownAcceleration / 5)
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
|
||||
if (value + throttled > max || value >= max) {
|
||||
setValue(max)
|
||||
} else {
|
||||
setValue(value + throttled)
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
|
||||
if (value - throttled < min || value <= min) {
|
||||
setValue(min)
|
||||
} else {
|
||||
setValue(value - throttled)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset acceleration after 100ms of no events
|
||||
clearTimeout(accelerationTimer)
|
||||
accelerationTimer = setTimeout(() => (keydownAcceleration = 1), 100)
|
||||
}
|
||||
|
||||
function calculateNewValue(clientX) {
|
||||
// Find distance between cursor and element's left cord (20px / 2 = 10px) - Center of thumb
|
||||
let delta = clientX - (elementX + 10)
|
||||
|
||||
// Use width of the container minus (5px * 2 sides) offset for percent calc
|
||||
let percent = (delta * 100) / (container.clientWidth - 10)
|
||||
|
||||
// Limit percent 0 -> 100
|
||||
percent = percent < 0 ? 0 : percent > 100 ? 100 : percent
|
||||
|
||||
// Limit value min -> max
|
||||
setValue(Math.round((percent * (max - min)) / 100 + min))
|
||||
}
|
||||
|
||||
// Handles both dragging of touch/mouse as well as simple one-off click/touches
|
||||
function updateValueOnEvent(e) {
|
||||
// touchstart && mousedown are one-off updates, otherwise expect a currentPointer node
|
||||
if (!currentThumb && e.type !== 'touchstart' && e.type !== 'mousedown') return false
|
||||
|
||||
if (e.stopPropagation) e.stopPropagation()
|
||||
if (e.preventDefault) e.preventDefault()
|
||||
|
||||
// Get client's x cord either touch or mouse
|
||||
const clientX =
|
||||
e.type === 'touchmove' || e.type === 'touchstart' ? e.touches[0].clientX : e.clientX
|
||||
|
||||
calculateNewValue(clientX)
|
||||
}
|
||||
|
||||
// React to left position of element relative to window
|
||||
$: if (element) elementX = element.getBoundingClientRect().left
|
||||
|
||||
// Set a class based on if dragging
|
||||
$: holding = Boolean(currentThumb)
|
||||
|
||||
// Update progressbar and thumb styles to represent value
|
||||
$: if (progressBar && thumb) {
|
||||
// Limit value min -> max
|
||||
value = value > min ? value : min
|
||||
value = value < max ? value : max
|
||||
|
||||
let percent = ((value - min) * 100) / (max - min)
|
||||
let offsetLeft = (container.clientWidth - 10) * (percent / 100) + 5
|
||||
|
||||
// Update thumb position + active range track width
|
||||
thumb.style.left = `${offsetLeft}px`
|
||||
progressBar.style.width = `${offsetLeft}px`
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:touchmove|nonpassive={updateValueOnEvent}
|
||||
on:touchcancel={onDragEnd}
|
||||
on:touchend={onDragEnd}
|
||||
on:mousemove={updateValueOnEvent}
|
||||
on:mouseup={onDragEnd}
|
||||
on:resize={resizeWindow}
|
||||
/>
|
||||
<div class="range">
|
||||
<div
|
||||
class="range__wrapper"
|
||||
tabindex="0"
|
||||
on:keydown={onKeyPress}
|
||||
bind:this={element}
|
||||
role="slider"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
{id}
|
||||
on:pointerdown|stopPropagation
|
||||
on:mousedown|stopPropagation={onTrackEvent}
|
||||
on:touchstart|stopPropagation={onTrackEvent}
|
||||
>
|
||||
<div class="range__track" bind:this={container}>
|
||||
<div class="range__track--highlighted" bind:this={progressBar} />
|
||||
<div
|
||||
class="range__thumb"
|
||||
class:range__thumb--holding={holding}
|
||||
bind:this={thumb}
|
||||
on:touchstart={onDragStart}
|
||||
on:mousedown={onDragStart}
|
||||
on:mouseover={() => (thumbHover = true)}
|
||||
on:mouseout={() => (thumbHover = false)}
|
||||
>
|
||||
{#if holding || thumbHover}
|
||||
<div class="range__tooltip" in:fly={{ y: 7, duration: 200 }} out:fade={{ duration: 100 }}>
|
||||
{value}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
.mouse-over-shield {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgba(255, 0, 0, 0);
|
||||
z-index: 10000;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
.range {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range__wrapper {
|
||||
min-width: 100%;
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.range__wrapper:focus-visible > .range__track {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 3px var(--track-focus, #6185ff);
|
||||
}
|
||||
|
||||
.range__track {
|
||||
height: 6px;
|
||||
background-color: var(--track-bgcolor, #d0d0d0);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.range__track--highlighted {
|
||||
background-color: var(--track-highlight-bgcolor, #6185ff);
|
||||
background: var(--track-highlight-bg, linear-gradient(90deg, #6185ff, #9c65ff));
|
||||
width: 0;
|
||||
height: 6px;
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.range__thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: var(--thumb-bgcolor, white);
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
margin-top: -8px;
|
||||
transition: box-shadow 100ms;
|
||||
user-select: none;
|
||||
box-shadow: var(
|
||||
--thumb-boxshadow,
|
||||
0 1px 1px 0 rgba(0, 0, 0, 0.14),
|
||||
0 0px 2px 1px rgba(0, 0, 0, 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
.range__thumb--holding {
|
||||
box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 2px 1px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 6px var(--thumb-holding-outline, rgba(113, 119, 250, 0.3));
|
||||
}
|
||||
|
||||
.range__tooltip {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -33px;
|
||||
color: var(--tooltip-text, white);
|
||||
width: 38px;
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
background-color: var(--tooltip-bgcolor, #6185ff);
|
||||
background: var(--tooltip-bg, linear-gradient(45deg, #6185ff, #9c65ff));
|
||||
}
|
||||
|
||||
.range__tooltip::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
background-color: var(--tooltip-bgcolor, #6185ff);
|
||||
bottom: -3px;
|
||||
left: calc(50% - 3px);
|
||||
clip-path: polygon(0% 0%, 100% 100%, 0% 100%);
|
||||
transform: rotate(-45deg);
|
||||
border-radius: 0 0 0 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -97,6 +97,7 @@
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
bind:extra={schema.properties[argName]}
|
||||
/>
|
||||
{:else}
|
||||
Expected argument to be an object, got {JSON.stringify(args)} instead
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import Popover from './Popover.svelte'
|
||||
</script>
|
||||
|
||||
<Popover notClickable class="flex">
|
||||
<Popover notClickable>
|
||||
<Icon
|
||||
class="text-gray-500 font-thin inline-block align-middle w-4"
|
||||
data={faInfoCircle}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/common'
|
||||
import Range from '$lib/components/Range.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { AppInput } from '../../inputType'
|
||||
import type { Output } from '../../rx'
|
||||
import type { AppEditorContext } from '../../types'
|
||||
import AlignWrapper from '../helpers/AlignWrapper.svelte'
|
||||
import InputValue from '../helpers/InputValue.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: Record<string, AppInput>
|
||||
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
|
||||
export const staticOutputs: string[] = ['result']
|
||||
|
||||
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
let value: number
|
||||
let min = 0
|
||||
let max = 42
|
||||
|
||||
$: outputs = $worldStore?.outputsById[id] as {
|
||||
result: Output<number | null>
|
||||
}
|
||||
$: if (value || !value) {
|
||||
// Disallow 'e' character in numbers
|
||||
// if(value && value.toString().includes('e')) {
|
||||
// value = +value.toString().replaceAll('e', '')
|
||||
// }
|
||||
const num = isNaN(+value) ? null : +value
|
||||
outputs?.result.set(num)
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputValue {id} input={configuration.min} bind:value={min} />
|
||||
<InputValue {id} input={configuration.max} bind:value={max} />
|
||||
|
||||
<AlignWrapper {verticalAlignment}>
|
||||
<div class="flex w-full gap-1 px-1">
|
||||
<span>{min}</span>
|
||||
<div class="grow">
|
||||
<Range bind:value {min} {max} />
|
||||
</div>
|
||||
<span>{max}</span>
|
||||
<span class="mx-2"><Badge large color="blue">{value}</Badge></span>
|
||||
</div>
|
||||
</AlignWrapper>
|
||||
@@ -18,6 +18,7 @@
|
||||
import AppScatterChart from '../components/dataDisplay/AppScatterChart.svelte'
|
||||
import AppTimeseries from '../components/dataDisplay/AppTimeseries.svelte'
|
||||
import AppHtml from '../components/dataDisplay/AppHtml.svelte'
|
||||
import AppSliderInputs from '../components/numberInputs/AppSliderInputs.svelte'
|
||||
|
||||
export let component: AppComponent
|
||||
export let selected: boolean
|
||||
@@ -134,6 +135,8 @@
|
||||
/>
|
||||
{:else if component.type === 'numberinputcomponent'}
|
||||
<NumberInputComponent {...component} bind:staticOutputs={$staticOutputs[component.id]} />
|
||||
{:else if component.type === 'slidercomponent'}
|
||||
<AppSliderInputs {...component} bind:staticOutputs={$staticOutputs[component.id]} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,28 @@ const inputs: ComponentSet = {
|
||||
},
|
||||
card: false
|
||||
},
|
||||
{
|
||||
softWrap: true,
|
||||
verticalAlignment: 'center',
|
||||
id: 'slidercomponent',
|
||||
type: 'slidercomponent',
|
||||
componentInput: undefined,
|
||||
configuration: {
|
||||
min: {
|
||||
type: 'static',
|
||||
value: 0,
|
||||
fieldType: 'number',
|
||||
onlyStatic: true,
|
||||
},
|
||||
max: {
|
||||
type: 'static',
|
||||
value: 42,
|
||||
fieldType: 'number',
|
||||
onlyStatic: true,
|
||||
},
|
||||
},
|
||||
card: false
|
||||
},
|
||||
{
|
||||
softWrap: true,
|
||||
verticalAlignment: 'center',
|
||||
|
||||
@@ -20,6 +20,7 @@ export type TextInputComponent = BaseComponent<'textinputcomponent'>
|
||||
export type PasswordInputComponent = BaseComponent<'passwordinputcomponent'>
|
||||
export type DateInputComponent = BaseComponent<'dateinputcomponent'>
|
||||
export type NumberInputComponent = BaseComponent<'numberinputcomponent'>
|
||||
export type SliderComponent = BaseComponent<'slidercomponent'>
|
||||
export type HtmlComponent = BaseComponent<'htmlcomponent'>
|
||||
export type TimeseriesComponent = BaseComponent<'timeseriescomponent'>
|
||||
export type ButtonComponent = BaseComponent<'buttoncomponent'> & {
|
||||
@@ -74,6 +75,7 @@ export type AppComponent = BaseAppComponent &
|
||||
| PasswordInputComponent
|
||||
| DateInputComponent
|
||||
| NumberInputComponent
|
||||
| SliderComponent
|
||||
| BarChartComponent
|
||||
| TimeseriesComponent
|
||||
| HtmlComponent
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Calendar,
|
||||
ToggleLeft,
|
||||
GripHorizontal,
|
||||
Code2
|
||||
Code2,
|
||||
SlidersHorizontal
|
||||
} from 'lucide-svelte'
|
||||
import type { AppInput, InputType, ResultAppInput, StaticAppInput } from './inputType'
|
||||
import type { AppComponent } from './types'
|
||||
@@ -146,6 +147,10 @@ export const displayData: Record<AppComponent['type'], { name: string; icon: any
|
||||
name: 'Number',
|
||||
icon: Binary
|
||||
},
|
||||
slidercomponent: {
|
||||
name: 'Slider',
|
||||
icon: SlidersHorizontal
|
||||
},
|
||||
passwordinputcomponent: {
|
||||
name: 'Password',
|
||||
icon: Lock
|
||||
|
||||
@@ -353,16 +353,18 @@
|
||||
<Tab value="code">Code</Tab>
|
||||
<Tab value="dependencies">Dependencies lock file</Tab>
|
||||
<Tab value="arguments"
|
||||
>Arguments JSON Schema
|
||||
<Tooltip>
|
||||
The jsonschema defines the constraints that the payload must respect to be compatible
|
||||
with the input parameters of this script. The UI form is generated automatically from
|
||||
the script jsonschema. See
|
||||
<a href="https://json-schema.org/" class="text-blue-500">
|
||||
jsonschema documentation
|
||||
</a>
|
||||
</Tooltip></Tab
|
||||
>
|
||||
><span class="inline-flex items-center gap-1">
|
||||
Arguments JSON Schema
|
||||
<Tooltip>
|
||||
The jsonschema defines the constraints that the payload must respect to be
|
||||
compatible with the input parameters of this script. The UI form is generated
|
||||
automatically from the script jsonschema. See
|
||||
<a href="https://json-schema.org/" class="text-blue-500">
|
||||
jsonschema documentation
|
||||
</a>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Tab>
|
||||
<svelte:fragment slot="content">
|
||||
<TabContent value="code">
|
||||
<div class="border rounded-sm mt-2">
|
||||
|
||||
Reference in New Issue
Block a user