@visns-studio/visns-components 6.17.1 → 6.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +45 -25
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +134 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/navActive.js +25 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import styles from '../styles/GenericSettings.module.scss';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The settings hub: a rail of sections beside one panel.
|
|
9
|
+
*
|
|
10
|
+
* CONFIG-DRIVEN, like every other Generic in this library. A section is a
|
|
11
|
+
* config entry, not a route and not a component this file knows about:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* id: 'integrations',
|
|
15
|
+
* label: 'Integrations',
|
|
16
|
+
* description: 'Connected systems and their credentials.',
|
|
17
|
+
* group: 'Platform', // optional rail heading
|
|
18
|
+
* permission: 'Settings', // optional; hidden when the user lacks it
|
|
19
|
+
* renderer: 'integrations', // key into the `renderers` prop
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The host supplies the actual panels through `renderers`, the same slot
|
|
23
|
+
* GenericDetail and Navigation use. That is deliberate: the library owns the
|
|
24
|
+
* layout, the deep-linking and the permission filtering; the application owns
|
|
25
|
+
* what a section actually shows, so adding one never means editing this file.
|
|
26
|
+
*
|
|
27
|
+
* Sections deep-link as `/settings/:section`, so a panel can be linked to and
|
|
28
|
+
* survives a refresh — which a tab index held only in state cannot.
|
|
29
|
+
*/
|
|
30
|
+
const GenericSettings = ({
|
|
31
|
+
setting,
|
|
32
|
+
userProfile,
|
|
33
|
+
renderers = {},
|
|
34
|
+
hasPermission,
|
|
35
|
+
basePath = '/settings',
|
|
36
|
+
}) => {
|
|
37
|
+
const params = useParams();
|
|
38
|
+
const navigate = useNavigate();
|
|
39
|
+
|
|
40
|
+
const page = setting?.page ?? {};
|
|
41
|
+
const allSections = useMemo(
|
|
42
|
+
() => (Array.isArray(setting?.sections) ? setting.sections : []),
|
|
43
|
+
[setting]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Only the sections this user may see.
|
|
48
|
+
*
|
|
49
|
+
* A section with no `permission` is open. The check is delegated to the
|
|
50
|
+
* host via `hasPermission`, because the library has no opinion on how the
|
|
51
|
+
* application models permissions — and a section hidden here is still
|
|
52
|
+
* enforced by whatever its panel calls.
|
|
53
|
+
*/
|
|
54
|
+
const sections = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
allSections.filter((section) => {
|
|
57
|
+
if (!section?.permission) return true;
|
|
58
|
+
if (typeof hasPermission !== 'function') return true;
|
|
59
|
+
|
|
60
|
+
return hasPermission(userProfile, section.permission);
|
|
61
|
+
}),
|
|
62
|
+
[allSections, hasPermission, userProfile]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const [query, setQuery] = useState('');
|
|
66
|
+
|
|
67
|
+
const visible = useMemo(() => {
|
|
68
|
+
const term = query.trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (!term) return sections;
|
|
71
|
+
|
|
72
|
+
return sections.filter((s) =>
|
|
73
|
+
`${s.label} ${s.description ?? ''} ${s.keywords ?? ''}`
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.includes(term)
|
|
76
|
+
);
|
|
77
|
+
}, [sections, query]);
|
|
78
|
+
|
|
79
|
+
const activeId = params.section ?? sections[0]?.id;
|
|
80
|
+
const active = sections.find((s) => s.id === activeId) ?? sections[0];
|
|
81
|
+
|
|
82
|
+
// A URL naming a section that does not exist (a stale bookmark, or one the
|
|
83
|
+
// user lost permission to) lands on the first section rather than a blank
|
|
84
|
+
// panel with no way forward.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!params.section || sections.length === 0) return;
|
|
87
|
+
|
|
88
|
+
if (!sections.some((s) => s.id === params.section)) {
|
|
89
|
+
navigate(`${basePath}/${sections[0].id}`, { replace: true });
|
|
90
|
+
}
|
|
91
|
+
}, [params.section, sections, navigate, basePath]);
|
|
92
|
+
|
|
93
|
+
const open = useCallback(
|
|
94
|
+
(id) => navigate(`${basePath}/${id}`),
|
|
95
|
+
[navigate, basePath]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const grouped = useMemo(() => {
|
|
99
|
+
const out = new Map();
|
|
100
|
+
|
|
101
|
+
visible.forEach((section) => {
|
|
102
|
+
const key = section.group ?? '';
|
|
103
|
+
|
|
104
|
+
if (!out.has(key)) out.set(key, []);
|
|
105
|
+
out.get(key).push(section);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return [...out.entries()];
|
|
109
|
+
}, [visible]);
|
|
110
|
+
|
|
111
|
+
const renderPanel = () => {
|
|
112
|
+
if (!active) {
|
|
113
|
+
return <p className={styles.empty}>Nothing to configure here.</p>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const render = renderers[active.renderer];
|
|
117
|
+
|
|
118
|
+
if (typeof render !== 'function') {
|
|
119
|
+
// A named renderer the host did not supply is a config error, and a
|
|
120
|
+
// blank panel is the one outcome that hides it.
|
|
121
|
+
return (
|
|
122
|
+
<p className={styles.empty}>
|
|
123
|
+
No panel is registered for “{active.label}”.
|
|
124
|
+
</p>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return render({ section: active, userProfile });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Enough sections that scanning the rail is slower than typing.
|
|
132
|
+
const showSearch = sections.length >= 8;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={styles.page}>
|
|
136
|
+
<div className={styles.layout}>
|
|
137
|
+
<nav className={styles.rail} aria-label="Settings sections">
|
|
138
|
+
{showSearch && (
|
|
139
|
+
<div className={styles.railSearch}>
|
|
140
|
+
<Search size={14} strokeWidth={2} aria-hidden="true" />
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
value={query}
|
|
144
|
+
placeholder="Find a setting…"
|
|
145
|
+
aria-label="Find a setting"
|
|
146
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{grouped.map(([group, list]) => (
|
|
152
|
+
<div key={group || 'ungrouped'} className={styles.railGroup}>
|
|
153
|
+
{group && (
|
|
154
|
+
<p className={styles.railGroupTitle}>{group}</p>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
<ul>
|
|
158
|
+
{list.map((section) => {
|
|
159
|
+
const isActive = section.id === active?.id;
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<li key={section.id}>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className={
|
|
166
|
+
isActive
|
|
167
|
+
? styles.railItemActive
|
|
168
|
+
: styles.railItem
|
|
169
|
+
}
|
|
170
|
+
// The rail is a set of
|
|
171
|
+
// destinations, so the current
|
|
172
|
+
// one is announced as such.
|
|
173
|
+
aria-current={
|
|
174
|
+
isActive ? 'page' : undefined
|
|
175
|
+
}
|
|
176
|
+
onClick={() => open(section.id)}
|
|
177
|
+
>
|
|
178
|
+
{section.label}
|
|
179
|
+
</button>
|
|
180
|
+
</li>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</ul>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
|
|
187
|
+
{visible.length === 0 && (
|
|
188
|
+
<p className={styles.railEmpty}>
|
|
189
|
+
Nothing matches “{query}”.
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
192
|
+
</nav>
|
|
193
|
+
|
|
194
|
+
<section className={styles.panel} aria-live="polite">
|
|
195
|
+
{active && (
|
|
196
|
+
<header className={styles.panelHead}>
|
|
197
|
+
{/* The only heading on the screen: the page title
|
|
198
|
+
is a kicker above the section, rather than a
|
|
199
|
+
page header repeating what the rail already
|
|
200
|
+
says. `page.description` survives as its
|
|
201
|
+
tooltip so hosts still passing one keep it. */}
|
|
202
|
+
<p
|
|
203
|
+
className={styles.eyebrow}
|
|
204
|
+
title={page.description || undefined}
|
|
205
|
+
>
|
|
206
|
+
{page.title ?? 'Settings'}
|
|
207
|
+
</p>
|
|
208
|
+
<h1>{active.label}</h1>
|
|
209
|
+
{active.description && <p>{active.description}</p>}
|
|
210
|
+
</header>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className={styles.panelBody}>{renderPanel()}</div>
|
|
214
|
+
</section>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export default GenericSettings;
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useCallback, useId, useRef } from 'react';
|
|
2
2
|
import { X } from 'lucide-react';
|
|
3
3
|
import formStyles from '../styles/Form.module.scss';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The sheet's entry animation.
|
|
7
|
+
*
|
|
8
|
+
* Inline rather than in the stylesheet because everything else about this
|
|
9
|
+
* component's geometry is inline too, and splitting the two means a consumer
|
|
10
|
+
* overriding one gets the other from somewhere else. The reduced-motion query
|
|
11
|
+
* is not optional: a panel flying in from the edge is exactly the vestibular
|
|
12
|
+
* trigger the preference exists for.
|
|
13
|
+
*/
|
|
14
|
+
const SHEET_KEYFRAMES = `
|
|
15
|
+
@keyframes visnsSheetIn {
|
|
16
|
+
from { transform: translateX(24px); opacity: 0.6; }
|
|
17
|
+
to { transform: translateX(0); opacity: 1; }
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-reduced-motion: reduce) {
|
|
20
|
+
@keyframes visnsSheetIn {
|
|
21
|
+
from { opacity: 1; }
|
|
22
|
+
to { opacity: 1; }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
5
27
|
/**
|
|
6
28
|
* StandardModal Component
|
|
7
29
|
*
|
|
@@ -13,7 +35,8 @@ import formStyles from '../styles/Form.module.scss';
|
|
|
13
35
|
* @param {Function} props.onClose - Function to call when modal should close
|
|
14
36
|
* @param {string} props.title - Modal title (only used if showHeader is true)
|
|
15
37
|
* @param {React.ReactNode} props.children - Modal content
|
|
16
|
-
* @param {string} props.size -
|
|
38
|
+
* @param {string} props.size - Size: 'small', 'medium', 'large' (default: 'medium')
|
|
39
|
+
* @param {string} props.variant - 'modal' (centred) or 'sheet' (side panel)
|
|
17
40
|
* @param {boolean} props.closeOnDocumentClick - Whether to close on outside click (default: true)
|
|
18
41
|
* @param {boolean} props.closeOnEscape - Whether to close on escape key (default: true)
|
|
19
42
|
* @param {string} props.verticalAlign - Vertical alignment: 'center', 'top' (default: 'center')
|
|
@@ -26,6 +49,22 @@ const StandardModal = ({
|
|
|
26
49
|
title,
|
|
27
50
|
children,
|
|
28
51
|
size = 'medium',
|
|
52
|
+
/**
|
|
53
|
+
* 'modal' — centred dialog. Correct for a short, atomic task: a
|
|
54
|
+
* confirmation, a destructive action, a handful of fields.
|
|
55
|
+
* 'sheet' — a panel sliding in from the right. The 2026 default for CRUD
|
|
56
|
+
* on a record reached from a list, and what every comparable
|
|
57
|
+
* product (Linear, Stripe, Shopify, Salesforce) settled on.
|
|
58
|
+
*
|
|
59
|
+
* The reason is not fashion. A centred dialog over a table hides the row
|
|
60
|
+
* you are editing and the rows around it, and a tall form makes the dialog
|
|
61
|
+
* grow until it fights the viewport — which is exactly what a `medium`
|
|
62
|
+
* modal at `80vw` was doing here. A sheet keeps the list visible, gives a
|
|
63
|
+
* long form a natural vertical scroll, and has a fixed width, so the form
|
|
64
|
+
* inside has one column measure instead of a width that changes with the
|
|
65
|
+
* window.
|
|
66
|
+
*/
|
|
67
|
+
variant = 'modal',
|
|
29
68
|
closeOnDocumentClick = true,
|
|
30
69
|
closeOnEscape = true,
|
|
31
70
|
verticalAlign = 'center',
|
|
@@ -54,6 +93,69 @@ const StandardModal = ({
|
|
|
54
93
|
};
|
|
55
94
|
|
|
56
95
|
const { isMobile, isTablet } = getDeviceType();
|
|
96
|
+
|
|
97
|
+
const titleId = useId();
|
|
98
|
+
const panelRef = useRef(null);
|
|
99
|
+
const returnFocusRef = useRef(null);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Move focus into the panel on open, and put it back where it came from on
|
|
103
|
+
* close.
|
|
104
|
+
*
|
|
105
|
+
* Neither happened before. Opening left focus on the row behind the
|
|
106
|
+
* dialog, so the first Tab walked the table underneath instead of the
|
|
107
|
+
* form; closing dropped focus to <body>, which sends a keyboard user back
|
|
108
|
+
* to the top of the page every time they edit a record.
|
|
109
|
+
*/
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!isOpen) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
returnFocusRef.current =
|
|
116
|
+
typeof document !== 'undefined' ? document.activeElement : null;
|
|
117
|
+
|
|
118
|
+
const focusable = panelRef.current?.querySelector(
|
|
119
|
+
'input:not([type="hidden"]):not([disabled]), select, textarea, button, [href], [tabindex]:not([tabindex="-1"])'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// The panel itself as a fallback, so focus is never left outside it.
|
|
123
|
+
(focusable ?? panelRef.current)?.focus?.({ preventScroll: true });
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
returnFocusRef.current?.focus?.({ preventScroll: true });
|
|
127
|
+
};
|
|
128
|
+
}, [isOpen]);
|
|
129
|
+
|
|
130
|
+
/** Keep Tab inside the dialog while it is open. */
|
|
131
|
+
const handleTabKey = useCallback((event) => {
|
|
132
|
+
if (event.key !== 'Tab' || !panelRef.current) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const items = [
|
|
137
|
+
...panelRef.current.querySelectorAll(
|
|
138
|
+
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
|
|
139
|
+
),
|
|
140
|
+
].filter((el) => el.offsetParent !== null);
|
|
141
|
+
|
|
142
|
+
if (items.length === 0) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const first = items[0];
|
|
147
|
+
const last = items[items.length - 1];
|
|
148
|
+
|
|
149
|
+
// Wrap at both ends, or Tab escapes to the browser chrome and the user
|
|
150
|
+
// has no way back into the dialog without a mouse.
|
|
151
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
last.focus();
|
|
154
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
first.focus();
|
|
157
|
+
}
|
|
158
|
+
}, []);
|
|
57
159
|
|
|
58
160
|
// Handle window resize
|
|
59
161
|
useEffect(() => {
|
|
@@ -106,6 +208,8 @@ const StandardModal = ({
|
|
|
106
208
|
return classes;
|
|
107
209
|
};
|
|
108
210
|
|
|
211
|
+
const isSheet = variant === 'sheet';
|
|
212
|
+
|
|
109
213
|
// Get overlay styles
|
|
110
214
|
const getOverlayStyle = () => {
|
|
111
215
|
const baseStyle = {
|
|
@@ -114,24 +218,66 @@ const StandardModal = ({
|
|
|
114
218
|
left: 0,
|
|
115
219
|
right: 0,
|
|
116
220
|
bottom: 0,
|
|
117
|
-
|
|
221
|
+
// Was a flat 50% black. A scrim that heavy on a sheet buries the
|
|
222
|
+
// list the sheet exists to keep visible, so the sheet dims less.
|
|
223
|
+
backgroundColor: isSheet
|
|
224
|
+
? 'rgba(15, 23, 32, 0.32)'
|
|
225
|
+
: 'rgba(0, 0, 0, 0.5)',
|
|
118
226
|
display: 'flex',
|
|
119
|
-
alignItems:
|
|
120
|
-
|
|
227
|
+
alignItems: isSheet
|
|
228
|
+
? 'stretch'
|
|
229
|
+
: verticalAlign === 'top'
|
|
230
|
+
? 'flex-start'
|
|
231
|
+
: 'center',
|
|
232
|
+
// A sheet is pinned to the right edge; a modal is centred.
|
|
233
|
+
justifyContent: isSheet ? 'flex-end' : 'center',
|
|
121
234
|
zIndex: 1000,
|
|
122
|
-
padding: '20px',
|
|
235
|
+
padding: isSheet ? 0 : '20px',
|
|
123
236
|
...customStyles.overlay
|
|
124
237
|
};
|
|
125
238
|
|
|
126
|
-
if (verticalAlign === 'top') {
|
|
239
|
+
if (!isSheet && verticalAlign === 'top') {
|
|
127
240
|
baseStyle.paddingTop = '5%';
|
|
128
241
|
}
|
|
129
242
|
|
|
130
243
|
return baseStyle;
|
|
131
244
|
};
|
|
132
245
|
|
|
246
|
+
/**
|
|
247
|
+
* A sheet's width is FIXED, not a share of the window.
|
|
248
|
+
*
|
|
249
|
+
* A form wants one comfortable measure. `80vw` gave it a different measure
|
|
250
|
+
* on every monitor and stretched two-column rows into something unreadable
|
|
251
|
+
* on a wide screen. Below the tablet breakpoint the sheet becomes
|
|
252
|
+
* full-width, which is the only sensible shape on a phone.
|
|
253
|
+
*/
|
|
254
|
+
const getSheetStyle = () => {
|
|
255
|
+
const width = { small: 420, medium: 560, large: 800 }[size] ?? 560;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
position: 'relative',
|
|
259
|
+
backgroundColor: 'white',
|
|
260
|
+
width: isMobile ? '100vw' : `min(${width}px, 96vw)`,
|
|
261
|
+
maxWidth: '100vw',
|
|
262
|
+
height: '100vh',
|
|
263
|
+
display: 'flex',
|
|
264
|
+
flexDirection: 'column',
|
|
265
|
+
borderRadius: 0,
|
|
266
|
+
boxShadow: '-12px 0 40px -12px rgba(0, 0, 0, 0.28)',
|
|
267
|
+
overflowY: 'auto',
|
|
268
|
+
// Honours prefers-reduced-motion via the media query below; the
|
|
269
|
+
// transform is the entry, and it is deliberately short.
|
|
270
|
+
animation: 'visnsSheetIn 180ms cubic-bezier(0.32, 0.72, 0, 1)',
|
|
271
|
+
...customStyles.modal,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
133
275
|
// Get size-based styles for the modal content with device-aware positioning
|
|
134
276
|
const getContentStyle = () => {
|
|
277
|
+
if (isSheet) {
|
|
278
|
+
return getSheetStyle();
|
|
279
|
+
}
|
|
280
|
+
|
|
135
281
|
const baseStyle = {
|
|
136
282
|
position: 'relative',
|
|
137
283
|
backgroundColor: 'white',
|
|
@@ -190,9 +336,20 @@ const StandardModal = ({
|
|
|
190
336
|
style={getOverlayStyle()}
|
|
191
337
|
onClick={handleBackdropClick}
|
|
192
338
|
>
|
|
339
|
+
<style>{SHEET_KEYFRAMES}</style>
|
|
193
340
|
<div
|
|
341
|
+
ref={panelRef}
|
|
194
342
|
style={getContentStyle()}
|
|
195
343
|
onClick={(e) => e.stopPropagation()}
|
|
344
|
+
/* This had none of these. Without `role="dialog"` and
|
|
345
|
+
`aria-modal` a screen reader treats the panel as ordinary
|
|
346
|
+
page content and keeps reading the table behind it; without
|
|
347
|
+
the label it announces an unnamed dialog. */
|
|
348
|
+
role="dialog"
|
|
349
|
+
aria-modal="true"
|
|
350
|
+
aria-label={!showHeader && title ? title : undefined}
|
|
351
|
+
aria-labelledby={showHeader && title ? titleId : undefined}
|
|
352
|
+
onKeyDown={handleTabKey}
|
|
196
353
|
>
|
|
197
354
|
<div className={formStyles.modalwrap}>
|
|
198
355
|
<div
|
|
@@ -200,7 +357,7 @@ const StandardModal = ({
|
|
|
200
357
|
>
|
|
201
358
|
{showHeader && (
|
|
202
359
|
<div className={formStyles.modal__header} style={customStyles.header}>
|
|
203
|
-
<h1 style={customStyles.title}>{title}</h1>
|
|
360
|
+
<h1 id={titleId} style={customStyles.title}>{title}</h1>
|
|
204
361
|
<button
|
|
205
362
|
className={formStyles.modal__close}
|
|
206
363
|
onClick={onClose}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart defaults for GenericDashboard — 2026.
|
|
3
|
+
*
|
|
4
|
+
* The widget renderers used to spread `widget.props` straight into the nivo
|
|
5
|
+
* component with nothing underneath, so any dashboard.json that did not spell
|
|
6
|
+
* out a palette got nivo's default `nivo` scheme: a rainbow, cycled, with no
|
|
7
|
+
* colour-vision guarantee and a heavy dashed grid. These defaults sit BENEATH
|
|
8
|
+
* the spread, so every existing config keeps whatever it set and only the gaps
|
|
9
|
+
* are filled.
|
|
10
|
+
*
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
* The palette is validated, not chosen by eye.
|
|
13
|
+
* ---------------------------------------------------------------------------
|
|
14
|
+
* Six categorical hues, slot 1 the brand green. Assigned in fixed order and
|
|
15
|
+
* never cycled — a seventh series folds into "Other" or becomes small
|
|
16
|
+
* multiples rather than inventing a hue.
|
|
17
|
+
*
|
|
18
|
+
* light worst adjacent CVD ΔE 9.2 (deutan) · normal-vision ΔE 27.6
|
|
19
|
+
* dark worst adjacent CVD ΔE 9.4 (deutan) · normal-vision ΔE 19.7
|
|
20
|
+
*
|
|
21
|
+
* Both clear the ≥8 CVD target and the ≥15 normal-vision floor. The dark row
|
|
22
|
+
* is the same six hues re-stepped for a dark surface and validated against it,
|
|
23
|
+
* not an inversion of the light row.
|
|
24
|
+
*
|
|
25
|
+
* Three light-mode hues sit below 3:1 against white. That is a contrast WARN,
|
|
26
|
+
* not a pass — it obliges relief, which is why `defaultsFor` turns legends on
|
|
27
|
+
* for every multi-series chart and the table widget exists as the text view.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const DASHBOARD_SERIES_LIGHT = [
|
|
31
|
+
'#1baf7a', // 1 · green (brand)
|
|
32
|
+
'#eb6834', // 2 · orange
|
|
33
|
+
'#2a78d6', // 3 · blue
|
|
34
|
+
'#eda100', // 4 · yellow
|
|
35
|
+
'#4a3aa7', // 5 · violet
|
|
36
|
+
'#e87ba4', // 6 · magenta
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const DASHBOARD_SERIES_DARK = [
|
|
40
|
+
'#199e70',
|
|
41
|
+
'#d95926',
|
|
42
|
+
'#3987e5',
|
|
43
|
+
'#c98500',
|
|
44
|
+
'#9085e9',
|
|
45
|
+
'#d55181',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Status colours are reserved. They mean a state, never "series 4", and always
|
|
50
|
+
* ship with a label or an icon rather than carrying meaning by colour alone.
|
|
51
|
+
*/
|
|
52
|
+
export const DASHBOARD_STATUS = {
|
|
53
|
+
good: '#1f7a47',
|
|
54
|
+
warning: '#a36a14',
|
|
55
|
+
critical: '#be3e3e',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const prefersDark = () =>
|
|
59
|
+
typeof window !== 'undefined' &&
|
|
60
|
+
typeof window.matchMedia === 'function' &&
|
|
61
|
+
(document?.documentElement?.dataset?.theme === 'dark' ||
|
|
62
|
+
(document?.documentElement?.dataset?.theme !== 'light' &&
|
|
63
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches));
|
|
64
|
+
|
|
65
|
+
export const seriesColors = () =>
|
|
66
|
+
prefersDark() ? DASHBOARD_SERIES_DARK : DASHBOARD_SERIES_LIGHT;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The nivo theme. Grid and axes are one step off the surface, hairline and
|
|
70
|
+
* SOLID — a dashed grid competes with the data for attention, and there is
|
|
71
|
+
* nothing for the dashes to mean.
|
|
72
|
+
*/
|
|
73
|
+
export const dashboardTheme = () => {
|
|
74
|
+
const dark = prefersDark();
|
|
75
|
+
const ink = dark ? '#e6e9e8' : '#2b2b2b';
|
|
76
|
+
const muted = dark ? 'rgba(230,233,232,0.62)' : 'rgba(43,43,43,0.62)';
|
|
77
|
+
const line = dark ? 'rgba(230,233,232,0.14)' : 'rgba(43,43,43,0.10)';
|
|
78
|
+
const surface = dark ? '#171b1a' : '#ffffff';
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
background: 'transparent',
|
|
82
|
+
text: {
|
|
83
|
+
// Text wears text tokens, never a series colour.
|
|
84
|
+
fill: muted,
|
|
85
|
+
fontSize: 11,
|
|
86
|
+
fontFamily: 'inherit',
|
|
87
|
+
},
|
|
88
|
+
axis: {
|
|
89
|
+
domain: { line: { stroke: line, strokeWidth: 1 } },
|
|
90
|
+
ticks: {
|
|
91
|
+
line: { stroke: line, strokeWidth: 1 },
|
|
92
|
+
text: { fill: muted, fontSize: 11 },
|
|
93
|
+
},
|
|
94
|
+
legend: { text: { fill: muted, fontSize: 11, fontWeight: 600 } },
|
|
95
|
+
},
|
|
96
|
+
grid: { line: { stroke: line, strokeWidth: 1 } },
|
|
97
|
+
legends: { text: { fill: ink, fontSize: 11 } },
|
|
98
|
+
tooltip: {
|
|
99
|
+
container: {
|
|
100
|
+
background: surface,
|
|
101
|
+
color: ink,
|
|
102
|
+
fontSize: 12,
|
|
103
|
+
borderRadius: 8,
|
|
104
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
|
105
|
+
padding: '6px 10px',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const legendBelow = (count) =>
|
|
112
|
+
count >= 2
|
|
113
|
+
? [
|
|
114
|
+
{
|
|
115
|
+
// A legend is always present for two or more series: identity
|
|
116
|
+
// must never rest on colour alone.
|
|
117
|
+
anchor: 'bottom',
|
|
118
|
+
direction: 'row',
|
|
119
|
+
translateY: 56,
|
|
120
|
+
itemWidth: 96,
|
|
121
|
+
itemHeight: 16,
|
|
122
|
+
symbolSize: 10,
|
|
123
|
+
symbolShape: 'circle',
|
|
124
|
+
itemTextColor: 'inherit',
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
: [];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Per-type defaults. `seriesCount` decides only whether a legend is drawn — a
|
|
131
|
+
* single series needs none, because the card's own heading names it.
|
|
132
|
+
*/
|
|
133
|
+
export const defaultsFor = (type, seriesCount = 1) => {
|
|
134
|
+
const colors = seriesColors();
|
|
135
|
+
const theme = dashboardTheme();
|
|
136
|
+
const legends = legendBelow(seriesCount);
|
|
137
|
+
|
|
138
|
+
switch (type) {
|
|
139
|
+
case 'bar':
|
|
140
|
+
return {
|
|
141
|
+
theme,
|
|
142
|
+
colors,
|
|
143
|
+
// 4px rounded data-end, square at the baseline is nivo's
|
|
144
|
+
// behaviour for a positive bar with a radius.
|
|
145
|
+
borderRadius: 4,
|
|
146
|
+
// A 2px gap in the surface colour separates adjacent bars.
|
|
147
|
+
// padding is a fraction of the band; innerPadding is the pixel
|
|
148
|
+
// gap between grouped bars.
|
|
149
|
+
padding: 0.45,
|
|
150
|
+
innerPadding: 2,
|
|
151
|
+
enableGridX: false,
|
|
152
|
+
enableGridY: true,
|
|
153
|
+
enableLabel: false,
|
|
154
|
+
axisTop: null,
|
|
155
|
+
axisRight: null,
|
|
156
|
+
margin: {
|
|
157
|
+
top: 12,
|
|
158
|
+
right: 16,
|
|
159
|
+
bottom: legends.length ? 72 : 40,
|
|
160
|
+
left: 48,
|
|
161
|
+
},
|
|
162
|
+
legends,
|
|
163
|
+
animate: true,
|
|
164
|
+
motionConfig: 'gentle',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
case 'line':
|
|
168
|
+
return {
|
|
169
|
+
theme,
|
|
170
|
+
colors,
|
|
171
|
+
lineWidth: 2,
|
|
172
|
+
pointSize: 8,
|
|
173
|
+
pointBorderWidth: 2,
|
|
174
|
+
// The 2px ring is in the SURFACE colour so a marker stays
|
|
175
|
+
// legible where it crosses another line.
|
|
176
|
+
pointBorderColor: {
|
|
177
|
+
from: 'color',
|
|
178
|
+
modifiers: [['brighter', 3]],
|
|
179
|
+
},
|
|
180
|
+
pointColor: { from: 'color' },
|
|
181
|
+
enableArea: false,
|
|
182
|
+
areaOpacity: 0.1,
|
|
183
|
+
useMesh: true,
|
|
184
|
+
enableGridX: false,
|
|
185
|
+
enableGridY: true,
|
|
186
|
+
curve: 'monotoneX',
|
|
187
|
+
axisTop: null,
|
|
188
|
+
axisRight: null,
|
|
189
|
+
margin: {
|
|
190
|
+
top: 12,
|
|
191
|
+
right: 20,
|
|
192
|
+
bottom: legends.length ? 72 : 40,
|
|
193
|
+
left: 48,
|
|
194
|
+
},
|
|
195
|
+
legends,
|
|
196
|
+
animate: true,
|
|
197
|
+
motionConfig: 'gentle',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
case 'pie':
|
|
201
|
+
return {
|
|
202
|
+
theme,
|
|
203
|
+
colors,
|
|
204
|
+
innerRadius: 0.62,
|
|
205
|
+
padAngle: 1.2,
|
|
206
|
+
cornerRadius: 3,
|
|
207
|
+
activeOuterRadiusOffset: 6,
|
|
208
|
+
borderWidth: 0,
|
|
209
|
+
// Direct labels on the arcs, which is the relief the light
|
|
210
|
+
// palette's contrast WARN requires.
|
|
211
|
+
enableArcLinkLabels: true,
|
|
212
|
+
arcLinkLabelsThickness: 1,
|
|
213
|
+
arcLinkLabelsColor: { from: 'color' },
|
|
214
|
+
arcLinkLabelsTextColor: 'inherit',
|
|
215
|
+
arcLabelsSkipAngle: 12,
|
|
216
|
+
arcLabelsTextColor: {
|
|
217
|
+
from: 'color',
|
|
218
|
+
modifiers: [['darker', 3]],
|
|
219
|
+
},
|
|
220
|
+
margin: { top: 16, right: 90, bottom: 16, left: 90 },
|
|
221
|
+
animate: true,
|
|
222
|
+
motionConfig: 'gentle',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
return { theme, colors };
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export default defaultsFor;
|