@nqmcreative/ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +209 -15
  2. package/cli/index.mjs +247 -70
  3. package/dist/core/color.d.ts +24 -0
  4. package/dist/core/color.js +39 -0
  5. package/dist/core/index.d.ts +8 -1
  6. package/dist/core/index.js +5 -1
  7. package/dist/core/locale.svelte.d.ts +8 -0
  8. package/dist/core/locale.svelte.js +14 -0
  9. package/dist/core/number.d.ts +22 -0
  10. package/dist/core/number.js +33 -0
  11. package/dist/core/pin.d.ts +23 -0
  12. package/dist/core/pin.js +38 -0
  13. package/dist/core/tags.d.ts +37 -0
  14. package/dist/core/tags.js +51 -0
  15. package/dist/core/time.d.ts +30 -0
  16. package/dist/core/time.js +78 -0
  17. package/dist/styles/matte/ColorInput.svelte +110 -0
  18. package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
  19. package/dist/styles/matte/CurrencyInput.svelte +108 -0
  20. package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
  21. package/dist/styles/matte/FileInput.svelte +194 -0
  22. package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
  23. package/dist/styles/matte/InputAddon.svelte +36 -0
  24. package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
  25. package/dist/styles/matte/PinInput.svelte +137 -0
  26. package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
  27. package/dist/styles/matte/SearchInput.svelte +87 -0
  28. package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
  29. package/dist/styles/matte/TagsInput.svelte +173 -0
  30. package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
  31. package/dist/styles/matte/TimeInput.svelte +129 -0
  32. package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
  33. package/dist/styles/matte/index.d.ts +8 -0
  34. package/dist/styles/matte/index.js +8 -0
  35. package/dist/styles/paper/ColorInput.svelte +112 -0
  36. package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
  37. package/dist/styles/paper/CurrencyInput.svelte +108 -0
  38. package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
  39. package/dist/styles/paper/FileInput.svelte +204 -0
  40. package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
  41. package/dist/styles/paper/InputAddon.svelte +36 -0
  42. package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
  43. package/dist/styles/paper/PinInput.svelte +137 -0
  44. package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
  45. package/dist/styles/paper/SearchInput.svelte +92 -0
  46. package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
  47. package/dist/styles/paper/TagsInput.svelte +178 -0
  48. package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
  49. package/dist/styles/paper/TimeInput.svelte +155 -0
  50. package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
  51. package/dist/styles/paper/index.d.ts +8 -0
  52. package/dist/styles/paper/index.js +8 -0
  53. package/dist/styles/sprout/ColorInput.svelte +112 -0
  54. package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
  55. package/dist/styles/sprout/CurrencyInput.svelte +108 -0
  56. package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
  57. package/dist/styles/sprout/FileInput.svelte +205 -0
  58. package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
  59. package/dist/styles/sprout/InputAddon.svelte +37 -0
  60. package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
  61. package/dist/styles/sprout/PinInput.svelte +138 -0
  62. package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
  63. package/dist/styles/sprout/SearchInput.svelte +92 -0
  64. package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
  65. package/dist/styles/sprout/TagsInput.svelte +179 -0
  66. package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
  67. package/dist/styles/sprout/TimeInput.svelte +155 -0
  68. package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
  69. package/dist/styles/sprout/index.d.ts +8 -0
  70. package/dist/styles/sprout/index.js +8 -0
  71. package/package.json +98 -2
  72. package/registry.json +143 -1
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import { useLocale } from '../../core/locale.svelte.js';
3
+ import { addTag, splitTags, type TagRejection } from '../../core/tags.js';
4
+ import { toneFocusWithinBorder, toneSoft, type Tone } from '../../core/tones.js';
5
+
6
+ interface Props {
7
+ /** Bindable list of tags, in the order they were added. */
8
+ tags?: string[];
9
+ placeholder?: string;
10
+ /** Cap on how many may be held. `0` means no limit. */
11
+ max?: number;
12
+ /** Shortest a tag may be, after trimming. */
13
+ minLength?: number;
14
+ /** Keys that commit the tag under the caret, besides Enter. */
15
+ separators?: string[];
16
+ /** Refuse a tag that differs from an existing one only in case. */
17
+ caseInsensitive?: boolean;
18
+ /** Commit whatever is half-typed when the field loses focus. */
19
+ commitOnBlur?: boolean;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ tone?: Tone;
23
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
24
+ name?: string;
25
+ onadd?: (tag: string) => void;
26
+ onremove?: (tag: string) => void;
27
+ onreject?: (reason: TagRejection, raw: string) => void;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ tags = $bindable([]),
33
+ placeholder,
34
+ max = 0,
35
+ minLength = 1,
36
+ separators = [','],
37
+ caseInsensitive = true,
38
+ commitOnBlur = true,
39
+ disabled = false,
40
+ invalid = false,
41
+ tone = 'brand',
42
+ name,
43
+ onadd,
44
+ onremove,
45
+ onreject,
46
+ class: className = ''
47
+ }: Props = $props();
48
+
49
+ const t = useLocale();
50
+
51
+ let draft = $state('');
52
+ let field: HTMLInputElement | null = $state(null);
53
+ /** The last refusal, cleared as soon as the next keystroke lands. */
54
+ let refused = $state<TagRejection | null>(null);
55
+
56
+ const full = $derived(max > 0 && tags.length >= max);
57
+ const rules = $derived({ max, minLength, caseInsensitive });
58
+
59
+ function commit(raw: string) {
60
+ const { tags: next, rejected } = addTag(tags, raw, rules);
61
+ if (rejected) {
62
+ // An empty commit is the Enter key on an empty field, not a mistake.
63
+ if (rejected !== 'empty') {
64
+ refused = rejected;
65
+ onreject?.(rejected, raw);
66
+ }
67
+ return false;
68
+ }
69
+ tags = next;
70
+ onadd?.(next[next.length - 1]);
71
+ return true;
72
+ }
73
+
74
+ function remove(index: number) {
75
+ const [gone] = tags.slice(index, index + 1);
76
+ tags = tags.filter((_, at) => at !== index);
77
+ if (gone !== undefined) onremove?.(gone);
78
+ }
79
+
80
+ function onKeydown(event: KeyboardEvent) {
81
+ refused = null;
82
+
83
+ if (event.key === 'Enter' || separators.includes(event.key)) {
84
+ event.preventDefault();
85
+ if (commit(draft)) draft = '';
86
+ return;
87
+ }
88
+
89
+ // Empty field: Backspace peels the last chip off, the way chips behave
90
+ // everywhere else in this library.
91
+ if (event.key === 'Backspace' && draft === '' && tags.length) {
92
+ event.preventDefault();
93
+ remove(tags.length - 1);
94
+ }
95
+ }
96
+
97
+ function onPaste(event: ClipboardEvent) {
98
+ const text = event.clipboardData?.getData('text') ?? '';
99
+ const parts = splitTags(text, separators);
100
+ if (parts.length <= 1) return;
101
+
102
+ event.preventDefault();
103
+ for (const part of parts) commit(part);
104
+ draft = '';
105
+ }
106
+
107
+ function onBlur() {
108
+ if (commitOnBlur && draft.trim() && commit(draft)) draft = '';
109
+ }
110
+
111
+ const messages: Record<TagRejection, string> = $derived({
112
+ empty: '',
113
+ short: `${minLength}+`,
114
+ duplicate: t.current.duplicateTag,
115
+ full: `${t.current.selected} ${tags.length}/${max}`
116
+ });
117
+ </script>
118
+
119
+ <div class="flex w-full flex-col gap-1.5 {className}">
120
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
121
+ <div
122
+ onclick={() => field?.focus()}
123
+ class="flex w-full flex-wrap items-center gap-1.5 border bg-bg py-1.5 pr-1.5 pl-2 transition-colors duration-150 ease-brand-out
124
+ {invalid
125
+ ? 'border-danger focus-within:border-danger'
126
+ : `border-hairline ${toneFocusWithinBorder[tone]}`}
127
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
128
+ >
129
+ {#each tags as tag, index (tag)}
130
+ <span
131
+ class="inline-flex items-center gap-1.5 rounded-full py-1 pr-1 pl-2.5 font-mono text-xs {toneSoft[
132
+ tone
133
+ ]}"
134
+ >
135
+ {tag}
136
+ <button
137
+ type="button"
138
+ onclick={() => remove(index)}
139
+ aria-label="{t.current.removeItem} {tag}"
140
+ class="inline-flex size-4 items-center justify-center rounded-full transition-colors duration-150 hover:bg-bg focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
141
+ >
142
+ <svg class="size-2.5" viewBox="0 0 10 10" fill="none" aria-hidden="true">
143
+ <path d="m1.5 1.5 7 7M8.5 1.5l-7 7" stroke="currentColor" stroke-width="1.5" />
144
+ </svg>
145
+ </button>
146
+ </span>
147
+ {/each}
148
+
149
+ <input
150
+ bind:this={field}
151
+ bind:value={draft}
152
+ {disabled}
153
+ readonly={full}
154
+ placeholder={tags.length === 0 ? (placeholder ?? t.current.addTag) : ''}
155
+ aria-label={placeholder ?? t.current.addTag}
156
+ aria-invalid={invalid ? 'true' : undefined}
157
+ onkeydown={onKeydown}
158
+ onpaste={onPaste}
159
+ onblur={onBlur}
160
+ class="h-7 min-w-24 flex-1 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
161
+ />
162
+
163
+ {#if name}
164
+ <input type="hidden" {name} value={tags.join(',')} />
165
+ {/if}
166
+ </div>
167
+
168
+ {#if refused && messages[refused]}
169
+ <span class="font-mono text-xs text-danger">{messages[refused]}</span>
170
+ {:else if max}
171
+ <span class="font-mono text-xs text-text-muted">{tags.length}/{max}</span>
172
+ {/if}
173
+ </div>
@@ -0,0 +1,29 @@
1
+ import { type TagRejection } from '../../core/tags.js';
2
+ import { type Tone } from '../../core/tones.js';
3
+ interface Props {
4
+ /** Bindable list of tags, in the order they were added. */
5
+ tags?: string[];
6
+ placeholder?: string;
7
+ /** Cap on how many may be held. `0` means no limit. */
8
+ max?: number;
9
+ /** Shortest a tag may be, after trimming. */
10
+ minLength?: number;
11
+ /** Keys that commit the tag under the caret, besides Enter. */
12
+ separators?: string[];
13
+ /** Refuse a tag that differs from an existing one only in case. */
14
+ caseInsensitive?: boolean;
15
+ /** Commit whatever is half-typed when the field loses focus. */
16
+ commitOnBlur?: boolean;
17
+ disabled?: boolean;
18
+ invalid?: boolean;
19
+ tone?: Tone;
20
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
21
+ name?: string;
22
+ onadd?: (tag: string) => void;
23
+ onremove?: (tag: string) => void;
24
+ onreject?: (reason: TagRejection, raw: string) => void;
25
+ class?: string;
26
+ }
27
+ declare const TagsInput: import("svelte").Component<Props, {}, "tags">;
28
+ type TagsInput = ReturnType<typeof TagsInput>;
29
+ export default TagsInput;
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { useLocale } from '../../core/locale.svelte.js';
4
+ import { clampTime, formatTime, parseTime, stepTime } from '../../core/time.js';
5
+ import type { Tone } from '../../core/tones.js';
6
+ import Input, { type InputSize } from './Input.svelte';
7
+
8
+ interface Props extends Omit<
9
+ HTMLInputAttributes,
10
+ 'size' | 'prefix' | 'suffix' | 'min' | 'max' | 'step'
11
+ > {
12
+ /** Bindable `HH:MM`, or `''` for empty. */
13
+ value?: string;
14
+ /** Inclusive bounds, `HH:MM`. */
15
+ min?: string;
16
+ max?: string;
17
+ /** Minutes one press of a stepper moves, and what typing snaps to. */
18
+ step?: number;
19
+ size?: InputSize;
20
+ tone?: Tone;
21
+ invalid?: boolean;
22
+ /** Hide the steppers and let the field be typed into only. */
23
+ steppers?: boolean;
24
+ class?: string;
25
+ }
26
+
27
+ let {
28
+ value = $bindable(''),
29
+ min,
30
+ max,
31
+ step = 15,
32
+ size = 'md',
33
+ tone = 'brand',
34
+ invalid = false,
35
+ disabled = false,
36
+ steppers = true,
37
+ class: className = '',
38
+ ...rest
39
+ }: Props = $props();
40
+
41
+ const t = useLocale();
42
+
43
+ // Typed text and the bound value are separate until the field settles:
44
+ // `9` is a valid thing to be halfway through typing and an invalid time.
45
+ let text = $state('');
46
+ let editing = $state(false);
47
+
48
+ $effect(() => {
49
+ if (!editing) text = value;
50
+ });
51
+
52
+ function settle() {
53
+ editing = false;
54
+ const minutes = parseTime(text);
55
+ value = minutes === null ? '' : formatTime(clampTime(minutes, min, max, step));
56
+ text = value;
57
+ }
58
+
59
+ function nudge(direction: 1 | -1) {
60
+ value = stepTime(value, direction, step, min, max);
61
+ text = value;
62
+ }
63
+
64
+ function onKeydown(event: KeyboardEvent) {
65
+ if (event.key === 'ArrowUp') {
66
+ event.preventDefault();
67
+ nudge(1);
68
+ } else if (event.key === 'ArrowDown') {
69
+ event.preventDefault();
70
+ nudge(-1);
71
+ } else if (event.key === 'Enter') {
72
+ settle();
73
+ }
74
+ }
75
+
76
+ const stepper =
77
+ 'grid size-6 place-items-center text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current';
78
+ </script>
79
+
80
+ <Input
81
+ bind:value={text}
82
+ {size}
83
+ {tone}
84
+ {invalid}
85
+ {disabled}
86
+ inputmode="numeric"
87
+ placeholder="--:--"
88
+ onfocus={() => (editing = true)}
89
+ onblur={settle}
90
+ onkeydown={onKeydown}
91
+ class="[&_input]:tabular-nums {className}"
92
+ {...rest}
93
+ >
94
+ {#snippet prefix()}
95
+ <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
96
+ <circle cx="10" cy="10" r="6.5" stroke="currentColor" stroke-width="1.5" />
97
+ <path d="M10 6.5V10l2.5 1.5" stroke="currentColor" stroke-width="1.5" />
98
+ </svg>
99
+ {/snippet}
100
+
101
+ {#snippet suffix()}
102
+ {#if steppers}
103
+ <span class="-mr-2 flex flex-col">
104
+ <button
105
+ type="button"
106
+ class={stepper}
107
+ {disabled}
108
+ onclick={() => nudge(1)}
109
+ aria-label={t.current.increase}
110
+ >
111
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
112
+ <path d="m3 7.5 3-3 3 3" stroke="currentColor" stroke-width="1.5" />
113
+ </svg>
114
+ </button>
115
+ <button
116
+ type="button"
117
+ class={stepper}
118
+ {disabled}
119
+ onclick={() => nudge(-1)}
120
+ aria-label={t.current.decrease}
121
+ >
122
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
123
+ <path d="m3 4.5 3 3 3-3" stroke="currentColor" stroke-width="1.5" />
124
+ </svg>
125
+ </button>
126
+ </span>
127
+ {/if}
128
+ {/snippet}
129
+ </Input>
@@ -0,0 +1,21 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import type { Tone } from '../../core/tones.js';
3
+ import { type InputSize } from './Input.svelte';
4
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'suffix' | 'min' | 'max' | 'step'> {
5
+ /** Bindable `HH:MM`, or `''` for empty. */
6
+ value?: string;
7
+ /** Inclusive bounds, `HH:MM`. */
8
+ min?: string;
9
+ max?: string;
10
+ /** Minutes one press of a stepper moves, and what typing snaps to. */
11
+ step?: number;
12
+ size?: InputSize;
13
+ tone?: Tone;
14
+ invalid?: boolean;
15
+ /** Hide the steppers and let the field be typed into only. */
16
+ steppers?: boolean;
17
+ class?: string;
18
+ }
19
+ declare const TimeInput: import("svelte").Component<Props, {}, "value">;
20
+ type TimeInput = ReturnType<typeof TimeInput>;
21
+ export default TimeInput;
@@ -30,6 +30,14 @@ export type { CheckboxOption } from './CheckboxGroup.svelte';
30
30
  export { default as SegmentedControl } from './SegmentedControl.svelte';
31
31
  export type { SegmentOption, SegmentedSize } from './SegmentedControl.svelte';
32
32
  export { default as InputGroup } from './InputGroup.svelte';
33
+ export { default as InputAddon } from './InputAddon.svelte';
34
+ export { default as SearchInput } from './SearchInput.svelte';
35
+ export { default as PinInput } from './PinInput.svelte';
36
+ export { default as TagsInput } from './TagsInput.svelte';
37
+ export { default as CurrencyInput } from './CurrencyInput.svelte';
38
+ export { default as TimeInput } from './TimeInput.svelte';
39
+ export { default as ColorInput } from './ColorInput.svelte';
40
+ export { default as FileInput } from './FileInput.svelte';
33
41
  export { default as Combobox } from './Combobox.svelte';
34
42
  export type { ComboboxOption } from './Combobox.svelte';
35
43
  export { default as MultiSelect } from './MultiSelect.svelte';
@@ -26,6 +26,14 @@ export { default as RadioGroup } from './RadioGroup.svelte';
26
26
  export { default as CheckboxGroup } from './CheckboxGroup.svelte';
27
27
  export { default as SegmentedControl } from './SegmentedControl.svelte';
28
28
  export { default as InputGroup } from './InputGroup.svelte';
29
+ export { default as InputAddon } from './InputAddon.svelte';
30
+ export { default as SearchInput } from './SearchInput.svelte';
31
+ export { default as PinInput } from './PinInput.svelte';
32
+ export { default as TagsInput } from './TagsInput.svelte';
33
+ export { default as CurrencyInput } from './CurrencyInput.svelte';
34
+ export { default as TimeInput } from './TimeInput.svelte';
35
+ export { default as ColorInput } from './ColorInput.svelte';
36
+ export { default as FileInput } from './FileInput.svelte';
29
37
  export { default as Combobox } from './Combobox.svelte';
30
38
  export { default as MultiSelect } from './MultiSelect.svelte';
31
39
  export { default as DatePicker } from './DatePicker.svelte';
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { normaliseHex } from '../../core/color.js';
4
+ import { useLocale } from '../../core/locale.svelte.js';
5
+ import type { Tone } from '../../core/tones.js';
6
+ import Input, { type InputSize } from './Input.svelte';
7
+
8
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'suffix' | 'type'> {
9
+ /** Bindable `#rrggbb`. Shorthand and alpha are normalised away on blur. */
10
+ value?: string;
11
+ size?: InputSize;
12
+ tone?: Tone;
13
+ invalid?: boolean;
14
+ /** Fixed choices under the field. Empty hides the row. */
15
+ swatches?: string[];
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ value = $bindable('#000000'),
21
+ size = 'md',
22
+ tone = 'brand',
23
+ invalid = false,
24
+ disabled = false,
25
+ swatches = [],
26
+ class: className = '',
27
+ ...rest
28
+ }: Props = $props();
29
+
30
+ const t = useLocale();
31
+
32
+ // The text can be mid-typing (`#a2`) while the swatch needs a real colour,
33
+ // so the swatch follows the last value that parsed rather than the field.
34
+ let text = $state('');
35
+ let editing = $state(false);
36
+
37
+ $effect(() => {
38
+ if (!editing) text = value;
39
+ });
40
+
41
+ const resolved = $derived(normaliseHex(text) ?? normaliseHex(value) ?? '#000000');
42
+
43
+ // Same reason as CurrencyInput: at input time the bound `text` is one
44
+ // keystroke behind, so the element is the only current source.
45
+ function onText(event: Event & { currentTarget: HTMLInputElement }) {
46
+ const hex = normaliseHex(event.currentTarget.value);
47
+ if (hex) value = hex;
48
+ }
49
+
50
+ function settle() {
51
+ editing = false;
52
+ value = normaliseHex(text) ?? value;
53
+ text = value;
54
+ }
55
+
56
+ function choose(hex: string) {
57
+ value = normaliseHex(hex) ?? value;
58
+ text = value;
59
+ }
60
+ </script>
61
+
62
+ <div class="flex w-full flex-col gap-2 {className}">
63
+ <Input
64
+ bind:value={text}
65
+ {size}
66
+ {tone}
67
+ {invalid}
68
+ {disabled}
69
+ spellcheck="false"
70
+ autocapitalize="none"
71
+ placeholder="#000000"
72
+ oninput={onText}
73
+ onfocus={() => (editing = true)}
74
+ onblur={settle}
75
+ class="[&_input]:uppercase [&_input]:tabular-nums"
76
+ {...rest}
77
+ >
78
+ {#snippet prefix()}
79
+ <span
80
+ class="relative grid size-5 place-items-center overflow-hidden rounded border border-hairline-strong"
81
+ >
82
+ <span class="absolute inset-0" style="background-color: {resolved}"></span>
83
+ <input
84
+ type="color"
85
+ value={resolved}
86
+ {disabled}
87
+ aria-label={t.current.pickColour}
88
+ oninput={(event) => choose(event.currentTarget.value)}
89
+ class="absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
90
+ />
91
+ </span>
92
+ {/snippet}
93
+ </Input>
94
+
95
+ {#if swatches.length}
96
+ <div class="flex flex-wrap gap-1.5">
97
+ {#each swatches as swatch (swatch)}
98
+ <button
99
+ type="button"
100
+ {disabled}
101
+ onclick={() => choose(swatch)}
102
+ title={swatch}
103
+ aria-label={swatch}
104
+ aria-pressed={resolved === normaliseHex(swatch)}
105
+ class="size-6 rounded-md border transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:pointer-events-none disabled:opacity-50
106
+ {resolved === normaliseHex(swatch) ? 'border-text' : 'border-hairline-strong'}"
107
+ style="background-color: {swatch}"
108
+ ></button>
109
+ {/each}
110
+ </div>
111
+ {/if}
112
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import type { Tone } from '../../core/tones.js';
3
+ import { type InputSize } from './Input.svelte';
4
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'suffix' | 'type'> {
5
+ /** Bindable `#rrggbb`. Shorthand and alpha are normalised away on blur. */
6
+ value?: string;
7
+ size?: InputSize;
8
+ tone?: Tone;
9
+ invalid?: boolean;
10
+ /** Fixed choices under the field. Empty hides the row. */
11
+ swatches?: string[];
12
+ class?: string;
13
+ }
14
+ declare const ColorInput: import("svelte").Component<Props, {}, "value">;
15
+ type ColorInput = ReturnType<typeof ColorInput>;
16
+ export default ColorInput;
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { formatGrouped, parseGrouped } from '../../core/number.js';
4
+ import type { Tone } from '../../core/tones.js';
5
+ import Input, { type InputSize } from './Input.svelte';
6
+
7
+ interface Props extends Omit<
8
+ HTMLInputAttributes,
9
+ 'size' | 'prefix' | 'suffix' | 'value' | 'min' | 'max'
10
+ > {
11
+ /** Bindable amount. `null` is an empty field, which is not the same as 0. */
12
+ value?: number | null;
13
+ min?: number;
14
+ max?: number;
15
+ /** Thousands separator. */
16
+ group?: string;
17
+ /** Decimal separator. */
18
+ decimal?: string;
19
+ /** Decimal places the amount settles to when the field loses focus. */
20
+ precision?: number;
21
+ /** Leading mark — `Rp`, `$`, `€`. */
22
+ currency?: string;
23
+ /** Trailing mark instead, for the locales that write it that way. */
24
+ unit?: string;
25
+ size?: InputSize;
26
+ tone?: Tone;
27
+ invalid?: boolean;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ value = $bindable(null),
33
+ min,
34
+ max,
35
+ group = ',',
36
+ decimal = '.',
37
+ precision = 2,
38
+ currency,
39
+ unit,
40
+ size = 'md',
41
+ tone = 'brand',
42
+ invalid = false,
43
+ disabled = false,
44
+ class: className = '',
45
+ ...rest
46
+ }: Props = $props();
47
+
48
+ const options = $derived({ group, decimal, precision });
49
+
50
+ /**
51
+ * What the field shows. Two states on purpose: grouped and padded while the
52
+ * caret is elsewhere, and exactly what was typed while it is not — grouping
53
+ * a number as it is typed moves the caret out from under the fingers.
54
+ */
55
+ let text = $state('');
56
+ let editing = $state(false);
57
+
58
+ $effect(() => {
59
+ if (editing) return;
60
+ text = value === null || value === undefined ? '' : formatGrouped(value, options);
61
+ });
62
+
63
+ // Read off the element, not off `text`: the binding that feeds `text` has
64
+ // not flushed yet when this fires, so it still holds the previous keystroke.
65
+ function onInput(event: Event & { currentTarget: HTMLInputElement }) {
66
+ const parsed = parseGrouped(event.currentTarget.value, options);
67
+ value = parsed === null ? null : clamp(parsed);
68
+ }
69
+
70
+ function clamp(amount: number) {
71
+ if (min !== undefined && amount < min) return min;
72
+ if (max !== undefined && amount > max) return max;
73
+ return amount;
74
+ }
75
+
76
+ function settle() {
77
+ editing = false;
78
+ const parsed = parseGrouped(text, options);
79
+ value = parsed === null ? null : clamp(parsed);
80
+ text = value === null ? '' : formatGrouped(value, options);
81
+ }
82
+ </script>
83
+
84
+ <Input
85
+ bind:value={text}
86
+ {size}
87
+ {tone}
88
+ {invalid}
89
+ {disabled}
90
+ inputmode="decimal"
91
+ oninput={onInput}
92
+ onfocus={() => (editing = true)}
93
+ onblur={settle}
94
+ class={className}
95
+ {...rest}
96
+ >
97
+ {#snippet prefix()}
98
+ {#if currency}
99
+ <span class="text-xs font-medium text-text-muted">{currency}</span>
100
+ {/if}
101
+ {/snippet}
102
+
103
+ {#snippet suffix()}
104
+ {#if unit}
105
+ <span class="text-xs font-medium text-text-muted">{unit}</span>
106
+ {/if}
107
+ {/snippet}
108
+ </Input>
@@ -0,0 +1,26 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import type { Tone } from '../../core/tones.js';
3
+ import { type InputSize } from './Input.svelte';
4
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'suffix' | 'value' | 'min' | 'max'> {
5
+ /** Bindable amount. `null` is an empty field, which is not the same as 0. */
6
+ value?: number | null;
7
+ min?: number;
8
+ max?: number;
9
+ /** Thousands separator. */
10
+ group?: string;
11
+ /** Decimal separator. */
12
+ decimal?: string;
13
+ /** Decimal places the amount settles to when the field loses focus. */
14
+ precision?: number;
15
+ /** Leading mark — `Rp`, `$`, `€`. */
16
+ currency?: string;
17
+ /** Trailing mark instead, for the locales that write it that way. */
18
+ unit?: string;
19
+ size?: InputSize;
20
+ tone?: Tone;
21
+ invalid?: boolean;
22
+ class?: string;
23
+ }
24
+ declare const CurrencyInput: import("svelte").Component<Props, {}, "value">;
25
+ type CurrencyInput = ReturnType<typeof CurrencyInput>;
26
+ export default CurrencyInput;