@feedmepos/mf-report 5.27.54 → 5.28.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 (39) hide show
  1. package/dist/{App-C6xpf-Q0.js → App-BDhSFQX_.js} +45 -44
  2. package/dist/DateRangeSelect-CRsKMi0U.js +437 -0
  3. package/dist/{Default.vue_vue_type_script_setup_true_lang-BwPZXC_d.js → Default.vue_vue_type_script_setup_true_lang-WGOOX2b8.js} +12 -11
  4. package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-BpgXiPBY.js → DynamicWidget.vue_vue_type_script_setup_true_lang-CQM-l5Rk.js} +182 -181
  5. package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-CVShUWJC.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-CMioHpn1.js} +5091 -5717
  6. package/dist/{InsightView-BYW66Wrv.js → InsightView-lnOobZAr.js} +17 -15
  7. package/dist/{Integration-DQPJBE-0.js → Integration-D5ssimUg.js} +27 -25
  8. package/dist/{Integrations-BW4H9lUS.js → Integrations-CaqGvkzI.js} +8 -7
  9. package/dist/{Layout-CMlh08aB.js → Layout-DS0XCT_E.js} +1 -1
  10. package/dist/LocationSelect.vue_vue_type_script_setup_true_lang-DC9QYJk-.js +8055 -0
  11. package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-BUvD1_b0.js → MenuTab.vue_vue_type_script_setup_true_lang-nOukNXp3.js} +11 -10
  12. package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-TpeQ_Wm1.js → NavigationTab.vue_vue_type_script_setup_true_lang-DXNbt08D.js} +9636 -17677
  13. package/dist/{OverviewView-BtGpQkBY.js → OverviewView-BkIozvMs.js} +5 -5
  14. package/dist/{Report-CJ9myNLK.js → Report-HiSGRNhn.js} +27 -25
  15. package/dist/ReportCanvasView-ILVhB1KL.js +448 -0
  16. package/dist/{ReportEditor-DtnTBbY1.js → ReportEditor-B4tyVB8s.js} +30 -28
  17. package/dist/{ReportView-Ci8nFyI9.js → ReportView-CIQQHvWT.js} +14 -13
  18. package/dist/ReportViewRoute-B3KwVB0v.js +18 -0
  19. package/dist/{SelectComponent.vue_vue_type_script_setup_true_lang-Ckn2p74A.js → SelectComponent.vue_vue_type_script_setup_true_lang-DW7Dk7iQ.js} +1 -1
  20. package/dist/{Setting-QSiMSAQk.js → Setting-CvToMUKq.js} +5 -5
  21. package/dist/{Template-CAPlAQO9.js → Template-BBQEaHrM.js} +10 -9
  22. package/dist/{TileEditor-e7ZCWMU9.js → TileEditor-Cqy98LmJ.js} +19 -17
  23. package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-B4g3YPzM.js → TilePageStack.vue_vue_type_script_setup_true_lang-CzggUdfI.js} +3 -3
  24. package/dist/{TransitionFade-DqIazHHG.js → TransitionFade-D-8BYs6T.js} +36 -33
  25. package/dist/{_plugin-vue_export-helper-kkiiQoKl.js → _plugin-vue_export-helper-C6QDExPN.js} +276 -291
  26. package/dist/{app-BAgs-zHf.js → app-Bjt_JuJi.js} +670 -567
  27. package/dist/app.js +1 -1
  28. package/dist/assets/{formatChartData.worker-epD1fsTW.js → formatChartData.worker-BeBGCyYW.js} +1 -1
  29. package/dist/assets/{processTableData.worker-BJZ_Z45H.js → processTableData.worker-frTYV0ok.js} +39 -39
  30. package/dist/global-loader-BuxFfehu.js +19 -0
  31. package/dist/index-CtZE4sab.js +82 -0
  32. package/dist/report-DjGDWvm3.js +635 -0
  33. package/dist/report-canvas-frame.html +888 -0
  34. package/dist/report-canvas-libs/chart.js-4.js +14 -0
  35. package/dist/report-canvas-libs/fm-ui-1.js +904 -0
  36. package/dist/style.css +1 -1
  37. package/dist/{useRestaurantPermission-ChYMJfXG.js → useRestaurantPermission-Dkbh_cv8.js} +4 -4
  38. package/package.json +1 -1
  39. package/dist/DateRangeSelect-DYVOvSCA.js +0 -517
