@wishbone-media/spark 0.18.0 → 0.20.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 (31) hide show
  1. package/dist/index.css +1 -1
  2. package/dist/index.js +1301 -1028
  3. package/formkit.theme.mjs +4 -4
  4. package/package.json +1 -1
  5. package/src/assets/css/index.css +1 -0
  6. package/src/assets/css/nprogress.css +6 -0
  7. package/src/assets/css/spark-table.css +31 -6
  8. package/src/assets/images/brands/mr-antenna.png +0 -0
  9. package/src/assets/images/brands/mr-group.png +0 -0
  10. package/src/assets/images/brands/mr-gutter-cleaning.png +0 -0
  11. package/src/assets/images/brands/mr-pest-controller.png +0 -0
  12. package/src/components/SparkBrandSelector.vue +9 -9
  13. package/src/components/SparkModalContainer.vue +1 -1
  14. package/src/components/SparkOverlay.vue +1 -1
  15. package/src/components/SparkTable.vue +145 -13
  16. package/src/components/SparkTablePaginationDetails.vue +1 -1
  17. package/src/components/SparkTableToolbar.vue +1 -1
  18. package/src/components/plugins/SparkTableFilterSelect.vue +8 -2
  19. package/src/components/plugins/SparkTableReset.vue +43 -14
  20. package/src/components/plugins/SparkTableSearch.vue +10 -5
  21. package/src/composables/index.js +2 -1
  22. package/src/composables/sparkModalService.js +49 -0
  23. package/src/composables/useSparkTableRouteSync.js +308 -0
  24. package/src/containers/SparkDefaultContainer.vue +11 -1
  25. package/src/plugins/fontawesome.js +5 -0
  26. package/src/stores/app-selector.js +8 -2
  27. package/src/stores/brand-filter.js +1 -1
  28. package/src/stores/navigation.js +1 -0
  29. package/src/utils/sparkTable/renderers/actions.js +27 -2
  30. package/src/utils/sparkTable/renderers/boolean.js +97 -0
  31. package/src/utils/sparkTable/renderers/index.js +3 -0
@@ -1,4 +1,5 @@
1
1
  import { reactive, markRaw } from 'vue'
2
+ import SparkModalDialog from '@/components/SparkModalDialog.vue'
2
3
 
