@visns-studio/visns-components 6.20.1 → 6.23.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 +1 -1
- package/src/components/DataGrid.jsx +15 -2
- package/src/components/Navigation.jsx +10 -1
- package/src/components/columns/SelectFilterEditor.jsx +372 -0
- package/src/components/controls/DataGridSearch.jsx +6 -1
- package/src/components/layout/AnchoredMenu.jsx +131 -0
- package/src/components/layout/BandedList.jsx +158 -0
- package/src/components/layout/FactsBand.jsx +150 -0
- package/src/components/layout/PageHeader.jsx +90 -0
- package/src/components/layout/ScrollRegion.jsx +53 -0
- package/src/components/layout/StatStrip.jsx +170 -0
- package/src/components/layout/useContainedHeight.js +82 -0
- package/src/components/navigation/TabStrip.jsx +156 -0
- package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
- package/src/components/phone/phonePresenceEndpoints.js +34 -0
- package/src/components/phone/phonePresenceHelpers.js +298 -0
- package/src/components/phone/useZoomPhoneLive.js +165 -0
- package/src/components/sms/SmsClientConversations.jsx +8 -0
- package/src/components/sms/SmsInbox.jsx +11 -0
- package/src/components/sms/SmsThreadPanel.jsx +258 -29
- package/src/components/sms/smsHelpers.js +95 -0
- package/src/components/styles/AnchoredMenu.module.scss +43 -0
- package/src/components/styles/BandedList.module.scss +278 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/FactsBand.module.scss +108 -0
- package/src/components/styles/GenericDetail.module.scss +12 -2
- package/src/components/styles/PageHeader.module.scss +98 -0
- package/src/components/styles/ScrollRegion.module.scss +20 -0
- package/src/components/styles/Sms.module.scss +131 -0
- package/src/components/styles/StatStrip.module.scss +183 -0
- package/src/components/styles/TabStrip.module.scss +96 -0
- package/src/components/styles/Vault.module.scss +46 -0
- package/src/components/styles/ZoomPhone.module.scss +550 -0
- package/src/components/styles/_surface.scss +49 -0
- package/src/components/styles/global-datagrid.css +23 -0
- package/src/components/vault/VaultAccessLog.jsx +24 -0
- package/src/components/vault/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +88 -2
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
94
94
|
},
|
|
95
95
|
"name": "@visns-studio/visns-components",
|
|
96
|
-
"version": "6.
|
|
96
|
+
"version": "6.23.0",
|
|
97
97
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
98
98
|
"main": "src/index.js",
|
|
99
99
|
"files": [
|
|
@@ -37,10 +37,10 @@ import 'reactjs-popup/dist/index.css';
|
|
|
37
37
|
import parse from 'html-react-parser';
|
|
38
38
|
import { saveAs } from 'file-saver';
|
|
39
39
|
import NumberFilter from '@visns-studio/visns-datagrid-enterprise/NumberFilter';
|
|
40
|
-
import SelectFilter from '@visns-studio/visns-datagrid-enterprise/SelectFilter';
|
|
41
40
|
import DateFilter from '@visns-studio/visns-datagrid-enterprise/DateFilter';
|
|
42
41
|
import ReactDataGrid from '@visns-studio/visns-datagrid-enterprise';
|
|
43
42
|
import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
|
|
43
|
+
import SelectFilterEditor from './columns/SelectFilterEditor';
|
|
44
44
|
import { toast } from 'react-toastify';
|
|
45
45
|
import fetchUtil from '../utils/fetchUtil';
|
|
46
46
|
import { confirmDialog } from './utils/ConfirmDialog';
|
|
@@ -4387,7 +4387,11 @@ const DataGrid = forwardRef(
|
|
|
4387
4387
|
filterEditor = NumberFilter;
|
|
4388
4388
|
break;
|
|
4389
4389
|
case 'select':
|
|
4390
|
-
|
|
4390
|
+
// The grid's own SelectFilter, wrapped so
|
|
4391
|
+
// that opening a dropdown which already
|
|
4392
|
+
// holds a value clears it — see
|
|
4393
|
+
// SelectFilterEditor for the semantics.
|
|
4394
|
+
filterEditor = SelectFilterEditor;
|
|
4391
4395
|
|
|
4392
4396
|
// Inline options: `filter.options` as
|
|
4393
4397
|
// [{ id, label }].
|
|
@@ -4424,6 +4428,15 @@ const DataGrid = forwardRef(
|
|
|
4424
4428
|
column.filter.searchable === false,
|
|
4425
4429
|
// Explicitly enable clear icon
|
|
4426
4430
|
clearIcon: true,
|
|
4431
|
+
// Opening a filled dropdown clears
|
|
4432
|
+
// it (see SelectFilterEditor). A
|
|
4433
|
+
// column that must always carry a
|
|
4434
|
+
// value opts out with
|
|
4435
|
+
// `filter: { clearOnOpen: false }`;
|
|
4436
|
+
// a multi-select, excluded by
|
|
4437
|
+
// default, opts in with `true`.
|
|
4438
|
+
clearOnOpen:
|
|
4439
|
+
column.filter.clearOnOpen,
|
|
4427
4440
|
};
|
|
4428
4441
|
} else {
|
|
4429
4442
|
// Show loading state for URL-based filters without data
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
2
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
3
3
|
import {
|
|
4
|
+
AudioLines,
|
|
4
5
|
BarChart3,
|
|
5
6
|
Briefcase,
|
|
6
7
|
Bug,
|
|
@@ -59,6 +60,12 @@ const NAV_ICONS = {
|
|
|
59
60
|
// key. Module scope because two surfaces read it now: the header's action
|
|
60
61
|
// chips and — when a project opts in — the sidebar's pinned action rows.
|
|
61
62
|
const SETTING_ICONS = {
|
|
63
|
+
// A waveform, for recordings. `phone` is a handset and now belongs to the
|
|
64
|
+
// live Zoom Phone roster, which is a different question entirely — one chip
|
|
65
|
+
// is "listen to a call that happened", the other is "who is on a call right
|
|
66
|
+
// now", and two identical handsets in one header made them impossible to
|
|
67
|
+
// tell apart.
|
|
68
|
+
audioLines: AudioLines,
|
|
62
69
|
bug: Bug,
|
|
63
70
|
calendar: Calendar,
|
|
64
71
|
key: KeyRound,
|
|
@@ -607,7 +614,9 @@ function Navigation({
|
|
|
607
614
|
className={
|
|
608
615
|
cleanUrl(child.url) ===
|
|
609
616
|
bestMatchingUrl(
|
|
610
|
-
n.children.map(
|
|
617
|
+
n.children.map(
|
|
618
|
+
(one) => one.url
|
|
619
|
+
),
|
|
611
620
|
currentPage
|
|
612
621
|
)
|
|
613
622
|
? styles.active
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import SelectFilter from '@visns-studio/visns-datagrid-enterprise/SelectFilter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The grid's dropdown column filter, with one added behaviour: opening a
|
|
6
|
+
* filter that already holds a value clears that value.
|
|
7
|
+
*
|
|
8
|
+
* The complaint this answers is the everyday one — the Client filter says
|
|
9
|
+
* "Smith, John", you want a different client, and the only way to get a
|
|
10
|
+
* usable list is to hit the small × first, because the combo re-opens still
|
|
11
|
+
* carrying the old selection. Clicking the filter is already the gesture for
|
|
12
|
+
* "I want to choose again", so it now IS the clear: the chip disappears, the
|
|
13
|
+
* grid refilters as if the column were never filtered, and the menu you just
|
|
14
|
+
* opened shows the whole option list with an empty search box.
|
|
15
|
+
*
|
|
16
|
+
* ---------------------------------------------------------------------------
|
|
17
|
+
* Deliberate consequence: closing without picking leaves the filter CLEARED.
|
|
18
|
+
* There is no "restore what was there" on collapse — the click was the clear,
|
|
19
|
+
* and a filter that silently came back would make the gesture unpredictable.
|
|
20
|
+
* Re-picking the old option is one click away in the list that is already open.
|
|
21
|
+
*
|
|
22
|
+
* ---------------------------------------------------------------------------
|
|
23
|
+
* Only a DELIBERATE open clears — and that distinction has to be made here,
|
|
24
|
+
* because the combo expands on focus (`expandOnFocus`, on by default). Without
|
|
25
|
+
* the gate below, Tab-ing along the filter row would wipe every filled
|
|
26
|
+
* dropdown focus passed through, which is nobody's idea of "I clicked it".
|
|
27
|
+
* So an expand clears only when it followed, within a moment, either
|
|
28
|
+
*
|
|
29
|
+
* - a pointer press inside this combo (a click on the field, its chip area
|
|
30
|
+
* or its toggle arrow), or
|
|
31
|
+
* - a key press inside this combo that means "open this / start searching"
|
|
32
|
+
* (Enter, Space, Arrow Up/Down, F4, or a printable character).
|
|
33
|
+
*
|
|
34
|
+
* A Tab is neither: the keystroke lands on the element being left, so it is
|
|
35
|
+
* both the wrong key and outside this combo. Nothing else moves the needle —
|
|
36
|
+
* a programmatic focus, a saved view restoring a filter, or the grid's own
|
|
37
|
+
* focus handling all expand without a gesture and so leave the value alone.
|
|
38
|
+
*
|
|
39
|
+
* ---------------------------------------------------------------------------
|
|
40
|
+
* Why a wrapper and not a patch.
|
|
41
|
+
*
|
|
42
|
+
* `SelectFilter` lives in `@visns-studio/visns-datagrid-*` (the Inovua fork),
|
|
43
|
+
* which is vendor code we do not edit. Everything needed is reachable from
|
|
44
|
+
* outside it:
|
|
45
|
+
*
|
|
46
|
+
* - `filterEditorProps` is spread straight onto the underlying `Combo`
|
|
47
|
+
* (SelectFilter.render -> `finalProps`), so an `onExpandedChange` handed
|
|
48
|
+
* down there is called by the combo on every expand and collapse —
|
|
49
|
+
* mouse, keyboard and programmatic alike, since they all funnel through
|
|
50
|
+
* `ComboBox.setExpanded()`. One interception point covers every way of
|
|
51
|
+
* opening the list.
|
|
52
|
+
* - The gesture behind an expand is not passed to `onExpandedChange`, and
|
|
53
|
+
* the combo neither forwards `onClick`/`onMouseDown` (its own handlers
|
|
54
|
+
* replace them on the root node) nor calls `props.onKeyDown` until after
|
|
55
|
+
* it has already expanded. So the gesture is read from the page instead:
|
|
56
|
+
* capture-phase `window` listeners record the last pointer/key press
|
|
57
|
+
* before any React handler runs, and the expand asks what that was.
|
|
58
|
+
* - The clear itself goes through the `SelectFilter` instance's own
|
|
59
|
+
* `onChange`, which is exactly what the × button ends up calling: it drops
|
|
60
|
+
* the displayed value and emits the emptied `filterValue` to the grid
|
|
61
|
+
* (honouring `filterDelay`, as the × does). It deliberately does NOT go
|
|
62
|
+
* through `Combo.setValue()`, which collapses the list when
|
|
63
|
+
* `collapseOnSelect` is set — and SelectFilter always sets it, so clearing
|
|
64
|
+
* that way would shut the menu the user just opened.
|
|
65
|
+
*
|
|
66
|
+
* The editor contract (props, the required `render` wrapper, the `setValue`
|
|
67
|
+
* the grid calls on the ref) is documented in NativeDateFilterEditor; this
|
|
68
|
+
* component forwards all of it to `SelectFilter` untouched.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Filter editors are handed `filterEditorProps` both as a top-level prop and
|
|
73
|
+
* spread across their props (renderFilter/index.tsx `filterProps`), and it may
|
|
74
|
+
* be an object or a `(props) => object` factory — SelectFilter resolves it the
|
|
75
|
+
* same way.
|
|
76
|
+
*/
|
|
77
|
+
const resolveEditorProps = (props) => {
|
|
78
|
+
let { filterEditorProps } = props;
|
|
79
|
+
|
|
80
|
+
if (filterEditorProps === undefined) {
|
|
81
|
+
filterEditorProps =
|
|
82
|
+
props.filterValue && props.filterValue.filterEditorProps;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof filterEditorProps === 'function') {
|
|
86
|
+
return filterEditorProps(props) || {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return filterEditorProps || {};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long after a key or pointer press an expand still counts as that
|
|
94
|
+
* gesture's doing. A click expands in the same task; this is loose enough to
|
|
95
|
+
* survive a slow frame and far too tight to catch the next thing the user does.
|
|
96
|
+
*/
|
|
97
|
+
const GESTURE_WINDOW_MS = 700;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Keys that mean "open this list", as opposed to Tab, which means "leave".
|
|
101
|
+
* A printable character counts too: typing into a filter is as deliberate as
|
|
102
|
+
* clicking it.
|
|
103
|
+
*/
|
|
104
|
+
const OPEN_KEYS = ['Enter', ' ', 'Spacebar', 'ArrowDown', 'ArrowUp', 'F4'];
|
|
105
|
+
|
|
106
|
+
const isOpenKey = (event) => {
|
|
107
|
+
const { key } = event;
|
|
108
|
+
|
|
109
|
+
if (typeof key !== 'string' || key === 'Tab') {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (OPEN_KEYS.indexOf(key) !== -1) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return key.length === 1 && !event.ctrlKey && !event.metaKey;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The last user gesture seen anywhere on the page: `{ kind, target, at }`,
|
|
122
|
+
* where `kind` is 'open' for a press that means business and 'other' for
|
|
123
|
+
* everything else (a Tab, an Escape, a modifier).
|
|
124
|
+
*/
|
|
125
|
+
let lastGesture = null;
|
|
126
|
+
let watching = false;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One set of capture-phase listeners for the whole page, however many filters
|
|
130
|
+
* are on screen. Capture on `window` runs before React's delegated handlers,
|
|
131
|
+
* which is the point: the gesture is on record before the combo has decided to
|
|
132
|
+
* expand. They are passive and never removed — three listeners for the life of
|
|
133
|
+
* the page is cheaper than mounting and unmounting them per filter cell.
|
|
134
|
+
*/
|
|
135
|
+
const watchGestures = () => {
|
|
136
|
+
if (watching || typeof window === 'undefined') {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
watching = true;
|
|
141
|
+
|
|
142
|
+
const remember = (event, kind) => {
|
|
143
|
+
lastGesture = {
|
|
144
|
+
kind,
|
|
145
|
+
target: event.target,
|
|
146
|
+
at: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const options = { capture: true, passive: true };
|
|
151
|
+
|
|
152
|
+
window.addEventListener(
|
|
153
|
+
'mousedown',
|
|
154
|
+
(event) => remember(event, 'open'),
|
|
155
|
+
options
|
|
156
|
+
);
|
|
157
|
+
window.addEventListener(
|
|
158
|
+
'touchstart',
|
|
159
|
+
(event) => remember(event, 'open'),
|
|
160
|
+
options
|
|
161
|
+
);
|
|
162
|
+
window.addEventListener(
|
|
163
|
+
'keydown',
|
|
164
|
+
(event) => remember(event, isOpenKey(event) ? 'open' : 'other'),
|
|
165
|
+
options
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Did the user just ask this combo to open, or did the combo open itself?
|
|
171
|
+
*
|
|
172
|
+
* `node` is the combo's own root element: a gesture outside it — the Tab press
|
|
173
|
+
* that lands on the element being left, a click elsewhere that moves focus —
|
|
174
|
+
* did not open this list, whatever else it did.
|
|
175
|
+
*/
|
|
176
|
+
export const openedByGesture = (
|
|
177
|
+
node,
|
|
178
|
+
gesture = lastGesture,
|
|
179
|
+
now = Date.now()
|
|
180
|
+
) => {
|
|
181
|
+
if (!gesture || gesture.kind !== 'open' || !gesture.target) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (now - gesture.at > GESTURE_WINDOW_MS) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return !!node && !!node.contains && node.contains(gesture.target);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* "Is something actually filtered here?" — `null` is the `select` filter
|
|
194
|
+
* type's emptyValue, `''` is what DataGrid's dependent-filter reset writes,
|
|
195
|
+
* and a multi-select carries an array.
|
|
196
|
+
*/
|
|
197
|
+
export const hasFilterValue = (value) => {
|
|
198
|
+
if (value === null || value === undefined || value === '') {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value.length > 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return true;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The whole decision, kept pure so it can be reasoned about (and tested)
|
|
211
|
+
* without a combo box.
|
|
212
|
+
*
|
|
213
|
+
* @param {boolean} expanded the state the combo just moved to
|
|
214
|
+
* @param {boolean} openedDeliberately the user opened it (see openedByGesture)
|
|
215
|
+
* rather than focus opening it for them
|
|
216
|
+
* @param {*} value what the filter currently holds
|
|
217
|
+
* @param {boolean} multiple multi-select (`operator: 'inlist'`)
|
|
218
|
+
* @param {boolean} clearOnOpen per-column opt out (or opt in, for multiple)
|
|
219
|
+
* @param {boolean} disabled
|
|
220
|
+
* @param {boolean} readOnly
|
|
221
|
+
*/
|
|
222
|
+
export const shouldClearOnOpen = ({
|
|
223
|
+
expanded,
|
|
224
|
+
openedDeliberately,
|
|
225
|
+
value,
|
|
226
|
+
multiple,
|
|
227
|
+
clearOnOpen,
|
|
228
|
+
disabled,
|
|
229
|
+
readOnly,
|
|
230
|
+
} = {}) => {
|
|
231
|
+
// Collapsing is not a clear, and an already-empty filter has nothing to
|
|
232
|
+
// clear — opening one must be a no-op so no needless refetch is posted.
|
|
233
|
+
if (!expanded || disabled || readOnly) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Focus alone expands the combo. That is not the user opening it, and
|
|
238
|
+
// clearing there would empty every filter a Tab travels past.
|
|
239
|
+
if (!openedDeliberately) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (clearOnOpen === false) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A multi-select is opened to ADD a choice as often as to replace one, so
|
|
248
|
+
// wiping the existing selections on open would make it impossible to pick
|
|
249
|
+
// a second value. Those columns keep the old behaviour unless a column
|
|
250
|
+
// explicitly asks for the clear with `filter.clearOnOpen: true`.
|
|
251
|
+
if (multiple && clearOnOpen !== true) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return hasFilterValue(value);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const SelectFilterEditor = forwardRef((props, ref) => {
|
|
259
|
+
const { filterValue, emptyValue, onChange, disabled, readOnly } = props;
|
|
260
|
+
|
|
261
|
+
// Idempotent: the first filter cell on the page arms the listeners, the
|
|
262
|
+
// rest are no-ops. Done during render rather than in an effect so a combo
|
|
263
|
+
// that is clicked the instant it appears is already being watched.
|
|
264
|
+
watchGestures();
|
|
265
|
+
|
|
266
|
+
const innerRef = useRef(null);
|
|
267
|
+
|
|
268
|
+
// The grid reaches the editor through this ref: `GenericFilter.setValue`
|
|
269
|
+
// calls `setValue` on it (DataGrid's dependent-filter reset does exactly
|
|
270
|
+
// that), and `getInputRef` is read for focus handling. Both belong to the
|
|
271
|
+
// wrapped SelectFilter.
|
|
272
|
+
useImperativeHandle(
|
|
273
|
+
ref,
|
|
274
|
+
() => ({
|
|
275
|
+
setValue: (next) => {
|
|
276
|
+
if (innerRef.current && innerRef.current.setValue) {
|
|
277
|
+
innerRef.current.setValue(next);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
getInputRef: () =>
|
|
281
|
+
innerRef.current && innerRef.current.getInputRef
|
|
282
|
+
? innerRef.current.getInputRef()
|
|
283
|
+
: null,
|
|
284
|
+
}),
|
|
285
|
+
[]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// `null` is the `select` filter type's emptyValue (community-edition
|
|
289
|
+
// filterTypes.ts) and what the combo's × emits; fall back to it so a grid
|
|
290
|
+
// that does not pass the prop still clears to the same value.
|
|
291
|
+
const noValue = emptyValue === undefined ? null : emptyValue;
|
|
292
|
+
|
|
293
|
+
// Kept fresh on every render so the stable handler below always sees the
|
|
294
|
+
// current props without re-arming the combo's prop on each render.
|
|
295
|
+
const clearRef = useRef(null);
|
|
296
|
+
clearRef.current = (expanded) => {
|
|
297
|
+
const instance = innerRef.current;
|
|
298
|
+
const combo =
|
|
299
|
+
instance && instance.getInputRef && instance.getInputRef();
|
|
300
|
+
const editorProps = resolveEditorProps(props);
|
|
301
|
+
|
|
302
|
+
// The displayed value is SelectFilter's own state — with a
|
|
303
|
+
// `filterDelay` in play it is ahead of the `filterValue` prop, so it,
|
|
304
|
+
// not the prop, decides whether there is anything to clear.
|
|
305
|
+
const current =
|
|
306
|
+
instance && instance.state
|
|
307
|
+
? instance.state.value
|
|
308
|
+
: filterValue && filterValue.value;
|
|
309
|
+
|
|
310
|
+
if (
|
|
311
|
+
!shouldClearOnOpen({
|
|
312
|
+
expanded,
|
|
313
|
+
openedDeliberately: openedByGesture(
|
|
314
|
+
combo && combo.getComboNode ? combo.getComboNode() : null
|
|
315
|
+
),
|
|
316
|
+
value: current,
|
|
317
|
+
multiple: editorProps.multiple,
|
|
318
|
+
clearOnOpen: editorProps.clearOnOpen,
|
|
319
|
+
disabled,
|
|
320
|
+
readOnly,
|
|
321
|
+
})
|
|
322
|
+
) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (instance && typeof instance.onChange === 'function') {
|
|
327
|
+
// The × button's path: drops the chip immediately and posts the
|
|
328
|
+
// emptied filter to the grid after `filterDelay`.
|
|
329
|
+
instance.onChange(noValue);
|
|
330
|
+
} else if (onChange) {
|
|
331
|
+
onChange({ ...filterValue, value: noValue });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Leftover search text would filter the list the user is looking at.
|
|
335
|
+
// The combo normally clears it on select/blur, so this is a backstop;
|
|
336
|
+
// `skipFilter` avoids a redundant re-filter, and passing `null` (not
|
|
337
|
+
// `''`) keeps `clearValueOnEmpty` out of it — that path calls
|
|
338
|
+
// `Combo.setValue`, which would collapse the open menu.
|
|
339
|
+
if (combo && typeof combo.setText === 'function' && combo.getText()) {
|
|
340
|
+
combo.setText(null, { skipFilter: true });
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const editorProps = resolveEditorProps(props);
|
|
345
|
+
|
|
346
|
+
// `clearOnOpen` is ours, not the combo's — and the combo hands every prop
|
|
347
|
+
// it does not recognise to a DOM node (`cleanProps` in ComboBox.render),
|
|
348
|
+
// which would put an unknown attribute on a div. Keep it out of what goes
|
|
349
|
+
// down.
|
|
350
|
+
const { clearOnOpen, ...comboProps } = editorProps;
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<SelectFilter
|
|
354
|
+
{...props}
|
|
355
|
+
ref={innerRef}
|
|
356
|
+
filterEditorProps={{
|
|
357
|
+
...comboProps,
|
|
358
|
+
onExpandedChange: (expanded) => {
|
|
359
|
+
if (editorProps.onExpandedChange) {
|
|
360
|
+
editorProps.onExpandedChange(expanded);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
clearRef.current(expanded);
|
|
364
|
+
},
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
SelectFilterEditor.displayName = 'SelectFilterEditor';
|
|
371
|
+
|
|
372
|
+
export default SelectFilterEditor;
|
|
@@ -240,8 +240,13 @@ const DataGridSearch = ({
|
|
|
240
240
|
<div className={styles['icon-search']}>
|
|
241
241
|
<Search strokeWidth={2} size={14} />
|
|
242
242
|
</div>
|
|
243
|
+
{/* type="search", not "text": host apps style bare text inputs
|
|
244
|
+
globally (throughlife's _forms.scss padded rule among them),
|
|
245
|
+
and that padding put the magnifier on top of the placeholder.
|
|
246
|
+
Search inputs are excluded from such rules, so the module's
|
|
247
|
+
own matched icon-offset/indent pair applies. */}
|
|
243
248
|
<input
|
|
244
|
-
type="
|
|
249
|
+
type="search"
|
|
245
250
|
placeholder="Search"
|
|
246
251
|
value={search}
|
|
247
252
|
onChange={handleChangeSearch}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
import styles from '../styles/AnchoredMenu.module.scss';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `⋯` menu on a row, rendered where nothing can clip it.
|
|
8
|
+
*
|
|
9
|
+
* Every list in this library's world had the same bug: the menu is
|
|
10
|
+
* `position: absolute` inside the row, the row is inside a card, and the card
|
|
11
|
+
* has `overflow: hidden` for its rounded corners — so on the LAST row of a band
|
|
12
|
+
* the menu was cut off entirely and the button looked like it did nothing. Give
|
|
13
|
+
* the list a scroll region (`ScrollRegion`) and it gets worse, because a
|
|
14
|
+
* scrolling ancestor clips too and now every row is the last row.
|
|
15
|
+
*
|
|
16
|
+
* There is no arrangement of `overflow` that fixes it, so the menu is not in the
|
|
17
|
+
* list at all: it is portalled to `document.body` and positioned `fixed` against
|
|
18
|
+
* the trigger's rectangle. Nothing between it and the viewport can clip it.
|
|
19
|
+
*
|
|
20
|
+
* IT CLOSES ON SCROLL rather than following the trigger. A menu that tracks a
|
|
21
|
+
* row while the list moves under it is more code and a worse idea — by the time
|
|
22
|
+
* it has travelled two hundred pixels the reader has lost which row it belongs
|
|
23
|
+
* to. Scrolling means "not that one", so it closes.
|
|
24
|
+
*
|
|
25
|
+
* @param {boolean} open Whether the menu is showing.
|
|
26
|
+
* @param {Element} anchor The trigger element to hang it off.
|
|
27
|
+
* @param {func} onClose Called on a click outside, Escape, or a scroll.
|
|
28
|
+
* @param {'start'|'end'} align Which edge of the trigger it lines up with.
|
|
29
|
+
* @param {number} gap Space between the trigger and the menu.
|
|
30
|
+
* @param {string} label Accessible name for the menu.
|
|
31
|
+
*/
|
|
32
|
+
const AnchoredMenu = ({
|
|
33
|
+
open,
|
|
34
|
+
anchor,
|
|
35
|
+
onClose,
|
|
36
|
+
align = 'end',
|
|
37
|
+
gap = 4,
|
|
38
|
+
label = 'Actions',
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
}) => {
|
|
42
|
+
const [position, setPosition] = useState(null);
|
|
43
|
+
|
|
44
|
+
const place = useCallback(() => {
|
|
45
|
+
if (!anchor) return;
|
|
46
|
+
|
|
47
|
+
const rect = anchor.getBoundingClientRect();
|
|
48
|
+
|
|
49
|
+
setPosition({
|
|
50
|
+
top: rect.bottom + gap,
|
|
51
|
+
left: align === 'end' ? null : rect.left,
|
|
52
|
+
right: align === 'end' ? window.innerWidth - rect.right : null,
|
|
53
|
+
// Enough room below? Otherwise flip above the trigger. Measured
|
|
54
|
+
// against a nominal menu height rather than the real one, which is
|
|
55
|
+
// not known until after it has been placed — and being 20px out on
|
|
56
|
+
// the decision is invisible, where a menu off the bottom of the
|
|
57
|
+
// window is not.
|
|
58
|
+
flip: window.innerHeight - rect.bottom < 180 && rect.top > 180,
|
|
59
|
+
anchorTop: rect.top,
|
|
60
|
+
});
|
|
61
|
+
}, [anchor, align, gap]);
|
|
62
|
+
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
if (!open) {
|
|
65
|
+
setPosition(null);
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
place();
|
|
71
|
+
|
|
72
|
+
return undefined;
|
|
73
|
+
}, [open, place]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return undefined;
|
|
77
|
+
|
|
78
|
+
const close = () => onClose?.();
|
|
79
|
+
const onKeyDown = (event) => {
|
|
80
|
+
if (event.key === 'Escape') close();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Capture, so a scroll inside a list region closes it too — the event
|
|
84
|
+
// does not bubble from a scrolling element.
|
|
85
|
+
window.addEventListener('scroll', close, true);
|
|
86
|
+
window.addEventListener('resize', close);
|
|
87
|
+
window.addEventListener('keydown', onKeyDown);
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener('scroll', close, true);
|
|
91
|
+
window.removeEventListener('resize', close);
|
|
92
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
93
|
+
};
|
|
94
|
+
}, [open, onClose]);
|
|
95
|
+
|
|
96
|
+
if (!open || !position || typeof document === 'undefined') return null;
|
|
97
|
+
|
|
98
|
+
const style = {
|
|
99
|
+
top: position.flip ? undefined : position.top,
|
|
100
|
+
bottom: position.flip
|
|
101
|
+
? window.innerHeight - position.anchorTop + gap
|
|
102
|
+
: undefined,
|
|
103
|
+
left: position.left ?? undefined,
|
|
104
|
+
right: position.right ?? undefined,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return createPortal(
|
|
108
|
+
<>
|
|
109
|
+
{/* A full-window hit target behind the menu, so one click anywhere
|
|
110
|
+
closes it however far the list has been scrolled. */}
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className={styles.scrim}
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
tabIndex={-1}
|
|
116
|
+
onClick={() => onClose?.()}
|
|
117
|
+
/>
|
|
118
|
+
<div
|
|
119
|
+
className={[styles.menu, className].filter(Boolean).join(' ')}
|
|
120
|
+
role="menu"
|
|
121
|
+
aria-label={label}
|
|
122
|
+
style={style}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
</>,
|
|
127
|
+
document.body
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export default AnchoredMenu;
|