@poodle64/ui 2026.8.14 → 2026.8.17
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 +305 -12
- package/dist/components/ui/form/form-button.svelte +7 -0
- package/dist/components/ui/form/form-button.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-description.svelte +18 -0
- package/dist/components/ui/form/form-description.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-element-field.svelte +25 -0
- package/dist/components/ui/form/form-element-field.svelte.d.ts +28 -0
- package/dist/components/ui/form/form-field-errors.svelte +31 -0
- package/dist/components/ui/form/form-field-errors.svelte.d.ts +8 -0
- package/dist/components/ui/form/form-field.svelte +25 -0
- package/dist/components/ui/form/form-field.svelte.d.ts +28 -0
- package/dist/components/ui/form/form-fieldset.svelte +16 -0
- package/dist/components/ui/form/form-fieldset.svelte.d.ts +27 -0
- package/dist/components/ui/form/form-label.svelte +25 -0
- package/dist/components/ui/form/form-label.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-legend.svelte +17 -0
- package/dist/components/ui/form/form-legend.svelte.d.ts +4 -0
- package/dist/components/ui/form/index.d.ts +11 -0
- package/dist/components/ui/form/index.js +13 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/library-browse/document-table.svelte +13 -1
- package/dist/components/ui/page-header/page-header.svelte +70 -33
- package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
- package/dist/format.d.ts +222 -0
- package/dist/format.js +422 -0
- package/dist/styles.css +179 -5
- package/package.json +16 -2
- package/registry/component-map.json +74 -4
- package/registry/component-map.md +18 -2
package/dist/format.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The household's Australian value formatters.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Two apps had hand-rolled the same job — `godswood/frontend/src/lib/utils/
|
|
7
|
+
* formatters.ts` and `pebblestone/frontend/src/lib/utils/format.ts` — and had
|
|
8
|
+
* already drifted on every decision that matters: whether a percentage arrives
|
|
9
|
+
* as `4.5` or `0.045`, whether money arrives as dollars or as integer cents,
|
|
10
|
+
* whether a date renders `19 Dec 2024` or `19/12/2024`, and whether a missing
|
|
11
|
+
* value reads `N/A` or `-`. All four are user-visible, and the fleet is
|
|
12
|
+
* entirely Australian, so the disagreement bought nothing.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS IN AND WHAT IS NOT
|
|
15
|
+
* --------------------------
|
|
16
|
+
* In: every formatter for a value class BOTH apps format — money, dates and
|
|
17
|
+
* times, percentages, plain numbers — including the variants only one app has
|
|
18
|
+
* today, because a value class the package owns it owns completely. Splitting
|
|
19
|
+
* money across two homes is how the drift started.
|
|
20
|
+
*
|
|
21
|
+
* Out: formatters for a value class only ONE app has, which are a domain
|
|
22
|
+
* vocabulary rather than a shared value class — loan repayment frequencies and
|
|
23
|
+
* AI model IDs (godswood), file sizes (godswood), pager arithmetic and the
|
|
24
|
+
* per-line GST recompute for bill approvals (pebblestone, and bound to Xero tax
|
|
25
|
+
* codes besides). Relative time ("2 hours ago") is out too: it lives on
|
|
26
|
+
* `date-fns` in the one app that has it, and a display formatter is not worth
|
|
27
|
+
* making that a dependency of every consumer of this package.
|
|
28
|
+
*
|
|
29
|
+
* THE CONVENTIONS THESE ENCODE
|
|
30
|
+
* ----------------------------
|
|
31
|
+
* - `en-AU`, AUD, and `Australia/Brisbane` wherever a timezone is implied. The
|
|
32
|
+
* zone is pinned rather than taken from the browser: the household's books
|
|
33
|
+
* are kept in AEST, so a laptop in another zone should not renumber them.
|
|
34
|
+
* - Dates render `DD Mon YYYY` or, on request, `DD/MM/YYYY` — both sanctioned
|
|
35
|
+
* Australian forms.
|
|
36
|
+
* - A negative amount carries its sign OUTSIDE the symbol: `-$1,234.56`, never
|
|
37
|
+
* `$-1,234.56`.
|
|
38
|
+
* - A missing value renders `N/A`, and every formatter takes a `fallback` to
|
|
39
|
+
* say otherwise. `-` is deliberately not the default: beside a money column
|
|
40
|
+
* it reads as a minus sign, which is the one wrong meaning available.
|
|
41
|
+
*
|
|
42
|
+
* No dependencies, no DOM: this module is `Intl` and arithmetic.
|
|
43
|
+
*/
|
|
44
|
+
/** The one locale every household app formats in. */
|
|
45
|
+
export const AU_LOCALE = 'en-AU';
|
|
46
|
+
/** The one timezone the household's records are kept in. */
|
|
47
|
+
export const AU_TIME_ZONE = 'Australia/Brisbane';
|
|
48
|
+
/** What a formatter renders when it has nothing to render. */
|
|
49
|
+
const FALLBACK = 'N/A';
|
|
50
|
+
/**
|
|
51
|
+
* Coerce an API value to a finite number, or null.
|
|
52
|
+
*
|
|
53
|
+
* `Number` rather than `parseFloat`, deliberately: `parseFloat('12abc')` is 12,
|
|
54
|
+
* which renders corrupt data as a plausible figure. A value that is not wholly
|
|
55
|
+
* numeric falls back instead.
|
|
56
|
+
*/
|
|
57
|
+
function toFiniteNumber(value) {
|
|
58
|
+
if (value === null || value === undefined)
|
|
59
|
+
return null;
|
|
60
|
+
if (typeof value === 'number')
|
|
61
|
+
return Number.isFinite(value) ? value : null;
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
if (trimmed === '')
|
|
64
|
+
return null;
|
|
65
|
+
const num = Number(trimmed);
|
|
66
|
+
return Number.isFinite(num) ? num : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Format a DOLLAR value as currency.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* formatCurrency(1234.56) // '$1,234.56'
|
|
73
|
+
* formatCurrency('1234.56', { decimals: 0 }) // '$1,235'
|
|
74
|
+
* formatCurrency(-500, { decimals: 0 }) // '-$500'
|
|
75
|
+
* formatCurrency(null) // 'N/A'
|
|
76
|
+
*/
|
|
77
|
+
export function formatCurrency(value, options = {}) {
|
|
78
|
+
const { decimals = 2, fallback = FALLBACK, currency = 'AUD' } = options;
|
|
79
|
+
const num = toFiniteNumber(value);
|
|
80
|
+
if (num === null)
|
|
81
|
+
return fallback;
|
|
82
|
+
return new Intl.NumberFormat(AU_LOCALE, {
|
|
83
|
+
style: 'currency',
|
|
84
|
+
currency,
|
|
85
|
+
minimumFractionDigits: decimals,
|
|
86
|
+
maximumFractionDigits: decimals
|
|
87
|
+
}).format(num);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Format an INTEGER-CENTS value as currency.
|
|
91
|
+
*
|
|
92
|
+
* Money held as integer cents is the correct storage for money, and the app
|
|
93
|
+
* that does it should not have to divide by 100 at every call site and hope
|
|
94
|
+
* the float lands.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* formatCurrencyFromCents(123456) // '$1,234.56'
|
|
98
|
+
* formatCurrencyFromCents(-123456, { decimals: 0 }) // '-$1,235'
|
|
99
|
+
* formatCurrencyFromCents(null) // 'N/A'
|
|
100
|
+
*/
|
|
101
|
+
export function formatCurrencyFromCents(cents, options = {}) {
|
|
102
|
+
if (cents === null || cents === undefined || !Number.isFinite(cents)) {
|
|
103
|
+
return options.fallback ?? FALLBACK;
|
|
104
|
+
}
|
|
105
|
+
return formatCurrency(cents / 100, options);
|
|
106
|
+
}
|
|
107
|
+
/** Convert a dollar value to whole cents. */
|
|
108
|
+
export function dollarsToCents(dollars) {
|
|
109
|
+
return Math.round(dollars * 100);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The currency prefix `formatCurrency` would use — `$` for AUD, `USD ` for
|
|
113
|
+
* USD — so the compact form and the full form never disagree about the symbol.
|
|
114
|
+
*/
|
|
115
|
+
function currencyPrefix(currency) {
|
|
116
|
+
const parts = new Intl.NumberFormat(AU_LOCALE, {
|
|
117
|
+
style: 'currency',
|
|
118
|
+
currency,
|
|
119
|
+
minimumFractionDigits: 0,
|
|
120
|
+
maximumFractionDigits: 0
|
|
121
|
+
}).formatToParts(0);
|
|
122
|
+
let prefix = '';
|
|
123
|
+
for (const part of parts) {
|
|
124
|
+
if (part.type === 'integer')
|
|
125
|
+
break;
|
|
126
|
+
if (part.type === 'currency' || part.type === 'literal')
|
|
127
|
+
prefix += part.value;
|
|
128
|
+
}
|
|
129
|
+
return prefix;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Abbreviate a money value for a chart axis or a dense tile.
|
|
133
|
+
*
|
|
134
|
+
* A y-axis tick has room for four or five characters, not for `$1,109,057.64`.
|
|
135
|
+
* Full precision belongs in the tooltip and the table; the axis exists to tell
|
|
136
|
+
* the reader which order of magnitude they are looking at.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* compactCurrency(1_109_057.64) // '$1.1m'
|
|
140
|
+
* compactCurrency(-1_500_000) // '-$1.5m'
|
|
141
|
+
* compactCurrency(12_400) // '$12k'
|
|
142
|
+
*/
|
|
143
|
+
export function compactCurrency(value, options = {}) {
|
|
144
|
+
const { currency = 'AUD', fallback = FALLBACK } = options;
|
|
145
|
+
if (value === null || value === undefined || !Number.isFinite(value))
|
|
146
|
+
return fallback;
|
|
147
|
+
const prefix = currencyPrefix(currency);
|
|
148
|
+
// Sign outside the symbol, as everywhere else here: `-$1.5m`, not `$-1.5m`.
|
|
149
|
+
const sign = value < 0 ? '-' : '';
|
|
150
|
+
const abs = Math.abs(value);
|
|
151
|
+
if (abs >= 1_000_000)
|
|
152
|
+
return `${sign}${prefix}${(abs / 1_000_000).toFixed(1)}m`;
|
|
153
|
+
if (abs >= 1_000)
|
|
154
|
+
return `${sign}${prefix}${Math.round(abs / 1_000)}k`;
|
|
155
|
+
return `${sign}${prefix}${Math.round(abs)}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Format a money figure that arrives as a STRING, without ever parsing it to a
|
|
159
|
+
* JS number.
|
|
160
|
+
*
|
|
161
|
+
* A securities tax P&L reaches a tax return, so a figure must render with the
|
|
162
|
+
* exact digits the API sent — `parseFloat` rounds at 2^53 and quietly changes
|
|
163
|
+
* cents on a nine-figure total. This groups the integer part with thousands
|
|
164
|
+
* separators and preserves the fractional part verbatim, so the displayed
|
|
165
|
+
* string is faithful to the source decimal whatever its magnitude.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* formatCurrencyString('1234.56') // '$1,234.56'
|
|
169
|
+
* formatCurrencyString('-1234567.89') // '-$1,234,567.89'
|
|
170
|
+
* formatCurrencyString('0') // '$0'
|
|
171
|
+
* formatCurrencyString(null) // 'N/A'
|
|
172
|
+
*/
|
|
173
|
+
export function formatCurrencyString(value, options = {}) {
|
|
174
|
+
const { fallback = FALLBACK } = options;
|
|
175
|
+
if (value === null || value === undefined)
|
|
176
|
+
return fallback;
|
|
177
|
+
const trimmed = String(value).trim();
|
|
178
|
+
if (trimmed === '' || trimmed === 'NaN')
|
|
179
|
+
return fallback;
|
|
180
|
+
let negative = false;
|
|
181
|
+
let body = trimmed;
|
|
182
|
+
if (body.startsWith('-')) {
|
|
183
|
+
negative = true;
|
|
184
|
+
body = body.slice(1);
|
|
185
|
+
}
|
|
186
|
+
else if (body.startsWith('+')) {
|
|
187
|
+
body = body.slice(1);
|
|
188
|
+
}
|
|
189
|
+
const [rawInt, rawFrac] = body.split('.');
|
|
190
|
+
const intPart = (rawInt ?? '').replace(/[^0-9]/g, '') || '0';
|
|
191
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
192
|
+
let out = grouped;
|
|
193
|
+
if (rawFrac !== undefined)
|
|
194
|
+
out += `.${rawFrac}`;
|
|
195
|
+
return `${negative ? '-' : ''}$${out}`;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Report the sign of a money value without parsing a string to a number — the
|
|
199
|
+
* float-free companion to `formatCurrencyString`, for choosing a tone class.
|
|
200
|
+
*/
|
|
201
|
+
export function isNegativeMoney(value) {
|
|
202
|
+
if (value === null || value === undefined)
|
|
203
|
+
return false;
|
|
204
|
+
if (typeof value === 'number')
|
|
205
|
+
return value < 0;
|
|
206
|
+
return value.trim().startsWith('-');
|
|
207
|
+
}
|
|
208
|
+
// --------------------------------------------------------------------------- //
|
|
209
|
+
// Numbers and percentages //
|
|
210
|
+
// --------------------------------------------------------------------------- //
|
|
211
|
+
/**
|
|
212
|
+
* Format a number with thousands separators.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* formatNumber(1234567) // '1,234,567'
|
|
216
|
+
* formatNumber(1234.567, { decimals: 2 }) // '1,234.57'
|
|
217
|
+
* formatNumber(null) // 'N/A'
|
|
218
|
+
*/
|
|
219
|
+
export function formatNumber(value, options = {}) {
|
|
220
|
+
const { decimals = 0, fallback = FALLBACK } = options;
|
|
221
|
+
const num = toFiniteNumber(value);
|
|
222
|
+
if (num === null)
|
|
223
|
+
return fallback;
|
|
224
|
+
return new Intl.NumberFormat(AU_LOCALE, {
|
|
225
|
+
minimumFractionDigits: decimals,
|
|
226
|
+
maximumFractionDigits: decimals
|
|
227
|
+
}).format(num);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Format a value that is ALREADY in percentage points: `4.5` renders `4.5%`.
|
|
231
|
+
*
|
|
232
|
+
* Its counterpart for a 0–1 ratio is `formatRatioAsPercentage`. The two are
|
|
233
|
+
* named apart on purpose — the apps disagreed about which one `formatPercent`
|
|
234
|
+
* meant, and picking either spelling for both would make a 100x error a
|
|
235
|
+
* one-character mistake.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* formatPercentage(4.5) // '4.5%'
|
|
239
|
+
* formatPercentage('4.5', { decimals: 2 }) // '4.50%'
|
|
240
|
+
* formatPercentage(null) // 'N/A'
|
|
241
|
+
*/
|
|
242
|
+
export function formatPercentage(value, options = {}) {
|
|
243
|
+
const { decimals = 1, fallback = FALLBACK } = options;
|
|
244
|
+
const num = toFiniteNumber(value);
|
|
245
|
+
if (num === null)
|
|
246
|
+
return fallback;
|
|
247
|
+
return `${num.toFixed(decimals)}%`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Format a 0–1 RATIO as a percentage: `0.045` renders `4.5%`.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* formatRatioAsPercentage(0.045) // '4.5%'
|
|
254
|
+
* formatRatioAsPercentage(1) // '100.0%'
|
|
255
|
+
* formatRatioAsPercentage(null) // 'N/A'
|
|
256
|
+
*/
|
|
257
|
+
export function formatRatioAsPercentage(value, options = {}) {
|
|
258
|
+
const { decimals = 1, fallback = FALLBACK } = options;
|
|
259
|
+
const num = toFiniteNumber(value);
|
|
260
|
+
if (num === null)
|
|
261
|
+
return fallback;
|
|
262
|
+
return `${(num * 100).toFixed(decimals)}%`;
|
|
263
|
+
}
|
|
264
|
+
// --------------------------------------------------------------------------- //
|
|
265
|
+
// Dates and times //
|
|
266
|
+
// --------------------------------------------------------------------------- //
|
|
267
|
+
// Intl `en-AU` short months are not uniformly three characters: June, July and
|
|
268
|
+
// Sept come back four. Force a canonical abbreviation so a date column aligns.
|
|
269
|
+
const SHORT_MONTHS = [
|
|
270
|
+
'Jan',
|
|
271
|
+
'Feb',
|
|
272
|
+
'Mar',
|
|
273
|
+
'Apr',
|
|
274
|
+
'May',
|
|
275
|
+
'Jun',
|
|
276
|
+
'Jul',
|
|
277
|
+
'Aug',
|
|
278
|
+
'Sep',
|
|
279
|
+
'Oct',
|
|
280
|
+
'Nov',
|
|
281
|
+
'Dec'
|
|
282
|
+
];
|
|
283
|
+
const LONG_MONTHS = [
|
|
284
|
+
'January',
|
|
285
|
+
'February',
|
|
286
|
+
'March',
|
|
287
|
+
'April',
|
|
288
|
+
'May',
|
|
289
|
+
'June',
|
|
290
|
+
'July',
|
|
291
|
+
'August',
|
|
292
|
+
'September',
|
|
293
|
+
'October',
|
|
294
|
+
'November',
|
|
295
|
+
'December'
|
|
296
|
+
];
|
|
297
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
298
|
+
/**
|
|
299
|
+
* Parse a timestamp the API returned.
|
|
300
|
+
*
|
|
301
|
+
* A backend that stores naive UTC (`datetime.now(UTC).replace(tzinfo=None)`)
|
|
302
|
+
* and serialises it with no offset hands JavaScript a string it reads as LOCAL
|
|
303
|
+
* time: in Brisbane that lands every stored moment ten hours early, so anything
|
|
304
|
+
* after 2pm shows the wrong DAY, not merely the wrong hour. An offset-less
|
|
305
|
+
* value carrying a time is therefore read as UTC; one that carries an offset is
|
|
306
|
+
* left alone, and a date-only value is left alone too (appending a `Z` to it
|
|
307
|
+
* produces an Invalid Date).
|
|
308
|
+
*/
|
|
309
|
+
export function parseApiDate(iso) {
|
|
310
|
+
const hasTime = iso.includes('T');
|
|
311
|
+
const hasOffset = /([zZ]|[+-]\d{2}:?\d{2})$/.test(iso);
|
|
312
|
+
return new Date(hasTime && !hasOffset ? `${iso}Z` : iso);
|
|
313
|
+
}
|
|
314
|
+
/** The calendar fields of an instant, as read in `timeZone`. */
|
|
315
|
+
function zonedParts(date, timeZone) {
|
|
316
|
+
const parts = new Intl.DateTimeFormat(AU_LOCALE, {
|
|
317
|
+
timeZone,
|
|
318
|
+
year: 'numeric',
|
|
319
|
+
month: '2-digit',
|
|
320
|
+
day: '2-digit',
|
|
321
|
+
hour: '2-digit',
|
|
322
|
+
minute: '2-digit',
|
|
323
|
+
hourCycle: 'h23'
|
|
324
|
+
}).formatToParts(date);
|
|
325
|
+
const value = (type) => parts.find((part) => part.type === type)?.value ?? '';
|
|
326
|
+
return {
|
|
327
|
+
day: Number(value('day')),
|
|
328
|
+
month: Number(value('month')),
|
|
329
|
+
year: Number(value('year')),
|
|
330
|
+
hour: value('hour'),
|
|
331
|
+
minute: value('minute')
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Render already-resolved calendar fields in one of the three date shapes. */
|
|
335
|
+
function renderDate(day, month, year, format) {
|
|
336
|
+
const dd = String(day).padStart(2, '0');
|
|
337
|
+
if (format === 'numeric') {
|
|
338
|
+
return `${dd}/${String(month).padStart(2, '0')}/${year}`;
|
|
339
|
+
}
|
|
340
|
+
const name = (format === 'long' ? LONG_MONTHS : SHORT_MONTHS)[month - 1];
|
|
341
|
+
return `${dd} ${name} ${year}`;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Format a date for an Australian reader.
|
|
345
|
+
*
|
|
346
|
+
* A date-only value (`YYYY-MM-DD`) renders the day it NAMES, with no timezone
|
|
347
|
+
* conversion — a date is not an instant, and converting one is how a booking
|
|
348
|
+
* dated the 1st shows as the 31st. A value carrying a time is an instant, and
|
|
349
|
+
* is read in `timeZone` (`Australia/Brisbane` by default, not the browser's
|
|
350
|
+
* zone, so the same record reads the same on a laptop in another country).
|
|
351
|
+
*
|
|
352
|
+
* A string that will not parse is returned unchanged rather than hidden behind
|
|
353
|
+
* the fallback: an unexpected date format is worth seeing.
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* formatDate('2024-12-19') // '19 Dec 2024'
|
|
357
|
+
* formatDate('2024-12-19', { format: 'long' }) // '19 December 2024'
|
|
358
|
+
* formatDate('2024-12-19', { format: 'numeric' }) // '19/12/2024'
|
|
359
|
+
* formatDate(null) // 'N/A'
|
|
360
|
+
*/
|
|
361
|
+
export function formatDate(value, options = {}) {
|
|
362
|
+
const { format = 'short', fallback = FALLBACK, timeZone = AU_TIME_ZONE } = options;
|
|
363
|
+
if (value === null || value === undefined)
|
|
364
|
+
return fallback;
|
|
365
|
+
if (typeof value === 'string') {
|
|
366
|
+
const trimmed = value.trim();
|
|
367
|
+
if (trimmed === '')
|
|
368
|
+
return fallback;
|
|
369
|
+
const dateOnly = DATE_ONLY.exec(trimmed);
|
|
370
|
+
if (dateOnly) {
|
|
371
|
+
const year = Number(dateOnly[1]);
|
|
372
|
+
const month = Number(dateOnly[2]);
|
|
373
|
+
const day = Number(dateOnly[3]);
|
|
374
|
+
// A well-shaped but impossible date (2024-13-45) is returned as sent.
|
|
375
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
376
|
+
return trimmed;
|
|
377
|
+
return renderDate(day, month, year, format);
|
|
378
|
+
}
|
|
379
|
+
const parsed = parseApiDate(trimmed);
|
|
380
|
+
if (Number.isNaN(parsed.getTime()))
|
|
381
|
+
return trimmed;
|
|
382
|
+
const parts = zonedParts(parsed, timeZone);
|
|
383
|
+
return renderDate(parts.day, parts.month, parts.year, format);
|
|
384
|
+
}
|
|
385
|
+
if (Number.isNaN(value.getTime()))
|
|
386
|
+
return fallback;
|
|
387
|
+
const parts = zonedParts(value, timeZone);
|
|
388
|
+
return renderDate(parts.day, parts.month, parts.year, format);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Format a timestamp as a date and a 24-hour time: `19 Dec 2024, 14:05`.
|
|
392
|
+
*
|
|
393
|
+
* 24 hours, not `2:05 pm`: it is unambiguous and it aligns in a column, which
|
|
394
|
+
* is where a timestamp almost always sits.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* formatDateTime('2024-12-19T04:05:00') // '19 Dec 2024, 14:05'
|
|
398
|
+
* formatDateTime('2024-12-19T04:05:00Z', { format: 'numeric' }) // '19/12/2024, 14:05'
|
|
399
|
+
* formatDateTime(null) // 'N/A'
|
|
400
|
+
*/
|
|
401
|
+
export function formatDateTime(value, options = {}) {
|
|
402
|
+
const { format = 'short', fallback = FALLBACK, timeZone = AU_TIME_ZONE } = options;
|
|
403
|
+
if (value === null || value === undefined)
|
|
404
|
+
return fallback;
|
|
405
|
+
let instant;
|
|
406
|
+
if (typeof value === 'string') {
|
|
407
|
+
const trimmed = value.trim();
|
|
408
|
+
if (trimmed === '')
|
|
409
|
+
return fallback;
|
|
410
|
+
instant = parseApiDate(trimmed);
|
|
411
|
+
if (Number.isNaN(instant.getTime()))
|
|
412
|
+
return trimmed;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
instant = value;
|
|
416
|
+
if (Number.isNaN(instant.getTime()))
|
|
417
|
+
return fallback;
|
|
418
|
+
}
|
|
419
|
+
const parts = zonedParts(instant, timeZone);
|
|
420
|
+
const date = renderDate(parts.day, parts.month, parts.year, format);
|
|
421
|
+
return `${date}, ${parts.hour}:${parts.minute}`;
|
|
422
|
+
}
|
package/dist/styles.css
CHANGED
|
@@ -198,7 +198,16 @@
|
|
|
198
198
|
--color-secondary: var(--secondary, var(--ds-color-surface-1));
|
|
199
199
|
--color-secondary-foreground: var(--secondary-foreground, var(--ds-color-foreground));
|
|
200
200
|
--color-muted: var(--muted, var(--ds-color-surface-1));
|
|
201
|
-
|
|
201
|
+
/* A SELECTION tint, not a surface rung (design-system#24). It was
|
|
202
|
+
--ds-color-surface-2 — the same rung --color-card resolves to — which made
|
|
203
|
+
`hover:bg-accent/50` on a card a no-op BY CONSTRUCTION: mixing a colour at
|
|
204
|
+
any opacity over a ground it is identical to cannot change a pixel. It also
|
|
205
|
+
left the menu/select/command highlight, the only thing this package's own
|
|
206
|
+
components use `bg-accent` for, at 1.03:1 against the popover it sits on in
|
|
207
|
+
light mode. A translucent tint of the accent composites over whatever
|
|
208
|
+
ground it lands on, so one value serves both, and it is the idiom this
|
|
209
|
+
package already ships for the SearchResults match highlight. */
|
|
210
|
+
--color-accent: var(--accent, color-mix(in oklch, var(--ds-color-primary) 12%, transparent));
|
|
202
211
|
--color-accent-foreground: var(--accent-foreground, var(--ds-color-foreground));
|
|
203
212
|
--color-input: var(--input, var(--ds-color-border));
|
|
204
213
|
|
|
@@ -788,22 +797,187 @@
|
|
|
788
797
|
the measure rather than outside it. That is the same arithmetic as the
|
|
789
798
|
`mx-auto max-w-4xl px-6` idiom this replaces, and keeping it means one
|
|
790
799
|
element rather than a second wrapper nobody asked for. */
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
800
|
+
/* Two classes, because the MEASURE and the CENTRING are two decisions and
|
|
801
|
+
only the shell's content box wants both (design-system#22).
|
|
802
|
+
|
|
803
|
+
`.ds-measure` caps the width and nothing else. It is for a block INSIDE a
|
|
804
|
+
page — the paragraph of explanatory prose on a route legitimately set to
|
|
805
|
+
`wide`, which would otherwise inherit a 120rem measure and run to ~123
|
|
806
|
+
characters a line. `.ds-shell-measure` is that plus `margin-inline: auto`,
|
|
807
|
+
which is right for the content box and wrong for a block within a page:
|
|
808
|
+
applied to a set of left-anchored paragraphs it indented them ~207px away
|
|
809
|
+
from their own label, measured in a consumer and reverted.
|
|
810
|
+
|
|
811
|
+
Same attribute, same custom properties, so a block retunes with the shell
|
|
812
|
+
rather than drifting from it. That is the whole point: the alternative an
|
|
813
|
+
app reaches for is a local `max-w-prose` or `max-w-[72ch]`, which is a
|
|
814
|
+
number typed once that never hears about a retune — four apps had written
|
|
815
|
+
ten of them between them, and not one matched this package's own 72ch. */
|
|
816
|
+
.ds-measure[data-measure='prose'],
|
|
795
817
|
.ds-shell-measure[data-measure='prose'] {
|
|
796
818
|
max-width: var(--ds-shell-measure-prose);
|
|
797
819
|
}
|
|
798
820
|
|
|
821
|
+
.ds-measure[data-measure='page'],
|
|
799
822
|
.ds-shell-measure[data-measure='page'] {
|
|
800
823
|
max-width: var(--ds-shell-measure-page);
|
|
801
824
|
}
|
|
802
825
|
|
|
826
|
+
.ds-measure[data-measure='wide'],
|
|
803
827
|
.ds-shell-measure[data-measure='wide'] {
|
|
804
828
|
max-width: var(--ds-shell-measure-wide);
|
|
805
829
|
}
|
|
806
830
|
|
|
831
|
+
.ds-shell-measure {
|
|
832
|
+
margin-inline: auto;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/* ── The prose face (design-system#32) ────────────────────────────────────
|
|
836
|
+
Styling for HTML the app did NOT author — rendered markdown, an extracted
|
|
837
|
+
document body, a description field that arrives as rich text.
|
|
838
|
+
|
|
839
|
+
Three consumers had built one of these, by three different mechanisms: a
|
|
840
|
+
hand-written token-based block, the Tailwind typography plugin, and a
|
|
841
|
+
second hand-written block that one of them had already duplicated inside
|
|
842
|
+
itself. Every consumer that renders a document body would otherwise derive
|
|
843
|
+
it a fourth time, and typography degrades worse than most things when it
|
|
844
|
+
is re-derived per app.
|
|
845
|
+
|
|
846
|
+
The package styles it and does NOT render it: sanitising untrusted HTML is
|
|
847
|
+
a security boundary, and a package cannot see the inputs the boundary is
|
|
848
|
+
protecting. The app owns the markdown-to-HTML seam and hands the result in.
|
|
849
|
+
|
|
850
|
+
It carries no measure. A reading measure and a reading face are two
|
|
851
|
+
decisions, and an app composes them:
|
|
852
|
+
|
|
853
|
+
<div class="ds-prose ds-measure" data-measure="prose">…</div>
|
|
854
|
+
|
|
855
|
+
Nothing here reaches for a colour or a length that is not a --ds-* token,
|
|
856
|
+
so a palette or a retune moves the whole face with it. */
|
|
857
|
+
.ds-prose {
|
|
858
|
+
color: var(--ds-color-foreground);
|
|
859
|
+
line-height: 1.7;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.ds-prose h1,
|
|
863
|
+
.ds-prose h2,
|
|
864
|
+
.ds-prose h3,
|
|
865
|
+
.ds-prose h4 {
|
|
866
|
+
font-family: var(--ds-font-display);
|
|
867
|
+
font-weight: 600;
|
|
868
|
+
line-height: 1.25;
|
|
869
|
+
margin-block: var(--ds-spacing-lg) var(--ds-spacing-sm);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/* `em` rather than the type scale: these headings sit INSIDE a document, so
|
|
873
|
+
they are relative to the body copy around them, not to the page's own
|
|
874
|
+
heading ramp. An <h1> in rendered content is not the page's <h1>. */
|
|
875
|
+
.ds-prose h1 {
|
|
876
|
+
font-size: 1.5em;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.ds-prose h2 {
|
|
880
|
+
font-size: 1.25em;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.ds-prose h3,
|
|
884
|
+
.ds-prose h4 {
|
|
885
|
+
font-size: 1.1em;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.ds-prose p,
|
|
889
|
+
.ds-prose ul,
|
|
890
|
+
.ds-prose ol,
|
|
891
|
+
.ds-prose blockquote {
|
|
892
|
+
margin-block: var(--ds-spacing-sm);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
.ds-prose ul,
|
|
896
|
+
.ds-prose ol {
|
|
897
|
+
padding-inline-start: var(--ds-spacing-lg);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Restored explicitly: the preflight reset strips list markers, and rendered
|
|
901
|
+
content is the one place a bullet carries meaning. */
|
|
902
|
+
.ds-prose ul {
|
|
903
|
+
list-style: disc;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.ds-prose ol {
|
|
907
|
+
list-style: decimal;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.ds-prose a {
|
|
911
|
+
color: var(--ds-color-primary);
|
|
912
|
+
text-decoration: underline;
|
|
913
|
+
text-underline-offset: 2px;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.ds-prose strong {
|
|
917
|
+
font-weight: 600;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.ds-prose code {
|
|
921
|
+
font-family: var(--ds-font-code);
|
|
922
|
+
font-size: 0.9em;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.ds-prose pre {
|
|
926
|
+
background: var(--ds-color-surface-1);
|
|
927
|
+
border: 1px solid var(--ds-color-border);
|
|
928
|
+
border-radius: var(--ds-radius-sm);
|
|
929
|
+
overflow-x: auto;
|
|
930
|
+
padding: var(--ds-spacing-sm);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
.ds-prose blockquote {
|
|
934
|
+
border-inline-start: 2px solid var(--ds-color-border-strong);
|
|
935
|
+
color: var(--ds-color-muted-foreground);
|
|
936
|
+
padding-inline-start: var(--ds-spacing-sm);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.ds-prose hr {
|
|
940
|
+
border: 0;
|
|
941
|
+
border-block-start: 1px solid var(--ds-color-border);
|
|
942
|
+
margin-block: var(--ds-spacing-lg);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/* A table in an extracted document is routinely wider than the measure around
|
|
946
|
+
it. It scrolls in its OWN box rather than pushing the page sideways — the
|
|
947
|
+
same rule the library surfaces learned at 390px. */
|
|
948
|
+
.ds-prose table {
|
|
949
|
+
border-collapse: collapse;
|
|
950
|
+
display: block;
|
|
951
|
+
font-size: 0.9em;
|
|
952
|
+
margin-block: var(--ds-spacing-md);
|
|
953
|
+
max-width: 100%;
|
|
954
|
+
overflow-x: auto;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.ds-prose th,
|
|
958
|
+
.ds-prose td {
|
|
959
|
+
border: 1px solid var(--ds-color-border);
|
|
960
|
+
padding: var(--ds-spacing-xs) var(--ds-spacing-sm);
|
|
961
|
+
text-align: start;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.ds-prose th {
|
|
965
|
+
color: var(--ds-color-muted-foreground);
|
|
966
|
+
font-weight: 600;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/* A figure in a document is evidence, so it gets the surface and border a
|
|
970
|
+
plate deserves, and never exceeds its column. */
|
|
971
|
+
.ds-prose img {
|
|
972
|
+
background: var(--ds-color-surface-2);
|
|
973
|
+
border: 1px solid var(--ds-color-border);
|
|
974
|
+
border-radius: var(--ds-radius-sm);
|
|
975
|
+
display: block;
|
|
976
|
+
height: auto;
|
|
977
|
+
margin-block: var(--ds-spacing-md);
|
|
978
|
+
max-width: 100%;
|
|
979
|
+
}
|
|
980
|
+
|
|
807
981
|
/* The content texture: the house atmosphere, painted once by the shell on its
|
|
808
982
|
own scrolling content region.
|
|
809
983
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.8.
|
|
3
|
+
"version": "2026.8.17",
|
|
4
4
|
"description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./dist/utils.d.ts",
|
|
21
21
|
"svelte": "./dist/utils.js"
|
|
22
22
|
},
|
|
23
|
+
"./format": {
|
|
24
|
+
"types": "./dist/format.d.ts",
|
|
25
|
+
"svelte": "./dist/format.js"
|
|
26
|
+
},
|
|
23
27
|
"./*": {
|
|
24
28
|
"types": "./dist/components/ui/*/index.d.ts",
|
|
25
29
|
"svelte": "./dist/components/ui/*/index.js"
|
|
@@ -27,9 +31,11 @@
|
|
|
27
31
|
},
|
|
28
32
|
"peerDependencies": {
|
|
29
33
|
"bits-ui": "^2.18.1",
|
|
34
|
+
"formsnap": "^2.0.1",
|
|
30
35
|
"mode-watcher": "^1.1.0",
|
|
31
36
|
"svelte": "^5.33.0",
|
|
32
|
-
"svelte-sonner": "^1.1.1"
|
|
37
|
+
"svelte-sonner": "^1.1.1",
|
|
38
|
+
"sveltekit-superforms": "^2.30.0"
|
|
33
39
|
},
|
|
34
40
|
"peerDependenciesMeta": {
|
|
35
41
|
"mode-watcher": {
|
|
@@ -37,6 +43,12 @@
|
|
|
37
43
|
},
|
|
38
44
|
"svelte-sonner": {
|
|
39
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"formsnap": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"sveltekit-superforms": {
|
|
51
|
+
"optional": true
|
|
40
52
|
}
|
|
41
53
|
},
|
|
42
54
|
"dependencies": {
|
|
@@ -58,6 +70,7 @@
|
|
|
58
70
|
"@testing-library/svelte": "^5.4.2",
|
|
59
71
|
"@types/node": "^22.20.1",
|
|
60
72
|
"bits-ui": "^2.18.1",
|
|
73
|
+
"formsnap": "^2.0.1",
|
|
61
74
|
"jsdom": "^29.1.1",
|
|
62
75
|
"mode-watcher": "^1.1.0",
|
|
63
76
|
"playwright": "^1.62.0",
|
|
@@ -65,6 +78,7 @@
|
|
|
65
78
|
"svelte": "^5.56.2",
|
|
66
79
|
"svelte-check": "^4.6.0",
|
|
67
80
|
"svelte-sonner": "^1.1.1",
|
|
81
|
+
"sveltekit-superforms": "^2.30.2",
|
|
68
82
|
"tailwindcss": "^4.3.2",
|
|
69
83
|
"typescript": "^6.0.3",
|
|
70
84
|
"vite": "^8.0.16",
|