@hakumi-dev/hakumi-components 0.1.18-pre → 0.1.19-pre

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 (56) hide show
  1. package/README.md +208 -381
  2. package/app/javascript/hakumi_components/controllers/hakumi/admin_panel_controller.js +5 -7
  3. package/app/javascript/hakumi_components/controllers/hakumi/back_top_controller.js +1 -1
  4. package/app/javascript/hakumi_components/controllers/hakumi/button_controller.js +108 -2
  5. package/app/javascript/hakumi_components/controllers/hakumi/calendar_controller.js +183 -95
  6. package/app/javascript/hakumi_components/controllers/hakumi/color_picker_controller.js +23 -285
  7. package/app/javascript/hakumi_components/controllers/hakumi/date_picker_controller.js +274 -262
  8. package/app/javascript/hakumi_components/controllers/hakumi/float_button_group_controller.js +2 -2
  9. package/app/javascript/hakumi_components/controllers/hakumi/message_controller.js +4 -2
  10. package/app/javascript/hakumi_components/controllers/hakumi/modal_controller.js +119 -125
  11. package/app/javascript/hakumi_components/controllers/hakumi/table/editable.js +291 -0
  12. package/app/javascript/hakumi_components/controllers/hakumi/table_controller.js +166 -366
  13. package/app/javascript/hakumi_components/controllers/hakumi/tabs_controller.js +8 -2
  14. package/app/javascript/hakumi_components/controllers/hakumi/tag_controller.js +27 -25
  15. package/app/javascript/hakumi_components/controllers/hakumi/tag_group_controller.js +19 -18
  16. package/app/javascript/hakumi_components/controllers/hakumi/theme_controller.js +116 -117
  17. package/app/javascript/hakumi_components/controllers/hakumi/transfer_controller.js +17 -1
  18. package/app/javascript/hakumi_components/controllers/hakumi/tree_controller.js +363 -78
  19. package/app/javascript/hakumi_components/controllers/hakumi/typography_controller.js +3 -3
  20. package/app/javascript/hakumi_components/controllers/hakumi/upload_controller.js +320 -204
  21. package/app/javascript/hakumi_components/core/render_component.js +37 -11
  22. package/app/javascript/hakumi_components/utils/color_helper.js +262 -0
  23. package/app/javascript/stylesheets/_base.scss +9 -0
  24. package/app/javascript/stylesheets/_hakumi_components.scss +1 -0
  25. package/app/javascript/stylesheets/components/_breadcrumb.scss +2 -2
  26. package/app/javascript/stylesheets/components/_calendar.scss +13 -13
  27. package/app/javascript/stylesheets/components/_cascader.scss +5 -5
  28. package/app/javascript/stylesheets/components/_checkbox.scss +9 -11
  29. package/app/javascript/stylesheets/components/_color_picker.scss +11 -11
  30. package/app/javascript/stylesheets/components/_date_picker.scss +4 -4
  31. package/app/javascript/stylesheets/components/_descriptions.scss +2 -2
  32. package/app/javascript/stylesheets/components/_drawer.scss +3 -3
  33. package/app/javascript/stylesheets/components/_dropdown.scss +2 -2
  34. package/app/javascript/stylesheets/components/_flex.scss +1 -1
  35. package/app/javascript/stylesheets/components/_float_button.scss +5 -5
  36. package/app/javascript/stylesheets/components/_form_item.scss +92 -0
  37. package/app/javascript/stylesheets/components/_image.scss +15 -15
  38. package/app/javascript/stylesheets/components/_input.scss +23 -113
  39. package/app/javascript/stylesheets/components/_layout.scss +27 -26
  40. package/app/javascript/stylesheets/components/_menu.scss +15 -15
  41. package/app/javascript/stylesheets/components/_modal.scss +13 -13
  42. package/app/javascript/stylesheets/components/_notification.scss +3 -3
  43. package/app/javascript/stylesheets/components/_popover.scss +1 -1
  44. package/app/javascript/stylesheets/components/_segmented.scss +3 -3
  45. package/app/javascript/stylesheets/components/_select.scss +6 -6
  46. package/app/javascript/stylesheets/components/_slider.scss +1 -1
  47. package/app/javascript/stylesheets/components/_spin.scss +2 -2
  48. package/app/javascript/stylesheets/components/_steps.scss +10 -10
  49. package/app/javascript/stylesheets/components/_switch.scss +11 -10
  50. package/app/javascript/stylesheets/components/_table.scss +6 -6
  51. package/app/javascript/stylesheets/components/_tag.scss +2 -2
  52. package/app/javascript/stylesheets/components/_tooltip.scss +4 -4
  53. package/app/javascript/stylesheets/components/_tree_select.scss +3 -3
  54. package/app/javascript/stylesheets/components/_typography.scss +3 -3
  55. package/app/javascript/stylesheets/components/_upload.scss +4 -4
  56. package/package.json +2 -2
