From 7fcb3e5569008fca1b108bde494bb07f81d112d3 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Wed, 20 Dec 2023 11:33:22 -0300 Subject: [PATCH] Range input (#442) --- .../components/input/lnglat.svelte | 2 +- packages/core/README.md | 3 +- packages/core/package.json | 5 +- packages/core/plugins.ts | 236 +++++++++++++++++- packages/core/src/lib/index.ts | 1 + .../lib/input/__tests__/range-input.spec.ts | 227 +++++++++++++++++ packages/core/src/lib/input/index.ts | 1 + packages/core/src/lib/input/input.svelte | 6 +- .../core/src/lib/input/range-input.svelte | 205 +++++++++++++++ .../core/src/lib/input/slider-input.svelte | 6 +- packages/core/src/routes/+page.svelte | 49 +++- packages/storybook/src/stories/input.mdx | 16 ++ .../src/stories/input.stories.svelte | 5 + packages/storybook/tailwind.config.ts | 2 + 14 files changed, 752 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/lib/input/__tests__/range-input.spec.ts create mode 100644 packages/core/src/lib/input/range-input.svelte diff --git a/packages/blocks/src/lib/navigation-map/components/input/lnglat.svelte b/packages/blocks/src/lib/navigation-map/components/input/lnglat.svelte index 58856894..ee4b1b55 100644 --- a/packages/blocks/src/lib/navigation-map/components/input/lnglat.svelte +++ b/packages/blocks/src/lib/navigation-map/components/input/lnglat.svelte @@ -7,7 +7,7 @@ import { LngLat } from 'maplibre-gl'; export let label: string | undefined = undefined; /** Whether the inputs are readonly. */ -export let readonly: boolean | undefined = undefined; +export let readonly = false; /** The longitude value. */ export let lng: number | undefined = undefined; diff --git a/packages/core/README.md b/packages/core/README.md index f9537e94..6134bef6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -16,6 +16,7 @@ Install [Tailwind][]. In the `tailwind.config.js`, add the components to the con ```js import { theme } from '@viamrobotics/prime-core/theme'; +import { plugins } from '@viamrobotics/prime-core/plugins'; /** @type {import('tailwindcss').Config} */ export default { @@ -24,7 +25,7 @@ export default { './node_modules/@viamrobotics/prime-core/**/*.{ts,svelte}', ], theme, - plugins: [], + plugins, }; ``` diff --git a/packages/core/package.json b/packages/core/package.json index f5dd14dc..5fd4af87 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,8 +24,9 @@ "types": "./dist/index.d.ts", "svelte": "./dist/index.js" }, - "./theme": "./theme.ts", - "./prime.css": "./prime.css" + "./prime.css": "./prime.css", + "./plugins": "./plugins.ts", + "./theme": "./theme.ts" }, "files": [ "dist", diff --git a/packages/core/plugins.ts b/packages/core/plugins.ts index 30243682..65ff7bbf 100644 --- a/packages/core/plugins.ts +++ b/packages/core/plugins.ts @@ -26,7 +26,7 @@ export const plugins = [ }, }), }, - { values: theme('spacing') } + { values: theme('width') } ); matchUtilities( @@ -83,5 +83,239 @@ export const plugins = [ }, { values: theme('borderColor') } ); + + matchUtilities( + { + 'slider-track': (value: string) => ({ + '&::-webkit-slider-runnable-track': { + background: value, + }, + '&::-moz-range-track': { + background: value, + }, + '&::-ms-track': { + background: 'transparent', + }, + '&::-ms-fill-lower': { + background: value, + }, + '&::-ms-fill-upper': { + background: value, + }, + }), + }, + { values: theme('colors') } + ); + + matchUtilities( + { + 'slider-track-w': (value: string) => ({ + '&::-webkit-slider-runnable-track': { + width: value, + }, + '&::-moz-range-track': { + width: value, + }, + '&::-ms-track': { + width: value, + }, + }), + }, + { values: theme('width') } + ); + + matchUtilities( + { + 'slider-track-h': (value: string) => ({ + '&::-webkit-slider-runnable-track': { + height: value, + }, + '&::-moz-range-track': { + height: value, + }, + '&::-ms-track': { + height: value, + }, + }), + }, + { values: theme('height') } + ); + + matchUtilities( + { + 'slider-track-cursor': (value: string) => ({ + '&::-webkit-slider-runnable-track': { + cursor: value, + }, + '&::-moz-range-track': { + cursor: value, + }, + '&::-ms-track': { + cursor: value, + }, + }), + }, + { values: theme('cursor') } + ); + + matchUtilities( + { + 'slider-thumb': (value: string) => ({ + '&::-webkit-slider-thumb': { + background: value, + }, + '&::-moz-range-thumb': { + background: value, + }, + '&::-ms-thumb': { + background: value, + }, + }), + }, + { + values: { + ...theme('backgroundColor'), + }, + } + ); + + matchUtilities( + { + 'slider-thumb-w': (value: string) => ({ + '&::-webkit-slider-thumb': { + width: value, + }, + '&::-moz-range-thumb': { + width: value, + }, + '&::-ms-thumb': { + width: value, + }, + }), + }, + { values: theme('width') } + ); + + matchUtilities( + { + 'slider-thumb-h': (value: string) => ({ + '&::-webkit-slider-thumb': { + height: value, + marginTop: `calc(-${value} / 2)`, + }, + '&::-moz-range-thumb': { + height: value, + }, + '&::-ms-thumb': { + height: value, + }, + }), + }, + { values: theme('height') } + ); + + /* + * The default border properties were causing rendering issues, so we + * split the width utility into it's own class to avoid that + */ + matchUtilities( + { + 'slider-thumb-border': (value: string) => ({ + '&::-webkit-slider-thumb': { + borderWidth: value, + }, + '&::-moz-range-thumb': { + borderWidth: value, + }, + '&::-ms-thumb': { + borderWidth: value, + }, + }), + }, + { values: theme('borderWidth') } + ); + + matchUtilities( + { + 'slider-thumb-border': (value: string) => ({ + '&::-webkit-slider-thumb': { + borderStyle: value, + }, + '&::-moz-range-thumb': { + borderStyle: value, + }, + '&::-ms-thumb': { + borderStyle: value, + }, + }), + }, + { + values: { + DEFAULT: 'solid', + solid: 'solid', + dashed: 'dashed', + dotted: 'dotted', + double: 'double', + }, + } + ); + + matchUtilities( + { + 'slider-thumb-border': (value: string) => ({ + '&::-webkit-slider-thumb': { + borderColor: value, + }, + '&::-moz-range-thumb': { + borderColor: value, + }, + '&::-ms-thumb': { + borderColor: value, + }, + }), + }, + { + values: { + ...theme('borderColor'), + DEFAULT: theme('borderColor.light'), + }, + } + ); + + matchUtilities( + { + 'slider-thumb-border': (value: string) => ({ + '&::-webkit-slider-thumb': { + borderRadius: value, + }, + '&::-moz-range-thumb': { + borderRadius: value, + }, + '&::-ms-thumb': { + borderRadius: value, + }, + }), + }, + { + values: { ...theme('borderRadius'), DEFAULT: '9999px' }, + } + ); + + matchUtilities( + { + 'slider-thumb-cursor': (value: string) => ({ + '&::-webkit-slider-thumb': { + cursor: value, + }, + '&::-moz-range-thumb': { + cursor: value, + }, + '&::-ms-thumb': { + cursor: value, + }, + }), + }, + { values: theme('cursor') } + ); }), ] satisfies OptionalConfig['plugins']; diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index faed6dbe..e5e499a1 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -29,6 +29,7 @@ export type { IconName } from './icon/icons'; export { Input, NumericInput, + RangeInput, RestrictedTextInput, SliderInput, TextInput, diff --git a/packages/core/src/lib/input/__tests__/range-input.spec.ts b/packages/core/src/lib/input/__tests__/range-input.spec.ts new file mode 100644 index 00000000..e9a62c09 --- /dev/null +++ b/packages/core/src/lib/input/__tests__/range-input.spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { cxTestArguments, cxTestResults } from '$lib/__tests__/cx-test'; +import { RangeInput } from '$lib'; + +describe('Range Input', () => { + it('Renders the inputs', () => { + render(RangeInput); + expect(screen.getByRole('spinbutton')).toHaveAttribute('type', 'number'); + expect(screen.getByRole('slider')).toHaveAttribute('type', 'range'); + }); + + it('Renders the inputs as readonly', () => { + render(RangeInput, { readonly: true }); + + const input = screen.getByRole('spinbutton'); + const slider = screen.getByRole('slider'); + + expect(input).toHaveClass( + 'h-7.5 w-full appearance-none border px-2 py-1.5 text-xs leading-tight outline-none' + ); + + expect(input).toHaveClass('bg-light border-transparent'); + expect(input).not.toHaveAttribute('aria-disabled'); + + expect(slider).toHaveClass( + 'slider-track-disabled-light slider-track-cursor-not-allowed slider-thumb-cursor-not-allowed cursor-not-allowed' + ); + + expect(slider).not.toHaveAttribute('aria-disabled'); + }); + + it('Renders the inputs as disabled', () => { + render(RangeInput, { disabled: true }); + + const input = screen.getByRole('spinbutton'); + const slider = screen.getByRole('slider'); + + expect(input).toHaveClass( + 'h-7.5 w-full appearance-none border px-2 py-1.5 text-xs leading-tight outline-none' + ); + + expect(input).toHaveClass( + 'bg-disabled-light text-disabled-dark border-disabled-light cursor-not-allowed' + ); + + expect(input).toHaveAttribute('aria-disabled', 'true'); + + expect(slider).toHaveClass( + 'slider-track-disabled-light slider-track-cursor-not-allowed slider-thumb-cursor-not-allowed cursor-not-allowed' + ); + + expect(slider).toHaveAttribute('aria-disabled'); + }); + + it('Renders the pips', () => { + render(RangeInput); + + const slider = screen.getByRole('slider'); + + expect(slider.list?.children.length).toBe(21); + }); + + it('It should not allow sliding below min', async () => { + render(RangeInput, { + min: 0, + }); + + const slider = screen.getByRole('slider'); + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.change(slider, { target: { value: 50 } }); + + expect(slider.valueAsNumber).toBe(50); + expect(input.valueAsNumber).toBe(50); + + await fireEvent.change(slider, { target: { value: -50 } }); + + expect(slider.valueAsNumber).toBe(0); + expect(input.valueAsNumber).toBe(0); + }); + + it('It should not allow setting the value below min', async () => { + render(RangeInput, { + min: 0, + }); + + const slider = screen.getByRole('slider'); + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.input(input, { target: { value: 50 } }); + + expect(slider.valueAsNumber).toBe(50); + expect(input.valueAsNumber).toBe(50); + + await fireEvent.input(input, { target: { value: -50 } }); + + expect(slider.valueAsNumber).toBe(0); + expect(input.valueAsNumber).toBe(0); + }); + + it('It should not allow sliding above max', async () => { + render(RangeInput, { + max: 100, + }); + + const slider = screen.getByRole('slider'); + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.change(slider, { target: { value: 150 } }); + + expect(slider.valueAsNumber).toBe(100); + expect(input.valueAsNumber).toBe(100); + }); + + it('It should not allow setting the value above max', async () => { + render(RangeInput, { + max: 100, + }); + + const slider = screen.getByRole('slider'); + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.input(input, { target: { value: 150 } }); + + expect(slider.valueAsNumber).toBe(100); + expect(input.valueAsNumber).toBe(100); + }); + + /** + * TODO (DTCurrie): It is difficult to test step values since we can really only directly + * set the value of range inputs using a change call. Range and numeric inputs do not support + * changing values with arrow keys, which would be the ideal solution for testing these. Once + * those are supported, we should be able to uncomment these tests. + * + * See: + * https://github.com/testing-library/user-event/issues/1066 + * https://github.com/testing-library/user-event/issues/1067 + */ + it.skip('It should slide at increments of step', async () => { + render(RangeInput, { + max: 100, + step: 5, + }); + + const slider = screen.getByRole('slider'); + + await fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(slider.valueAsNumber).toBe(5); + + await fireEvent.keyDown(slider, { key: 'ArrowUp' }); + + expect(slider.valueAsNumber).toBe(10); + }); + + it('Emits the input and change events when the input is changed', async () => { + const { component } = render(RangeInput); + + const onInput = vi.fn(); + const onChange = vi.fn(); + + component.$on('input', onInput); + component.$on('change', onChange); + + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.input(input, { target: { value: 1 } }); + + expect(onInput).toHaveBeenCalledTimes(1); + + await fireEvent.change(input, { target: { value: 2 } }); + + expect(onChange).toHaveBeenCalledTimes(1); + + const slider: HTMLInputElement = screen.getByRole('slider'); + + await fireEvent.input(slider, { target: { value: 3 } }); + + expect(onInput).toHaveBeenCalledTimes(2); + + await fireEvent.change(slider, { target: { value: 4 } }); + + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('Does not emit the input and change events when the input is disabled', async () => { + const { component } = render(RangeInput, { disabled: true }); + + const onInput = vi.fn(); + const onChange = vi.fn(); + + component.$on('input', onInput); + component.$on('change', onChange); + + const input: HTMLInputElement = screen.getByRole('spinbutton'); + + await fireEvent.input(input, { target: { value: 1 } }); + + expect(onInput).toHaveBeenCalledTimes(0); + + await fireEvent.change(input, { target: { value: 2 } }); + + expect(onChange).toHaveBeenCalledTimes(0); + + const slider: HTMLInputElement = screen.getByRole('slider'); + + await fireEvent.input(slider, { target: { value: 3 } }); + + expect(onInput).toHaveBeenCalledTimes(0); + + await fireEvent.change(slider, { target: { value: 4 } }); + + expect(onChange).toHaveBeenCalledTimes(0); + }); + + it('Renders with the passed cx classes', () => { + render(RangeInput, { + cx: cxTestArguments, + }); + + const container = + screen.getByRole('spinbutton').parentElement?.parentElement; + + expect(container).toHaveClass(cxTestResults); + }); +}); diff --git a/packages/core/src/lib/input/index.ts b/packages/core/src/lib/input/index.ts index 4125903e..24ed8399 100644 --- a/packages/core/src/lib/input/index.ts +++ b/packages/core/src/lib/input/index.ts @@ -3,4 +3,5 @@ export { default as NumericInput } from './numeric-input.svelte'; export { default as SliderInput } from './slider-input.svelte'; export { type NumericInputTypes } from './utils'; export { default as TextInput, type TextInputTypes } from './text-input.svelte'; +export { default as RangeInput } from './range-input.svelte'; export { default as RestrictedTextInput } from './restricted-text-input.svelte'; diff --git a/packages/core/src/lib/input/input.svelte b/packages/core/src/lib/input/input.svelte index bc2bfcea..bda3a944 100644 --- a/packages/core/src/lib/input/input.svelte +++ b/packages/core/src/lib/input/input.svelte @@ -29,10 +29,10 @@ import cx from 'classnames'; export let value: string | number | undefined = ''; /** Whether or not the input should be rendered as readonly and be operable. */ -export let readonly = false as boolean | undefined; +export let readonly = false; /** Whether or not the input should be rendered as readonly and be non-operable. */ -export let disabled = false as boolean | undefined; +export let disabled = false; /** The state of the input (info, warn, error, success), if any. */ export let state: InputState | undefined = 'none'; @@ -47,7 +47,7 @@ export { extraClasses as cx }; $: isInfo = state === 'info'; $: isWarn = state === 'warn'; $: isError = state === 'error'; -$: isInputReadOnly = disabled === true || readonly === true; +$: isInputReadOnly = disabled || readonly; $: handleDisabled = preventHandler(isInputReadOnly); $: handleDisabledKeydown = preventKeyboardHandler(isInputReadOnly); diff --git a/packages/core/src/lib/input/range-input.svelte b/packages/core/src/lib/input/range-input.svelte new file mode 100644 index 00000000..ca8a3ea2 --- /dev/null +++ b/packages/core/src/lib/input/range-input.svelte @@ -0,0 +1,205 @@ + + + + + + positionIndicator(value)} /> + +
+ +
+
+ + {min}{suffixValue} + + + {max}{suffixValue} + +
+ + + + {#if !isInputReadOnly} + + {/if} + + {#if showPips} + + {#each pips as pip} + + {/if} +
+
+ + diff --git a/packages/core/src/lib/input/slider-input.svelte b/packages/core/src/lib/input/slider-input.svelte index 581c4c9a..ee007a1f 100644 --- a/packages/core/src/lib/input/slider-input.svelte +++ b/packages/core/src/lib/input/slider-input.svelte @@ -37,10 +37,10 @@ export let min = Number.NEGATIVE_INFINITY; export let max = Number.POSITIVE_INFINITY; /** Whether or not the input should be rendered as readonly and be operable. */ -export let readonly = false as boolean | undefined; +export let readonly = false; /** Whether or not the input should be rendered as readonly and be non-operable. */ -export let disabled = false as boolean | undefined; +export let disabled = false; /** The HTML input element. */ export let input: HTMLInputElement | undefined = undefined; @@ -70,7 +70,7 @@ $: { } $: isNumber = type === 'number'; -$: isButtonDisabled = readonly === true || disabled === true; +$: isButtonDisabled = readonly || disabled; $: handleDisabled = preventHandler(isButtonDisabled); $: handlePointerMove = (event: PointerEvent) => { diff --git a/packages/core/src/routes/+page.svelte b/packages/core/src/routes/+page.svelte index 30f1d3fa..43765915 100644 --- a/packages/core/src/routes/+page.svelte +++ b/packages/core/src/routes/+page.svelte @@ -40,6 +40,7 @@ import { useNotify, Modal, CodeSnippet, + RangeInput, } from '$lib'; import { uniqueId } from 'lodash-es'; @@ -698,7 +699,10 @@ const onHoverDelayMsInput = (event: Event) => { console.log('oh no')} + on:click={() => { + // eslint-disable-next-line no-console + console.log('oh no'); + }} > danger @@ -936,6 +940,49 @@ const onHoverDelayMsInput = (event: Event) => { /> + +

Range Input

+
+ { + // eslint-disable-next-line no-console + console.log('RangeInput input', event); + }} + on:change={(event) => { + // eslint-disable-next-line no-console + console.log('RangeInput change', event); + }} + name="range" + /> + + + + + + + + + + +
+

Datetime Input

diff --git a/packages/storybook/src/stories/input.mdx b/packages/storybook/src/stories/input.mdx index 4e3f0078..275b106b 100644 --- a/packages/storybook/src/stories/input.mdx +++ b/packages/storybook/src/stories/input.mdx @@ -82,3 +82,19 @@ import { SliderInput } from '@viamrobotics/prime-core'; + +# Range Input + +A user input for numeric values within a specified range that allows easy +adjustments with a slider. This is ideal for number and integer fields you +expect the user to tweak often, should be restricted between a min and max +value, and the value can be incremented/decremented using the up/down arrow +keys. It also includes a numeric input for direct inputs. + +```ts +import { RangeInput } from '@viamrobotics/prime-core'; +``` + + + + diff --git a/packages/storybook/src/stories/input.stories.svelte b/packages/storybook/src/stories/input.stories.svelte index 4ebceed1..f2fb8441 100644 --- a/packages/storybook/src/stories/input.stories.svelte +++ b/packages/storybook/src/stories/input.stories.svelte @@ -4,6 +4,7 @@ import { Input, Label, NumericInput, + RangeInput, RestrictedTextInput, SliderInput, TextInput, @@ -47,3 +48,7 @@ const restrictInput = (inputValue: string) => + + + + diff --git a/packages/storybook/tailwind.config.ts b/packages/storybook/tailwind.config.ts index 035f437b..58f74393 100644 --- a/packages/storybook/tailwind.config.ts +++ b/packages/storybook/tailwind.config.ts @@ -1,5 +1,6 @@ import type { Config } from 'tailwindcss'; import { theme } from '@viamrobotics/prime-core/theme'; +import { plugins } from '@viamrobotics/prime-core/plugins'; export { theme } from '@viamrobotics/prime-core/theme'; @@ -10,6 +11,7 @@ export default { './node_modules/@viamrobotics/prime-core/**/*.{ts,svelte}', ], theme, + plugins, variants: { extend: {}, },