@wishbone-media/spark 0.21.0 → 0.23.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/dist/index.js +1406 -1223
- package/package.json +1 -1
- package/src/assets/css/spark-table.css +13 -0
- package/src/components/SparkTable.vue +23 -0
- package/src/utils/formatTemporal.js +335 -0
- package/src/utils/index.js +1 -0
- package/src/utils/sparkTable/renderers/boolean.js +3 -0
- package/src/utils/sparkTable/renderers/currency.js +71 -0
- package/src/utils/sparkTable/renderers/datetime.js +106 -0
- package/src/utils/sparkTable/renderers/index.js +6 -0
package/package.json
CHANGED
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
--ht-header-highlighted-background-color: transparent;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
.spark-table .handsontable td.current {
|
|
13
|
+
@apply relative;
|
|
14
|
+
|
|
15
|
+
&::before {
|
|
16
|
+
@apply absolute opacity-[0.14] content-[''] inset-0 bg-[var(--ht-cell-selection-background-color,#1a42e8)];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.spark-table .handsontable tr.ht__row_odd:hover td,
|
|
21
|
+
.spark-table .handsontable tr.ht__row_even:hover td {
|
|
22
|
+
@apply !bg-gray-100;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
.spark-table .ht_master .wtBorder {
|
|
13
26
|
@apply !hidden;
|
|
14
27
|
}
|
|
@@ -355,6 +355,29 @@ const sparkTable = reactive({
|
|
|
355
355
|
}
|
|
356
356
|
return stretchedWidth
|
|
357
357
|
},
|
|
358
|
+
/**
|
|
359
|
+
* Copy displayed cell content instead of raw data values
|
|
360
|
+
* This ensures custom renderers copy their visual output, not the underlying data
|
|
361
|
+
*/
|
|
362
|
+
beforeCopy: (data, coords) => {
|
|
363
|
+
const hot = table.value?.hotInstance
|
|
364
|
+
if (!hot) return
|
|
365
|
+
|
|
366
|
+
coords.forEach((range) => {
|
|
367
|
+
for (let row = range.startRow; row <= range.endRow; row++) {
|
|
368
|
+
for (let col = range.startCol; col <= range.endCol; col++) {
|
|
369
|
+
const td = hot.getCell(row, col)
|
|
370
|
+
if (td) {
|
|
371
|
+
const dataRow = row - coords[0].startRow
|
|
372
|
+
const dataCol = col - coords[0].startCol
|
|
373
|
+
// Prefer data-copy-value (for renderers like boolean with icons)
|
|
374
|
+
// Fall back to textContent for standard rendered content
|
|
375
|
+
data[dataRow][dataCol] = td.dataset.copyValue ?? td.textContent ?? ''
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
},
|
|
358
381
|
},
|
|
359
382
|
...props.settings,
|
|
360
383
|
})),
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format Temporal objects using Day.js-compatible format strings
|
|
3
|
+
*
|
|
4
|
+
* Uses native Temporal API and Intl.DateTimeFormat under the hood.
|
|
5
|
+
* Zero external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* @see https://day.js.org/docs/en/display/format for format token reference
|
|
8
|
+
*
|
|
9
|
+
* Supported tokens:
|
|
10
|
+
* | Token | Output | Description |
|
|
11
|
+
* |-------|---------------|---------------------------------|
|
|
12
|
+
* | YYYY | 2018 | Four-digit year |
|
|
13
|
+
* | YY | 18 | Two-digit year |
|
|
14
|
+
* | MMMM | January | Full month name |
|
|
15
|
+
* | MMM | Jan | Abbreviated month name |
|
|
16
|
+
* | MM | 01-12 | Month, 2-digits |
|
|
17
|
+
* | M | 1-12 | Month |
|
|
18
|
+
* | DD | 01-31 | Day of month, 2-digits |
|
|
19
|
+
* | D | 1-31 | Day of month |
|
|
20
|
+
* | Do | 1st, 2nd, 3rd | Day of month with ordinal |
|
|
21
|
+
* | dddd | Sunday | Full weekday name |
|
|
22
|
+
* | ddd | Sun | Abbreviated weekday name |
|
|
23
|
+
* | dd | Su | Min weekday name (2 chars) |
|
|
24
|
+
* | d | 0-6 | Day of week (0 = Sunday) |
|
|
25
|
+
* | HH | 00-23 | Hour (24-hour), 2-digits |
|
|
26
|
+
* | H | 0-23 | Hour (24-hour) |
|
|
27
|
+
* | hh | 01-12 | Hour (12-hour), 2-digits |
|
|
28
|
+
* | h | 1-12 | Hour (12-hour) |
|
|
29
|
+
* | kk | 01-24 | Hour (1-24), 2-digits |
|
|
30
|
+
* | k | 1-24 | Hour (1-24) |
|
|
31
|
+
* | mm | 00-59 | Minute, 2-digits |
|
|
32
|
+
* | m | 0-59 | Minute |
|
|
33
|
+
* | ss | 00-59 | Second, 2-digits |
|
|
34
|
+
* | s | 0-59 | Second |
|
|
35
|
+
* | SSS | 000-999 | Milliseconds, 3-digits |
|
|
36
|
+
* | A | AM/PM | Meridiem uppercase |
|
|
37
|
+
* | a | am/pm | Meridiem lowercase |
|
|
38
|
+
* | Z | +05:00 | UTC offset with colon |
|
|
39
|
+
* | ZZ | +0500 | UTC offset without colon |
|
|
40
|
+
* | Q | 1-4 | Quarter |
|
|
41
|
+
* | X | 1360013296 | Unix timestamp (seconds) |
|
|
42
|
+
* | x | 1360013296123 | Unix timestamp (milliseconds) |
|
|
43
|
+
*
|
|
44
|
+
* Escape characters by wrapping in square brackets: [MM] outputs "MM"
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* formatTemporal(temporal, 'YYYY-MM-DD') // "2025-12-03"
|
|
48
|
+
* formatTemporal(temporal, 'DD MMM YYYY, HH:mm') // "03 Dec 2025, 11:36"
|
|
49
|
+
* formatTemporal(temporal, 'dddd, MMMM Do YYYY') // "Wednesday, December 3rd 2025"
|
|
50
|
+
* formatTemporal(temporal, '[Today is] dddd') // "Today is Wednesday"
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get ordinal suffix for a number (1st, 2nd, 3rd, 4th, etc.)
|
|
55
|
+
* @param {number} n
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
const getOrdinal = (n) => {
|
|
59
|
+
const s = ['th', 'st', 'nd', 'rd']
|
|
60
|
+
const v = n % 100
|
|
61
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pad a number with leading zeros
|
|
66
|
+
* @param {number} n
|
|
67
|
+
* @param {number} length
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
const pad = (n, length = 2) => String(n).padStart(length, '0')
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a cached Intl.DateTimeFormat formatter
|
|
74
|
+
* @param {string} locale
|
|
75
|
+
* @param {Intl.DateTimeFormatOptions} options
|
|
76
|
+
* @returns {Intl.DateTimeFormat}
|
|
77
|
+
*/
|
|
78
|
+
const formatterCache = new Map()
|
|
79
|
+
const getFormatter = (locale, options) => {
|
|
80
|
+
const key = `${locale}:${JSON.stringify(options)}`
|
|
81
|
+
if (!formatterCache.has(key)) {
|
|
82
|
+
formatterCache.set(key, new Intl.DateTimeFormat(locale, options))
|
|
83
|
+
}
|
|
84
|
+
return formatterCache.get(key)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get formatted part from Intl.DateTimeFormat
|
|
89
|
+
* @param {Date} date
|
|
90
|
+
* @param {string} locale
|
|
91
|
+
* @param {Intl.DateTimeFormatOptions} options
|
|
92
|
+
* @param {string} type - The part type to extract
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
const getFormattedPart = (date, locale, options, type) => {
|
|
96
|
+
const formatter = getFormatter(locale, options)
|
|
97
|
+
const parts = formatter.formatToParts(date)
|
|
98
|
+
const part = parts.find(p => p.type === type)
|
|
99
|
+
return part ? part.value : ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Convert Temporal object to Date for Intl formatting
|
|
104
|
+
* @param {Temporal.PlainDateTime|Temporal.ZonedDateTime|Temporal.Instant} temporal
|
|
105
|
+
* @returns {Date}
|
|
106
|
+
*/
|
|
107
|
+
const temporalToDate = (temporal) => {
|
|
108
|
+
if (temporal.epochMilliseconds !== undefined) {
|
|
109
|
+
// ZonedDateTime or Instant
|
|
110
|
+
return new Date(temporal.epochMilliseconds)
|
|
111
|
+
}
|
|
112
|
+
// PlainDateTime - treat as local time
|
|
113
|
+
return new Date(
|
|
114
|
+
temporal.year,
|
|
115
|
+
temporal.month - 1,
|
|
116
|
+
temporal.day,
|
|
117
|
+
temporal.hour || 0,
|
|
118
|
+
temporal.minute || 0,
|
|
119
|
+
temporal.second || 0,
|
|
120
|
+
temporal.millisecond || 0
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract time components from a Temporal object
|
|
126
|
+
* @param {Temporal.PlainDateTime|Temporal.ZonedDateTime} temporal
|
|
127
|
+
* @returns {Object}
|
|
128
|
+
*/
|
|
129
|
+
const getTemporalComponents = (temporal) => {
|
|
130
|
+
const hour24 = temporal.hour || 0
|
|
131
|
+
const hour12 = hour24 % 12 || 12
|
|
132
|
+
const isPM = hour24 >= 12
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
year: temporal.year,
|
|
136
|
+
month: temporal.month,
|
|
137
|
+
day: temporal.day,
|
|
138
|
+
hour24,
|
|
139
|
+
hour12,
|
|
140
|
+
hourFrom1: hour24 === 0 ? 24 : hour24, // k format (1-24)
|
|
141
|
+
minute: temporal.minute || 0,
|
|
142
|
+
second: temporal.second || 0,
|
|
143
|
+
millisecond: temporal.millisecond || 0,
|
|
144
|
+
dayOfWeek: temporal.dayOfWeek, // 1=Monday, 7=Sunday in Temporal
|
|
145
|
+
isPM,
|
|
146
|
+
quarter: Math.ceil(temporal.month / 3),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get timezone offset string
|
|
152
|
+
* @param {Temporal.ZonedDateTime} temporal
|
|
153
|
+
* @param {boolean} withColon
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
const getTimezoneOffset = (temporal, withColon = true) => {
|
|
157
|
+
if (!temporal.offsetNanoseconds && temporal.offsetNanoseconds !== 0) {
|
|
158
|
+
return ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const offsetMinutes = Math.round(temporal.offsetNanoseconds / 60_000_000_000)
|
|
162
|
+
const sign = offsetMinutes >= 0 ? '+' : '-'
|
|
163
|
+
const absMinutes = Math.abs(offsetMinutes)
|
|
164
|
+
const hours = Math.floor(absMinutes / 60)
|
|
165
|
+
const minutes = absMinutes % 60
|
|
166
|
+
|
|
167
|
+
if (withColon) {
|
|
168
|
+
return `${sign}${pad(hours)}:${pad(minutes)}`
|
|
169
|
+
}
|
|
170
|
+
return `${sign}${pad(hours)}${pad(minutes)}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Token definitions with their replacement functions
|
|
175
|
+
* Order matters - longer tokens must be matched first
|
|
176
|
+
*/
|
|
177
|
+
const createTokens = (temporal, locale) => {
|
|
178
|
+
const comp = getTemporalComponents(temporal)
|
|
179
|
+
const date = temporalToDate(temporal)
|
|
180
|
+
|
|
181
|
+
// Convert Temporal dayOfWeek (1=Mon, 7=Sun) to JS dayOfWeek (0=Sun, 6=Sat)
|
|
182
|
+
const jsDayOfWeek = comp.dayOfWeek === 7 ? 0 : comp.dayOfWeek
|
|
183
|
+
|
|
184
|
+
return [
|
|
185
|
+
// Year
|
|
186
|
+
['YYYY', () => String(comp.year)],
|
|
187
|
+
['YY', () => String(comp.year).slice(-2)],
|
|
188
|
+
|
|
189
|
+
// Month
|
|
190
|
+
['MMMM', () => getFormattedPart(date, locale, { month: 'long' }, 'month')],
|
|
191
|
+
['MMM', () => getFormattedPart(date, locale, { month: 'short' }, 'month')],
|
|
192
|
+
['MM', () => pad(comp.month)],
|
|
193
|
+
['M', () => String(comp.month)],
|
|
194
|
+
|
|
195
|
+
// Day of month
|
|
196
|
+
['DD', () => pad(comp.day)],
|
|
197
|
+
['Do', () => getOrdinal(comp.day)],
|
|
198
|
+
['D', () => String(comp.day)],
|
|
199
|
+
|
|
200
|
+
// Day of week
|
|
201
|
+
['dddd', () => getFormattedPart(date, locale, { weekday: 'long' }, 'weekday')],
|
|
202
|
+
['ddd', () => getFormattedPart(date, locale, { weekday: 'short' }, 'weekday')],
|
|
203
|
+
['dd', () => getFormattedPart(date, locale, { weekday: 'short' }, 'weekday').slice(0, 2)],
|
|
204
|
+
['d', () => String(jsDayOfWeek)],
|
|
205
|
+
|
|
206
|
+
// Hour
|
|
207
|
+
['HH', () => pad(comp.hour24)],
|
|
208
|
+
['H', () => String(comp.hour24)],
|
|
209
|
+
['hh', () => pad(comp.hour12)],
|
|
210
|
+
['h', () => String(comp.hour12)],
|
|
211
|
+
['kk', () => pad(comp.hourFrom1)],
|
|
212
|
+
['k', () => String(comp.hourFrom1)],
|
|
213
|
+
|
|
214
|
+
// Minute
|
|
215
|
+
['mm', () => pad(comp.minute)],
|
|
216
|
+
['m', () => String(comp.minute)],
|
|
217
|
+
|
|
218
|
+
// Second
|
|
219
|
+
['ss', () => pad(comp.second)],
|
|
220
|
+
['s', () => String(comp.second)],
|
|
221
|
+
|
|
222
|
+
// Millisecond
|
|
223
|
+
['SSS', () => pad(comp.millisecond, 3)],
|
|
224
|
+
|
|
225
|
+
// AM/PM
|
|
226
|
+
['A', () => comp.isPM ? 'PM' : 'AM'],
|
|
227
|
+
['a', () => comp.isPM ? 'pm' : 'am'],
|
|
228
|
+
|
|
229
|
+
// Timezone offset
|
|
230
|
+
['ZZ', () => getTimezoneOffset(temporal, false)],
|
|
231
|
+
['Z', () => getTimezoneOffset(temporal, true)],
|
|
232
|
+
|
|
233
|
+
// Quarter
|
|
234
|
+
['Q', () => String(comp.quarter)],
|
|
235
|
+
|
|
236
|
+
// Unix timestamp
|
|
237
|
+
['x', () => String(temporal.epochMilliseconds ?? date.getTime())],
|
|
238
|
+
['X', () => String(Math.floor((temporal.epochMilliseconds ?? date.getTime()) / 1000))],
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Format a Temporal object using a Day.js-compatible format string
|
|
244
|
+
*
|
|
245
|
+
* @param {Temporal.PlainDateTime|Temporal.ZonedDateTime|Temporal.Instant|string} input - Temporal object or ISO string
|
|
246
|
+
* @param {string} formatStr - Day.js-compatible format string
|
|
247
|
+
* @param {Object} [options={}] - Options
|
|
248
|
+
* @param {string} [options.locale='en-US'] - Locale for month/weekday names
|
|
249
|
+
* @returns {string} Formatted date string
|
|
250
|
+
*/
|
|
251
|
+
export const formatTemporal = (input, formatStr, options = {}) => {
|
|
252
|
+
const { locale = 'en-US' } = options
|
|
253
|
+
|
|
254
|
+
if (!input) {
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Handle string input - parse as PlainDateTime
|
|
259
|
+
let temporal = input
|
|
260
|
+
if (typeof input === 'string') {
|
|
261
|
+
try {
|
|
262
|
+
// Try parsing as ZonedDateTime first, then PlainDateTime
|
|
263
|
+
if (input.includes('[') || input.includes('Z') || /[+-]\d{2}:\d{2}$/.test(input)) {
|
|
264
|
+
temporal = Temporal.ZonedDateTime.from(input)
|
|
265
|
+
} else {
|
|
266
|
+
// Handle "YYYY-MM-DD HH:mm:ss" format (common from APIs)
|
|
267
|
+
const normalized = input.replace(' ', 'T')
|
|
268
|
+
temporal = Temporal.PlainDateTime.from(normalized)
|
|
269
|
+
}
|
|
270
|
+
} catch (e) {
|
|
271
|
+
console.error('Failed to parse date string:', input, e)
|
|
272
|
+
return input
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const tokens = createTokens(temporal, locale)
|
|
277
|
+
|
|
278
|
+
// Build regex pattern for all tokens, including escaped sequences
|
|
279
|
+
// Escaped sequences: [anything] should be preserved literally
|
|
280
|
+
const tokenPattern = tokens.map(([token]) => token).join('|')
|
|
281
|
+
const regex = new RegExp(`\\[([^\\]]+)\\]|(${tokenPattern})`, 'g')
|
|
282
|
+
|
|
283
|
+
return formatStr.replace(regex, (match, escaped, token) => {
|
|
284
|
+
// If it's an escaped sequence, return the content without brackets
|
|
285
|
+
if (escaped !== undefined) {
|
|
286
|
+
return escaped
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Find and execute the token replacement
|
|
290
|
+
const tokenDef = tokens.find(([t]) => t === token)
|
|
291
|
+
if (tokenDef) {
|
|
292
|
+
return tokenDef[1]()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return match
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Parse a datetime string (e.g., from Laravel API) and optionally convert timezone
|
|
301
|
+
*
|
|
302
|
+
* @param {string} dateString - Date string like "2025-12-03 11:36:27"
|
|
303
|
+
* @param {Object} [options={}] - Options
|
|
304
|
+
* @param {string} [options.fromTimezone] - Source timezone (e.g., 'UTC')
|
|
305
|
+
* @param {string} [options.toTimezone] - Target timezone (e.g., 'Australia/Sydney')
|
|
306
|
+
* @returns {Temporal.PlainDateTime|Temporal.ZonedDateTime} Temporal object
|
|
307
|
+
*/
|
|
308
|
+
export const parseDatetime = (dateString, options = {}) => {
|
|
309
|
+
const { fromTimezone, toTimezone } = options
|
|
310
|
+
|
|
311
|
+
if (!dateString) {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Normalize "YYYY-MM-DD HH:mm:ss" to "YYYY-MM-DDTHH:mm:ss"
|
|
316
|
+
const normalized = dateString.replace(' ', 'T')
|
|
317
|
+
|
|
318
|
+
// If timezone conversion is requested
|
|
319
|
+
if (fromTimezone && toTimezone) {
|
|
320
|
+
// Parse as ZonedDateTime in the source timezone
|
|
321
|
+
const zoned = Temporal.PlainDateTime.from(normalized).toZonedDateTime(fromTimezone)
|
|
322
|
+
// Convert to target timezone
|
|
323
|
+
return zoned.withTimeZone(toTimezone)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (fromTimezone) {
|
|
327
|
+
// Just attach the source timezone
|
|
328
|
+
return Temporal.PlainDateTime.from(normalized).toZonedDateTime(fromTimezone)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Return as PlainDateTime (no timezone info)
|
|
332
|
+
return Temporal.PlainDateTime.from(normalized)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default formatTemporal
|
package/src/utils/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export * from './cookies.js'
|
|
|
2
2
|
export { customiseHeader } from './sparkTable/header.js'
|
|
3
3
|
export { renderHeaderTitle } from './sparkTable/header-title.js'
|
|
4
4
|
export { updateRow } from './sparkTable/update-row.js'
|
|
5
|
+
export { formatTemporal, parseDatetime } from './formatTemporal.js'
|
|
@@ -70,6 +70,9 @@ export const booleanRenderer = (sparkTable) => {
|
|
|
70
70
|
|
|
71
71
|
const colors = colorClasses[colorName] || colorClasses.gray
|
|
72
72
|
|
|
73
|
+
// Store the boolean value for copy/paste (since icon has no text content)
|
|
74
|
+
td.dataset.copyValue = truthy ? 'true' : 'false'
|
|
75
|
+
|
|
73
76
|
// Create circular container
|
|
74
77
|
const container = document.createElement('div')
|
|
75
78
|
container.classList.add(
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spark Currency Renderer
|
|
3
|
+
*
|
|
4
|
+
* Formats numbers as Australian currency with dollar sign and comma separators.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* {
|
|
8
|
+
* data: 'total',
|
|
9
|
+
* renderer: 'spark.currency',
|
|
10
|
+
* rendererConfig: {
|
|
11
|
+
* decimals: 2, // Decimal places (default: 2)
|
|
12
|
+
* emptyText: '-', // Text for null/empty values (default: '')
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* - 1234.5 → "$1,234.50"
|
|
18
|
+
* - 1000000 → "$1,000,000.00"
|
|
19
|
+
* - -500 → "-$500.00"
|
|
20
|
+
* - null → ""
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Format a number as Australian currency
|
|
25
|
+
* @param {number} value
|
|
26
|
+
* @param {number} decimals
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
const formatCurrency = (value, decimals = 2) => {
|
|
30
|
+
const num = Number(value)
|
|
31
|
+
if (isNaN(num)) return null
|
|
32
|
+
|
|
33
|
+
const isNegative = num < 0
|
|
34
|
+
const absValue = Math.abs(num)
|
|
35
|
+
|
|
36
|
+
// Format with commas and decimals
|
|
37
|
+
const formatted = absValue.toLocaleString('en-AU', {
|
|
38
|
+
minimumFractionDigits: decimals,
|
|
39
|
+
maximumFractionDigits: decimals,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Add dollar sign (after negative sign if negative)
|
|
43
|
+
return isNegative ? `-$${formatted}` : `$${formatted}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const currencyRenderer = (sparkTable) => {
|
|
47
|
+
return (instance, td, row, col, prop, value, cellProperties) => {
|
|
48
|
+
td.innerHTML = ''
|
|
49
|
+
td.classList.add('spark-table-cell-currency')
|
|
50
|
+
|
|
51
|
+
const config = cellProperties.rendererConfig || {}
|
|
52
|
+
const { decimals = 2, emptyText = '' } = config
|
|
53
|
+
|
|
54
|
+
// Handle null/empty values
|
|
55
|
+
if (value === null || value === undefined || value === '') {
|
|
56
|
+
td.textContent = emptyText
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const formatted = formatCurrency(value, decimals)
|
|
61
|
+
|
|
62
|
+
if (formatted === null) {
|
|
63
|
+
td.textContent = emptyText
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const span = document.createElement('span')
|
|
68
|
+
span.textContent = formatted
|
|
69
|
+
td.appendChild(span)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { formatTemporal, parseDatetime } from '@/utils/formatTemporal.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spark DateTime Renderer
|
|
5
|
+
*
|
|
6
|
+
* Renders datetime values with customizable formatting and timezone conversion.
|
|
7
|
+
* Uses native Temporal API with Day.js-compatible format strings.
|
|
8
|
+
*
|
|
9
|
+
* Input format: "2025-12-03 11:36:27" (typical Laravel API datetime) or null
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* {
|
|
13
|
+
* data: 'created_at',
|
|
14
|
+
* renderer: 'spark.datetime',
|
|
15
|
+
* rendererConfig: {
|
|
16
|
+
* format: 'DD MMM YYYY, HH:mm', // Day.js-compatible format string
|
|
17
|
+
* fromTimezone: 'UTC', // Source timezone (optional)
|
|
18
|
+
* toTimezone: 'Australia/Sydney', // Target timezone (optional)
|
|
19
|
+
* locale: 'en-AU', // Locale for month/weekday names (optional)
|
|
20
|
+
* emptyText: '-', // Text to show for null/empty values (optional)
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Format tokens (Day.js-compatible):
|
|
25
|
+
* | Token | Output | Description |
|
|
26
|
+
* |-------|---------------|---------------------------------|
|
|
27
|
+
* | YYYY | 2018 | Four-digit year |
|
|
28
|
+
* | YY | 18 | Two-digit year |
|
|
29
|
+
* | MMMM | January | Full month name |
|
|
30
|
+
* | MMM | Jan | Abbreviated month name |
|
|
31
|
+
* | MM | 01-12 | Month, 2-digits |
|
|
32
|
+
* | M | 1-12 | Month |
|
|
33
|
+
* | DD | 01-31 | Day of month, 2-digits |
|
|
34
|
+
* | D | 1-31 | Day of month |
|
|
35
|
+
* | Do | 1st, 2nd | Day of month with ordinal |
|
|
36
|
+
* | dddd | Sunday | Full weekday name |
|
|
37
|
+
* | ddd | Sun | Abbreviated weekday name |
|
|
38
|
+
* | dd | Su | Min weekday name (2 chars) |
|
|
39
|
+
* | d | 0-6 | Day of week (0 = Sunday) |
|
|
40
|
+
* | HH | 00-23 | Hour (24-hour), 2-digits |
|
|
41
|
+
* | H | 0-23 | Hour (24-hour) |
|
|
42
|
+
* | hh | 01-12 | Hour (12-hour), 2-digits |
|
|
43
|
+
* | h | 1-12 | Hour (12-hour) |
|
|
44
|
+
* | mm | 00-59 | Minute, 2-digits |
|
|
45
|
+
* | m | 0-59 | Minute |
|
|
46
|
+
* | ss | 00-59 | Second, 2-digits |
|
|
47
|
+
* | s | 0-59 | Second |
|
|
48
|
+
* | SSS | 000-999 | Milliseconds, 3-digits |
|
|
49
|
+
* | A | AM/PM | Meridiem uppercase |
|
|
50
|
+
* | a | am/pm | Meridiem lowercase |
|
|
51
|
+
* | Z | +05:00 | UTC offset with colon |
|
|
52
|
+
* | ZZ | +0500 | UTC offset without colon |
|
|
53
|
+
*
|
|
54
|
+
* Escape characters by wrapping in square brackets: [at] outputs "at"
|
|
55
|
+
*
|
|
56
|
+
* Examples:
|
|
57
|
+
* - 'DD MMM YYYY, HH:mm' → "03 Dec 2025, 11:36"
|
|
58
|
+
* - 'YYYY-MM-DD' → "2025-12-03"
|
|
59
|
+
* - 'dddd, MMMM Do YYYY' → "Wednesday, December 3rd 2025"
|
|
60
|
+
* - 'h:mm A' → "11:36 AM"
|
|
61
|
+
* - 'DD/MM/YY [at] h:mma' → "03/12/25 at 11:36am"
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
export const datetimeRenderer = (sparkTable) => {
|
|
65
|
+
return (instance, td, row, col, prop, value, cellProperties) => {
|
|
66
|
+
// Clear cell
|
|
67
|
+
td.innerHTML = ''
|
|
68
|
+
td.classList.add('spark-table-cell-datetime')
|
|
69
|
+
|
|
70
|
+
const config = cellProperties.rendererConfig || {}
|
|
71
|
+
const {
|
|
72
|
+
format = 'DD MMM YYYY, HH:mm',
|
|
73
|
+
fromTimezone,
|
|
74
|
+
toTimezone,
|
|
75
|
+
locale = 'en-US',
|
|
76
|
+
emptyText = '',
|
|
77
|
+
} = config
|
|
78
|
+
|
|
79
|
+
// Handle null/empty values
|
|
80
|
+
if (!value) {
|
|
81
|
+
td.textContent = emptyText
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Parse the datetime string and optionally convert timezone
|
|
87
|
+
const temporal = parseDatetime(value, { fromTimezone, toTimezone })
|
|
88
|
+
|
|
89
|
+
if (!temporal) {
|
|
90
|
+
td.textContent = emptyText
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Format using Day.js-compatible format string
|
|
95
|
+
const formatted = formatTemporal(temporal, format, { locale })
|
|
96
|
+
|
|
97
|
+
const span = document.createElement('span')
|
|
98
|
+
span.textContent = formatted
|
|
99
|
+
td.appendChild(span)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('Error formatting datetime:', error, { value, format })
|
|
102
|
+
// Fall back to showing the raw value
|
|
103
|
+
td.textContent = value
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -2,9 +2,11 @@ import { baseRenderer, registerRenderer } from 'handsontable/renderers'
|
|
|
2
2
|
import { actionsRenderer } from './actions.js'
|
|
3
3
|
import { badgeRenderer } from './badge.js'
|
|
4
4
|
import { booleanRenderer } from './boolean.js'
|
|
5
|
+
import { currencyRenderer } from './currency.js'
|
|
5
6
|
import { linkRenderer } from './link.js'
|
|
6
7
|
import { imageRenderer } from './image.js'
|
|
7
8
|
import { dateRenderer } from './date.js'
|
|
9
|
+
import { datetimeRenderer } from './datetime.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Renderer Registry for SparkTable
|
|
@@ -52,9 +54,11 @@ export const getRenderer = (key) => {
|
|
|
52
54
|
* - spark.actions: Inline action buttons
|
|
53
55
|
* - spark.badge: Colored status badges
|
|
54
56
|
* - spark.boolean: Boolean indicators with check/times icons
|
|
57
|
+
* - spark.currency: Australian currency formatting
|
|
55
58
|
* - spark.link: Clickable links (email, phone, custom)
|
|
56
59
|
* - spark.image: Image thumbnails
|
|
57
60
|
* - spark.date: Formatted dates
|
|
61
|
+
* - spark.datetime: Advanced datetime formatting with timezone support
|
|
58
62
|
* - style.capitalize: Legacy renderer for text capitalization
|
|
59
63
|
*
|
|
60
64
|
* @param {Object} sparkTable - SparkTable instance
|
|
@@ -64,9 +68,11 @@ export const registerSparkRenderers = (sparkTable) => {
|
|
|
64
68
|
register('spark.actions', actionsRenderer(sparkTable))
|
|
65
69
|
register('spark.badge', badgeRenderer(sparkTable))
|
|
66
70
|
register('spark.boolean', booleanRenderer(sparkTable))
|
|
71
|
+
register('spark.currency', currencyRenderer(sparkTable))
|
|
67
72
|
register('spark.link', linkRenderer(sparkTable))
|
|
68
73
|
register('spark.image', imageRenderer(sparkTable))
|
|
69
74
|
register('spark.date', dateRenderer(sparkTable))
|
|
75
|
+
register('spark.datetime', datetimeRenderer(sparkTable))
|
|
70
76
|
|
|
71
77
|
// Legacy renderer for backward compatibility
|
|
72
78
|
register('style.capitalize', (instance, td, row, col, prop, value) => {
|