@@ -0,0 +1,904 @@
1
+ /**
2
+ * FM.ui — general controls the AI may use instead of hand-writing one, delivered to the
3
+ * sandbox as the `fm-ui@1` lib bundle (plain JS: the frame has no framework and no network).
4
+ *
5
+ * Deliberately domain-free and param-free. Every control takes a `value` and emits
6
+ * `onChange`; which param it writes is the section's decision, via FM.setParams.
7
+ */
8
+ ;(function () {
9
+ "use strict"
10
+
11
+ var FM = (window.FM = window.FM || {})
12
+ if (FM.ui) return
13
+
14
+ var STYLE_ID = "fm-ui-style"
15
+ // Chrome follows the classic report filter row: a filled chip trigger, orange for the active state.
16
+ var CSS = [
17
+ ".fm-c{position:relative;display:inline-block;font:inherit;vertical-align:top}",
18
+ ".fm-c *{box-sizing:border-box}",
19
+ ".fm-btn{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:6px 12px;",
20
+ "border:1px solid transparent;border-radius:999px;background:#f2f2f7;color:#6b6b6b;",
21
+ "font:inherit;font-weight:600;cursor:pointer;white-space:nowrap}",
22
+ ".fm-btn:hover{background:#eaeaef}",
23
+ ".fm-btn[aria-expanded=true]{border-color:var(--fm-signature,#ff7823);color:var(--fm-fg,#1c1c1e)}",
24
+ ".fm-btn .fm-caret{color:#6b6b6b;font-size:10px}",
25
+ ".fm-pop{position:absolute;z-index:20;top:calc(100% + 4px);left:0;min-width:100%;",
26
+ "max-height:280px;overflow:auto;padding:4px;border:1px solid var(--fm-line,#f0f0f0);",
27
+ "border-radius:8px;background:#fff;box-shadow:0 6px 24px rgba(28,28,30,.12)}",
28
+ ".fm-pop[hidden]{display:none}",
29
+ ".fm-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:4px;cursor:pointer;white-space:nowrap}",
30
+ ".fm-opt:hover{background:#fafafa}",
31
+ ".fm-opt[aria-selected=true]{background:var(--fm-signature-tint,#fff3e6);color:#6b3100;font-weight:600}",
32
+ ".fm-opt input[type=checkbox]{accent-color:var(--fm-signature,#ff7823)}",
33
+ ".fm-search{width:100%;margin:2px 0 4px;padding:6px 8px;border:1px solid var(--fm-line,#f0f0f0);",
34
+ "border-radius:4px;font:inherit}",
35
+ ".fm-empty{padding:8px;color:var(--fm-muted,#6b6c70)}",
36
+ ".fm-seg{display:inline-flex;border:1px solid var(--fm-line,#f0f0f0);border-radius:999px;overflow:hidden}",
37
+ ".fm-seg button{min-height:32px;padding:6px 14px;border:0;border-right:1px solid var(--fm-line,#f0f0f0);",
38
+ "background:#fff;color:var(--fm-muted,#6b6c70);font:inherit;font-weight:600;cursor:pointer}",
39
+ ".fm-seg button:last-child{border-right:0}",
40
+ ".fm-seg button[aria-pressed=true]{background:var(--fm-signature,#ff7823);color:#fff}",
41
+ ".fm-dr-body{display:flex;gap:8px;padding:4px}",
42
+ ".fm-dr-presets{min-width:132px;border-right:1px solid var(--fm-line,#f0f0f0);padding-right:4px}",
43
+ ".fm-dr-custom{display:flex;flex-direction:column;gap:6px;padding:4px 4px 4px 0}",
44
+ ".fm-dr-custom label{display:flex;flex-direction:column;gap:2px;color:var(--fm-muted,#6b6c70);font-size:12px}",
45
+ ".fm-dr-custom input{padding:6px 8px;border:1px solid var(--fm-line,#f0f0f0);border-radius:4px;font:inherit}",
46
+ ".fm-dr-apply{align-self:flex-end;padding:6px 14px;border:0;border-radius:999px;",
47
+ "background:var(--fm-signature,#ff7823);color:#fff;font:inherit;font-weight:600;cursor:pointer}",
48
+ ".fm-ui-table tr.fm-row-btn{cursor:pointer}",
49
+ ".fm-ui-table tr.fm-row-btn:hover td{background:#fafafa}",
50
+ ".fm-ui-table tr.fm-row-btn[aria-selected=true] td{background:var(--fm-signature-tint,#fff3e6)}",
51
+ ".fm-ui-table tr.fm-row-btn:focus-visible{outline:2px solid var(--fm-signature,#ff7823);outline-offset:-2px}",
52
+ ".fm-dlg{position:fixed;inset:0;z-index:25;display:flex;align-items:center;justify-content:center;",
53
+ "padding:24px;background:rgba(28,28,30,.4)}",
54
+ ".fm-dlg[hidden]{display:none}",
55
+ ".fm-dlg-card{position:relative;display:flex;flex-direction:column;min-width:320px;",
56
+ "max-width:min(720px,100%);max-height:100%;overflow:hidden;border-radius:12px;background:#fff;",
57
+ "box-shadow:0 12px 48px rgba(28,28,30,.24);outline:none}",
58
+ ".fm-dlg-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;",
59
+ "padding:16px 20px;border-bottom:1px solid var(--fm-line,#f0f0f0)}",
60
+ ".fm-dlg-title{margin:0;font-size:16px;font-weight:600;color:var(--fm-fg,#1c1c1e)}",
61
+ ".fm-dlg-subtitle{margin:2px 0 0;font-size:12px;color:var(--fm-muted,#6b6c70)}",
62
+ ".fm-dlg-close{border:0;background:none;padding:4px 8px;font-size:14px;",
63
+ "color:var(--fm-muted,#6b6c70);cursor:pointer;border-radius:4px}",
64
+ ".fm-dlg-close:hover{background:#f2f2f7;color:var(--fm-fg,#1c1c1e)}",
65
+ ".fm-dlg-body{overflow:auto;overscroll-behavior:contain;padding:0 20px 16px}",
66
+ // the shell scrims busy sections; a dialog hosted inside one inherits the same treatment
67
+ "section[data-fm-busy] .fm-dlg-card::after{content:'';position:absolute;inset:0;",
68
+ "background:rgba(255,255,255,.62);pointer-events:none}"
69
+ ].join("")
70
+
71
+ function ensureStyle() {
72
+ if (document.getElementById(STYLE_ID)) return
73
+ var style = document.createElement("style")
74
+ style.id = STYLE_ID
75
+ style.textContent = CSS
76
+ document.head.appendChild(style)
77
+ }
78
+
79
+ function el(tag, className, text) {
80
+ var node = document.createElement(tag)
81
+ if (className) node.className = className
82
+ if (text !== undefined && text !== null) node.textContent = String(text)
83
+ return node
84
+ }
85
+
86
+ function resolve(target) {
87
+ var node = typeof target === "string" ? document.querySelector(target) : target
88
+ if (!node) throw new Error("FM.ui: no element for " + target)
89
+ return node
90
+ }
91
+
92
+ function normalizeOptions(options) {
93
+ return (options || []).map(function (option) {
94
+ if (option === null || typeof option !== "object") {
95
+ return { value: option, label: String(option) }
96
+ }
97
+ return { value: option.value, label: String(option.label != null ? option.label : option.value) }
98
+ })
99
+ }
100
+
101
+ function asArray(value) {
102
+ if (value === null || value === undefined) return []
103
+ return Array.isArray(value) ? value.slice() : [value]
104
+ }
105
+
106
+ /** One open popover at a time, and an outside click closes it. */
107
+ var openPopovers = []
108
+ function registerPopover(close) {
109
+ openPopovers.push(close)
110
+ return function () {
111
+ openPopovers = openPopovers.filter(function (entry) {
112
+ return entry !== close
113
+ })
114
+ }
115
+ }
116
+ document.addEventListener("click", function (event) {
117
+ openPopovers.slice().forEach(function (close) {
118
+ close(event)
119
+ })
120
+ })
121
+
122
+ function popover(root, button, panel) {
123
+ var open = false
124
+ function set(next) {
125
+ open = next
126
+ panel.hidden = !open
127
+ button.setAttribute("aria-expanded", String(open))
128
+ }
129
+ button.addEventListener("click", function (event) {
130
+ event.stopPropagation()
131
+ set(!open)
132
+ })
133
+ panel.addEventListener("click", function (event) {
134
+ event.stopPropagation()
135
+ })
136
+ registerPopover(function (event) {
137
+ if (open && !root.contains(event.target)) set(false)
138
+ })
139
+ set(false)
140
+ return { close: function () { set(false) }, isOpen: function () { return open } }
141
+ }
142
+
143
+ // --- dropdown ------------------------------------------------------------
144
+
145
+ /**
146
+ * Single or multi select. `value` is one value, or an array when `multiple`.
147
+ * Multi-select fires onChange on close/Apply, not per click.
148
+ */
149
+ function dropdown(target, options) {
150
+ ensureStyle()
151
+ var host = resolve(target)
152
+ var config = options || {}
153
+ var items = normalizeOptions(config.options)
154
+ var multiple = !!config.multiple
155
+ var searchable = config.searchable === undefined ? items.length > 8 : !!config.searchable
156
+ var selected = multiple ? asArray(config.value) : config.value
157
+ var filter = ""
158
+
159
+ var root = el("div", "fm-c")
160
+ var button = el("button", "fm-btn")
161
+ var label = el("span")
162
+ var caret = el("span", "fm-caret", "▾")
163
+ button.appendChild(label)
164
+ button.appendChild(caret)
165
+ var panel = el("div", "fm-pop")
166
+ panel.hidden = true
167
+ var search = el("input", "fm-search")
168
+ search.type = "search"
169
+ search.placeholder = "Search"
170
+ var list = el("div")
171
+ if (searchable) panel.appendChild(search)
172
+ panel.appendChild(list)
173
+ root.appendChild(button)
174
+ root.appendChild(panel)
175
+ host.innerHTML = ""
176
+ host.appendChild(root)
177
+
178
+ function isSelected(value) {
179
+ return multiple ? selected.indexOf(value) >= 0 : selected === value
180
+ }
181
+
182
+ function summary() {
183
+ if (multiple) {
184
+ if (selected.length === 0) return config.placeholder || "None"
185
+ if (selected.length === items.length && items.length > 0) return config.allLabel || "All"
186
+ if (selected.length === 1) {
187
+ var one = items.filter(function (item) { return item.value === selected[0] })[0]
188
+ return one ? one.label : String(selected[0])
189
+ }
190
+ return selected.length + " selected"
191
+ }
192
+ var match = items.filter(function (item) { return item.value === selected })[0]
193
+ return match ? match.label : config.placeholder || "Select"
194
+ }
195
+
196
+ function emit() {
197
+ if (typeof config.onChange === "function") {
198
+ config.onChange(multiple ? selected.slice() : selected)
199
+ }
200
+ }
201
+
202
+ function renderList() {
203
+ list.innerHTML = ""
204
+ var needle = filter.toLowerCase()
205
+ var visible = items.filter(function (item) {
206
+ return !needle || item.label.toLowerCase().indexOf(needle) >= 0
207
+ })
208
+ if (multiple && items.length > 1 && visible.length > 0) {
209
+ // Select all acts on what the search leaves visible, as the classic outlet picker does.
210
+ var shown = visible.map(function (item) { return item.value })
211
+ var allShownSelected = shown.length > 0 && shown.every(isSelected)
212
+ var all = el("div", "fm-opt", allShownSelected ? "Clear all" : "Select all")
213
+ all.setAttribute("aria-selected", String(allShownSelected))
214
+ all.addEventListener("click", function () {
215
+ selected = allShownSelected
216
+ ? selected.filter(function (value) { return shown.indexOf(value) < 0 })
217
+ : selected.concat(shown.filter(function (value) { return !isSelected(value) }))
218
+ label.textContent = summary()
219
+ renderList()
220
+ emit()
221
+ })
222
+ list.appendChild(all)
223
+ }
224
+ if (visible.length === 0) {
225
+ list.appendChild(el("div", "fm-empty", "No matches"))
226
+ return
227
+ }
228
+ visible.forEach(function (item) {
229
+ var option = el("div", "fm-opt")
230
+ option.setAttribute("role", "option")
231
+ option.setAttribute("aria-selected", String(isSelected(item.value)))
232
+ if (multiple) {
233
+ var box = el("input")
234
+ box.type = "checkbox"
235
+ box.checked = isSelected(item.value)
236
+ box.tabIndex = -1
237
+ option.appendChild(box)
238
+ }
239
+ option.appendChild(el("span", null, item.label))
240
+ option.addEventListener("click", function () {
241
+ if (multiple) {
242
+ selected = isSelected(item.value)
243
+ ? selected.filter(function (value) { return value !== item.value })
244
+ : selected.concat([item.value])
245
+ } else {
246
+ selected = item.value
247
+ control.close()
248
+ }
249
+ label.textContent = summary()
250
+ renderList()
251
+ emit()
252
+ })
253
+ list.appendChild(option)
254
+ })
255
+ }
256
+
257
+ search.addEventListener("input", function () {
258
+ filter = search.value
259
+ renderList()
260
+ })
261
+
262
+ var control = popover(root, button, panel)
263
+ label.textContent = summary()
264
+ renderList()
265
+
266
+ return {
267
+ element: root,
268
+ getValue: function () { return multiple ? selected.slice() : selected },
269
+ setValue: function (next) {
270
+ selected = multiple ? asArray(next) : next
271
+ label.textContent = summary()
272
+ renderList()
273
+ },
274
+ setOptions: function (next) {
275
+ items = normalizeOptions(next)
276
+ label.textContent = summary()
277
+ renderList()
278
+ },
279
+ destroy: function () { host.innerHTML = "" }
280
+ }
281
+ }
282
+
283
+ // --- dateRange -----------------------------------------------------------
284
+
285
+ /** "YYYY-MM-DD" for an instant, read in the given IANA zone. */
286
+ function ymdIn(date, timezone) {
287
+ try {
288
+ return new Intl.DateTimeFormat("en-CA", {
289
+ timeZone: timezone,
290
+ year: "numeric",
291
+ month: "2-digit",
292
+ day: "2-digit"
293
+ }).format(date)
294
+ } catch (err) {
295
+ return date.toISOString().slice(0, 10)
296
+ }
297
+ }
298
+
299
+ /** Zone offset at an instant, in ms — the standard two-format diff. */
300
+ function offsetMs(date, timezone) {
301
+ try {
302
+ var local = new Date(date.toLocaleString("en-US", { timeZone: timezone }))
303
+ var utc = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }))
304
+ return local.getTime() - utc.getTime()
305
+ } catch (err) {
306
+ return 0
307
+ }
308
+ }
309
+
310
+ /** Day boundary in the report's zone, as the ISO instant the host and SQL expect. */
311
+ function boundaryIso(ymd, timezone, endOfDay) {
312
+ var base = Date.parse(ymd + (endOfDay ? "T23:59:59.999Z" : "T00:00:00.000Z"))
313
+ var guess = new Date(base - offsetMs(new Date(base), timezone))
314
+ // re-read the offset at the guessed instant so DST days land on the right side
315
+ return new Date(base - offsetMs(guess, timezone)).toISOString()
316
+ }
317
+
318
+ function shiftYmd(ymd, days) {
319
+ var date = new Date(Date.parse(ymd + "T00:00:00.000Z"))
320
+ date.setUTCDate(date.getUTCDate() + days)
321
+ return date.toISOString().slice(0, 10)
322
+ }
323
+
324
+ function monthStart(ymd, monthsBack) {
325
+ var date = new Date(Date.parse(ymd + "T00:00:00.000Z"))
326
+ date.setUTCDate(1)
327
+ date.setUTCMonth(date.getUTCMonth() - (monthsBack || 0))
328
+ return date.toISOString().slice(0, 10)
329
+ }
330
+
331
+ function monthEnd(ymd) {
332
+ var date = new Date(Date.parse(ymd + "T00:00:00.000Z"))
333
+ date.setUTCMonth(date.getUTCMonth() + 1)
334
+ date.setUTCDate(0)
335
+ return date.toISOString().slice(0, 10)
336
+ }
337
+
338
+ /** Monday of the week `weeksBack` before the one holding `ymd`. */
339
+ function weekStart(ymd, weeksBack) {
340
+ var date = new Date(Date.parse(ymd + "T00:00:00.000Z"))
341
+ var monday = (date.getUTCDay() + 6) % 7
342
+ date.setUTCDate(date.getUTCDate() - monday - 7 * (weeksBack || 0))
343
+ return date.toISOString().slice(0, 10)
344
+ }
345
+
346
+ // The classic report's presets, boundaries included — a merchant comparing the two must not
347
+ // read the same label against a different range. "Last 7 days" excludes today, weeks start Monday.
348
+ var DEFAULT_PRESETS = [
349
+ { key: "today", label: "Today", range: function (t) { return [t, t] } },
350
+ { key: "yesterday", label: "Yesterday", range: function (t) { var d = shiftYmd(t, -1); return [d, d] } },
351
+ {
352
+ key: "thisWeek",
353
+ label: "This week",
354
+ range: function (t) { var s = weekStart(t, 0); return [s, shiftYmd(s, 6)] }
355
+ },
356
+ {
357
+ key: "lastWeek",
358
+ label: "Last week",
359
+ range: function (t) { var s = weekStart(t, 1); return [s, shiftYmd(s, 6)] }
360
+ },
361
+ { key: "last7", label: "Last 7 days", range: function (t) { return [shiftYmd(t, -7), shiftYmd(t, -1)] } },
362
+ { key: "thisMonth", label: "This month", range: function (t) { var s = monthStart(t, 0); return [s, monthEnd(s)] } },
363
+ {
364
+ key: "lastMonth",
365
+ label: "Last month",
366
+ range: function (t) {
367
+ var start = monthStart(t, 1)
368
+ return [start, monthEnd(start)]
369
+ }
370
+ }
371
+ ]
372
+
373
+ /**
374
+ * Preset list plus a custom range. Emits `{ from, to, preset }` with ISO instants at the
375
+ * report timezone's day boundaries — the section decides which params they land on.
376
+ */
377
+ function dateRange(target, options) {
378
+ ensureStyle()
379
+ var host = resolve(target)
380
+ var config = options || {}
381
+ var timezone = config.timezone || (window.env && window.env.timezone) || "UTC"
382
+ var presets = config.presets || DEFAULT_PRESETS
383
+ var todayYmd = ymdIn(new Date(), timezone)
384
+ var value = config.value || {}
385
+ var fromYmd = value.from ? ymdIn(new Date(value.from), timezone) : shiftYmd(todayYmd, -29)
386
+ var toYmd = value.to ? ymdIn(new Date(value.to), timezone) : todayYmd
387
+ var presetKey = config.preset || null
388
+
389
+ var root = el("div", "fm-c")
390
+ var button = el("button", "fm-btn")
391
+ var label = el("span")
392
+ button.appendChild(label)
393
+ button.appendChild(el("span", "fm-caret", "▾"))
394
+ var panel = el("div", "fm-pop")
395
+ panel.hidden = true
396
+ var body = el("div", "fm-dr-body")
397
+ var presetList = el("div", "fm-dr-presets")
398
+ var custom = el("div", "fm-dr-custom")
399
+ var fromLabel = el("label")
400
+ var toLabel = el("label")
401
+ fromLabel.appendChild(el("span", null, "From"))
402
+ toLabel.appendChild(el("span", null, "To"))
403
+ var fromInput = el("input")
404
+ var toInput = el("input")
405
+ fromInput.type = "date"
406
+ toInput.type = "date"
407
+ fromLabel.appendChild(fromInput)
408
+ toLabel.appendChild(toInput)
409
+ var apply = el("button", "fm-dr-apply", "Apply")
410
+ custom.appendChild(fromLabel)
411
+ custom.appendChild(toLabel)
412
+ custom.appendChild(apply)
413
+ body.appendChild(presetList)
414
+ body.appendChild(custom)
415
+ panel.appendChild(body)
416
+ root.appendChild(button)
417
+ root.appendChild(panel)
418
+ host.innerHTML = ""
419
+ host.appendChild(root)
420
+
421
+ function summary() {
422
+ var active = presets.filter(function (preset) { return preset.key === presetKey })[0]
423
+ if (active) return active.label
424
+ return fromYmd === toYmd ? fromYmd : fromYmd + " – " + toYmd
425
+ }
426
+
427
+ function emit() {
428
+ label.textContent = summary()
429
+ fromInput.value = fromYmd
430
+ toInput.value = toYmd
431
+ renderPresets()
432
+ if (typeof config.onChange === "function") {
433
+ config.onChange({
434
+ from: boundaryIso(fromYmd, timezone, false),
435
+ to: boundaryIso(toYmd, timezone, true),
436
+ fromDate: fromYmd,
437
+ toDate: toYmd,
438
+ preset: presetKey
439
+ })
440
+ }
441
+ }
442
+
443
+ function renderPresets() {
444
+ presetList.innerHTML = ""
445
+ presets.forEach(function (preset) {
446
+ var option = el("div", "fm-opt", preset.label)
447
+ option.setAttribute("aria-selected", String(preset.key === presetKey))
448
+ option.addEventListener("click", function () {
449
+ var range = preset.range(ymdIn(new Date(), timezone))
450
+ fromYmd = range[0]
451
+ toYmd = range[1]
452
+ presetKey = preset.key
453
+ control.close()
454
+ emit()
455
+ })
456
+ presetList.appendChild(option)
457
+ })
458
+ }
459
+
460
+ apply.addEventListener("click", function () {
461
+ if (!fromInput.value || !toInput.value) return
462
+ fromYmd = fromInput.value
463
+ toYmd = toInput.value
464
+ if (fromYmd > toYmd) {
465
+ var swap = fromYmd
466
+ fromYmd = toYmd
467
+ toYmd = swap
468
+ }
469
+ presetKey = null
470
+ control.close()
471
+ emit()
472
+ })
473
+
474
+ var control = popover(root, button, panel)
475
+ label.textContent = summary()
476
+ fromInput.value = fromYmd
477
+ toInput.value = toYmd
478
+ renderPresets()
479
+
480
+ return {
481
+ element: root,
482
+ getValue: function () {
483
+ return {
484
+ from: boundaryIso(fromYmd, timezone, false),
485
+ to: boundaryIso(toYmd, timezone, true),
486
+ fromDate: fromYmd,
487
+ toDate: toYmd,
488
+ preset: presetKey
489
+ }
490
+ },
491
+ setValue: function (next) {
492
+ if (!next) return
493
+ if (next.from) fromYmd = ymdIn(new Date(next.from), timezone)
494
+ if (next.to) toYmd = ymdIn(new Date(next.to), timezone)
495
+ presetKey = next.preset || null
496
+ label.textContent = summary()
497
+ fromInput.value = fromYmd
498
+ toInput.value = toYmd
499
+ renderPresets()
500
+ },
501
+ destroy: function () { host.innerHTML = "" }
502
+ }
503
+ }
504
+
505
+ // --- segmented -----------------------------------------------------------
506
+
507
+ /** Inline toggle group; for small option sets where a popover is overkill. */
508
+ function segmented(target, options) {
509
+ ensureStyle()
510
+ var host = resolve(target)
511
+ var config = options || {}
512
+ var items = normalizeOptions(config.items || config.options)
513
+ var value = config.value
514
+
515
+ var root = el("div", "fm-seg")
516
+ host.innerHTML = ""
517
+ host.appendChild(root)
518
+
519
+ function render() {
520
+ root.innerHTML = ""
521
+ items.forEach(function (item) {
522
+ var button = el("button", null, item.label)
523
+ button.type = "button"
524
+ button.setAttribute("aria-pressed", String(item.value === value))
525
+ button.addEventListener("click", function () {
526
+ if (item.value === value) return
527
+ value = item.value
528
+ render()
529
+ if (typeof config.onChange === "function") config.onChange(value)
530
+ })
531
+ root.appendChild(button)
532
+ })
533
+ }
534
+
535
+ render()
536
+
537
+ return {
538
+ element: root,
539
+ getValue: function () { return value },
540
+ setValue: function (next) {
541
+ value = next
542
+ render()
543
+ },
544
+ destroy: function () { host.innerHTML = "" }
545
+ }
546
+ }
547
+
548
+ // --- table ---------------------------------------------------------------
549
+
550
+ var ROW_PX = 52 // the frame's fmr-table row height
551
+ var HEAD_PX = 34
552
+ var RESERVE_ROWS = 8 // a paged table holds this much height, so a short last page keeps the panel put
553
+
554
+ function normalizeColumns(columns) {
555
+ return (columns || []).map(function (column) {
556
+ var classes = []
557
+ if (column.align === "right") classes.push("fmr-num")
558
+ if (column.dim) classes.push("fmr-dim")
559
+ if (column.className) classes.push(column.className)
560
+ return {
561
+ key: column.key,
562
+ label: column.label === undefined ? column.key : column.label,
563
+ className: classes.join(" "),
564
+ format: typeof column.format === "function" ? column.format : null
565
+ }
566
+ })
567
+ }
568
+
569
+ function cellText(column, row) {
570
+ var value = column.key ? row[column.key] : undefined
571
+ if (column.format) return String(column.format(value, row))
572
+ return value === null || value === undefined || value === "" ? "—" : String(value)
573
+ }
574
+
575
+ /**
576
+ * Paged data table. How long a page is, is a reading decision: a report that IS one table pages
577
+ * large (50-100), a table under a summary takes the default 20, a tile beside a chart takes 5-10.
578
+ * `pageSize: 0` renders every row. Paging is a slice of the rows already held — no re-query.
579
+ * `onRowClick(row, index)` makes every whole row the click target — pointer, hover, keyboard,
580
+ * and the clicked row stays highlighted until `clearSelection()`. Pass `rowKey` (a column key
581
+ * or `row => key`) so the highlight is the entity, not the position — it then survives the
582
+ * setRows every param change triggers.
583
+ */
584
+ function table(target, options) {
585
+ ensureStyle()
586
+ var host = resolve(target)
587
+ var config = options || {}
588
+ var columns = normalizeColumns(config.columns)
589
+ var rows = config.rows || []
590
+ var pageSize = config.pageSize === undefined ? 20 : Math.max(0, Number(config.pageSize) || 0)
591
+ var reserveRows =
592
+ config.reserveRows === undefined
593
+ ? Math.min(pageSize || RESERVE_ROWS, RESERVE_ROWS)
594
+ : Math.max(0, Number(config.reserveRows) || 0)
595
+ var emptyText = config.empty === undefined ? "No rows." : config.empty
596
+ var lastHeight = 0
597
+ var onRowClick = typeof config.onRowClick === "function" ? config.onRowClick : null
598
+ var keyOf =
599
+ typeof config.rowKey === "function"
600
+ ? config.rowKey
601
+ : config.rowKey
602
+ ? function (row) { return row[config.rowKey] }
603
+ : null
604
+ var page = 0
605
+ var selectedIndex = -1
606
+ var selectedKey
607
+
608
+ /** Keyed selection is the entity, so it survives setRows; positional dies with the old rows. */
609
+ function isSelected(absIndex, row) {
610
+ if (keyOf) return selectedKey !== undefined && row && keyOf(row) === selectedKey
611
+ return absIndex === selectedIndex
612
+ }
613
+
614
+ var root = el("div", "fm-ui-table")
615
+ var wrap = el("div", "fmr-scroll-x")
616
+ var tableEl = el("table", "fmr-table")
617
+ var thead = el("thead")
618
+ var headRow = el("tr")
619
+ var tbody = el("tbody")
620
+ var foot = el("div", "fmr-tile-foot fmr-pager")
621
+ var note = el("span", "fmr-pager-note")
622
+ var buttons = el("div", "fmr-pager-btns")
623
+ var prev = el("button", "fmr-link", "Previous")
624
+ var next = el("button", "fmr-link", "Next")
625
+ prev.type = "button"
626
+ next.type = "button"
627
+
628
+ columns.forEach(function (column) {
629
+ headRow.appendChild(el("th", column.className, column.label))
630
+ })
631
+ thead.appendChild(headRow)
632
+ tableEl.appendChild(thead)
633
+ tableEl.appendChild(tbody)
634
+ wrap.appendChild(tableEl)
635
+ buttons.appendChild(prev)
636
+ buttons.appendChild(next)
637
+ foot.appendChild(note)
638
+ foot.appendChild(buttons)
639
+ root.appendChild(wrap)
640
+ root.appendChild(foot)
641
+ host.innerHTML = ""
642
+ host.appendChild(root)
643
+
644
+ function pages() {
645
+ if (!pageSize) return 1
646
+ return Math.max(1, Math.ceil(rows.length / pageSize))
647
+ }
648
+
649
+ function render() {
650
+ var total = pages()
651
+ if (page > total - 1) page = total - 1
652
+ if (page < 0) page = 0
653
+ var from = pageSize ? page * pageSize : 0
654
+ var slice = pageSize ? rows.slice(from, from + pageSize) : rows
655
+
656
+ tbody.textContent = ""
657
+ // empty copy of "" means the table has nothing to say yet — header only, not a blank row
658
+ if (!rows.length && emptyText) {
659
+ var blank = el("tr")
660
+ var cell = el("td", "fmr-empty", emptyText)
661
+ cell.colSpan = columns.length || 1
662
+ blank.appendChild(cell)
663
+ tbody.appendChild(blank)
664
+ }
665
+ slice.forEach(function (row, i) {
666
+ var tr = el("tr")
667
+ columns.forEach(function (column) {
668
+ tr.appendChild(el("td", column.className, cellText(column, row)))
669
+ })
670
+ if (onRowClick) {
671
+ var absIndex = from + i
672
+ tr.className = "fm-row-btn"
673
+ tr.tabIndex = 0
674
+ tr.setAttribute("aria-selected", String(isSelected(absIndex, row)))
675
+ tr.addEventListener("click", function () { select(absIndex, row) })
676
+ tr.addEventListener("keydown", function (event) {
677
+ if (event.key !== "Enter" && event.key !== " ") return
678
+ event.preventDefault()
679
+ select(absIndex, row)
680
+ })
681
+ }
682
+ tbody.appendChild(tr)
683
+ })
684
+
685
+ // reserved only while the table actually pages; a single short page keeps its ragged bottom
686
+ var paging = pageSize > 0 && rows.length > pageSize
687
+ // …and while a table is waiting on rows it holds the height it last had, so the panel
688
+ // around it does not collapse and grow again between two drills
689
+ wrap.style.minHeight =
690
+ !rows.length && !emptyText && lastHeight
691
+ ? lastHeight + "px"
692
+ : paging && reserveRows
693
+ ? HEAD_PX + reserveRows * ROW_PX + "px"
694
+ : ""
695
+ if (rows.length) lastHeight = Math.round(wrap.getBoundingClientRect().height) || lastHeight
696
+
697
+ foot.hidden = !paging
698
+ note.textContent = paging
699
+ ? from + 1 + "–" + Math.min(from + pageSize, rows.length) + " of " + rows.length
700
+ : ""
701
+ prev.disabled = page === 0
702
+ next.disabled = page >= total - 1
703
+ }
704
+
705
+ /** Highlight without a re-render, so a keyboard row keeps its focus. */
706
+ function paintSelection() {
707
+ var from = pageSize ? page * pageSize : 0
708
+ Array.prototype.forEach.call(tbody.children, function (tr, i) {
709
+ if (tr.className.indexOf("fm-row-btn") < 0) return
710
+ tr.setAttribute("aria-selected", String(isSelected(from + i, rows[from + i])))
711
+ })
712
+ }
713
+
714
+ function select(absIndex, row) {
715
+ if (keyOf) selectedKey = keyOf(row)
716
+ else selectedIndex = absIndex
717
+ paintSelection()
718
+ onRowClick(row, absIndex)
719
+ }
720
+
721
+ function go(next_) {
722
+ var total = pages()
723
+ var wanted = Math.min(Math.max(0, next_), total - 1)
724
+ if (wanted === page) return
725
+ page = wanted
726
+ render()
727
+ if (typeof config.onPage === "function") config.onPage(page)
728
+ }
729
+
730
+ prev.addEventListener("click", function () { go(page - 1) })
731
+ next.addEventListener("click", function () { go(page + 1) })
732
+ render()
733
+
734
+ return {
735
+ element: root,
736
+ /** new rows are new data: back to the first page; only a keyed selection survives */
737
+ setRows: function (nextRows) {
738
+ rows = nextRows || []
739
+ page = 0
740
+ selectedIndex = -1
741
+ render()
742
+ },
743
+ /** The empty copy is a claim about the data; a table awaiting rows has none to make. */
744
+ setEmpty: function (next) {
745
+ emptyText = next == null ? "" : String(next)
746
+ render()
747
+ },
748
+ clearSelection: function () {
749
+ selectedIndex = -1
750
+ selectedKey = undefined
751
+ paintSelection()
752
+ },
753
+ setColumns: function (nextColumns) {
754
+ columns = normalizeColumns(nextColumns)
755
+ headRow.textContent = ""
756
+ columns.forEach(function (column) {
757
+ headRow.appendChild(el("th", column.className, column.label))
758
+ })
759
+ render()
760
+ },
761
+ setPage: go,
762
+ getPage: function () {
763
+ var total = pages()
764
+ var from = pageSize ? page * pageSize : 0
765
+ return {
766
+ page: page,
767
+ pages: total,
768
+ pageSize: pageSize,
769
+ from: rows.length ? from + 1 : 0,
770
+ to: pageSize ? Math.min(from + pageSize, rows.length) : rows.length,
771
+ total: rows.length
772
+ }
773
+ },
774
+ destroy: function () { host.innerHTML = "" }
775
+ }
776
+ }
777
+
778
+ // --- dialog ----------------------------------------------------------------
779
+
780
+ var openDialog = null
781
+ var lockedAt = 0
782
+
783
+ /**
784
+ * The frame's scroller is the viewport (body's `overflow-y: auto` propagates to it, because the
785
+ * root's overflow is visible), so `overflow: hidden` on the root makes it unscrollable and the
786
+ * browser clamps the offset to 0 — the page jumps to the top the moment a dialog opens. Hold the
787
+ * offset on the body while locked, and give it back on unlock.
788
+ *
789
+ * `height: auto` is not cosmetic. Once the body is out of flow that propagation stops, so the
790
+ * body becomes its own scroll container — and the frame sizes it `height: 100%`, which would
791
+ * clip the report at one viewport and leave everything below the fold blank grey.
792
+ */
793
+ function lockScroll() {
794
+ lockedAt = window.scrollY || document.documentElement.scrollTop || 0
795
+ var body = document.body
796
+ body.style.height = "auto"
797
+ body.style.position = "fixed"
798
+ body.style.top = -lockedAt + "px"
799
+ body.style.left = "0"
800
+ body.style.right = "0"
801
+ document.documentElement.style.overflow = "hidden"
802
+ }
803
+
804
+ function unlockScroll() {
805
+ var body = document.body
806
+ body.style.height = ""
807
+ body.style.position = ""
808
+ body.style.top = ""
809
+ body.style.left = ""
810
+ body.style.right = ""
811
+ document.documentElement.style.overflow = ""
812
+ window.scrollTo(0, lockedAt)
813
+ }
814
+
815
+ /**
816
+ * Modal over the report — where a drill-down's detail belongs, so a click's response
817
+ * appears where the viewer is looking. Host the target node inside your own section:
818
+ * the shell's busy scrim then covers the card while the drill query runs.
819
+ * Render into `body`; ×, Esc and a backdrop click close it.
820
+ */
821
+ function dialog(target, options) {
822
+ ensureStyle()
823
+ var host = resolve(target)
824
+ var config = options || {}
825
+
826
+ var root = el("div", "fm-dlg")
827
+ root.hidden = true
828
+ root.setAttribute("role", "dialog")
829
+ root.setAttribute("aria-modal", "true")
830
+ var card = el("div", "fm-dlg-card")
831
+ card.tabIndex = -1
832
+ var head = el("div", "fm-dlg-head")
833
+ var titles = el("div")
834
+ var title = el("h2", "fm-dlg-title", config.title || "")
835
+ var subtitle = el("p", "fm-dlg-subtitle", config.subtitle || "")
836
+ subtitle.hidden = !config.subtitle
837
+ var closeBtn = el("button", "fm-dlg-close", "✕")
838
+ closeBtn.type = "button"
839
+ closeBtn.setAttribute("aria-label", "Close")
840
+ var body = el("div", "fm-dlg-body")
841
+ titles.appendChild(title)
842
+ titles.appendChild(subtitle)
843
+ head.appendChild(titles)
844
+ head.appendChild(closeBtn)
845
+ card.appendChild(head)
846
+ card.appendChild(body)
847
+ root.appendChild(card)
848
+ host.innerHTML = ""
849
+ host.appendChild(root)
850
+
851
+ var lastFocus = null
852
+ function onKey(event) {
853
+ if (event.key === "Escape") close()
854
+ }
855
+
856
+ function open() {
857
+ if (openDialog && openDialog !== control) openDialog.close()
858
+ openDialog = control
859
+ if (root.hidden) {
860
+ lastFocus = document.activeElement
861
+ root.hidden = false
862
+ lockScroll()
863
+ document.addEventListener("keydown", onKey)
864
+ }
865
+ card.focus()
866
+ }
867
+
868
+ function close() {
869
+ if (root.hidden) return
870
+ root.hidden = true
871
+ unlockScroll()
872
+ document.removeEventListener("keydown", onKey)
873
+ if (openDialog === control) openDialog = null
874
+ if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus()
875
+ lastFocus = null
876
+ if (typeof config.onClose === "function") config.onClose()
877
+ }
878
+
879
+ root.addEventListener("click", function (event) {
880
+ if (event.target === root) close()
881
+ })
882
+ closeBtn.addEventListener("click", close)
883
+
884
+ var control = {
885
+ element: root,
886
+ body: body,
887
+ setTitle: function (next) { title.textContent = next == null ? "" : String(next) },
888
+ setSubtitle: function (next) {
889
+ subtitle.textContent = next == null ? "" : String(next)
890
+ subtitle.hidden = !next
891
+ },
892
+ open: open,
893
+ close: close,
894
+ isOpen: function () { return !root.hidden },
895
+ destroy: function () {
896
+ close()
897
+ host.innerHTML = ""
898
+ }
899
+ }
900
+ return control
901
+ }
902
+
903
+ FM.ui = { dropdown: dropdown, dateRange: dateRange, segmented: segmented, table: table, dialog: dialog }
904
+ })()