@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.
- package/dist/{App-C6xpf-Q0.js → App-BDhSFQX_.js} +45 -44
- package/dist/DateRangeSelect-CRsKMi0U.js +437 -0
- 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
- 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
- package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-CVShUWJC.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-CMioHpn1.js} +5091 -5717
- package/dist/{InsightView-BYW66Wrv.js → InsightView-lnOobZAr.js} +17 -15
- package/dist/{Integration-DQPJBE-0.js → Integration-D5ssimUg.js} +27 -25
- package/dist/{Integrations-BW4H9lUS.js → Integrations-CaqGvkzI.js} +8 -7
- package/dist/{Layout-CMlh08aB.js → Layout-DS0XCT_E.js} +1 -1
- package/dist/LocationSelect.vue_vue_type_script_setup_true_lang-DC9QYJk-.js +8055 -0
- 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
- 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
- package/dist/{OverviewView-BtGpQkBY.js → OverviewView-BkIozvMs.js} +5 -5
- package/dist/{Report-CJ9myNLK.js → Report-HiSGRNhn.js} +27 -25
- package/dist/ReportCanvasView-ILVhB1KL.js +448 -0
- package/dist/{ReportEditor-DtnTBbY1.js → ReportEditor-B4tyVB8s.js} +30 -28
- package/dist/{ReportView-Ci8nFyI9.js → ReportView-CIQQHvWT.js} +14 -13
- package/dist/ReportViewRoute-B3KwVB0v.js +18 -0
- package/dist/{SelectComponent.vue_vue_type_script_setup_true_lang-Ckn2p74A.js → SelectComponent.vue_vue_type_script_setup_true_lang-DW7Dk7iQ.js} +1 -1
- package/dist/{Setting-QSiMSAQk.js → Setting-CvToMUKq.js} +5 -5
- package/dist/{Template-CAPlAQO9.js → Template-BBQEaHrM.js} +10 -9
- package/dist/{TileEditor-e7ZCWMU9.js → TileEditor-Cqy98LmJ.js} +19 -17
- package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-B4g3YPzM.js → TilePageStack.vue_vue_type_script_setup_true_lang-CzggUdfI.js} +3 -3
- package/dist/{TransitionFade-DqIazHHG.js → TransitionFade-D-8BYs6T.js} +36 -33
- package/dist/{_plugin-vue_export-helper-kkiiQoKl.js → _plugin-vue_export-helper-C6QDExPN.js} +276 -291
- package/dist/{app-BAgs-zHf.js → app-Bjt_JuJi.js} +670 -567
- package/dist/app.js +1 -1
- package/dist/assets/{formatChartData.worker-epD1fsTW.js → formatChartData.worker-BeBGCyYW.js} +1 -1
- package/dist/assets/{processTableData.worker-BJZ_Z45H.js → processTableData.worker-frTYV0ok.js} +39 -39
- package/dist/global-loader-BuxFfehu.js +19 -0
- package/dist/index-CtZE4sab.js +82 -0
- package/dist/report-DjGDWvm3.js +635 -0
- package/dist/report-canvas-frame.html +888 -0
- package/dist/report-canvas-libs/chart.js-4.js +14 -0
- package/dist/report-canvas-libs/fm-ui-1.js +904 -0
- package/dist/style.css +1 -1
- package/dist/{useRestaurantPermission-ChYMJfXG.js → useRestaurantPermission-Dkbh_cv8.js} +4 -4
- package/package.json +1 -1
- 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
|
+
})()
|