diff --git a/src/lib/components/SpinButton/README.md b/src/lib/components/SpinButton/README.md new file mode 100644 index 0000000..ecdc9d1 --- /dev/null +++ b/src/lib/components/SpinButton/README.md @@ -0,0 +1,6 @@ +--- +path: component/spin-button +title: Spin Button +--- + +# Spin Button diff --git a/src/lib/components/SpinButton/example/+page.svelte b/src/lib/components/SpinButton/example/+page.svelte new file mode 100644 index 0000000..e3c0ca5 --- /dev/null +++ b/src/lib/components/SpinButton/example/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/lib/components/SpinButton/example/Quantity.svelte b/src/lib/components/SpinButton/example/Quantity.svelte new file mode 100644 index 0000000..ba0ac19 --- /dev/null +++ b/src/lib/components/SpinButton/example/Quantity.svelte @@ -0,0 +1,28 @@ + + +
+ +
+ + +
+
diff --git a/src/lib/components/SpinButton/index.ts b/src/lib/components/SpinButton/index.ts new file mode 100644 index 0000000..53bc193 --- /dev/null +++ b/src/lib/components/SpinButton/index.ts @@ -0,0 +1,2 @@ +export * from './spinButton.js' +export * from './spinButton.types.js' diff --git a/src/lib/components/SpinButton/spinButton.ts b/src/lib/components/SpinButton/spinButton.ts new file mode 100644 index 0000000..3519060 --- /dev/null +++ b/src/lib/components/SpinButton/spinButton.ts @@ -0,0 +1,222 @@ +import { onBrowserMount } from '$lib/helpers/environment.js' +import { generateId } from '$lib/helpers/uuid.js' +import { derived, get, readonly, writable } from 'svelte/store' +import type { SpinButton, SpinButtonConfig } from './spinButton.types.js' + +export const createSpinButton = (config?: SpinButtonConfig): SpinButton => { + const { disabled, min, max, step, value, strict } = { ...config } + + const value$ = writable(value || 0) + const disabled$ = writable(disabled || false) + const invalid$ = writable(false) + + const min$ = writable(min || 0) + const max$ = writable(max || 100) + const step$ = writable(step || 1) + + const baseId = generateId() + const inputId = `${baseId}-input` + const incrementId = `${baseId}-increment` + const decrementId = `${baseId}-decrement` + + const inputAttrs = derived( + [disabled$, value$, invalid$], + ([disabled, value, invalid]) => ({ + role: 'spinbutton', + 'aria-disabled': disabled, + 'aria-valuemin': config?.min || 0, + 'aria-valuemax': config?.max || 100, + 'aria-valuenow': value, + 'aria-invalid': invalid && !strict ? true : undefined, + 'data-spinbutton-input': inputId, + }) + ) + + // If the value is above or equal to the max, disable the increment button + const incrementButtonAttrs = derived( + [disabled$, value$, max$], + ([disabled, value, max]) => { + const isDisabled = value >= max || disabled + return { + role: 'button', + 'aria-disabled': isDisabled, + tabIndex: isDisabled ? -1 : 0, + 'data-spinbutton-more': incrementId, + } + } + ) + + const decrementButtonAttrs = derived( + [disabled$, value$, min$], + ([disabled, value, min]) => { + const isDisabled = value <= min || disabled + return { + role: 'button', + 'aria-disabled': isDisabled, + tabIndex: isDisabled ? -1 : 0, + 'data-spinbutton-less': decrementId, + } + } + ) + + const increase = () => { + const disabled = get(disabled$) + + if (disabled) { + return + } + + const value = get(value$) + const max = get(max$) + const step = get(step$) + const newValue = value + step + + if (newValue > max) { + value$.set(max) + } else { + value$.set(value + step) + } + } + + const decrease = () => { + const disabled = get(disabled$) + + if (disabled) { + return + } + + const value = get(value$) + const min = get(min$) + const step = get(step$) + const newValue = value - step + + if (newValue < min) { + value$.set(min) + } else { + value$.set(value - step) + } + } + + const onInputChange = (event: Event) => { + const disabled = get(disabled$) + + if (disabled) { + return + } + + const target = event.target as HTMLInputElement + const value = Number(target.value) + + if (Number.isNaN(value)) { + invalid$.set(true) + return + } + + const min = get(min$) + const max = get(max$) + + if (value < min || value > max) { + invalid$.set(true) + return + } + } + + const onIncrementClick = (event: MouseEvent) => { + event.preventDefault() + increase() + } + + const onIncrementKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + increase() + } + } + + const onDecrementClick = (event: MouseEvent) => { + event.preventDefault() + decrease() + } + + const onDecrementKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + decrease() + } + } + + onBrowserMount(() => { + const inputNode = document.querySelector( + `[data-spinbutton-input="${inputId}"]` + ) + + if (!inputNode) { + throw new Error('Could not find the input for the spin button') + } + + const incrementNode = document.querySelector( + `[data-spinbutton-more="${incrementId}"]` + ) + + if (!incrementNode) { + throw new Error('Could not find the increment button for the spin button') + } + + const decrementNode = document.querySelector( + `[data-spinbutton-less="${decrementId}"]` + ) + + if (!decrementNode) { + throw new Error('Could not find the decrement button for the spin button') + } + + inputNode.addEventListener('change', onInputChange) + incrementNode.addEventListener('click', onIncrementClick) + incrementNode.addEventListener('keydown', onIncrementKeyDown) + decrementNode.addEventListener('click', onDecrementClick) + decrementNode.addEventListener('keydown', onDecrementKeyDown) + + // Update the input value and invalid state when the value changes + const unsubscribe = value$.subscribe((value) => { + inputNode.value = value.toString() + + const min = get(min$) + const max = get(max$) + let isInvalid = false + + if (strict) { + if (value < min) { + value$.set(min) + } else if (value > max) { + value$.set(max) + } + } else { + if (value < min || value > max) { + isInvalid = true + } + } + + invalid$.set(isInvalid) + }) + + return () => { + unsubscribe() + inputNode.removeEventListener('change', onInputChange) + incrementNode.removeEventListener('click', onIncrementClick) + incrementNode.removeEventListener('keydown', onIncrementKeyDown) + decrementNode.removeEventListener('click', onDecrementClick) + decrementNode.removeEventListener('keydown', onDecrementKeyDown) + } + }) + + return { + value: value$, + disabled: disabled$, + invalid: readonly(invalid$), + inputAttrs, + incrementButtonAttrs, + decrementButtonAttrs, + increase, + decrease, + } +} diff --git a/src/lib/components/SpinButton/spinButton.types.ts b/src/lib/components/SpinButton/spinButton.types.ts new file mode 100644 index 0000000..e11d35c --- /dev/null +++ b/src/lib/components/SpinButton/spinButton.types.ts @@ -0,0 +1,39 @@ +import type { HTMLAttributes } from '$lib/helpers/types.js' +import type { Readable, Writable } from 'svelte/store' + +export type SpinButtonConfig = { + /** If true, the input will be disabled. */ + disabled?: boolean + + /** If true, the input will be readonly. */ + readonly?: boolean + + /** The initial value of the input. Defaults to 0. */ + value?: number + + /** The minimum value of the input. Defaults to 0. */ + min?: number + + /** The maximum value of the input. Defaults to 100. */ + max?: number + + /** The amount to increment or decrement the value by. Defaults to 1. */ + step?: number + + /** + * If true, the value will be clamped to the min and max values. And the input + * will never be marked as invalid. + */ + strict?: boolean +} + +export type SpinButton = { + value: Writable + disabled: Writable + invalid: Readable + inputAttrs: Readable + incrementButtonAttrs: Readable + decrementButtonAttrs: Readable + increase: () => void + decrease: () => void +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 471461c..dec8957 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -13,6 +13,7 @@ export * from './components/Menu/index.js' export * from './components/Popover/index.js' export * from './components/RadioGroup/index.js' export * from './components/Select/index.js' +export * from './components/SpinButton/index.js' export * from './components/Switch/index.js' export * from './components/Tabs/index.js' export * from './components/TagGroup/index.js'