@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
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * "Be as tall as whatever is left of the window, and scroll inside that."
5
+ *
6
+ * The problem it solves is not scrolling, it is LOSING THE CHROME. A list of
7
+ * four hundred clients scrolls the whole document, which takes the summary
8
+ * strip, the search box and the view tabs off the top of the screen — so
9
+ * finding a name and then narrowing the search means scrolling back up to
10
+ * controls that were in front of you a moment ago. Give the rows their own
11
+ * scroll region and everything above them stays put.
12
+ *
13
+ * WHY MEASURED RATHER THAN A CONSTANT. The obvious implementation is
14
+ * `max-height: calc(100vh - 22rem)` with the 22rem counted off the design. It
15
+ * is wrong on half the states of a real page: the strip has not loaded yet, a
16
+ * truncation notice appeared at a hundred rows, the header wrapped to two lines
17
+ * on a tablet. So the height is read from where the region actually starts —
18
+ * `getBoundingClientRect().top` — and recomputed whenever the window resizes or
19
+ * anything above it changes size.
20
+ *
21
+ * The `ResizeObserver` watches the region's PARENT rather than the region
22
+ * itself: the region's own height is the thing being set, and observing it
23
+ * would be a loop. What is wanted is "did the chrome above me move?", and the
24
+ * parent growing is the signal for that.
25
+ *
26
+ * Returns `null` until the first measurement, which is deliberate — a region
27
+ * rendered with no max-height for one frame is a page that is briefly too long,
28
+ * where one rendered at a guessed height is a page that visibly jumps.
29
+ *
30
+ * @param {object} ref Ref on the scrolling element.
31
+ * @param {number} gutter Space to leave under it, in pixels.
32
+ * @param {*} deps Anything that changes what is above it.
33
+ * @param {boolean} enabled False leaves the height null and installs no
34
+ * listeners — for a component where the containment
35
+ * is opt-in and the hook still has to be called
36
+ * unconditionally.
37
+ */
38
+ const useContainedHeight = (ref, gutter = 24, deps = null, enabled = true) => {
39
+ const [height, setHeight] = useState(null);
40
+
41
+ const measure = useCallback(() => {
42
+ const node = ref.current;
43
+
44
+ if (!enabled || !node || typeof window === 'undefined') return;
45
+
46
+ const top = node.getBoundingClientRect().top;
47
+ const available = window.innerHeight - top - gutter;
48
+
49
+ // A floor rather than a clamp to zero: on a short window the region
50
+ // still has to be usable, and 200px is about four rows.
51
+ setHeight(Math.max(available, 200));
52
+ }, [ref, gutter, enabled]);
53
+
54
+ useEffect(() => {
55
+ if (!enabled) return undefined;
56
+
57
+ measure();
58
+
59
+ window.addEventListener('resize', measure);
60
+
61
+ // Orientation changes on an iPad settle a beat after the event.
62
+ const settle = setTimeout(measure, 200);
63
+
64
+ let observer = null;
65
+
66
+ if (typeof ResizeObserver !== 'undefined' && ref.current?.parentElement) {
67
+ observer = new ResizeObserver(measure);
68
+ observer.observe(ref.current.parentElement);
69
+ }
70
+
71
+ return () => {
72
+ window.removeEventListener('resize', measure);
73
+ clearTimeout(settle);
74
+ if (observer) observer.disconnect();
75
+ };
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [measure, deps, enabled]);
78
+
79
+ return height;
80
+ };
81
+
82
+ export default useContainedHeight;
@@ -0,0 +1,156 @@
1
+ import React, { useCallback } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+
4
+ import styles from '../styles/TabStrip.module.scss';
5
+
6
+ /**
7
+ * react-router's search params, if there is a Router.
8
+ *
9
+ * Same shape and the same reasoning as `auth/useOptionalRouter`: the hook is
10
+ * called unconditionally on every render and only its throw is swallowed, which
11
+ * is what keeps this legal under the rules of hooks — whether a Router is above
12
+ * a component is fixed for that component's lifetime, so React never sees the
13
+ * hook count change.
14
+ *
15
+ * A strip used outside a Router simply cannot be URL-driven, and falls back to
16
+ * being controlled.
17
+ */
18
+ const useOptionalSearchParams = () => {
19
+ try {
20
+ return useSearchParams();
21
+ } catch (error) {
22
+ // No Router above us. Expected wherever this is mounted outside an SPA.
23
+ return [null, null];
24
+ }
25
+ };
26
+
27
+ /**
28
+ * The underline tabs, driven either by the URL or by the caller.
29
+ *
30
+ * TWO MODES, and which one a page wants depends on whether the tab is a place.
31
+ *
32
+ * CONTROLLED — pass `active` and `onChange`. The right answer when the tab is
33
+ * a filter over the same page ("Active / Retired / All"): those are not
34
+ * locations, and putting them in the URL invites somebody to bookmark a view
35
+ * of a list rather than the list.
36
+ *
37
+ * URL-DRIVEN — pass `param` (usually `'tab'`) and no `active`. The right
38
+ * answer when the tab is a SECTION of a record: a deep link, a refresh and a
39
+ * shared link all have to land in the same place. `replace` defaults to true
40
+ * so five tab clicks do not become five back-button steps through a page
41
+ * nobody ever left; a page whose tabs genuinely are separate destinations can
42
+ * turn it off.
43
+ *
44
+ * Real `<button>`s in a tablist rather than links, in both modes: even when the
45
+ * tab is reflected in the URL, switching it is a state change rather than a
46
+ * navigation, and a router push per click fills the back button with noise.
47
+ *
48
+ * @param {Array} tabs `[{key, label, badge, badgeTone, disabled}]`.
49
+ * @param {string} active Controlled mode: the current key.
50
+ * @param {func} onChange Called with the key. Fires in URL mode too, so a
51
+ * page can invalidate a cache on the way past.
52
+ * @param {string} param URL mode: the search param that owns the tab.
53
+ * @param {bool} replace URL mode: replace rather than push. Default true.
54
+ * @param {string} fallback What an unrecognised (or absent) param means.
55
+ * Defaults to the first tab.
56
+ * @param {'quiet'|'danger'} badgeTone Default tone for every badge.
57
+ */
58
+ const TabStrip = ({
59
+ tabs = [],
60
+ active,
61
+ onChange,
62
+ param,
63
+ replace = true,
64
+ fallback,
65
+ ariaLabel = 'Sections',
66
+ badgeTone = 'quiet',
67
+ className,
68
+ tabClassName,
69
+ ...rest
70
+ }) => {
71
+ const [searchParams, setSearchParams] = useOptionalSearchParams();
72
+
73
+ const urlDriven = Boolean(param) && searchParams !== null && active === undefined;
74
+
75
+ const known = (key) => tabs.some((tab) => tab.key === key);
76
+ const defaultKey = fallback ?? tabs[0]?.key;
77
+
78
+ let current = active;
79
+
80
+ if (urlDriven) {
81
+ const requested = searchParams.get(param);
82
+
83
+ current = known(requested) ? requested : defaultKey;
84
+ }
85
+
86
+ const select = useCallback(
87
+ (key) => {
88
+ if (urlDriven) {
89
+ setSearchParams(
90
+ (params) => {
91
+ const next = new URLSearchParams(params);
92
+
93
+ next.set(param, key);
94
+
95
+ return next;
96
+ },
97
+ { replace }
98
+ );
99
+ }
100
+
101
+ if (onChange) onChange(key);
102
+ },
103
+ [urlDriven, setSearchParams, param, replace, onChange]
104
+ );
105
+
106
+ if (tabs.length === 0) return null;
107
+
108
+ return (
109
+ <div
110
+ className={[styles.strip, className].filter(Boolean).join(' ')}
111
+ role="tablist"
112
+ aria-label={ariaLabel}
113
+ {...rest}
114
+ >
115
+ {tabs.map((tab) => {
116
+ const selected = current === tab.key;
117
+ const tone = tab.badgeTone ?? badgeTone;
118
+
119
+ return (
120
+ <button
121
+ key={tab.key}
122
+ type="button"
123
+ role="tab"
124
+ aria-selected={selected}
125
+ disabled={tab.disabled}
126
+ className={[
127
+ styles.tab,
128
+ selected ? styles.active : null,
129
+ tabClassName,
130
+ ]
131
+ .filter(Boolean)
132
+ .join(' ')}
133
+ onClick={() => select(tab.key)}
134
+ >
135
+ {tab.label}
136
+
137
+ {tab.badge ? (
138
+ <span
139
+ className={[
140
+ styles.badge,
141
+ tone === 'danger'
142
+ ? styles.badgeDanger
143
+ : styles.badgeQuiet,
144
+ ].join(' ')}
145
+ >
146
+ {tab.badge}
147
+ </span>
148
+ ) : null}
149
+ </button>
150
+ );
151
+ })}
152
+ </div>
153
+ );
154
+ };
155
+
156
+ export default TabStrip;