@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,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
+ }
@@ -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';
@@ -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',
@@ -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;
@@ -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;
@@ -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;