@wishbone-media/spark 0.21.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -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
@@ -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,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
+ }
@@ -5,6 +5,7 @@ import { booleanRenderer } from './boolean.js'
5
5
  import { linkRenderer } from './link.js'
6
6
  import { imageRenderer } from './image.js'
7
7
  import { dateRenderer } from './date.js'
8
+ import { datetimeRenderer } from './datetime.js'
8
9
 
9
10
  /**
10
11
  * Renderer Registry for SparkTable
@@ -67,6 +68,7 @@ export const registerSparkRenderers = (sparkTable) => {
67
68
  register('spark.link', linkRenderer(sparkTable))
68
69
  register('spark.image', imageRenderer(sparkTable))
69
70
  register('spark.date', dateRenderer(sparkTable))
71
+ register('spark.datetime', datetimeRenderer(sparkTable))
70
72
 
71
73
  // Legacy renderer for backward compatibility
72
74
  register('style.capitalize', (instance, td, row, col, prop, value) => {