@xeplr/ui-utils 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/confirm/confirm.js +116 -47
- package/src/dataImport/validateDesign.js +27 -8
- package/src/dateRange/dateRange.js +273 -0
- package/src/dateRange/designs/DateRangeSample.jsx +138 -0
- package/src/dateRange/designs/dateRange.css +69 -0
- package/src/dateRange/designs/index.js +1 -0
- package/src/dateRange/pages.jsx +10 -0
- package/src/dateRange/useDateRangeController.js +118 -0
- package/src/dateRange/validateDesign.js +20 -0
- package/src/fileUpload/validateDesign.js +27 -8
- package/src/index.js +64 -2
- package/src/progress/ProgressNotifier.jsx +118 -0
- package/src/progress/designs/progressNotifier.css +73 -0
- package/src/progress/progressSource.js +168 -0
- package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
- package/src/rangeField/designs/index.js +1 -0
- package/src/rangeField/designs/rangeField.css +26 -0
- package/src/rangeField/pages.jsx +10 -0
- package/src/rangeField/rangeField.js +29 -0
- package/src/rangeField/useRangeFieldController.js +52 -0
- package/src/rangeField/validateDesign.js +3 -0
- package/src/textField/designs/TextFieldSample.jsx +75 -0
- package/src/textField/designs/index.js +1 -0
- package/src/textField/designs/textField.css +92 -0
- package/src/textField/pages.jsx +10 -0
- package/src/textField/textField.js +65 -0
- package/src/textField/useTextFieldController.js +101 -0
- package/src/textField/validateDesign.js +3 -0
- package/src/theme/default.theme.json +839 -83
- package/src/theme/index.js +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "UI utility components: file upload with progress, snackbar, static data (countries/states)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -28,5 +28,8 @@
|
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^18.0.0 || ^19.0.0",
|
|
30
30
|
"@xeplr/ui-table": "^1.0.0"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
|
|
31
34
|
}
|
|
32
35
|
}
|
package/src/confirm/confirm.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
// Imperative, self-mounting
|
|
1
|
+
// Imperative, self-mounting modal dialogs — same shape as ../snackbar/snackbar.js
|
|
2
2
|
// (own DOM node, inline styles, no provider/mount point needed, no CSS import).
|
|
3
|
-
// Unlike the snackbar
|
|
3
|
+
// Unlike the snackbar these are modal and return a Promise, since a caller needs
|
|
4
4
|
// the answer before proceeding (e.g. "enter this workspace?").
|
|
5
|
+
//
|
|
6
|
+
// Two shapes, ONE dialog surface:
|
|
7
|
+
// raiseConfirm — yes/no. The common case, and unchanged.
|
|
8
|
+
// raiseChoice — any number of answers, because some questions genuinely have
|
|
9
|
+
// three. "You have unsaved changes and you are leaving" is the
|
|
10
|
+
// standing example: save, discard, or come back — and folding
|
|
11
|
+
// that into a boolean forces the caller to either drop one of
|
|
12
|
+
// the three or ask twice.
|
|
13
|
+
//
|
|
14
|
+
// They share _root and _cancelPending deliberately: two dialog surfaces could
|
|
15
|
+
// each hold one open at once, and the second would silently cover the first.
|
|
5
16
|
|
|
6
17
|
var _root = null;
|
|
7
|
-
// A second
|
|
8
|
-
//
|
|
9
|
-
//
|
|
18
|
+
// A second dialog before the first resolves would otherwise stack a second
|
|
19
|
+
// overlay into the same _root and add a second document keydown listener —
|
|
20
|
+
// cancel whatever's still open before mounting a new one.
|
|
10
21
|
var _cancelPending = null;
|
|
11
22
|
|
|
12
23
|
function ensureRoot() {
|
|
@@ -18,20 +29,18 @@ function ensureRoot() {
|
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* @param {
|
|
24
|
-
* @param {string}
|
|
25
|
-
* @param {string}
|
|
26
|
-
* @param {
|
|
27
|
-
*
|
|
32
|
+
* The shell both shapes are built from.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} spec
|
|
35
|
+
* @param {string} spec.title
|
|
36
|
+
* @param {string} spec.message
|
|
37
|
+
* @param {Array} spec.buttons - [{ label, value, style: 'primary'|'danger'|'plain', isDefault }]
|
|
38
|
+
* rendered left to right; the LAST one is focused.
|
|
39
|
+
* @param {*} spec.dismissValue - what Escape, the overlay and a superseding
|
|
40
|
+
* dialog all resolve to.
|
|
41
|
+
* @returns {Promise<*>} the chosen button's `value`
|
|
28
42
|
*/
|
|
29
|
-
|
|
30
|
-
options = options || {};
|
|
31
|
-
var title = options.title || 'Are you sure?';
|
|
32
|
-
var confirmLabel = options.confirmLabel || 'Confirm';
|
|
33
|
-
var cancelLabel = options.cancelLabel || 'Cancel';
|
|
34
|
-
|
|
43
|
+
function openDialog(spec) {
|
|
35
44
|
if (_cancelPending) _cancelPending();
|
|
36
45
|
|
|
37
46
|
return new Promise(function(resolve) {
|
|
@@ -40,16 +49,23 @@ export function raiseConfirm(message, options) {
|
|
|
40
49
|
function cleanup(result) {
|
|
41
50
|
document.removeEventListener('keydown', onKeyDown);
|
|
42
51
|
root.innerHTML = '';
|
|
43
|
-
if (_cancelPending ===
|
|
52
|
+
if (_cancelPending === cleanupAsDismiss) _cancelPending = null;
|
|
44
53
|
resolve(result);
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
function
|
|
48
|
-
_cancelPending =
|
|
56
|
+
function cleanupAsDismiss() { cleanup(spec.dismissValue); }
|
|
57
|
+
_cancelPending = cleanupAsDismiss;
|
|
49
58
|
|
|
50
59
|
function onKeyDown(e) {
|
|
51
|
-
if (e.key === 'Escape') cleanup(
|
|
52
|
-
if (e.key === 'Enter')
|
|
60
|
+
if (e.key === 'Escape') cleanup(spec.dismissValue);
|
|
61
|
+
if (e.key === 'Enter') {
|
|
62
|
+
// Enter takes the DEFAULT, and only if one was named. With three
|
|
63
|
+
// answers there is no obvious one to guess at, and guessing would
|
|
64
|
+
// commit somebody's work — or throw it away — on a stray keypress.
|
|
65
|
+
for (var i = 0; i < spec.buttons.length; i++) {
|
|
66
|
+
if (spec.buttons[i].isDefault) return cleanup(spec.buttons[i].value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
// var(--xeplr-*, fallback) — picks up @xeplr/ui-account's theme tokens
|
|
@@ -58,42 +74,45 @@ export function raiseConfirm(message, options) {
|
|
|
58
74
|
var overlay = document.createElement('div');
|
|
59
75
|
overlay.style.cssText = 'position:fixed;inset:0;background:var(--xeplr-bg-overlay,rgba(0,0,0,0.55));z-index:100000;'
|
|
60
76
|
+ 'display:flex;align-items:center;justify-content:center;';
|
|
61
|
-
overlay.onclick = function(e) { if (e.target === overlay) cleanup(
|
|
77
|
+
overlay.onclick = function(e) { if (e.target === overlay) cleanup(spec.dismissValue); };
|
|
62
78
|
|
|
63
79
|
var box = document.createElement('div');
|
|
64
80
|
box.style.cssText = 'background:var(--xeplr-bg-tertiary,#1a1a1a);color:var(--xeplr-text-primary,#e0e0e0);'
|
|
65
81
|
+ 'border:1px solid var(--xeplr-border-primary,#333);border-radius:10px;'
|
|
66
|
-
+ 'padding:20px;max-width:
|
|
82
|
+
+ 'padding:20px;max-width:420px;width:calc(100% - 40px);font:14px/1.5 system-ui,sans-serif;'
|
|
67
83
|
+ 'box-shadow:var(--xeplr-shadow-lg,0 12px 32px rgba(0,0,0,0.4));';
|
|
68
84
|
|
|
69
85
|
var titleEl = document.createElement('div');
|
|
70
86
|
titleEl.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:8px;';
|
|
71
|
-
titleEl.textContent = title;
|
|
87
|
+
titleEl.textContent = spec.title;
|
|
72
88
|
|
|
73
89
|
var messageEl = document.createElement('div');
|
|
74
90
|
messageEl.style.cssText = 'color:var(--xeplr-text-secondary,#bbb);margin-bottom:18px;';
|
|
75
|
-
messageEl.textContent = message;
|
|
91
|
+
messageEl.textContent = spec.message;
|
|
76
92
|
|
|
77
93
|
var actions = document.createElement('div');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
// Wraps, because three labels that each say what they DO ("Leave without
|
|
95
|
+
// saving") are longer than "OK" and must not run off a narrow dialog.
|
|
96
|
+
actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;';
|
|
97
|
+
|
|
98
|
+
var lastBtn = null;
|
|
99
|
+
spec.buttons.forEach(function(def) {
|
|
100
|
+
var btn = document.createElement('button');
|
|
101
|
+
btn.type = 'button';
|
|
102
|
+
btn.textContent = def.label;
|
|
103
|
+
if (def.style === 'primary' || def.style === 'danger') {
|
|
104
|
+
var bg = def.style === 'danger' ? 'var(--xeplr-danger,#dc2626)' : 'var(--xeplr-accent,#646cff)';
|
|
105
|
+
btn.style.cssText = 'padding:8px 14px;border:none;border-radius:6px;'
|
|
106
|
+
+ 'background:' + bg + ';color:var(--xeplr-accent-text,#fff);font-size:13px;font-weight:600;cursor:pointer;';
|
|
107
|
+
} else {
|
|
108
|
+
btn.style.cssText = 'padding:8px 14px;border:1px solid var(--xeplr-border-primary,#444);border-radius:6px;'
|
|
109
|
+
+ 'background:var(--xeplr-bg-secondary,#232323);color:var(--xeplr-text-primary,#e0e0e0);font-size:13px;cursor:pointer;';
|
|
110
|
+
}
|
|
111
|
+
btn.onclick = function() { cleanup(def.value); };
|
|
112
|
+
actions.appendChild(btn);
|
|
113
|
+
lastBtn = btn;
|
|
114
|
+
});
|
|
115
|
+
|
|
97
116
|
box.appendChild(titleEl);
|
|
98
117
|
box.appendChild(messageEl);
|
|
99
118
|
box.appendChild(actions);
|
|
@@ -101,6 +120,56 @@ export function raiseConfirm(message, options) {
|
|
|
101
120
|
root.appendChild(overlay);
|
|
102
121
|
|
|
103
122
|
document.addEventListener('keydown', onKeyDown);
|
|
104
|
-
|
|
123
|
+
if (lastBtn) lastBtn.focus();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} message
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {string} [options.title='Are you sure?']
|
|
131
|
+
* @param {string} [options.confirmLabel='Confirm']
|
|
132
|
+
* @param {string} [options.cancelLabel='Cancel']
|
|
133
|
+
* @param {boolean} [options.danger=false] - style the confirm button as destructive
|
|
134
|
+
* @returns {Promise<boolean>} resolves true (confirmed) or false (cancelled/dismissed)
|
|
135
|
+
*/
|
|
136
|
+
export function raiseConfirm(message, options) {
|
|
137
|
+
options = options || {};
|
|
138
|
+
return openDialog({
|
|
139
|
+
title: options.title || 'Are you sure?',
|
|
140
|
+
message: message,
|
|
141
|
+
dismissValue: false,
|
|
142
|
+
buttons: [
|
|
143
|
+
{ label: options.cancelLabel || 'Cancel', value: false, style: 'plain' },
|
|
144
|
+
{ label: options.confirmLabel || 'Confirm', value: true, isDefault: true,
|
|
145
|
+
style: options.danger ? 'danger' : 'primary' }
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A question with more than two answers.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} message
|
|
154
|
+
* @param {object} options
|
|
155
|
+
* @param {string} [options.title='Are you sure?']
|
|
156
|
+
* @param {Array} options.choices - [{ value, label, style?, isDefault? }], left to
|
|
157
|
+
* right; the last is focused, and `style` is 'primary' | 'danger' |
|
|
158
|
+
* omitted for plain. Put the answer somebody most likely wants last.
|
|
159
|
+
* @param {*} [options.dismissValue=null] - Escape / clicking away / being
|
|
160
|
+
* superseded. Default null, so "they closed the dialog" is
|
|
161
|
+
* distinguishable from every deliberate answer.
|
|
162
|
+
* @returns {Promise<*>} the chosen `value`
|
|
163
|
+
*/
|
|
164
|
+
export function raiseChoice(message, options) {
|
|
165
|
+
options = options || {};
|
|
166
|
+
var choices = options.choices || [];
|
|
167
|
+
return openDialog({
|
|
168
|
+
title: options.title || 'Are you sure?',
|
|
169
|
+
message: message,
|
|
170
|
+
dismissValue: options.dismissValue === undefined ? null : options.dismissValue,
|
|
171
|
+
buttons: choices.map(function(c) {
|
|
172
|
+
return { label: c.label, value: c.value, style: c.style || 'plain', isDefault: c.isDefault };
|
|
173
|
+
})
|
|
105
174
|
});
|
|
106
175
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Validates that required elements exist in the rendered design
|
|
5
|
-
*
|
|
4
|
+
* Validates that required elements exist in the rendered design, and reports
|
|
5
|
+
* loudly when they don't — console.error plus a data-xeplr-design-invalid
|
|
6
|
+
* marker on the container.
|
|
7
|
+
*
|
|
8
|
+
* A rule matches by id, role or selector. `anyOf` takes a list of selectors
|
|
9
|
+
* and passes when ANY one is present — which is what a design with tabs or
|
|
10
|
+
* steps needs, since only the current one is mounted and demanding all of
|
|
11
|
+
* them at once can never pass.
|
|
6
12
|
*
|
|
7
13
|
* @param {string} componentName - Name of the page (for error messages)
|
|
8
|
-
* @param {Array<{id?: string, role?: string, selector?: string, label: string}>} requiredElements
|
|
14
|
+
* @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
|
|
9
15
|
*/
|
|
10
16
|
export function useDesignValidator(componentName, requiredElements) {
|
|
11
17
|
var containerRef = useRef(null);
|
|
@@ -22,20 +28,33 @@ export function useDesignValidator(componentName, requiredElements) {
|
|
|
22
28
|
found = !!containerRef.current.querySelector('#' + rule.id);
|
|
23
29
|
} else if (rule.role) {
|
|
24
30
|
found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
|
|
31
|
+
} else if (rule.anyOf) {
|
|
32
|
+
for (var j = 0; j < rule.anyOf.length && !found; j++) {
|
|
33
|
+
found = !!containerRef.current.querySelector(rule.anyOf[j]);
|
|
34
|
+
}
|
|
25
35
|
} else if (rule.selector) {
|
|
26
36
|
found = !!containerRef.current.querySelector(rule.selector);
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
if (!found) {
|
|
30
|
-
|
|
40
|
+
var where = rule.id ? ' (id="' + rule.id + '")'
|
|
41
|
+
: rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
|
|
42
|
+
: rule.selector ? ' (' + rule.selector + ')' : '';
|
|
43
|
+
missing.push(rule.label + where);
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
if (missing.length > 0) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
// Reported, not thrown. Throwing from an effect gives React nothing to
|
|
49
|
+
// catch, so a design rule — a development-time check — took the whole
|
|
50
|
+
// application down with it. The container is marked so the failure is
|
|
51
|
+
// visible on the page as well as in the console, which is what "fail
|
|
52
|
+
// loudly" was ever meant to buy.
|
|
53
|
+
var message = '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
|
|
54
|
+
missing.map(function(m) { return ' - ' + m; }).join('\n');
|
|
55
|
+
console.error(message);
|
|
56
|
+
containerRef.current.setAttribute('data-xeplr-design-invalid', componentName);
|
|
57
|
+
containerRef.current.setAttribute('title', message);
|
|
39
58
|
}
|
|
40
59
|
}, []);
|
|
41
60
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// DateRange — model. A date SELECTION, and how it becomes a real from/to
|
|
2
|
+
// range. Pure: no React, no DOM.
|
|
3
|
+
//
|
|
4
|
+
// One shape covers every way a person names a span of time, which is the
|
|
5
|
+
// point. A default set once, a preset that keeps moving, and a range typed by
|
|
6
|
+
// hand are all the same value, so an app can fall back between them without
|
|
7
|
+
// three different mechanisms.
|
|
8
|
+
//
|
|
9
|
+
// { kind: 'quick', preset: 'current_month' }
|
|
10
|
+
// { kind: 'range', from: '2022-01-01', to: '2022-03-31' }
|
|
11
|
+
// { kind: 'single', value: '2022-05' } // a day, a month, or a year
|
|
12
|
+
// null // not set — inherit
|
|
13
|
+
//
|
|
14
|
+
// EVERYTHING RESOLVES TO A RANGE, including a single date. "May 2022" is the
|
|
15
|
+
// range 1–31 May; "2022" is the whole year; "1/1/2022" is that one day. A
|
|
16
|
+
// consumer therefore always gets {from, to} and never has to ask which
|
|
17
|
+
// comparison was meant — the selection already says.
|
|
18
|
+
|
|
19
|
+
// April. Overridable per call — a financial year starts in a different month
|
|
20
|
+
// in most of the world, and hardcoding one is how a report quietly reports
|
|
21
|
+
// the wrong year.
|
|
22
|
+
var DEFAULT_FISCAL_START_MONTH = 4
|
|
23
|
+
|
|
24
|
+
function pad(n) { return String(n).padStart(2, '0') }
|
|
25
|
+
function iso(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}` }
|
|
26
|
+
function lastDay(y, m) { return new Date(y, m + 1, 0).getDate() }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The presets people actually ask for. Each resolves against `now`, so a
|
|
30
|
+
* report saved with one stays current instead of freezing on the day it was
|
|
31
|
+
* built — the whole reason to prefer a preset over a literal date.
|
|
32
|
+
*/
|
|
33
|
+
export const QUICK_PRESETS = [
|
|
34
|
+
{ key: 'today', label: 'Today' },
|
|
35
|
+
{ key: 'yesterday', label: 'Yesterday' },
|
|
36
|
+
{ key: 'dby', label: 'Day before yesterday' },
|
|
37
|
+
{ key: 'last_7_days', label: 'Last 7 days' },
|
|
38
|
+
{ key: 'last_30_days', label: 'Last 30 days' },
|
|
39
|
+
{ key: 'current_month', label: 'Current month' },
|
|
40
|
+
{ key: 'current_mtd', label: 'Current month-to-date' },
|
|
41
|
+
{ key: 'last_month', label: 'Last month' },
|
|
42
|
+
{ key: 'current_quarter', label: 'Current quarter' },
|
|
43
|
+
{ key: 'current_qtd', label: 'Current quarter-to-date' },
|
|
44
|
+
{ key: 'last_quarter', label: 'Last quarter' },
|
|
45
|
+
{ key: 'current_calendar_year', label: 'Current calendar year' },
|
|
46
|
+
{ key: 'current_ytd', label: 'Current year-to-date' },
|
|
47
|
+
{ key: 'last_calendar_year', label: 'Last calendar year' },
|
|
48
|
+
{ key: 'current_financial_year', label: 'Current financial year' },
|
|
49
|
+
{ key: 'current_fytd', label: 'Current financial year-to-date' },
|
|
50
|
+
{ key: 'last_financial_year', label: 'Last financial year' }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const PRESET_LABEL = new Map(QUICK_PRESETS.map((p) => [p.key, p.label]))
|
|
54
|
+
|
|
55
|
+
const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june',
|
|
56
|
+
'july', 'august', 'september', 'october', 'november', 'december']
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses the free text a Single selection accepts: a full date, a month, or a
|
|
60
|
+
* year. Returns a {from, to} range, since a month or a year IS a range.
|
|
61
|
+
*
|
|
62
|
+
* Day-first for the ambiguous slash form (1/2/2022 is 1 February), matching
|
|
63
|
+
* where this is used rather than the American reading.
|
|
64
|
+
*/
|
|
65
|
+
export function parseSingleDate(text) {
|
|
66
|
+
const raw = String(text || '').trim()
|
|
67
|
+
if (!raw) return null
|
|
68
|
+
|
|
69
|
+
// 2022
|
|
70
|
+
let m = /^(\d{4})$/.exec(raw)
|
|
71
|
+
if (m) {
|
|
72
|
+
const y = Number(m[1])
|
|
73
|
+
return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2022-05 or 2022/05
|
|
77
|
+
m = /^(\d{4})[-/](\d{1,2})$/.exec(raw)
|
|
78
|
+
if (m) {
|
|
79
|
+
const y = Number(m[1]); const mo = Number(m[2]) - 1
|
|
80
|
+
if (mo < 0 || mo > 11) return null
|
|
81
|
+
return { from: iso(y, mo, 1), to: iso(y, mo, lastDay(y, mo)) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2022-05-17
|
|
85
|
+
m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(raw)
|
|
86
|
+
if (m) {
|
|
87
|
+
const y = Number(m[1]); const mo = Number(m[2]) - 1; const d = Number(m[3])
|
|
88
|
+
if (mo < 0 || mo > 11) return null
|
|
89
|
+
return { from: iso(y, mo, d), to: iso(y, mo, d) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// May 2022 / may 2022
|
|
93
|
+
m = /^([A-Za-z]+)\s+(\d{4})$/.exec(raw)
|
|
94
|
+
if (m) {
|
|
95
|
+
const idx = MONTHS.findIndex((name) => name.startsWith(m[1].toLowerCase()))
|
|
96
|
+
if (idx === -1) return null
|
|
97
|
+
const y = Number(m[2])
|
|
98
|
+
return { from: iso(y, idx, 1), to: iso(y, idx, lastDay(y, idx)) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 17/5/2022 — day first.
|
|
102
|
+
m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/.exec(raw)
|
|
103
|
+
if (m) {
|
|
104
|
+
const d = Number(m[1]); const mo = Number(m[2]) - 1; const y = Number(m[3])
|
|
105
|
+
if (mo < 0 || mo > 11) return null
|
|
106
|
+
return { from: iso(y, mo, d), to: iso(y, mo, d) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shift(now, days) {
|
|
113
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days)
|
|
114
|
+
return iso(d.getFullYear(), d.getMonth(), d.getDate())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function monthRange(y, m) {
|
|
118
|
+
return { from: iso(y, m, 1), to: iso(y, m, lastDay(y, m)) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What somebody might mean, as they type — months and years, never days.
|
|
123
|
+
*
|
|
124
|
+
* A day is faster to finish typing than to pick out of a list, and there are
|
|
125
|
+
* thirty of them. A MONTH is the opposite: "j" is three different months, and
|
|
126
|
+
* each of those is a different year depending on what you were just looking
|
|
127
|
+
* at. That ambiguity is the whole reason to suggest anything.
|
|
128
|
+
*
|
|
129
|
+
* ANCHORED to the year already in play — if the filter currently says
|
|
130
|
+
* Feb 2025, typing "j" offers Jan/Jun/Jul 2025 first, because somebody
|
|
131
|
+
* comparing months is almost always comparing them within a year. Other years
|
|
132
|
+
* follow, so the less common case is one click rather than more typing.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text what has been typed so far
|
|
135
|
+
* @param {number} anchor the year to offer first
|
|
136
|
+
* @param {number} years how many additional years to offer per month
|
|
137
|
+
*/
|
|
138
|
+
export function singleDateSuggestions(text, anchor, years) {
|
|
139
|
+
const q = String(text || '').trim().toLowerCase()
|
|
140
|
+
const year = Number(anchor) || new Date().getFullYear()
|
|
141
|
+
const depth = years == null ? 2 : years
|
|
142
|
+
if (!q) return []
|
|
143
|
+
|
|
144
|
+
// A year on its own — "202" is a prefix of several, and a whole year is a
|
|
145
|
+
// legitimate answer in its own right.
|
|
146
|
+
if (/^\d{1,4}$/.test(q)) {
|
|
147
|
+
const out = []
|
|
148
|
+
for (let y = year + 1; y >= year - 6; y--) {
|
|
149
|
+
if (String(y).startsWith(q)) out.push({ value: String(y), label: String(y), note: 'the whole year' })
|
|
150
|
+
}
|
|
151
|
+
return out.slice(0, 8)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// "jan 20", "june 2024" — the month is settled, so only years are open.
|
|
155
|
+
const withYear = /^([a-z]+)\s+(\d{1,4})$/.exec(q)
|
|
156
|
+
const monthQuery = withYear ? withYear[1] : q
|
|
157
|
+
const yearQuery = withYear ? withYear[2] : null
|
|
158
|
+
if (!/^[a-z]+$/.test(monthQuery)) return []
|
|
159
|
+
|
|
160
|
+
const hits = []
|
|
161
|
+
MONTHS.forEach((name, i) => {
|
|
162
|
+
if (!name.startsWith(monthQuery)) return
|
|
163
|
+
hits.push({ name: name.charAt(0).toUpperCase() + name.slice(1), index: i })
|
|
164
|
+
})
|
|
165
|
+
if (!hits.length) return []
|
|
166
|
+
|
|
167
|
+
// The anchor year first for every match, THEN the other years — so a list
|
|
168
|
+
// of three months reads Jan/Jun/Jul 2025 before it reads Jan 2024, rather
|
|
169
|
+
// than burying two of this year's months under last year's January.
|
|
170
|
+
const out = []
|
|
171
|
+
const push = (m, y) => {
|
|
172
|
+
if (yearQuery && !String(y).startsWith(yearQuery)) return
|
|
173
|
+
out.push({ value: m.name + ' ' + y, label: m.name + ' ' + y, note: null })
|
|
174
|
+
}
|
|
175
|
+
hits.forEach((m) => push(m, year))
|
|
176
|
+
for (let d = 1; d <= depth; d++) {
|
|
177
|
+
hits.forEach((m) => push(m, year - d))
|
|
178
|
+
}
|
|
179
|
+
hits.forEach((m) => push(m, year + 1))
|
|
180
|
+
return out.slice(0, 10)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function resolveQuickPreset(preset, now, options) {
|
|
184
|
+
const fiscalStart = ((options && options.fiscalStartMonth) || DEFAULT_FISCAL_START_MONTH) - 1
|
|
185
|
+
const y = now.getFullYear()
|
|
186
|
+
const m = now.getMonth()
|
|
187
|
+
const today = iso(y, m, now.getDate())
|
|
188
|
+
const q = Math.floor(m / 3)
|
|
189
|
+
// The fiscal year containing `now` began this calendar year only if we're
|
|
190
|
+
// already past its start month — same rule as shared/params.js.
|
|
191
|
+
const fy = m >= fiscalStart ? y : y - 1
|
|
192
|
+
|
|
193
|
+
switch (preset) {
|
|
194
|
+
case 'today': return { from: today, to: today }
|
|
195
|
+
case 'yesterday': return { from: shift(now, 1), to: shift(now, 1) }
|
|
196
|
+
case 'dby': return { from: shift(now, 2), to: shift(now, 2) }
|
|
197
|
+
case 'last_7_days': return { from: shift(now, 6), to: today }
|
|
198
|
+
case 'last_30_days': return { from: shift(now, 29), to: today }
|
|
199
|
+
|
|
200
|
+
case 'current_month': return monthRange(y, m)
|
|
201
|
+
case 'current_mtd': return { from: iso(y, m, 1), to: today }
|
|
202
|
+
case 'last_month': return m === 0 ? monthRange(y - 1, 11) : monthRange(y, m - 1)
|
|
203
|
+
|
|
204
|
+
case 'current_quarter': return { from: iso(y, q * 3, 1), to: iso(y, q * 3 + 2, lastDay(y, q * 3 + 2)) }
|
|
205
|
+
case 'current_qtd': return { from: iso(y, q * 3, 1), to: today }
|
|
206
|
+
case 'last_quarter': {
|
|
207
|
+
const ly = q === 0 ? y - 1 : y
|
|
208
|
+
const lq = q === 0 ? 3 : q - 1
|
|
209
|
+
return { from: iso(ly, lq * 3, 1), to: iso(ly, lq * 3 + 2, lastDay(ly, lq * 3 + 2)) }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'current_calendar_year': return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
|
|
213
|
+
case 'current_ytd': return { from: iso(y, 0, 1), to: today }
|
|
214
|
+
case 'last_calendar_year': return { from: iso(y - 1, 0, 1), to: iso(y - 1, 11, 31) }
|
|
215
|
+
|
|
216
|
+
case 'current_financial_year': {
|
|
217
|
+
const end = new Date(fy + 1, fiscalStart, 0)
|
|
218
|
+
return { from: iso(fy, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
|
|
219
|
+
}
|
|
220
|
+
case 'current_fytd': return { from: iso(fy, fiscalStart, 1), to: today }
|
|
221
|
+
case 'last_financial_year': {
|
|
222
|
+
const end = new Date(fy, fiscalStart, 0)
|
|
223
|
+
return { from: iso(fy - 1, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
default: return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A selection -> { from, to }, or null when it resolves to nothing.
|
|
232
|
+
*
|
|
233
|
+
* A null result is "no constraint", never "match nothing" — an unresolvable
|
|
234
|
+
* selection must widen rather than silently empty the report.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveDateValue(value, now, options) {
|
|
237
|
+
if (!value) return null
|
|
238
|
+
if (value.kind === 'quick') return resolveQuickPreset(value.preset, now || new Date(), options)
|
|
239
|
+
if (value.kind === 'single') return parseSingleDate(value.value)
|
|
240
|
+
if (value.kind === 'range') {
|
|
241
|
+
const from = value.from || null
|
|
242
|
+
const to = value.to || null
|
|
243
|
+
if (!from && !to) return null
|
|
244
|
+
return { from, to }
|
|
245
|
+
}
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** What the chip and the panel read. */
|
|
250
|
+
export function dateValueLabel(value) {
|
|
251
|
+
if (!value) return null
|
|
252
|
+
if (value.kind === 'quick') return PRESET_LABEL.get(value.preset) || value.preset
|
|
253
|
+
if (value.kind === 'single') return value.value || null
|
|
254
|
+
if (value.kind === 'range') {
|
|
255
|
+
if (value.from && value.to) return `${value.from} → ${value.to}`
|
|
256
|
+
if (value.from) return `From ${value.from}`
|
|
257
|
+
if (value.to) return `Until ${value.to}`
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* True when a selection would actually constrain anything.
|
|
265
|
+
*
|
|
266
|
+
* Defined as "resolves to a range", NOT "has something typed in it". A Single
|
|
267
|
+
* holding unparseable text has a label but no range — treating that as set
|
|
268
|
+
* would let it pass a "do we have a default?" check and then silently apply no
|
|
269
|
+
* filter at all, which is the worst of both.
|
|
270
|
+
*/
|
|
271
|
+
export function dateValueIsSet(value, now) {
|
|
272
|
+
return Boolean(resolveDateValue(value, now || new Date()))
|
|
273
|
+
}
|