3
4
  class SparkModalService {
4
5
  constructor() {
@@ -21,6 +22,54 @@ class SparkModalService {
21
22
  this.state.isVisible = false
22
23
  this.state.eventHandlers = {}
23
24
  }
25
+
26
+ /**
27
+ * Show a confirmation dialog and return a Promise
28
+ *
29
+ * @param {Object} options - Confirmation options
30
+ * @param {string} [options.title='Confirm'] - Dialog title
31
+ * @param {string} [options.message='Are you sure?'] - Dialog message
32
+ * @param {string} [options.type='warning'] - Dialog type (info, success, warning, danger)
33
+ * @param {string} [options.confirmText='Confirm'] - Confirm button text
34
+ * @param {string} [options.cancelText='Cancel'] - Cancel button text
35
+ * @param {string} [options.confirmVariant='primary'] - Confirm button variant
36
+ * @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled
37
+ */
38
+ confirm = (options = {}) => {
39
+ return new Promise((resolve) => {
40
+ const {
41
+ title = 'Confirm',
42
+ message = 'Are you sure?',
43
+ type = 'warning',
44
+ confirmText = 'Confirm',
45
+ cancelText = 'Cancel',
46
+ confirmVariant = 'primary',
47
+ } = options
48
+
49
+ this.show(
50
+ SparkModalDialog,
51
+ {
52
+ title,
53
+ message,
54
+ type,
55
+ buttons: [
56
+ { text: confirmText, variant: confirmVariant, event: 'confirm' },
57
+ { text: cancelText, variant: 'secondary', event: 'cancel' },
58
+ ],
59
+ },
60
+ {
61
+ confirm: () => {
62
+ this.hide()
63
+ resolve(true)
64
+ },
65
+ cancel: () => {
66
+ this.hide()
67
+ resolve(false)
68
+ },
69
+ },
70
+ )
71
+ })
72
+ }
24
73
  }
25
74
 
26
75
  export const sparkModalService = new SparkModalService()
@@ -0,0 +1,308 @@
1
+ import { watch, onMounted } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+
4
+ /**
5
+ * Composable for syncing SparkTable params with URL query parameters and localStorage
6
+ *
7
+ * @param {Object} sparkTable - The sparkTable reactive object
8
+ * @param {Object} options - Configuration options
9
+ * @param {string|null} [options.namespace=null] - URL namespace for params. If null, uses flat URLs (?page=1). If string, uses namespaced URLs (?namespace[page]=1)
10
+ * @param {boolean} [options.syncToRoute=true] - Whether to sync params to URL
11
+ * @param {boolean} [options.persistToStorage=false] - Whether to persist params to localStorage
12
+ * @param {boolean} [options.restoreOnMount=true] - Whether to restore params on mount
13
+ * @param {number} [options.storageTTL=7] - Days to keep localStorage data (0 = no expiry)
14
+ * @returns {Object} Utility methods for manual control
15
+ *
16
+ * @example
17
+ * // Flat URL sync (clean URLs for single-table pages)
18
+ * useSparkTableRouteSync(sparkTable, { namespace: null })
19
+ * // Result: ?page=1&filter[status]=active
20
+ *
21
+ * @example
22
+ * // Namespaced URL sync (for multi-table pages)
23
+ * useSparkTableRouteSync(sparkTable, { namespace: 'users' })
24
+ * // Result: ?users[page]=1&users[filter[status]]=active
25
+ *
26
+ * @example
27
+ * // Both URL and localStorage (URL takes precedence)
28
+ * useSparkTableRouteSync(sparkTable, {
29
+ * namespace: null,
30
+ * syncToRoute: true,
31
+ * persistToStorage: true
32
+ * })
33
+ */
34
+ export const useSparkTableRouteSync = (sparkTable, options = {}) => {
35
+ const router = useRouter()
36
+ const route = useRoute()
37
+
38
+ const namespace = options.namespace // null = flat URLs, string = namespaced URLs
39
+ const useFlatUrls = namespace === null
40
+ const syncToRoute = options.syncToRoute !== false
41
+ const persistToStorage = options.persistToStorage === true
42
+ const restoreOnMount = options.restoreOnMount !== false
43
+ const storageTTL = options.storageTTL || 7 // days
44
+
45
+ // localStorage key: use namespace if provided, otherwise use route path
46
+ const storageKey = useFlatUrls ? `spark-table:${route.path}` : `spark-table:${namespace}`
47
+
48
+ /**
49
+ * Flatten nested params into query string format
50
+ * e.g., { filter: { role: 'admin' } } => { 'filter[role]': 'admin' }
51
+ */
52
+ const flattenParams = (params, prefix = '') => {
53
+ const flattened = {}
54
+
55
+ Object.keys(params).forEach((key) => {
56
+ const value = params[key]
57
+ const queryKey = prefix ? `${prefix}[${key}]` : key
58
+
59
+ if (value !== null && value !== undefined) {
60
+ if (typeof value === 'object' && !Array.isArray(value)) {
61
+ // Recursively flatten nested objects
62
+ Object.assign(flattened, flattenParams(value, queryKey))
63
+ } else {
64
+ flattened[queryKey] = String(value)
65
+ }
66
+ }
67
+ })
68
+
69
+ return flattened
70
+ }
71
+
72
+ /**
73
+ * Unflatten query params back to nested structure
74
+ * e.g., { 'filter[role]': 'admin' } => { filter: { role: 'admin' } }
75
+ */
76
+ const unflattenParams = (flatParams) => {
77
+ const unflattened = {}
78
+
79
+ Object.keys(flatParams).forEach((key) => {
80
+ const value = flatParams[key]
81
+
82
+ // Parse bracket notation: "filter[role]" => ["filter", "role"]
83
+ const matches = key.match(/([^\[]+)(?:\[([^\]]+)\])?/)
84
+ if (!matches) return
85
+
86
+ const [, rootKey, nestedKey] = matches
87
+
88
+ if (nestedKey) {
89
+ // Nested param
90
+ if (!unflattened[rootKey]) {
91
+ unflattened[rootKey] = {}
92
+ }
93
+ unflattened[rootKey][nestedKey] = value
94
+ } else {
95
+ // Top-level param
96
+ unflattened[rootKey] = value
97
+ }
98
+ })
99
+
100
+ return unflattened
101
+ }
102
+
103
+ /**
104
+ * Save params to localStorage with timestamp
105
+ */
106
+ const saveToStorage = () => {
107
+ if (!persistToStorage) return
108
+
109
+ try {
110
+ const data = {
111
+ params: sparkTable.params,
112
+ timestamp: Date.now(),
113
+ }
114
+ localStorage.setItem(storageKey, JSON.stringify(data))
115
+ } catch (error) {
116
+ console.warn('Failed to save table state to localStorage:', error)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Load params from localStorage (respecting TTL)
122
+ */
123
+ const loadFromStorage = () => {
124
+ if (!persistToStorage) return null
125
+
126
+ try {
127
+ const stored = localStorage.getItem(storageKey)
128
+ if (!stored) return null
129
+
130
+ const data = JSON.parse(stored)
131
+
132
+ // Check TTL if configured
133
+ if (storageTTL > 0) {
134
+ const age = Date.now() - data.timestamp
135
+ const maxAge = storageTTL * 24 * 60 * 60 * 1000 // days to ms
136
+
137
+ if (age > maxAge) {
138
+ // Expired, clear it
139
+ localStorage.removeItem(storageKey)
140
+ return null
141
+ }
142
+ }
143
+
144
+ return data.params
145
+ } catch (error) {
146
+ console.warn('Failed to load table state from localStorage:', error)
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Clear localStorage for this table
153
+ */
154
+ const clearStorage = () => {
155
+ try {
156
+ localStorage.removeItem(storageKey)
157
+ } catch (error) {
158
+ console.warn('Failed to clear table state from localStorage:', error)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Sync current sparkTable.params to route query
164
+ */
165
+ const syncParamsToRoute = () => {
166
+ if (!syncToRoute) return
167
+ const currentQuery = { ...route.query }
168
+
169
+ if (useFlatUrls) {
170
+ // Flat mode: remove ALL table-related params from current URL first
171
+ // This ensures params that were removed from sparkTable.params are also removed from URL
172
+ Object.keys(currentQuery).forEach((key) => {
173
+ if (isTableParam(key)) {
174
+ delete currentQuery[key]
175
+ }
176
+ })
177
+ // Then add back the current params
178
+ const flattenedParams = flattenParams(sparkTable.params)
179
+ Object.assign(currentQuery, flattenedParams)
180
+ } else {
181
+ // Namespaced mode: remove old namespace params
182
+ Object.keys(currentQuery).forEach((key) => {
183
+ if (key.startsWith(`${namespace}[`) || key === namespace) {
184
+ delete currentQuery[key]
185
+ }
186
+ })
187
+ // Flatten params with namespace prefix
188
+ const flattenedParams = flattenParams(sparkTable.params, namespace)
189
+ Object.assign(currentQuery, flattenedParams)
190
+ }
191
+
192
+ // Use replace to avoid polluting history
193
+ router.replace({ query: currentQuery })
194
+ }
195
+
196
+ /**
197
+ * Helper to identify if a query param belongs to SparkTable
198
+ */
199
+ const isTableParam = (key) => {
200
+ const tableParams = ['page', 'limit', 'search', 'orderBy', 'sortedBy']
201
+ if (tableParams.includes(key)) return true
202
+ if (key.includes('[')) return true // Any bracket notation is likely a filter
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * Coerce URL query param values to their proper types
208
+ * URL params are always strings, but page/limit should be integers
209
+ */
210
+ const coerceParamValue = (key, value) => {
211
+ const numericParams = ['page', 'limit']
212
+ if (numericParams.includes(key) && value !== null && value !== undefined) {
213
+ const parsed = parseInt(value, 10)
214
+ return isNaN(parsed) ? value : parsed
215
+ }
216
+ return value
217
+ }
218
+
219
+ /**
220
+ * Restore params from route query to sparkTable
221
+ */
222
+ const restoreFromRoute = () => {
223
+ if (useFlatUrls) {
224
+ // Flat mode: read any query param that looks like a table param
225
+ Object.keys(route.query).forEach((key) => {
226
+ if (isTableParam(key)) {
227
+ sparkTable.params[key] = coerceParamValue(key, route.query[key])
228
+ }
229
+ })
230
+ } else {
231
+ // Namespaced mode: extract params with namespace prefix
232
+ const prefix = `${namespace}[`
233
+
234
+ Object.keys(route.query).forEach((key) => {
235
+ if (key.startsWith(prefix)) {
236
+ // Remove namespace prefix and trailing bracket
237
+ // e.g., "table[selectFilters[is_quote]]" => "selectFilters[is_quote]"
238
+ // e.g., "table[page]" => "page"
239
+ const paramKey = key.slice(prefix.length, -1)
240
+ sparkTable.params[paramKey] = coerceParamValue(paramKey, route.query[key])
241
+ }
242
+ })
243
+ }
244
+ }
245
+
246
+ // Watch sparkTable.params and sync to route/storage
247
+ watch(
248
+ () => sparkTable.params,
249
+ () => {
250
+ if (syncToRoute) {
251
+ syncParamsToRoute()
252
+ }
253
+ if (persistToStorage) {
254
+ saveToStorage()
255
+ }
256
+ },
257
+ { deep: true },
258
+ )
259
+
260
+ // Restore from route/storage on mount if enabled
261
+ // Priority: URL params > localStorage > defaults
262
+ if (restoreOnMount) {
263
+ onMounted(() => {
264
+ let hasUrlParams = false
265
+
266
+ // First, try to restore from URL
267
+ if (syncToRoute) {
268
+ let hasRelevantParams = false
269
+
270
+ if (useFlatUrls) {
271
+ // Flat mode: check if any query param looks like a table param
272
+ hasRelevantParams = Object.keys(route.query).some((key) => isTableParam(key))
273
+ } else {
274
+ // Namespaced mode: check for namespace prefix
275
+ const prefix = `${namespace}[`
276
+ hasRelevantParams = Object.keys(route.query).some((key) =>
277
+ key.startsWith(prefix)
278
+ )
279
+ }
280
+
281
+ if (hasRelevantParams) {
282
+ hasUrlParams = true
283
+ restoreFromRoute()
284
+ }
285
+ }
286
+
287
+ // If no URL params, try localStorage
288
+ if (!hasUrlParams && persistToStorage) {
289
+ const storedParams = loadFromStorage()
290
+ if (storedParams && Object.keys(storedParams).length > 0) {
291
+ // Coerce numeric params in case localStorage has string values from previous buggy saves
292
+ Object.keys(storedParams).forEach((key) => {
293
+ storedParams[key] = coerceParamValue(key, storedParams[key])
294
+ })
295
+ Object.assign(sparkTable.params, storedParams)
296
+ }
297
+ }
298
+ })
299
+ }
300
+
301
+ return {
302
+ syncToRoute: syncParamsToRoute,
303
+ restoreFromRoute,
304
+ saveToStorage,
305
+ loadFromStorage,
306
+ clearStorage,
307
+ }
308
+ }
@@ -117,7 +117,7 @@
117
117
 
118
118
  <slot name="header-center">
119
119
  <div
120
- v-if="appStore.state.showBrandSelector"
120
+ v-if="showBrandSelector"
121
121
  class="absolute left-1/2 -translate-x-1/2 cursor-pointer h-9 flex items-center"
122
122
  @click="toggleBrandSelector"
123
123
  >
@@ -167,6 +167,7 @@
167
167
 
168
168
  <script setup>
169
169
  import { computed, h, useSlots } from 'vue'
170
+ import { useRoute } from 'vue-router'
170
171
  import {
171
172
  SparkOverlay,
172
173
  SparkBrandSelector,
@@ -196,6 +197,7 @@ const props = defineProps({
196
197
  const emit = defineEmits(['overlayClose'])
197
198
 
198
199
  const slots = useSlots()
200
+ const route = useRoute()
199
201
 
200
202
  const sparkBrandFilterStore = useSparkBrandFilterStore()
201
203
  const sparkAppSelectorStore = useSparkAppSelectorStore()
@@ -204,6 +206,14 @@ const appIcon = computed(() => {
204
206
  return sparkAppSelectorStore.getAppIcon(props.appStore.state.app)
205
207
  })
206
208
 
209
+ const showBrandSelector = computed(() => {
210
+ // Route meta takes precedence, falls back to app store setting
211
+ if (route.meta.hideBrandSelector === true) {
212
+ return false
213
+ }
214
+ return props.appStore.state.showBrandSelector
215
+ })
216
+
207
217
  const toggleAppSelector = () => {
208
218
  // Create component wrappers for slots if they exist
209
219
  const slotProps = {}
@@ -40,6 +40,8 @@ import {
40
40
  faSignOut,
41
41
  faEye,
42
42
  faUndo,
43
+ faBolt,
44
+ faCloudDownload,
43
45
  } from '@fortawesome/pro-regular-svg-icons'
44
46
 
45
47
  import {
@@ -87,6 +89,9 @@ export const Icons = {
87
89
  farSignOut: faSignOut,
88
90
  farEye: faEye,
89
91
  farUndo: faUndo,
92
+ farBolt: faBolt,
93
+ farCloudDownload: faCloudDownload,
94
+
90
95
  fadSort: faSortDuotone,
91
96
  fadSortDown: faSortDownDuotone,
92
97
  fadSortUp: faSortUpDuotone,
@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
2
2
  import { computed, reactive } from 'vue'
3
3
 
4
4
  const DEFAULT_APPS = [
5
+ {
6
+ name: 'Bolt',
7
+ description: 'Job management',
8
+ href: 'https://bolt-next.letsbolt.io',
9
+ icon: 'farBolt',
10
+ },
5
11
  {
6
12
  name: '3CX',
7
13
  description: 'VOIP Phone',
@@ -34,13 +40,13 @@ const DEFAULT_APPS = [
34
40
  },
35
41
  {
36
42
  name: 'ReVuze',
37
- description: 'Get Customer feedback',
43
+ description: 'Get customer feedback',
38
44
  href: 'https://revuze-next.letsbolt.io',
39
45
  icon: 'farComments',
40
46
  },
41
47
  {
42
48
  name: 'Tabula',
43
- description: 'Admin interface',
49
+ description: 'Admin interface template',
44
50
  href: 'https://tabula.letsbolt.io',
45
51
  icon: 'farCompass',
46
52
  },
@@ -17,7 +17,7 @@ export const useSparkBrandFilterStore = defineStore(
17
17
 
18
18
  // Validate brand structure
19
19
  const validBrands = config.brands.filter((brand) => {
20
- const isValid = brand.id && brand.name && brand.logo
20
+ const isValid = brand.name && brand.logo
21
21
  if (!isValid) {
22
22
  console.warn('useSparkBrandFilterStore: Invalid brand object', brand)
23
23
  }
@@ -14,6 +14,7 @@ export const useSparkNavStore = defineStore('sparkNav', () => {
14
14
 
15
15
  const initialize = (menuConfig = []) => {
16
16
  state.menu = menuConfig
17
+ syncWithRoute()
17
18
  }
18
19
 
19
20
  const findMenuItemByRoute = (items, route) => {
@@ -1,4 +1,5 @@
1
1
  import { icon } from '@fortawesome/fontawesome-svg-core'
2
+ import { sparkModalService } from '@/composables/sparkModalService'
2
3
 
3
4
  /**
4
5
  * Spark Actions Renderer
@@ -25,6 +26,16 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
25
26
  * {
26
27
  * icon: 'download',
27
28
  * handler: (row) => downloadFile(row.file_url)
29
+ * },
30
+ * {
31
+ * icon: 'trash',
32
+ * label: 'Delete',
33
+ * event: 'deleteRow',
34
+ * confirm: true,
35
+ * confirmTitle: 'Delete Item',
36
+ * confirmType: 'danger',
37
+ * confirmText: 'Delete',
38
+ * cancelText: 'Keep'
28
39
  * }
29
40
  * ]
30
41
  * }
@@ -37,6 +48,11 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
37
48
  * @property {string} [event] - Event name to emit (e.g., 'editRow')
38
49
  * @property {Function} [handler] - Custom handler function (row) => void
39
50
  * @property {boolean|string} [confirm] - Show confirmation dialog (true or custom message)
51
+ * @property {string} [confirmTitle] - Custom confirmation dialog title (default: 'Confirm')
52
+ * @property {string} [confirmType] - Confirmation dialog type: 'info', 'success', 'warning', 'danger' (default: 'warning')
53
+ * @property {string} [confirmText] - Confirm button text (default: 'Confirm')
54
+ * @property {string} [cancelText] - Cancel button text (default: 'Cancel')
55
+ * @property {string} [confirmVariant] - Confirm button variant (default: 'primary')
40
56
  * @property {Function} [condition] - Conditional visibility (row) => boolean
41
57
  */
42
58
 
@@ -89,7 +105,7 @@ export const actionsRenderer = (sparkTable) => {
89
105
  }
90
106
 
91
107
  // Add click handler
92
- button.addEventListener('click', (e) => {
108
+ button.addEventListener('click', async (e) => {
93
109
  e.preventDefault()
94
110
  e.stopPropagation()
95
111
 
@@ -100,7 +116,16 @@ export const actionsRenderer = (sparkTable) => {
100
116
  ? action.confirm
101
117
  : `Are you sure you want to ${action.label?.toLowerCase() || 'perform this action'}?`
102
118
 
103
- if (!window.confirm(confirmMessage)) {
119
+ const confirmed = await sparkModalService.confirm({
120
+ title: action.confirmTitle,
121
+ message: confirmMessage,
122
+ type: action.confirmType,
123
+ confirmText: action.confirmText,
124
+ cancelText: action.cancelText,
125
+ confirmVariant: action.confirmVariant,
126
+ })
127
+
128
+ if (!confirmed) {
104
129
  return
105
130
  }
106
131
  }
@@ -0,0 +1,97 @@
1
+ import { icon } from '@fortawesome/fontawesome-svg-core'
2
+
3
+ /**
4
+ * Spark Boolean Renderer
5
+ *
6
+ * Renders boolean-like values as check/times icons in a circular container
7
+ *
8
+ * Truthy values: true, 1, '1', 'yes', 'true'
9
+ * Falsy values: false, 0, '0', 'no', 'false', null, undefined, ''
10
+ *
11
+ * Usage:
12
+ * {
13
+ * data: 'is_active',
14
+ * renderer: 'spark.boolean',
15
+ * rendererConfig: {
16
+ * trueIcon: 'check', // Default: 'check'
17
+ * falseIcon: 'xmark', // Default: 'xmark'
18
+ * trueColor: 'green', // Default: 'green'
19
+ * falseColor: 'red', // Default: 'red'
20
+ * size: 32, // Default: 32 (px)
21
+ * iconPrefix: 'far' // Default: 'far' (solid)
22
+ * }
23
+ * }
24
+ */
25
+
26
+ const colorClasses = {
27
+ green: { bg: 'bg-green-100', text: 'text-green-500' },
28
+ red: { bg: 'bg-red-100', text: 'text-red-500' },
29
+ yellow: { bg: 'bg-yellow-100', text: 'text-yellow-500' },
30
+ blue: { bg: 'bg-blue-100', text: 'text-blue-500' },
31
+ gray: { bg: 'bg-gray-100', text: 'text-gray-500' },
32
+ purple: { bg: 'bg-purple-100', text: 'text-purple-500' },
33
+ }
34
+
35
+ /**
36
+ * Determines if a value is truthy in the context of boolean display
37
+ * @param {*} value - The value to check
38
+ * @returns {boolean}
39
+ */
40
+ const isTruthy = (value) => {
41
+ if (value === null || value === undefined || value === '') {
42
+ return false
43
+ }
44
+ if (typeof value === 'boolean') {
45
+ return value
46
+ }
47
+ if (typeof value === 'number') {
48
+ return value === 1
49
+ }
50
+ if (typeof value === 'string') {
51
+ const normalized = value.toLowerCase().trim()
52
+ return normalized === '1' || normalized === 'yes' || normalized === 'true'
53
+ }
54
+ return false
55
+ }
56
+
57
+ export const booleanRenderer = (sparkTable) => {
58
+ return (instance, td, row, col, prop, value, cellProperties) => {
59
+ // Clear cell
60
+ td.innerHTML = ''
61
+ td.classList.add('spark-table-cell-boolean')
62
+
63
+ const config = cellProperties.rendererConfig || {}
64
+
65
+ const truthy = isTruthy(value)
66
+ const iconName = truthy ? (config.trueIcon || 'check') : (config.falseIcon || 'xmark')
67
+ const colorName = truthy ? (config.trueColor || 'green') : (config.falseColor || 'red')
68
+ const size = config.size || 32
69
+ const iconPrefix = config.iconPrefix || 'far'
70
+
71
+ const colors = colorClasses[colorName] || colorClasses.gray
72
+
73
+ // Create circular container
74
+ const container = document.createElement('div')
75
+ container.classList.add(
76
+ 'inline-flex',
77
+ 'items-center',
78
+ 'justify-center',
79
+ 'rounded-full',
80
+ colors.bg,
81
+ colors.text,
82
+ )
83
+ container.style.width = `${size}px`
84
+ container.style.height = `${size}px`
85
+
86
+ // Create icon
87
+ const iconElement = document.createElement('span')
88
+ iconElement.innerHTML = icon({ prefix: iconPrefix, iconName }).html
89
+ iconElement.classList.add('flex', 'items-center', 'justify-center')
90
+ // Icon size is roughly 50% of container
91
+ const iconSize = Math.round(size * 0.5)
92
+ iconElement.style.fontSize = `${iconSize}px`
93
+
94
+ container.appendChild(iconElement)
95
+ td.appendChild(container)
96
+ }
97
+ }
@@ -1,6 +1,7 @@
1
1
  import { baseRenderer, registerRenderer } from 'handsontable/renderers'
2
2
  import { actionsRenderer } from './actions.js'
3
3
  import { badgeRenderer } from './badge.js'
4
+ import { booleanRenderer } from './boolean.js'
4
5
  import { linkRenderer } from './link.js'
5
6
  import { imageRenderer } from './image.js'
6
7
  import { dateRenderer } from './date.js'
@@ -50,6 +51,7 @@ export const getRenderer = (key) => {
50
51
  * Registers the following renderers:
51
52
  * - spark.actions: Inline action buttons
52
53
  * - spark.badge: Colored status badges
54
+ * - spark.boolean: Boolean indicators with check/times icons
53
55
  * - spark.link: Clickable links (email, phone, custom)
54
56
  * - spark.image: Image thumbnails
55
57
  * - spark.date: Formatted dates
@@ -61,6 +63,7 @@ export const registerSparkRenderers = (sparkTable) => {
61
63
  // Register Spark renderers
62
64
  register('spark.actions', actionsRenderer(sparkTable))
63
65
  register('spark.badge', badgeRenderer(sparkTable))
66
+ register('spark.boolean', booleanRenderer(sparkTable))
64
67
  register('spark.link', linkRenderer(sparkTable))
65
68
  register('spark.image', imageRenderer(sparkTable))
66
69
  register('spark.date', dateRenderer(sparkTable))