@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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex colours for the colour field.
|
|
3
|
+
*
|
|
4
|
+
* Only hex: it is the one notation `<input type="color">` accepts and the one
|
|
5
|
+
* every design tool copies to the clipboard. Anything else the app can convert
|
|
6
|
+
* before it binds.
|
|
7
|
+
*/
|
|
8
|
+
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
9
|
+
/**
|
|
10
|
+
* `abc`, `#ABC`, `#aabbcc` → `#aabbcc`. Returns `null` when it is not a hex
|
|
11
|
+
* colour, so a half-typed value can be left alone rather than corrected under
|
|
12
|
+
* the caret.
|
|
13
|
+
*
|
|
14
|
+
* Shorthand is expanded and alpha is dropped: the native swatch understands
|
|
15
|
+
* neither, and a value the swatch cannot show is worse than a rounded one.
|
|
16
|
+
*/
|
|
17
|
+
export function normaliseHex(text) {
|
|
18
|
+
const match = HEX.exec(text.trim());
|
|
19
|
+
if (!match)
|
|
20
|
+
return null;
|
|
21
|
+
let digits = match[1].toLowerCase();
|
|
22
|
+
if (digits.length <= 4)
|
|
23
|
+
digits = [...digits].map((digit) => digit + digit).join('');
|
|
24
|
+
return `#${digits.slice(0, 6)}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* True when a colour is dark enough to want light text on top of it.
|
|
28
|
+
*
|
|
29
|
+
* Rec. 601 luma rather than WCAG relative luminance — the threshold is for
|
|
30
|
+
* picking one of two label colours on a swatch, not for proving a contrast
|
|
31
|
+
* ratio, and this is the version everyone's design tokens are tuned against.
|
|
32
|
+
*/
|
|
33
|
+
export function isDark(hex) {
|
|
34
|
+
const value = normaliseHex(hex);
|
|
35
|
+
if (!value)
|
|
36
|
+
return false;
|
|
37
|
+
const [r, g, b] = [1, 3, 5].map((at) => parseInt(value.slice(at, at + 2), 16));
|
|
38
|
+
return (r * 299 + g * 587 + b * 114) / 1000 < 140;
|
|
39
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -25,7 +25,14 @@ export { pageRange } from './pagination.js';
|
|
|
25
25
|
export { sortFiles, matchesAccept, formatSize } from './files.js';
|
|
26
26
|
export type { RejectedFile, FileRules } from './files.js';
|
|
27
27
|
export { passwordScore } from './password.js';
|
|
28
|
-
export { clampToStep, percentOf, stepDecimals } from './number.js';
|
|
28
|
+
export { clampToStep, formatGrouped, parseGrouped, percentOf, stepDecimals } from './number.js';
|
|
29
|
+
export type { GroupOptions } from './number.js';
|
|
30
|
+
export { caretIndex, clearFrom, fillFrom, sanitisePin } from './pin.js';
|
|
31
|
+
export type { PinMode } from './pin.js';
|
|
32
|
+
export { addTag, splitTags } from './tags.js';
|
|
33
|
+
export type { TagRules, TagRejection } from './tags.js';
|
|
34
|
+
export { normaliseHex, isDark } from './color.js';
|
|
35
|
+
export { MINUTES_IN_DAY, clampTime, formatClock, formatTime, parseTime, stepTime } from './time.js';
|
|
29
36
|
export { toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from './date.js';
|
|
30
37
|
export type { DateFormat } from './date.js';
|
|
31
38
|
export { calendarKeyMove, dateHint } from './calendar.js';
|
package/dist/core/index.js
CHANGED
|
@@ -23,7 +23,11 @@ export { nextSort, sortRows, toggleKey } from './table.js';
|
|
|
23
23
|
export { pageRange } from './pagination.js';
|
|
24
24
|
export { sortFiles, matchesAccept, formatSize } from './files.js';
|
|
25
25
|
export { passwordScore } from './password.js';
|
|
26
|
-
export { clampToStep, percentOf, stepDecimals } from './number.js';
|
|
26
|
+
export { clampToStep, formatGrouped, parseGrouped, percentOf, stepDecimals } from './number.js';
|
|
27
|
+
export { caretIndex, clearFrom, fillFrom, sanitisePin } from './pin.js';
|
|
28
|
+
export { addTag, splitTags } from './tags.js';
|
|
29
|
+
export { normaliseHex, isDark } from './color.js';
|
|
30
|
+
export { MINUTES_IN_DAY, clampTime, formatClock, formatTime, parseTime, stepTime } from './time.js';
|
|
27
31
|
/* --- dates --- */
|
|
28
32
|
export { toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from './date.js';
|
|
29
33
|
export { calendarKeyMove, dateHint } from './calendar.js';
|
|
@@ -52,6 +52,14 @@ export interface Locale {
|
|
|
52
52
|
passwordStrength: [string, string, string, string, string];
|
|
53
53
|
decrease: string;
|
|
54
54
|
increase: string;
|
|
55
|
+
chooseFile: string;
|
|
56
|
+
noFile: string;
|
|
57
|
+
clearSearch: string;
|
|
58
|
+
/** Prefixed to the box number, so box three is labelled `Digit 3`. */
|
|
59
|
+
digit: string;
|
|
60
|
+
addTag: string;
|
|
61
|
+
duplicateTag: string;
|
|
62
|
+
pickColour: string;
|
|
55
63
|
/** BCP 47 tag handed to `Intl` for month and weekday names. */
|
|
56
64
|
dateLocale: string;
|
|
57
65
|
/** Order of the date parts the input accepts and prints. */
|
|
@@ -51,6 +51,13 @@ export const enUS = {
|
|
|
51
51
|
passwordStrength: ['Too short', 'Weak', 'Fair', 'Good', 'Strong'],
|
|
52
52
|
decrease: 'Decrease',
|
|
53
53
|
increase: 'Increase',
|
|
54
|
+
chooseFile: 'Choose file',
|
|
55
|
+
noFile: 'No file chosen',
|
|
56
|
+
clearSearch: 'Clear search',
|
|
57
|
+
digit: 'Digit',
|
|
58
|
+
addTag: 'Add a tag',
|
|
59
|
+
duplicateTag: 'Already added',
|
|
60
|
+
pickColour: 'Pick a colour',
|
|
54
61
|
dateLocale: 'en-US',
|
|
55
62
|
dateFormat: 'mdy',
|
|
56
63
|
today: 'Today',
|
|
@@ -103,6 +110,13 @@ export const idID = {
|
|
|
103
110
|
passwordStrength: ['Terlalu pendek', 'Lemah', 'Cukup', 'Baik', 'Kuat'],
|
|
104
111
|
decrease: 'Kurangi',
|
|
105
112
|
increase: 'Tambah',
|
|
113
|
+
chooseFile: 'Pilih berkas',
|
|
114
|
+
noFile: 'Belum ada berkas',
|
|
115
|
+
clearSearch: 'Hapus pencarian',
|
|
116
|
+
digit: 'Digit',
|
|
117
|
+
addTag: 'Tambah tag',
|
|
118
|
+
duplicateTag: 'Sudah ditambahkan',
|
|
119
|
+
pickColour: 'Pilih warna',
|
|
106
120
|
dateLocale: 'id-ID',
|
|
107
121
|
dateFormat: 'dmy',
|
|
108
122
|
today: 'Hari ini',
|
package/dist/core/number.d.ts
CHANGED
|
@@ -5,3 +5,25 @@ export declare function stepDecimals(step: number): number;
|
|
|
5
5
|
export declare function clampToStep(value: number, min: number, max: number, step: number): number;
|
|
6
6
|
/** Position of `value` within `min…max`, as 0–100. */
|
|
7
7
|
export declare function percentOf(value: number, min: number, max: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* How a grouped number is written. Separators are the app's to choose rather
|
|
10
|
+
* than `Intl`'s, because the field has to *parse* what it prints — and a
|
|
11
|
+
* locale that groups with a non-breaking space produces a character nobody can
|
|
12
|
+
* type back in.
|
|
13
|
+
*/
|
|
14
|
+
export interface GroupOptions {
|
|
15
|
+
/** Thousands separator. */
|
|
16
|
+
group?: string;
|
|
17
|
+
/** Decimal separator. */
|
|
18
|
+
decimal?: string;
|
|
19
|
+
/** Fixed decimal places. `undefined` keeps whatever was typed. */
|
|
20
|
+
precision?: number;
|
|
21
|
+
}
|
|
22
|
+
/** `1234567.5` → `1,234,567.50`, or `1.234.567,50` in a locale that swaps them. */
|
|
23
|
+
export declare function formatGrouped(value: number, options?: GroupOptions): string;
|
|
24
|
+
/**
|
|
25
|
+
* The inverse, and deliberately forgiving: anything that is not a digit, a
|
|
26
|
+
* minus or the decimal separator is dropped, so a pasted `Rp 1.250.000,-`
|
|
27
|
+
* still reads as a number. `null` means there were no digits at all.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseGrouped(text: string, options?: GroupOptions): number | null;
|
package/dist/core/number.js
CHANGED
|
@@ -15,3 +15,36 @@ export function percentOf(value, min, max) {
|
|
|
15
15
|
return 0;
|
|
16
16
|
return Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
17
17
|
}
|
|
18
|
+
/** `1234567.5` → `1,234,567.50`, or `1.234.567,50` in a locale that swaps them. */
|
|
19
|
+
export function formatGrouped(value, options = {}) {
|
|
20
|
+
const { group = ',', decimal = '.', precision } = options;
|
|
21
|
+
if (!Number.isFinite(value))
|
|
22
|
+
return '';
|
|
23
|
+
const fixed = precision === undefined ? String(value) : value.toFixed(precision);
|
|
24
|
+
const negative = fixed.startsWith('-');
|
|
25
|
+
const [whole, fraction] = (negative ? fixed.slice(1) : fixed).split('.');
|
|
26
|
+
// Replaced through a function so a separator that looks like `$&` stays put.
|
|
27
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, () => group);
|
|
28
|
+
return `${negative ? '-' : ''}${grouped}${fraction ? decimal + fraction : ''}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The inverse, and deliberately forgiving: anything that is not a digit, a
|
|
32
|
+
* minus or the decimal separator is dropped, so a pasted `Rp 1.250.000,-`
|
|
33
|
+
* still reads as a number. `null` means there were no digits at all.
|
|
34
|
+
*/
|
|
35
|
+
export function parseGrouped(text, options = {}) {
|
|
36
|
+
const { decimal = '.' } = options;
|
|
37
|
+
let cleaned = '';
|
|
38
|
+
for (const char of text) {
|
|
39
|
+
if (char === decimal)
|
|
40
|
+
cleaned += '.';
|
|
41
|
+
else if (char >= '0' && char <= '9')
|
|
42
|
+
cleaned += char;
|
|
43
|
+
else if (char === '-' && cleaned === '')
|
|
44
|
+
cleaned += char;
|
|
45
|
+
}
|
|
46
|
+
if (!cleaned || cleaned === '-' || cleaned === '.')
|
|
47
|
+
return null;
|
|
48
|
+
const value = Number(cleaned);
|
|
49
|
+
return Number.isFinite(value) ? value : null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time codes: what a character box accepts, and where a paste lands.
|
|
3
|
+
*
|
|
4
|
+
* The value is a plain compact string — `'0421'` means the first four boxes
|
|
5
|
+
* are filled and the rest are empty. Holes are impossible by construction,
|
|
6
|
+
* which is what lets the component always know which box to focus next.
|
|
7
|
+
*/
|
|
8
|
+
export type PinMode = 'numeric' | 'alphanumeric';
|
|
9
|
+
/** Strips whatever the mode refuses, then trims to `length` characters. */
|
|
10
|
+
export declare function sanitisePin(text: string, mode?: PinMode, length?: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Writes `text` into the boxes from `index` on.
|
|
13
|
+
*
|
|
14
|
+
* One keystroke replaces one box and leaves the tail alone; a six-character
|
|
15
|
+
* paste into the first box fills all six. Anything past `length` is dropped
|
|
16
|
+
* rather than wrapped, because the extra characters were never part of a code
|
|
17
|
+
* this long.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fillFrom(value: string, index: number, text: string, length: number, mode?: PinMode): string;
|
|
20
|
+
/** Clears one box and everything after it — what Backspace on a filled box does. */
|
|
21
|
+
export declare function clearFrom(value: string, index: number): string;
|
|
22
|
+
/** The box the caret belongs in: the first empty one, or the last. */
|
|
23
|
+
export declare function caretIndex(value: string, length: number): number;
|
package/dist/core/pin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time codes: what a character box accepts, and where a paste lands.
|
|
3
|
+
*
|
|
4
|
+
* The value is a plain compact string — `'0421'` means the first four boxes
|
|
5
|
+
* are filled and the rest are empty. Holes are impossible by construction,
|
|
6
|
+
* which is what lets the component always know which box to focus next.
|
|
7
|
+
*/
|
|
8
|
+
const ALLOWED = {
|
|
9
|
+
numeric: /[^0-9]/g,
|
|
10
|
+
alphanumeric: /[^0-9a-zA-Z]/g
|
|
11
|
+
};
|
|
12
|
+
/** Strips whatever the mode refuses, then trims to `length` characters. */
|
|
13
|
+
export function sanitisePin(text, mode = 'numeric', length = Infinity) {
|
|
14
|
+
return text.replace(ALLOWED[mode], '').slice(0, length);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Writes `text` into the boxes from `index` on.
|
|
18
|
+
*
|
|
19
|
+
* One keystroke replaces one box and leaves the tail alone; a six-character
|
|
20
|
+
* paste into the first box fills all six. Anything past `length` is dropped
|
|
21
|
+
* rather than wrapped, because the extra characters were never part of a code
|
|
22
|
+
* this long.
|
|
23
|
+
*/
|
|
24
|
+
export function fillFrom(value, index, text, length, mode = 'numeric') {
|
|
25
|
+
const head = value.slice(0, index);
|
|
26
|
+
const clean = sanitisePin(text, mode, length - head.length);
|
|
27
|
+
if (!clean)
|
|
28
|
+
return value;
|
|
29
|
+
return (head + clean + value.slice(index + clean.length)).slice(0, length);
|
|
30
|
+
}
|
|
31
|
+
/** Clears one box and everything after it — what Backspace on a filled box does. */
|
|
32
|
+
export function clearFrom(value, index) {
|
|
33
|
+
return value.slice(0, index);
|
|
34
|
+
}
|
|
35
|
+
/** The box the caret belongs in: the first empty one, or the last. */
|
|
36
|
+
export function caretIndex(value, length) {
|
|
37
|
+
return Math.min(value.length, length - 1);
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-text tokens: what counts as a new tag, and why one was turned away.
|
|
3
|
+
*
|
|
4
|
+
* The rejection is returned rather than thrown or swallowed, so the component
|
|
5
|
+
* can say *why* nothing happened — a silent no-op on a duplicate reads like a
|
|
6
|
+
* broken field.
|
|
7
|
+
*/
|
|
8
|
+
export type TagRejection = 'empty' | 'short' | 'duplicate' | 'full';
|
|
9
|
+
export interface TagRules {
|
|
10
|
+
/** Trim the ends and collapse inner runs of whitespace. Default `true`. */
|
|
11
|
+
trim?: boolean;
|
|
12
|
+
/** Ignore case when refusing duplicates. Default `true`. */
|
|
13
|
+
caseInsensitive?: boolean;
|
|
14
|
+
/** Cap on how many tags may be held. `0` means no limit. */
|
|
15
|
+
max?: number;
|
|
16
|
+
/** Shortest a tag may be, after trimming. */
|
|
17
|
+
minLength?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Sorts one commit into the next list, or the reason there isn't one.
|
|
21
|
+
*
|
|
22
|
+
* Order matters, the way it does in `sortFiles`: a tag is judged on being
|
|
23
|
+
* present, then long enough, then unique, then whether there is room — so the
|
|
24
|
+
* message a user sees is the first rule they actually broke.
|
|
25
|
+
*/
|
|
26
|
+
export declare function addTag(tags: string[], raw: string, rules?: TagRules): {
|
|
27
|
+
tags: string[];
|
|
28
|
+
rejected?: TagRejection;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Splits pasted text so one paste can commit several tags.
|
|
32
|
+
*
|
|
33
|
+
* Scanned character by character rather than with a RegExp: the separators are
|
|
34
|
+
* the app's to choose, and building a character class out of them means
|
|
35
|
+
* escaping every one that happens to be a metacharacter.
|
|
36
|
+
*/
|
|
37
|
+
export declare function splitTags(text: string, separators?: readonly string[]): string[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-text tokens: what counts as a new tag, and why one was turned away.
|
|
3
|
+
*
|
|
4
|
+
* The rejection is returned rather than thrown or swallowed, so the component
|
|
5
|
+
* can say *why* nothing happened — a silent no-op on a duplicate reads like a
|
|
6
|
+
* broken field.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Sorts one commit into the next list, or the reason there isn't one.
|
|
10
|
+
*
|
|
11
|
+
* Order matters, the way it does in `sortFiles`: a tag is judged on being
|
|
12
|
+
* present, then long enough, then unique, then whether there is room — so the
|
|
13
|
+
* message a user sees is the first rule they actually broke.
|
|
14
|
+
*/
|
|
15
|
+
export function addTag(tags, raw, rules = {}) {
|
|
16
|
+
const { trim = true, caseInsensitive = true, max = 0, minLength = 1 } = rules;
|
|
17
|
+
const value = trim ? raw.trim().replace(/\s+/g, ' ') : raw;
|
|
18
|
+
if (!value)
|
|
19
|
+
return { tags, rejected: 'empty' };
|
|
20
|
+
if (value.length < minLength)
|
|
21
|
+
return { tags, rejected: 'short' };
|
|
22
|
+
const fold = (text) => (caseInsensitive ? text.toLowerCase() : text);
|
|
23
|
+
if (tags.some((tag) => fold(tag) === fold(value)))
|
|
24
|
+
return { tags, rejected: 'duplicate' };
|
|
25
|
+
if (max && tags.length >= max)
|
|
26
|
+
return { tags, rejected: 'full' };
|
|
27
|
+
return { tags: [...tags, value] };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Splits pasted text so one paste can commit several tags.
|
|
31
|
+
*
|
|
32
|
+
* Scanned character by character rather than with a RegExp: the separators are
|
|
33
|
+
* the app's to choose, and building a character class out of them means
|
|
34
|
+
* escaping every one that happens to be a metacharacter.
|
|
35
|
+
*/
|
|
36
|
+
export function splitTags(text, separators = [',']) {
|
|
37
|
+
const marks = new Set([...separators, '\n', '\r', '\t']);
|
|
38
|
+
const found = [];
|
|
39
|
+
let current = '';
|
|
40
|
+
for (const char of text) {
|
|
41
|
+
if (marks.has(char)) {
|
|
42
|
+
found.push(current);
|
|
43
|
+
current = '';
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
current += char;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
found.push(current);
|
|
50
|
+
return found.map((tag) => tag.trim()).filter(Boolean);
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock times as `HH:MM` strings — the shape `<input type="time">` uses, so a
|
|
3
|
+
* form posts exactly what the browser would have posted.
|
|
4
|
+
*
|
|
5
|
+
* Everything here works in minutes since midnight. A day is 1440 of them, and
|
|
6
|
+
* nothing in this module crosses that boundary: stepping past 23:59 stops
|
|
7
|
+
* there rather than wrapping into a day this module cannot name.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MINUTES_IN_DAY = 1440;
|
|
10
|
+
/**
|
|
11
|
+
* Reads `9:5`, `09:05`, `0905` or `09:05:30` into minutes since midnight.
|
|
12
|
+
* Returns `null` for anything that is not a time, including 24:00 and 12:60.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseTime(text: string): number | null;
|
|
15
|
+
/** Minutes since midnight back to `HH:MM`, always four digits and a colon. */
|
|
16
|
+
export declare function formatTime(minutes: number): string;
|
|
17
|
+
/** Clamps into `min…max` and snaps to the nearest multiple of `step` minutes. */
|
|
18
|
+
export declare function clampTime(minutes: number, min?: string, max?: string, step?: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* Moves by whole steps. Stops at the bounds instead of wrapping — a stepper
|
|
21
|
+
* that rolls 23:55 round to 00:00 turns a late meeting into an early one.
|
|
22
|
+
*/
|
|
23
|
+
export declare function stepTime(value: string, direction: 1 | -1, step?: number, min?: string, max?: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* `13:05` as the locale writes it — `1:05 PM` in en-US, `13.05` in id-ID.
|
|
26
|
+
*
|
|
27
|
+
* Display only. The bound value stays `HH:MM` whatever this returns, because a
|
|
28
|
+
* localised string is not something a form can post.
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatClock(value: string, locale: string, hour12?: boolean): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock times as `HH:MM` strings — the shape `<input type="time">` uses, so a
|
|
3
|
+
* form posts exactly what the browser would have posted.
|
|
4
|
+
*
|
|
5
|
+
* Everything here works in minutes since midnight. A day is 1440 of them, and
|
|
6
|
+
* nothing in this module crosses that boundary: stepping past 23:59 stops
|
|
7
|
+
* there rather than wrapping into a day this module cannot name.
|
|
8
|
+
*/
|
|
9
|
+
export const MINUTES_IN_DAY = 1440;
|
|
10
|
+
/**
|
|
11
|
+
* Reads `9:5`, `09:05`, `0905` or `09:05:30` into minutes since midnight.
|
|
12
|
+
* Returns `null` for anything that is not a time, including 24:00 and 12:60.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTime(text) {
|
|
15
|
+
const trimmed = text.trim();
|
|
16
|
+
// Digits and colons only: `9a` and `later` are refusals, not a hint that
|
|
17
|
+
// someone meant 09:00. Without this a bare `Number('')` reads as midnight.
|
|
18
|
+
if (!trimmed || !/^[0-9:]+$/.test(trimmed))
|
|
19
|
+
return null;
|
|
20
|
+
const digits = trimmed.replace(/[^0-9]/g, '');
|
|
21
|
+
if (!digits)
|
|
22
|
+
return null;
|
|
23
|
+
let hours;
|
|
24
|
+
let minutes;
|
|
25
|
+
if (trimmed.includes(':')) {
|
|
26
|
+
const [rawHours, rawMinutes = '0'] = trimmed.split(':');
|
|
27
|
+
hours = Number(rawHours);
|
|
28
|
+
minutes = Number(rawMinutes);
|
|
29
|
+
}
|
|
30
|
+
else if (digits.length <= 2) {
|
|
31
|
+
// A bare number is an hour: `9` means 09:00.
|
|
32
|
+
hours = Number(digits);
|
|
33
|
+
minutes = 0;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
hours = Number(digits.slice(0, digits.length - 2));
|
|
37
|
+
minutes = Number(digits.slice(-2));
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes))
|
|
40
|
+
return null;
|
|
41
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59)
|
|
42
|
+
return null;
|
|
43
|
+
return hours * 60 + minutes;
|
|
44
|
+
}
|
|
45
|
+
/** Minutes since midnight back to `HH:MM`, always four digits and a colon. */
|
|
46
|
+
export function formatTime(minutes) {
|
|
47
|
+
const total = Math.max(0, Math.min(MINUTES_IN_DAY - 1, Math.round(minutes)));
|
|
48
|
+
const hours = Math.floor(total / 60);
|
|
49
|
+
return `${String(hours).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
|
50
|
+
}
|
|
51
|
+
/** Clamps into `min…max` and snaps to the nearest multiple of `step` minutes. */
|
|
52
|
+
export function clampTime(minutes, min, max, step = 1) {
|
|
53
|
+
const floor = min ? (parseTime(min) ?? 0) : 0;
|
|
54
|
+
const ceiling = max ? (parseTime(max) ?? MINUTES_IN_DAY - 1) : MINUTES_IN_DAY - 1;
|
|
55
|
+
const snapped = step > 1 ? Math.round(minutes / step) * step : minutes;
|
|
56
|
+
return Math.max(floor, Math.min(ceiling, snapped));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Moves by whole steps. Stops at the bounds instead of wrapping — a stepper
|
|
60
|
+
* that rolls 23:55 round to 00:00 turns a late meeting into an early one.
|
|
61
|
+
*/
|
|
62
|
+
export function stepTime(value, direction, step = 1, min, max) {
|
|
63
|
+
const current = parseTime(value) ?? clampTime(0, min, max, step);
|
|
64
|
+
return formatTime(clampTime(current + direction * step, min, max, step));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `13:05` as the locale writes it — `1:05 PM` in en-US, `13.05` in id-ID.
|
|
68
|
+
*
|
|
69
|
+
* Display only. The bound value stays `HH:MM` whatever this returns, because a
|
|
70
|
+
* localised string is not something a form can post.
|
|
71
|
+
*/
|
|
72
|
+
export function formatClock(value, locale, hour12) {
|
|
73
|
+
const minutes = parseTime(value);
|
|
74
|
+
if (minutes === null)
|
|
75
|
+
return '';
|
|
76
|
+
const date = new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60);
|
|
77
|
+
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12 }).format(date);
|
|
78
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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]:font-mono [&_input]:uppercase"
|
|
76
|
+
{...rest}
|
|
77
|
+
>
|
|
78
|
+
{#snippet prefix()}
|
|
79
|
+
<span class="relative grid size-5 place-items-center border border-hairline">
|
|
80
|
+
<span class="absolute inset-0" style="background-color: {resolved}"></span>
|
|
81
|
+
<input
|
|
82
|
+
type="color"
|
|
83
|
+
value={resolved}
|
|
84
|
+
{disabled}
|
|
85
|
+
aria-label={t.current.pickColour}
|
|
86
|
+
oninput={(event) => choose(event.currentTarget.value)}
|
|
87
|
+
class="absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
88
|
+
/>
|
|
89
|
+
</span>
|
|
90
|
+
{/snippet}
|
|
91
|
+
</Input>
|
|
92
|
+
|
|
93
|
+
{#if swatches.length}
|
|
94
|
+
<div class="flex flex-wrap gap-1.5">
|
|
95
|
+
{#each swatches as swatch (swatch)}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
{disabled}
|
|
99
|
+
onclick={() => choose(swatch)}
|
|
100
|
+
title={swatch}
|
|
101
|
+
aria-label={swatch}
|
|
102
|
+
aria-pressed={resolved === normaliseHex(swatch)}
|
|
103
|
+
class="size-6 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
|
|
104
|
+
{resolved === normaliseHex(swatch) ? 'border-text' : 'border-hairline'}"
|
|
105
|
+
style="background-color: {swatch}"
|
|
106
|
+
></button>
|
|
107
|
+
{/each}
|
|
108
|
+
</div>
|
|
109
|
+
{/if}
|
|
110
|
+
</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;
|