@@ -34,8 +34,8 @@ export default class extends RegistryController {
34
34
  updateState() {
35
35
  if (!this.hasActionsTarget) return
36
36
 
37
- this.actionsTarget.classList.toggle("open", this.open)
38
- this.element.classList.toggle("open", this.open)
37
+ this.actionsTarget.classList.toggle("hakumi-float-button-group-actions-open", this.open)
38
+ this.element.classList.toggle("hakumi-float-button-group-open", this.open)
39
39
  }
40
40
 
41
41
  openGroup() {
@@ -8,7 +8,9 @@ export default class extends RegistryController {
8
8
  duration: Number,
9
9
  maxCount: Number,
10
10
  top: Number,
11
- payload: String
11
+ payload: String,
12
+ // i18n
13
+ closeLabel: { type: String, default: "Close" }
12
14
  }
13
15
 
14
16
  setup() {
@@ -161,7 +163,7 @@ export default class extends RegistryController {
161
163
  const closeButton = document.createElement("button")
162
164
  closeButton.type = "button"
163
165
  closeButton.className = "hakumi-message-close"
164
- closeButton.setAttribute("aria-label", "Close")
166
+ closeButton.setAttribute("aria-label", this.closeLabelValue)
165
167
  closeButton.innerHTML = this.#closeIconMarkup()
166
168
  closeButton.addEventListener("click", () => this.close(key))
167
169
  content.appendChild(closeButton)
@@ -2,150 +2,144 @@ import RegistryController from "../base/registry_controller.js"
2
2
  import { ensureOverlayContainer } from "../../core/overlay_container.js"
3
3
 
4
4
  export default class extends RegistryController {
5
- static declarativeActions = ["close", "confirm", "cancel"]
6
- static targets = ["mask", "wrapper"]
7
- static values = {
8
- open: Boolean,
9
- maskClosable: { type: Boolean, default: true },
10
- keyboard: { type: Boolean, default: true },
11
- confirmLoading: Boolean
5
+ static declarativeActions = ["close", "confirm", "cancel"]
6
+ static targets = ["mask", "wrapper"]
7
+ static values = {
8
+ open: Boolean,
9
+ maskClosable: { type: Boolean, default: true },
10
+ keyboard: { type: Boolean, default: true },
11
+ confirmLoading: Boolean
12
+ }
13
+
14
+ setup() {
15
+ const overlayContainer = ensureOverlayContainer()
16
+ if (overlayContainer && this.element.parentElement !== overlayContainer) {
17
+ overlayContainer.appendChild(this.element)
12
18
  }
13
19
 
14
-
15
- setup() {
16
- const overlayContainer = ensureOverlayContainer()
17
- if (overlayContainer && this.element.parentElement !== overlayContainer) {
18
- overlayContainer.appendChild(this.element)
19
- }
20
-
21
- if (!this.openValue) this.element.style.display = "none"
22
-
23
- this.handleOpenChange()
20
+ if (!this.openValue) this.element.style.display = "none"
21
+
22
+ this.handleOpenChange()
23
+ }
24
+
25
+ teardown() {
26
+ if (this.animationTimeout) clearTimeout(this.animationTimeout)
27
+ }
28
+
29
+ registerApi() {
30
+ const api = {
31
+ open: () => this.open(),
32
+ close: () => this.close(),
33
+ toggle: () => this.toggle(),
34
+ isOpen: () => this.openValue,
35
+ getState: () => ({
36
+ open: this.openValue,
37
+ maskClosable: this.maskClosableValue,
38
+ keyboard: this.keyboardValue,
39
+ confirmLoading: this.confirmLoadingValue
40
+ })
24
41
  }
25
42
 
26
- teardown() {
27
- if (this.animationTimeout) clearTimeout(this.animationTimeout)
43
+ this.element.hakumiComponent = {
44
+ name: "modal",
45
+ version: 1,
46
+ singleton: false,
47
+ api,
48
+ destroy: () => api.close()
28
49
  }
50
+ }
29
51
 
30
- registerApi() {
31
- const api = {
32
- open: () => this.open(),
33
- close: () => this.close(),
34
- toggle: () => this.toggle(),
35
- isOpen: () => this.openValue,
36
- getState: () => ({
37
- open: this.openValue,
38
- maskClosable: this.maskClosableValue,
39
- keyboard: this.keyboardValue,
40
- confirmLoading: this.confirmLoadingValue
41
- })
42
- }
43
-
44
- this.element.hakumiComponent = {
45
- name: "modal",
46
- version: 1,
47
- singleton: false,
48
- api,
49
- destroy: () => api.close()
50
- }
51
- }
52
+ open() {
53
+ this.openValue = true
54
+ }
52
55
 
53
- open() {
54
- this.openValue = true
56
+ toggle() {
57
+ if (this.openValue) {
58
+ this.close()
59
+ } else {
60
+ this.open()
55
61
  }
56
-
57
- toggle() {
58
- if (this.openValue) {
59
- this.close()
60
- } else {
61
- this.open()
62
+ }
63
+
64
+ openValueChanged() {
65
+ this.handleOpenChange()
66
+ }
67
+
68
+ handleOpenChange() {
69
+ const modal = this.element.querySelector(".hakumi-modal")
70
+ const mask = this.maskTarget
71
+
72
+ if (this.openValue) {
73
+ this.element.style.display = "block"
74
+ document.body.style.overflow = "hidden"
75
+ document.body.style.userSelect = "none"
76
+ document.body.style.webkitUserSelect = "none"
77
+
78
+ requestAnimationFrame(() => {
79
+ if (mask) {
80
+ mask.classList.remove("hakumi-fade-leave", "hakumi-fade-leave-active")
81
+ mask.classList.add("hakumi-fade-enter", "hakumi-fade-enter-active")
62
82
  }
63
- }
64
-
65
- openValueChanged() {
66
- this.handleOpenChange()
67
- }
68
-
69
- handleOpenChange() {
70
- const modal = this.element.querySelector('.hakumi-modal')
71
- const mask = this.maskTarget
72
-
73
- if (this.openValue) {
74
- this.element.style.display = "block"
75
- document.body.style.overflow = "hidden"
76
- document.body.style.userSelect = "none"
77
- document.body.style.webkitUserSelect = "none"
78
-
79
83
 
80
- requestAnimationFrame(() => {
81
-
82
- if (mask) {
83
- mask.classList.remove("fade-leave", "fade-leave-active")
84
- mask.classList.add("fade-enter", "fade-enter-active")
85
- }
86
-
87
-
88
- if (modal) {
89
- modal.classList.remove("hakumi-zoom-leave", "hakumi-zoom-leave-active")
90
- modal.classList.add("hakumi-zoom-enter", "hakumi-zoom-enter-active")
91
- }
92
- })
93
- } else {
94
-
95
- if (mask) {
96
- mask.classList.remove("fade-enter", "fade-enter-active")
97
- mask.classList.add("fade-leave", "fade-leave-active")
98
- }
99
-
100
- if (modal) {
101
- modal.classList.remove("hakumi-zoom-enter", "hakumi-zoom-enter-active")
102
- modal.classList.add("hakumi-zoom-leave", "hakumi-zoom-leave-active")
103
- }
104
-
105
-
106
- this.animationTimeout = setTimeout(() => {
107
- if (!this.openValue) {
108
- this.element.style.display = "none"
109
- document.body.style.overflow = ""
110
- document.body.style.userSelect = ""
111
- document.body.style.webkitUserSelect = ""
112
-
113
- this.dispatch("hidden")
114
- }
115
- this.animationTimeout = null
116
- }, 300)
84
+ if (modal) {
85
+ modal.classList.remove("hakumi-zoom-leave", "hakumi-zoom-leave-active")
86
+ modal.classList.add("hakumi-zoom-enter", "hakumi-zoom-enter-active")
117
87
  }
88
+ })
89
+ } else {
90
+ if (mask) {
91
+ mask.classList.remove("hakumi-fade-enter", "hakumi-fade-enter-active")
92
+ mask.classList.add("hakumi-fade-leave", "hakumi-fade-leave-active")
93
+ }
94
+
95
+ if (modal) {
96
+ modal.classList.remove("hakumi-zoom-enter", "hakumi-zoom-enter-active")
97
+ modal.classList.add("hakumi-zoom-leave", "hakumi-zoom-leave-active")
98
+ }
99
+
100
+ this.animationTimeout = setTimeout(() => {
101
+ if (!this.openValue) {
102
+ this.element.style.display = "none"
103
+ document.body.style.overflow = ""
104
+ document.body.style.userSelect = ""
105
+ document.body.style.webkitUserSelect = ""
106
+
107
+ this.dispatch("hidden")
108
+ }
109
+ this.animationTimeout = null
110
+ }, 300)
118
111
  }
112
+ }
119
113
 
120
- close() {
121
- this.openValue = false
122
- this.dispatch("cancel")
123
- }
114
+ close() {
115
+ this.openValue = false
116
+ this.dispatch("cancel")
117
+ }
124
118
 
125
- handleMaskClick(_event) {
126
- if (this.maskClosableValue) {
127
- this.close()
128
- }
119
+ handleMaskClick(_event) {
120
+ if (this.maskClosableValue) {
121
+ this.close()
129
122
  }
123
+ }
130
124
 
131
- handleWrapperClick(event) {
132
- if (event.target === this.wrapperTarget && this.maskClosableValue) {
133
- this.close()
134
- }
125
+ handleWrapperClick(event) {
126
+ if (event.target === this.wrapperTarget && this.maskClosableValue) {
127
+ this.close()
135
128
  }
129
+ }
136
130
 
137
- handleKeydown(event) {
138
- if (this.keyboardValue && event.key === "Escape") {
139
- this.close()
140
- }
131
+ handleKeydown(event) {
132
+ if (this.keyboardValue && event.key === "Escape") {
133
+ this.close()
141
134
  }
135
+ }
142
136
 
143
- // Declarative action handlers (override base class)
144
- handleConfirm(_event) {
145
- this.dispatch("ok")
146
- }
137
+ // Declarative action handlers (override base class)
138
+ handleConfirm(_event) {
139
+ this.dispatch("ok")
140
+ }
147
141
 
148
- handleCancel(_event) {
149
- this.close()
150
- }
142
+ handleCancel(_event) {
143
+ this.close()
144
+ }
151
145
  }
@@ -0,0 +1,291 @@
1
+ export default class TableEditable {
2
+ constructor(controller) {
3
+ this.controller = controller
4
+ this._shouldCellUpdateHooks = new Map()
5
+ }
6
+
7
+ editCell(event) {
8
+ const cell = event.currentTarget
9
+ if (cell.querySelector(".hakumi-table-cell-editor")) return
10
+
11
+ const tableConfig = this.controller.editableConfigValue || {}
12
+ const mode = tableConfig.mode || "cell"
13
+
14
+ if (mode === "row") {
15
+ this.#editRow(cell)
16
+ } else {
17
+ this.#editSingleCell(cell)
18
+ }
19
+ }
20
+
21
+ registerShouldCellUpdate(name, fn) {
22
+ if (typeof fn !== "function") {
23
+ console.warn(`[Hakumi Table] registerShouldCellUpdate: "${name}" must be a function`)
24
+ return
25
+ }
26
+ this._shouldCellUpdateHooks.set(name, fn)
27
+ }
28
+
29
+ unregisterShouldCellUpdate(name) {
30
+ this._shouldCellUpdateHooks.delete(name)
31
+ }
32
+
33
+ #editSingleCell(cell) {
34
+ const { input, editor, originalContent, originalValue, config } = this.#createCellEditor(cell)
35
+ input.focus()
36
+
37
+ const save = () => {
38
+ if (!editor.isConnected) return
39
+
40
+ const detail = this.#buildEditDetail({ cell, input, originalValue, config })
41
+
42
+ if (!this.#shouldUpdateCell(cell, detail)) {
43
+ this.#cancelCellEditor({ cell, editor, originalContent })
44
+ return
45
+ }
46
+
47
+ this.#applyCellEditChange({ cell, editor, originalContent, newValue: detail.value })
48
+ this.#dispatchEdit(detail)
49
+ }
50
+
51
+ const cancel = () => {
52
+ if (!editor.isConnected) return
53
+ this.#cancelCellEditor({ cell, editor, originalContent })
54
+ }
55
+
56
+ input.addEventListener("blur", save)
57
+
58
+ input.addEventListener("keydown", (e) => {
59
+ if (e.key === "Enter") {
60
+ e.preventDefault()
61
+ input.blur()
62
+ } else if (e.key === "Escape") {
63
+ e.preventDefault()
64
+ input.removeEventListener("blur", save)
65
+ cancel()
66
+ }
67
+ })
68
+ }
69
+
70
+ #editRow(clickedCell) {
71
+ const row = clickedCell.closest("tr")
72
+ if (!row || row.classList.contains("hakumi-table-row-editing")) return
73
+
74
+ row.classList.add("hakumi-table-row-editing")
75
+ const rowKey = row.dataset.rowKey
76
+ const editableCells = row.querySelectorAll("td[data-editable='true']")
77
+ const editors = []
78
+
79
+ editableCells.forEach((cell) => {
80
+ const { input, originalContent, originalValue, config } = this.#createCellEditor(cell)
81
+ this.#assignEditorDataset(input, { cell, originalValue, config })
82
+
83
+ editors.push({ cell, input, originalContent, originalValue, config })
84
+ })
85
+
86
+ const clickedInput = clickedCell.querySelector(".hakumi-table-cell-input")
87
+ if (clickedInput) {
88
+ clickedInput.focus()
89
+ } else if (editors.length > 0) {
90
+ editors[0].input.focus()
91
+ }
92
+
93
+ const saveRow = () => {
94
+ const { allValid, changes } = this.#buildRowEditChanges(editors, rowKey)
95
+
96
+ if (!allValid) {
97
+ cancelRow()
98
+ return
99
+ }
100
+
101
+ changes.forEach((change) => this.#applyCellEditChange(change))
102
+
103
+ row.classList.remove("hakumi-table-row-editing")
104
+
105
+ this.#dispatchRowEdit({
106
+ rowKey,
107
+ changes: changes.map(({ detail }) => detail)
108
+ })
109
+ }
110
+
111
+ const cancelRow = () => {
112
+ editors.forEach(({ cell, originalContent }) => {
113
+ this.#cancelCellEditor({ cell, originalContent })
114
+ })
115
+ row.classList.remove("hakumi-table-row-editing")
116
+ }
117
+
118
+ editors.forEach(({ input }) => {
119
+ input.addEventListener("keydown", (e) => {
120
+ if (e.key === "Enter") {
121
+ e.preventDefault()
122
+ saveRow()
123
+ } else if (e.key === "Escape") {
124
+ e.preventDefault()
125
+ cancelRow()
126
+ }
127
+ })
128
+ })
129
+
130
+ this.controller.dispatch("rowEditing", {
131
+ detail: { rowKey, save: saveRow, cancel: cancelRow }
132
+ })
133
+ }
134
+
135
+ #assignEditorDataset(input, { cell, originalValue, config }) {
136
+ input.dataset.columnKey = config.key || cell.dataset.columnKey
137
+ input.dataset.dataIndex = config.data_index
138
+ input.dataset.originalValue = originalValue
139
+ }
140
+
141
+ #buildEditDetail({ cell, input, originalValue, config, rowKey = null }) {
142
+ return {
143
+ rowKey: rowKey ?? cell.closest("tr")?.dataset.rowKey,
144
+ columnKey: config.key || cell.dataset.columnKey,
145
+ dataIndex: config.data_index,
146
+ value: input.value,
147
+ originalValue
148
+ }
149
+ }
150
+
151
+ #buildRowEditChanges(editors, rowKey) {
152
+ const changes = []
153
+ let allValid = true
154
+
155
+ editors.forEach(({ cell, input, originalContent, originalValue, config }) => {
156
+ const detail = this.#buildEditDetail({ cell, input, originalValue, config, rowKey })
157
+
158
+ if (!this.#shouldUpdateCell(cell, detail)) {
159
+ allValid = false
160
+ return
161
+ }
162
+
163
+ changes.push({ cell, originalContent, newValue: detail.value, detail })
164
+ })
165
+
166
+ return { allValid, changes }
167
+ }
168
+
169
+ #applyCellEditChange({ cell, editor = null, originalContent, newValue }) {
170
+ if (originalContent) {
171
+ originalContent.textContent = newValue
172
+ originalContent.style.display = ""
173
+ }
174
+ cell.dataset.value = newValue
175
+ this.#removeCellEditor(cell, editor)
176
+ }
177
+
178
+ #cancelCellEditor({ cell, editor = null, originalContent }) {
179
+ if (originalContent) originalContent.style.display = ""
180
+ this.#removeCellEditor(cell, editor)
181
+ }
182
+
183
+ #removeCellEditor(cell, editor = null) {
184
+ const editorElement = editor || cell.querySelector(".hakumi-table-cell-editor")
185
+ editorElement?.remove()
186
+ }
187
+
188
+ #dispatchEdit(detail) {
189
+ this.controller.dispatch("edit", { detail })
190
+ }
191
+
192
+ #dispatchRowEdit(detail) {
193
+ const params = this.#changesToParams(detail.changes)
194
+
195
+ this.controller.dispatch("rowEdit", {
196
+ detail: {
197
+ ...detail,
198
+ params
199
+ }
200
+ })
201
+ }
202
+
203
+ #changesToParams(changes) {
204
+ const params = {}
205
+ if (!changes || !Array.isArray(changes)) return params
206
+
207
+ changes.forEach(({ columnKey, dataIndex, value }) => {
208
+ const key = dataIndex || columnKey
209
+ if (key) {
210
+ params[key] = value
211
+ }
212
+ })
213
+ return params
214
+ }
215
+
216
+ #shouldUpdateCell(cell, detail) {
217
+ const hookName = cell?.dataset.shouldCellUpdate
218
+ if (!hookName) return true
219
+
220
+ const hook = this.#resolveHook(hookName)
221
+ if (typeof hook !== "function") {
222
+ console.warn(`[Hakumi Table] shouldCellUpdate hook "${hookName}" is not a function`)
223
+ return true
224
+ }
225
+
226
+ try {
227
+ const result = hook(detail)
228
+ if (result === false) return false
229
+
230
+ if (typeof result === "string") {
231
+ this.controller.dispatch("cellUpdatePrevented", {
232
+ detail: {
233
+ ...detail,
234
+ reason: result
235
+ }
236
+ })
237
+ return false
238
+ }
239
+
240
+ return true
241
+ } catch (error) {
242
+ console.error(`[Hakumi Table] shouldCellUpdate hook "${hookName}" failed`, error)
243
+ return true
244
+ }
245
+ }
246
+
247
+ #resolveHook(name) {
248
+ if (!name) return null
249
+
250
+ if (this._shouldCellUpdateHooks.has(name)) {
251
+ return this._shouldCellUpdateHooks.get(name)
252
+ }
253
+
254
+ if (typeof window === "undefined") return null
255
+
256
+ if (window.HakumiTableShouldCellUpdate && typeof window.HakumiTableShouldCellUpdate[name] === "function") {
257
+ return window.HakumiTableShouldCellUpdate[name]
258
+ }
259
+
260
+ return name.split(".").reduce((acc, key) => (acc ? acc[key] : undefined), window)
261
+ }
262
+
263
+ #createCellEditor(cell) {
264
+ const config = parseJson(cell.dataset.editableConfig, {})
265
+ const originalContent = cell.querySelector(".hakumi-table-cell-content")
266
+ const originalValue = cell.dataset.value ?? (originalContent?.textContent?.trim() || "")
267
+
268
+ const editor = document.createElement("div")
269
+ editor.className = "hakumi-table-cell-editor"
270
+
271
+ const input = document.createElement("input")
272
+ input.type = config.input_type || "text"
273
+ input.value = originalValue
274
+ input.className = "hakumi-table-cell-input"
275
+
276
+ editor.appendChild(input)
277
+
278
+ if (originalContent) originalContent.style.display = "none"
279
+ cell.appendChild(editor)
280
+
281
+ return { input, editor, originalContent, originalValue, config }
282
+ }
283
+ }
284
+
285
+ function parseJson(value, fallback) {
286
+ try {
287
+ return JSON.parse(value || "")
288
+ } catch {
289
+ return fallback
290
+ }
291
+ }