@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.
- package/README.md +209 -15
- package/cli/index.mjs +247 -70
- package/dist/core/color.d.ts +24 -0
- package/dist/core/color.js +39 -0
- package/dist/core/index.d.ts +8 -1
- package/dist/core/index.js +5 -1
- package/dist/core/locale.svelte.d.ts +8 -0
- package/dist/core/locale.svelte.js +14 -0
- package/dist/core/number.d.ts +22 -0
- package/dist/core/number.js +33 -0
- package/dist/core/pin.d.ts +23 -0
- package/dist/core/pin.js +38 -0
- package/dist/core/tags.d.ts +37 -0
- package/dist/core/tags.js +51 -0
- package/dist/core/time.d.ts +30 -0
- package/dist/core/time.js +78 -0
- package/dist/styles/matte/ColorInput.svelte +110 -0
- package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/matte/CurrencyInput.svelte +108 -0
- package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/matte/FileInput.svelte +194 -0
- package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
- package/dist/styles/matte/InputAddon.svelte +36 -0
- package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/matte/PinInput.svelte +137 -0
- package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
- package/dist/styles/matte/SearchInput.svelte +87 -0
- package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/matte/TagsInput.svelte +173 -0
- package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/matte/TimeInput.svelte +129 -0
- package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/matte/index.d.ts +8 -0
- package/dist/styles/matte/index.js +8 -0
- package/dist/styles/paper/ColorInput.svelte +112 -0
- package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/paper/CurrencyInput.svelte +108 -0
- package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/paper/FileInput.svelte +204 -0
- package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
- package/dist/styles/paper/InputAddon.svelte +36 -0
- package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/paper/PinInput.svelte +137 -0
- package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
- package/dist/styles/paper/SearchInput.svelte +92 -0
- package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/paper/TagsInput.svelte +178 -0
- package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/paper/TimeInput.svelte +155 -0
- package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/paper/index.d.ts +8 -0
- package/dist/styles/paper/index.js +8 -0
- package/dist/styles/sprout/ColorInput.svelte +112 -0
- package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/sprout/CurrencyInput.svelte +108 -0
- package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/sprout/FileInput.svelte +205 -0
- package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
- package/dist/styles/sprout/InputAddon.svelte +37 -0
- package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/sprout/PinInput.svelte +138 -0
- package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
- package/dist/styles/sprout/SearchInput.svelte +92 -0
- package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/sprout/TagsInput.svelte +179 -0
- package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/sprout/TimeInput.svelte +155 -0
- package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/sprout/index.d.ts +8 -0
- package/dist/styles/sprout/index.js +8 -0
- package/package.json +98 -2
- package/registry.json +143 -1
|
@@ -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="font-mono text-xs tracking-wide">{currency}</span>
|
|
100
|
+
{/if}
|
|
101
|
+
{/snippet}
|
|
102
|
+
|
|
103
|
+
{#snippet suffix()}
|
|
104
|
+
{#if unit}
|
|
105
|
+
<span class="font-mono text-xs tracking-wide">{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;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
|
|
3
|
+
import { useLocale } from '../../core/locale.svelte.js';
|
|
4
|
+
import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
|
|
5
|
+
import type { InputSize } from './Input.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Bindable list of accepted files. */
|
|
9
|
+
files?: File[];
|
|
10
|
+
/** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
|
|
11
|
+
accept?: string;
|
|
12
|
+
multiple?: boolean;
|
|
13
|
+
/** Per-file ceiling in bytes. `0` means no limit. */
|
|
14
|
+
maxSize?: number;
|
|
15
|
+
/** Cap on how many files may be held at once. `0` means no limit. */
|
|
16
|
+
maxFiles?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
size?: InputSize;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
invalid?: boolean;
|
|
21
|
+
/** Text on the button. Defaults to the locale's `chooseFile`. */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Shown while nothing is chosen. Defaults to the locale's `noFile`. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Show the list of held files below the row. */
|
|
26
|
+
showList?: boolean;
|
|
27
|
+
/** The `name` the hidden input posts under, for a plain form submit. */
|
|
28
|
+
name?: string;
|
|
29
|
+
onaccept?: (files: File[]) => void;
|
|
30
|
+
onreject?: (rejected: RejectedFile[]) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
files = $bindable([]),
|
|
36
|
+
accept,
|
|
37
|
+
multiple = false,
|
|
38
|
+
maxSize = 0,
|
|
39
|
+
maxFiles = 0,
|
|
40
|
+
disabled = false,
|
|
41
|
+
size = 'md',
|
|
42
|
+
tone = 'brand',
|
|
43
|
+
invalid = false,
|
|
44
|
+
label,
|
|
45
|
+
placeholder,
|
|
46
|
+
showList = true,
|
|
47
|
+
name,
|
|
48
|
+
onaccept,
|
|
49
|
+
onreject,
|
|
50
|
+
class: className = ''
|
|
51
|
+
}: Props = $props();
|
|
52
|
+
|
|
53
|
+
const t = useLocale();
|
|
54
|
+
|
|
55
|
+
let input: HTMLInputElement | null = $state(null);
|
|
56
|
+
let rejected = $state<RejectedFile[]>([]);
|
|
57
|
+
|
|
58
|
+
const formatSize = (bytes: number) => toReadableSize(bytes, t.current.byteUnits);
|
|
59
|
+
|
|
60
|
+
/** One file reads as its name and weight; several read as a count. */
|
|
61
|
+
const summary = $derived.by(() => {
|
|
62
|
+
if (files.length === 0) return placeholder ?? t.current.noFile;
|
|
63
|
+
if (files.length === 1) return `${files[0].name} · ${formatSize(files[0].size)}`;
|
|
64
|
+
return `${files.length} ${t.current.selected}`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function add(incoming: FileList | File[] | null) {
|
|
68
|
+
if (!incoming || disabled) return;
|
|
69
|
+
|
|
70
|
+
const { accepted, rejected: bad } = sortFiles(incoming, {
|
|
71
|
+
accept,
|
|
72
|
+
maxSize,
|
|
73
|
+
maxFiles,
|
|
74
|
+
multiple,
|
|
75
|
+
held: files.length
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
rejected = bad;
|
|
79
|
+
if (bad.length) onreject?.(bad);
|
|
80
|
+
if (accepted.length === 0) return;
|
|
81
|
+
|
|
82
|
+
files = multiple ? [...files, ...accepted] : accepted.slice(0, 1);
|
|
83
|
+
onaccept?.(accepted);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clear() {
|
|
87
|
+
files = [];
|
|
88
|
+
rejected = [];
|
|
89
|
+
if (input) input.value = '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reasons: Record<RejectedFile['reason'], string> = $derived({
|
|
93
|
+
type: t.current.rejectedType,
|
|
94
|
+
size: t.current.rejectedSize,
|
|
95
|
+
count: t.current.rejectedCount
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const sizes: Record<InputSize, string> = {
|
|
99
|
+
sm: 'h-8 text-[13px]',
|
|
100
|
+
md: 'h-10 text-[15px]',
|
|
101
|
+
lg: 'h-11 text-base'
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const buttons: Record<InputSize, string> = {
|
|
105
|
+
sm: 'h-6 px-2 text-[11px]',
|
|
106
|
+
md: 'h-7 px-2.5 text-xs',
|
|
107
|
+
lg: 'h-8 px-3 text-xs'
|
|
108
|
+
};
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<div class="flex w-full flex-col gap-3 {className}">
|
|
112
|
+
<span
|
|
113
|
+
class="inline-flex w-full items-center gap-2 border bg-bg pr-1 pl-1 transition-colors duration-150 ease-brand-out
|
|
114
|
+
{sizes[size]}
|
|
115
|
+
{invalid
|
|
116
|
+
? 'border-danger focus-within:border-danger'
|
|
117
|
+
: `border-hairline ${toneFocusWithinBorder[tone]}`}
|
|
118
|
+
{disabled ? 'pointer-events-none opacity-50' : ''}"
|
|
119
|
+
>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
{disabled}
|
|
123
|
+
onclick={() => input?.click()}
|
|
124
|
+
class="shrink-0 border border-hairline bg-bg-alt font-mono tracking-wide text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
|
|
125
|
+
{buttons[size]}"
|
|
126
|
+
>
|
|
127
|
+
{label ?? t.current.chooseFile}
|
|
128
|
+
</button>
|
|
129
|
+
|
|
130
|
+
<span
|
|
131
|
+
class="min-w-0 flex-1 truncate font-sans {files.length ? 'text-text' : 'text-text-muted'}"
|
|
132
|
+
>
|
|
133
|
+
{summary}
|
|
134
|
+
</span>
|
|
135
|
+
|
|
136
|
+
{#if files.length}
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onclick={clear}
|
|
140
|
+
aria-label={t.current.remove}
|
|
141
|
+
class="mr-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
|
|
142
|
+
>
|
|
143
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
144
|
+
<path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
|
|
145
|
+
</svg>
|
|
146
|
+
</button>
|
|
147
|
+
{/if}
|
|
148
|
+
</span>
|
|
149
|
+
|
|
150
|
+
<input
|
|
151
|
+
bind:this={input}
|
|
152
|
+
type="file"
|
|
153
|
+
{name}
|
|
154
|
+
{accept}
|
|
155
|
+
{multiple}
|
|
156
|
+
{disabled}
|
|
157
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
158
|
+
class="sr-only"
|
|
159
|
+
onchange={(event) => add(event.currentTarget.files)}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{#if rejected.length}
|
|
163
|
+
<ul class="flex flex-col gap-1">
|
|
164
|
+
{#each rejected as item (item.file.name + item.reason)}
|
|
165
|
+
<li class="font-sans text-sm text-danger">
|
|
166
|
+
{item.file.name} — {reasons[item.reason]}
|
|
167
|
+
</li>
|
|
168
|
+
{/each}
|
|
169
|
+
</ul>
|
|
170
|
+
{/if}
|
|
171
|
+
|
|
172
|
+
{#if showList && multiple && files.length > 1}
|
|
173
|
+
<ul class="flex flex-col border border-hairline">
|
|
174
|
+
{#each files as file, i (file.name + file.size + i)}
|
|
175
|
+
<li
|
|
176
|
+
class="flex items-center gap-3 border-b border-hairline px-3.5 py-2.5 font-sans last:border-b-0"
|
|
177
|
+
>
|
|
178
|
+
<span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
|
|
179
|
+
<span class="shrink-0 font-mono text-xs text-text-muted">{formatSize(file.size)}</span>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onclick={() => (files = files.filter((_, at) => at !== i))}
|
|
183
|
+
aria-label="{t.current.remove} {file.name}"
|
|
184
|
+
class="-m-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
|
|
185
|
+
>
|
|
186
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
187
|
+
<path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
|
|
188
|
+
</svg>
|
|
189
|
+
</button>
|
|
190
|
+
</li>
|
|
191
|
+
{/each}
|
|
192
|
+
</ul>
|
|
193
|
+
{/if}
|
|
194
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type RejectedFile } from '../../core/files.js';
|
|
2
|
+
import { type Tone } from '../../core/tones.js';
|
|
3
|
+
import type { InputSize } from './Input.svelte';
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Bindable list of accepted files. */
|
|
6
|
+
files?: File[];
|
|
7
|
+
/** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
|
|
8
|
+
accept?: string;
|
|
9
|
+
multiple?: boolean;
|
|
10
|
+
/** Per-file ceiling in bytes. `0` means no limit. */
|
|
11
|
+
maxSize?: number;
|
|
12
|
+
/** Cap on how many files may be held at once. `0` means no limit. */
|
|
13
|
+
maxFiles?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
size?: InputSize;
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
/** Text on the button. Defaults to the locale's `chooseFile`. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Shown while nothing is chosen. Defaults to the locale's `noFile`. */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Show the list of held files below the row. */
|
|
23
|
+
showList?: boolean;
|
|
24
|
+
/** The `name` the hidden input posts under, for a plain form submit. */
|
|
25
|
+
name?: string;
|
|
26
|
+
onaccept?: (files: File[]) => void;
|
|
27
|
+
onreject?: (rejected: RejectedFile[]) => void;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const FileInput: import("svelte").Component<Props, {}, "files">;
|
|
31
|
+
type FileInput = ReturnType<typeof FileInput>;
|
|
32
|
+
export default FileInput;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import type { InputSize } from './Input.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
|
7
|
+
/** Match the Input it sits beside — the heights have to agree. */
|
|
8
|
+
size?: InputSize;
|
|
9
|
+
/** Paint it like a field instead of a fixed label. */
|
|
10
|
+
variant?: 'muted' | 'plain';
|
|
11
|
+
children: Snippet;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
size = 'md',
|
|
16
|
+
variant = 'muted',
|
|
17
|
+
class: className = '',
|
|
18
|
+
children,
|
|
19
|
+
...rest
|
|
20
|
+
}: Props = $props();
|
|
21
|
+
|
|
22
|
+
const sizes: Record<InputSize, string> = {
|
|
23
|
+
sm: 'h-8 px-2.5 text-[13px]',
|
|
24
|
+
md: 'h-10 px-3.5 text-[15px]',
|
|
25
|
+
lg: 'h-11 px-3.5 text-base'
|
|
26
|
+
};
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<span
|
|
30
|
+
class="inline-flex shrink-0 items-center gap-1.5 border border-hairline font-mono whitespace-nowrap text-text-secondary
|
|
31
|
+
{sizes[size]}
|
|
32
|
+
{variant === 'muted' ? 'bg-bg-alt' : 'bg-bg'} {className}"
|
|
33
|
+
{...rest}
|
|
34
|
+
>
|
|
35
|
+
{@render children()}
|
|
36
|
+
</span>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { InputSize } from './Input.svelte';
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
/** Match the Input it sits beside — the heights have to agree. */
|
|
6
|
+
size?: InputSize;
|
|
7
|
+
/** Paint it like a field instead of a fixed label. */
|
|
8
|
+
variant?: 'muted' | 'plain';
|
|
9
|
+
children: Snippet;
|
|
10
|
+
}
|
|
11
|
+
declare const InputAddon: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type InputAddon = ReturnType<typeof InputAddon>;
|
|
13
|
+
export default InputAddon;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useLocale } from '../../core/locale.svelte.js';
|
|
3
|
+
import { caretIndex, clearFrom, fillFrom, sanitisePin, type PinMode } from '../../core/pin.js';
|
|
4
|
+
import { toneFocusBorder, type Tone } from '../../core/tones.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Bindable code. Shorter than `length` means still incomplete. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** How many boxes. */
|
|
10
|
+
length?: number;
|
|
11
|
+
/** What a box accepts. */
|
|
12
|
+
mode?: PinMode;
|
|
13
|
+
/** Render the characters as dots. */
|
|
14
|
+
mask?: boolean;
|
|
15
|
+
/** Draw a gap after this many boxes — `3` gives `123 456`. */
|
|
16
|
+
groupAfter?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
/** `name` for a plain form submit — one hidden field holding the code. */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Fires once the last box is filled. */
|
|
23
|
+
oncomplete?: (value: string) => void;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
value = $bindable(''),
|
|
29
|
+
length = 6,
|
|
30
|
+
mode = 'numeric',
|
|
31
|
+
mask = false,
|
|
32
|
+
groupAfter = 0,
|
|
33
|
+
disabled = false,
|
|
34
|
+
invalid = false,
|
|
35
|
+
tone = 'brand',
|
|
36
|
+
name,
|
|
37
|
+
oncomplete,
|
|
38
|
+
class: className = ''
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const t = useLocale();
|
|
42
|
+
|
|
43
|
+
let boxes: HTMLInputElement[] = $state([]);
|
|
44
|
+
|
|
45
|
+
const chars = $derived(Array.from({ length }, (_, i) => value[i] ?? ''));
|
|
46
|
+
|
|
47
|
+
function focusAt(index: number) {
|
|
48
|
+
boxes[Math.max(0, Math.min(length - 1, index))]?.focus();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The DOM is written back on every keystroke, not only when the value
|
|
53
|
+
* changed: a refused character leaves the box holding text the state never
|
|
54
|
+
* accepted, and nothing else would clear it.
|
|
55
|
+
*/
|
|
56
|
+
function sync(box: HTMLInputElement, index: number) {
|
|
57
|
+
box.value = value[index] ?? '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commit(next: string, from: number) {
|
|
61
|
+
const was = value;
|
|
62
|
+
value = next;
|
|
63
|
+
if (value.length === length && was.length < length) oncomplete?.(value);
|
|
64
|
+
// Land on the first empty box, or the one after whatever was just typed.
|
|
65
|
+
focusAt(Math.min(Math.max(from, caretIndex(value, length)), length - 1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onInput(event: Event & { currentTarget: HTMLInputElement }, index: number) {
|
|
69
|
+
const typed = sanitisePin(event.currentTarget.value, mode);
|
|
70
|
+
if (!typed) {
|
|
71
|
+
sync(event.currentTarget, index);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const next = fillFrom(value, index, typed, length, mode);
|
|
75
|
+
commit(next, index + typed.length);
|
|
76
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
80
|
+
if (event.key === 'Backspace') {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
// An empty box deletes the one before it, the way a caret would.
|
|
83
|
+
const target = value[index] ? index : index - 1;
|
|
84
|
+
if (target < 0) return;
|
|
85
|
+
value = clearFrom(value, target);
|
|
86
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
87
|
+
focusAt(target);
|
|
88
|
+
} else if (event.key === 'ArrowLeft') {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
focusAt(index - 1);
|
|
91
|
+
} else if (event.key === 'ArrowRight') {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
focusAt(index + 1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onPaste(event: ClipboardEvent, index: number) {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const text = event.clipboardData?.getData('text') ?? '';
|
|
100
|
+
const clean = sanitisePin(text, mode);
|
|
101
|
+
if (!clean) return;
|
|
102
|
+
commit(fillFrom(value, index, clean, length, mode), index + clean.length);
|
|
103
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<div class="flex items-center gap-2 {className}">
|
|
108
|
+
{#each chars as char, index (index)}
|
|
109
|
+
{#if groupAfter && index > 0 && index % groupAfter === 0}
|
|
110
|
+
<span class="h-px w-2 bg-hairline-strong" aria-hidden="true"></span>
|
|
111
|
+
{/if}
|
|
112
|
+
<input
|
|
113
|
+
bind:this={boxes[index]}
|
|
114
|
+
value={char}
|
|
115
|
+
{disabled}
|
|
116
|
+
type={mask ? 'password' : 'text'}
|
|
117
|
+
inputmode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
118
|
+
autocomplete={index === 0 ? 'one-time-code' : 'off'}
|
|
119
|
+
autocapitalize="characters"
|
|
120
|
+
spellcheck="false"
|
|
121
|
+
maxlength={length}
|
|
122
|
+
aria-label="{t.current.digit} {index + 1}"
|
|
123
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
124
|
+
oninput={(event) => onInput(event, index)}
|
|
125
|
+
onkeydown={(event) => onKeydown(event, index)}
|
|
126
|
+
onpaste={(event) => onPaste(event, index)}
|
|
127
|
+
onfocus={(event) => event.currentTarget.select()}
|
|
128
|
+
class="size-11 border bg-bg text-center font-mono text-base text-text transition-colors duration-150 ease-brand-out focus:outline-none
|
|
129
|
+
{invalid ? 'border-danger' : `border-hairline ${toneFocusBorder[tone]}`}
|
|
130
|
+
{disabled ? 'pointer-events-none opacity-50' : ''}"
|
|
131
|
+
/>
|
|
132
|
+
{/each}
|
|
133
|
+
|
|
134
|
+
{#if name}
|
|
135
|
+
<input type="hidden" {name} {value} />
|
|
136
|
+
{/if}
|
|
137
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PinMode } from '../../core/pin.js';
|
|
2
|
+
import { type Tone } from '../../core/tones.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Bindable code. Shorter than `length` means still incomplete. */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** How many boxes. */
|
|
7
|
+
length?: number;
|
|
8
|
+
/** What a box accepts. */
|
|
9
|
+
mode?: PinMode;
|
|
10
|
+
/** Render the characters as dots. */
|
|
11
|
+
mask?: boolean;
|
|
12
|
+
/** Draw a gap after this many boxes — `3` gives `123 456`. */
|
|
13
|
+
groupAfter?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
/** `name` for a plain form submit — one hidden field holding the code. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Fires once the last box is filled. */
|
|
20
|
+
oncomplete?: (value: string) => void;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
declare const PinInput: import("svelte").Component<Props, {}, "value">;
|
|
24
|
+
type PinInput = ReturnType<typeof PinInput>;
|
|
25
|
+
export default PinInput;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
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' | 'type'> {
|
|
9
|
+
value?: string;
|
|
10
|
+
size?: InputSize;
|
|
11
|
+
tone?: Tone;
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Milliseconds to wait after the last keystroke before `onsearch` fires.
|
|
15
|
+
* `0` fires on every one.
|
|
16
|
+
*/
|
|
17
|
+
debounce?: number;
|
|
18
|
+
/** The debounced value. The bound `value` still updates immediately. */
|
|
19
|
+
onsearch?: (value: string) => void;
|
|
20
|
+
onclear?: () => void;
|
|
21
|
+
/** Replaces the magnifier. */
|
|
22
|
+
icon?: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
value = $bindable(''),
|
|
28
|
+
size = 'md',
|
|
29
|
+
tone = 'brand',
|
|
30
|
+
invalid = false,
|
|
31
|
+
disabled = false,
|
|
32
|
+
debounce = 0,
|
|
33
|
+
onsearch,
|
|
34
|
+
onclear,
|
|
35
|
+
icon,
|
|
36
|
+
class: className = '',
|
|
37
|
+
...rest
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const t = useLocale();
|
|
41
|
+
|
|
42
|
+
// The timer is cleared by the effect's own teardown, so a keystroke that
|
|
43
|
+
// lands mid-wait restarts the clock rather than queueing a second call.
|
|
44
|
+
$effect(() => {
|
|
45
|
+
const next = value;
|
|
46
|
+
if (!onsearch) return;
|
|
47
|
+
if (debounce <= 0) {
|
|
48
|
+
onsearch(next);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => onsearch(next), debounce);
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function clear() {
|
|
56
|
+
value = '';
|
|
57
|
+
onclear?.();
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<Input bind:value type="search" {size} {tone} {invalid} {disabled} class={className} {...rest}>
|
|
62
|
+
{#snippet prefix()}
|
|
63
|
+
{#if icon}
|
|
64
|
+
{@render icon()}
|
|
65
|
+
{:else}
|
|
66
|
+
<svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
67
|
+
<circle cx="9" cy="9" r="5.5" stroke="currentColor" stroke-width="1.5" />
|
|
68
|
+
<path d="m13 13 3.5 3.5" stroke="currentColor" stroke-width="1.5" />
|
|
69
|
+
</svg>
|
|
70
|
+
{/if}
|
|
71
|
+
{/snippet}
|
|
72
|
+
|
|
73
|
+
{#snippet suffix()}
|
|
74
|
+
{#if value}
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onclick={clear}
|
|
78
|
+
aria-label={t.current.clearSearch}
|
|
79
|
+
class="-mr-1 p-1 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
80
|
+
>
|
|
81
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
82
|
+
<path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
|
|
83
|
+
</svg>
|
|
84
|
+
</button>
|
|
85
|
+
{/if}
|
|
86
|
+
{/snippet}
|
|
87
|
+
</Input>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import type { Tone } from '../../core/tones.js';
|
|
4
|
+
import { type InputSize } from './Input.svelte';
|
|
5
|
+
interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'type'> {
|
|
6
|
+
value?: string;
|
|
7
|
+
size?: InputSize;
|
|
8
|
+
tone?: Tone;
|
|
9
|
+
invalid?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Milliseconds to wait after the last keystroke before `onsearch` fires.
|
|
12
|
+
* `0` fires on every one.
|
|
13
|
+
*/
|
|
14
|
+
debounce?: number;
|
|
15
|
+
/** The debounced value. The bound `value` still updates immediately. */
|
|
16
|
+
onsearch?: (value: string) => void;
|
|
17
|
+
onclear?: () => void;
|
|
18
|
+
/** Replaces the magnifier. */
|
|
19
|
+
icon?: Snippet;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
declare const SearchInput: import("svelte").Component<Props, {}, "value">;
|
|
23
|
+
type SearchInput = ReturnType<typeof SearchInput>;
|
|
24
|
+
export default SearchInput;
|