feat: add min/max constraint to number + slider component

This commit is contained in:
Ruben Fiszel
2023-01-07 08:51:09 +01:00
parent 50453ca690
commit 0bcdcaedcf
11 changed files with 449 additions and 25 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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