@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +15 -2
  3. package/src/components/Navigation.jsx +10 -1
  4. package/src/components/columns/SelectFilterEditor.jsx +372 -0
  5. package/src/components/controls/DataGridSearch.jsx +6 -1
  6. package/src/components/layout/AnchoredMenu.jsx +131 -0
  7. package/src/components/layout/BandedList.jsx +158 -0
  8. package/src/components/layout/FactsBand.jsx +150 -0
  9. package/src/components/layout/PageHeader.jsx +90 -0
  10. package/src/components/layout/ScrollRegion.jsx +53 -0
  11. package/src/components/layout/StatStrip.jsx +170 -0
  12. package/src/components/layout/useContainedHeight.js +82 -0
  13. package/src/components/navigation/TabStrip.jsx +156 -0
  14. package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
  15. package/src/components/phone/phonePresenceEndpoints.js +34 -0
  16. package/src/components/phone/phonePresenceHelpers.js +298 -0
  17. package/src/components/phone/useZoomPhoneLive.js +165 -0
  18. package/src/components/sms/SmsClientConversations.jsx +8 -0
  19. package/src/components/sms/SmsInbox.jsx +11 -0
  20. package/src/components/sms/SmsThreadPanel.jsx +258 -29
  21. package/src/components/sms/smsHelpers.js +95 -0
  22. package/src/components/styles/AnchoredMenu.module.scss +43 -0
  23. package/src/components/styles/BandedList.module.scss +278 -0
  24. package/src/components/styles/DataGrid.module.scss +10 -0
  25. package/src/components/styles/FactsBand.module.scss +108 -0
  26. package/src/components/styles/GenericDetail.module.scss +12 -2
  27. package/src/components/styles/PageHeader.module.scss +98 -0
  28. package/src/components/styles/ScrollRegion.module.scss +20 -0
  29. package/src/components/styles/Sms.module.scss +131 -0
  30. package/src/components/styles/StatStrip.module.scss +183 -0
  31. package/src/components/styles/TabStrip.module.scss +96 -0
  32. package/src/components/styles/Vault.module.scss +46 -0
  33. package/src/components/styles/ZoomPhone.module.scss +550 -0
  34. package/src/components/styles/_surface.scss +49 -0
  35. package/src/components/styles/global-datagrid.css +23 -0
  36. package/src/components/vault/VaultAccessLog.jsx +24 -0
  37. package/src/components/vault/VaultShareModal.jsx +126 -5
  38. package/src/components/vault/vaultShares.js +67 -3
  39. 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.20.1",
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
- filterEditor = SelectFilter;
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((one) => one.url),
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="text"
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;