@visns-studio/visns-components 6.20.1 → 6.21.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.
@@ -0,0 +1,170 @@
1
+ import React from 'react';
2
+
3
+ import styles from '../styles/StatStrip.module.scss';
4
+
5
+ /**
6
+ * A row of labelled figures about the set below it.
7
+ *
8
+ * Five screens in the CRM had each written this markup out by hand — support
9
+ * blocks, subscriptions, contacts, the client book and the Zoom dashboard — and
10
+ * they had already drifted: two of them weighted the label, one of them coloured
11
+ * the figure, and the padding was off by a hundredth of a rem in three places.
12
+ * It is one component now, in two variants (see StatStrip.module.scss), because
13
+ * the difference between "one card of hairline-separated cells" and "separate
14
+ * tiles" is a real design decision each of those pages made on purpose.
15
+ *
16
+ * What it deliberately does NOT do is fetch, format or decide. The figures
17
+ * arrive formatted; whether a number is alarming is the page's judgement, and it
18
+ * says so with `tone` / `emphasis`. That is what keeps this usable by a screen
19
+ * whose numbers are hours, one whose numbers are money and one whose numbers are
20
+ * counts of broken things.
21
+ *
22
+ * @param {'joined'|'tiles'} variant Hairline grid, or separate tiles.
23
+ * @param {string} minWidth Narrowest a column may get before the grid
24
+ * drops one, e.g. `'10rem'`. Writes
25
+ * `--vs-stat-min` inline — so pass this OR
26
+ * set the property from your own stylesheet
27
+ * (which is what you want if the value
28
+ * changes at a breakpoint), never both.
29
+ * @param {string} columns A whole `grid-template-columns` value,
30
+ * for the track lists auto-fit cannot say.
31
+ * Writes `--vs-stat-columns`.
32
+ */
33
+ const StatStrip = ({
34
+ variant = 'joined',
35
+ minWidth,
36
+ columns,
37
+ className,
38
+ style,
39
+ children,
40
+ ...rest
41
+ }) => {
42
+ const vars = { ...style };
43
+
44
+ if (minWidth) vars['--vs-stat-min'] = minWidth;
45
+ if (columns) vars['--vs-stat-columns'] = columns;
46
+
47
+ return (
48
+ <div
49
+ className={[styles.strip, styles[variant], className]
50
+ .filter(Boolean)
51
+ .join(' ')}
52
+ style={vars}
53
+ {...rest}
54
+ >
55
+ {children}
56
+ </div>
57
+ );
58
+ };
59
+
60
+ const TONES = {
61
+ accent: styles.toneAccent,
62
+ heading: styles.toneHeading,
63
+ warn: styles.toneWarn,
64
+ danger: styles.toneDanger,
65
+ };
66
+
67
+ const EMPHASIS = {
68
+ warn: styles.emphasisWarn,
69
+ danger: styles.emphasisDanger,
70
+ };
71
+
72
+ /**
73
+ * One figure: a label, a value, and a line of small print under it.
74
+ *
75
+ * Both of the shapes the CRM screens use are here, and they are the same three
76
+ * slots read two ways — "count + note" (`142` / *across 12 blocks*) and "value +
77
+ * share" (`$2,410` `/mo` / *$28,920 a year*). The unit is separate from the
78
+ * value so it can stay at small print rather than being sized up with the
79
+ * figure.
80
+ *
81
+ * `onClick` (or `href`) makes the whole cell the target rather than putting a
82
+ * link inside it — on the tablet this CRM is driven on, that is the difference
83
+ * between one tap and aiming at a word.
84
+ *
85
+ * @param {node} label Small caps over the figure.
86
+ * @param {node} value The figure itself, already formatted.
87
+ * @param {node} unit Small print immediately after the value.
88
+ * @param {node} note The line under it.
89
+ * @param {'accent'|'heading'|'warn'|'danger'} tone
90
+ * Colours the FIGURE. Pass it only while the figure
91
+ * is worth colouring: a permanently red tile is a
92
+ * tile nobody reads.
93
+ * @param {'warn'|'danger'} emphasis
94
+ * Skins the whole tile. `tiles` variant only.
95
+ * @param {'warn'} noteTone Weights the note instead of the figure.
96
+ */
97
+ const Stat = ({
98
+ label,
99
+ value,
100
+ unit,
101
+ note,
102
+ tone,
103
+ emphasis,
104
+ noteTone,
105
+ onClick,
106
+ href,
107
+ className,
108
+ valueClassName,
109
+ noteClassName,
110
+ children,
111
+ ...rest
112
+ }) => {
113
+ const interactive = Boolean(onClick || href);
114
+ const Element = href ? 'a' : onClick ? 'button' : 'div';
115
+
116
+ const body = children ?? (
117
+ <>
118
+ {label !== undefined && label !== null && (
119
+ <span className={styles.label}>{label}</span>
120
+ )}
121
+
122
+ {value !== undefined && value !== null && (
123
+ <span
124
+ className={[styles.value, TONES[tone], valueClassName]
125
+ .filter(Boolean)
126
+ .join(' ')}
127
+ >
128
+ {value}
129
+ {unit ? <span className={styles.unit}>{unit}</span> : null}
130
+ </span>
131
+ )}
132
+
133
+ {note !== undefined && note !== null && note !== '' && (
134
+ <span
135
+ className={[
136
+ styles.note,
137
+ noteTone === 'warn' ? styles.noteWarn : null,
138
+ noteClassName,
139
+ ]
140
+ .filter(Boolean)
141
+ .join(' ')}
142
+ >
143
+ {note}
144
+ </span>
145
+ )}
146
+ </>
147
+ );
148
+
149
+ return (
150
+ <Element
151
+ className={[
152
+ styles.stat,
153
+ interactive ? styles.interactive : null,
154
+ EMPHASIS[emphasis],
155
+ className,
156
+ ]
157
+ .filter(Boolean)
158
+ .join(' ')}
159
+ {...(Element === 'button' ? { type: 'button' } : {})}
160
+ {...(href ? { href } : {})}
161
+ {...(onClick ? { onClick } : {})}
162
+ {...rest}
163
+ >
164
+ {body}
165
+ </Element>
166
+ );
167
+ };
168
+
169
+ export { Stat };
170
+ export default StatStrip;
@@ -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;
@@ -157,6 +157,61 @@ export const toE164 = (value, countryCode = '61') => {
157
157
  return null;
158
158
  };
159
159
 
160
+ /* ------------------------------------------------------- dialling a number */
161
+
162
+ /**
163
+ * A number as a person would write it, or '' when there is nothing to show.
164
+ *
165
+ * A thin wrapper on `normaliseNumberForDisplay`, and the reason it exists is the
166
+ * empty case: the formatter returns whatever it was given when it cannot parse
167
+ * it, including `undefined`, and a display slot wants a string it can render.
168
+ *
169
+ * Use THIS rather than the data grid's `renderPhoneColumn` for anything that is
170
+ * not a grid cell. The grid's formatter collapses `+61` through the guard
171
+ * `/^61[2-9]\d{8}$/`, which excludes `4` and therefore silently fails to format
172
+ * the one case a contact screen cares about most — a stored `+61412345678`
173
+ * mobile falls through to its generic branch and renders unformatted.
174
+ */
175
+ export const displayNumber = (value) => normaliseNumberForDisplay(value) || '';
176
+
177
+ /**
178
+ * The `href` for a phone number, or null when it is not dialable.
179
+ *
180
+ * `tel:` wants an unambiguous number, not a pretty one: a tablet handing
181
+ * `(08) 9375 2549` to a dialler is handing it a string with brackets in it. So
182
+ * E.164 is preferred, and `toE164` produces it.
183
+ *
184
+ * THE 13 / 1300 / 1800 CAVEAT. `toE164` answers null for the Australian service
185
+ * prefixes, because they genuinely have no country-code form. They are still
186
+ * perfectly dialable, so rather than drop the link they fall back to the bare
187
+ * digits, which is what a handset expects for them.
188
+ *
189
+ * Anything with no digits at all — "ask reception", and it IS in this column in
190
+ * real data — gets no link, because an anchor that dials nothing is worse than
191
+ * plain text.
192
+ */
193
+ export const telHref = (value) => {
194
+ const e164 = toE164(value);
195
+
196
+ if (e164) return `tel:${e164}`;
197
+
198
+ const digits = String(value ?? '').replace(/[^\d]/g, '');
199
+
200
+ return /^1[38]00\d{6}$|^13\d{4}$/.test(digits) ? `tel:${digits}` : null;
201
+ };
202
+
203
+ /**
204
+ * As above, for the text action on a mobile.
205
+ *
206
+ * No service-prefix fallback here, deliberately: 13/1300/1800 numbers cannot
207
+ * receive an SMS at all, so a link to one is a promise that would be broken.
208
+ */
209
+ export const smsHref = (value) => {
210
+ const e164 = toE164(value);
211
+
212
+ return e164 ? `sms:${e164}` : null;
213
+ };
214
+
160
215
  /* ----------------------------------------------------------------- segments */
161
216
 
162
217
  /**
@@ -1159,6 +1214,7 @@ export default {
1159
1214
  dayLabel,
1160
1215
  describeRecipient,
1161
1216
  describeTransport,
1217
+ displayNumber,
1162
1218
  fillTemplate,
1163
1219
  groupMessagesByDay,
1164
1220
  initialsFor,
@@ -1174,9 +1230,11 @@ export default {
1174
1230
  RECIPIENT_HINTS,
1175
1231
  relativeTime,
1176
1232
  segmentCount,
1233
+ smsHref,
1177
1234
  splitPersonName,
1178
1235
  statusLabel,
1179
1236
  sumUnread,
1237
+ telHref,
1180
1238
  threadDisplayName,
1181
1239
  threadIdFromSearch,
1182
1240
  threadSubtitle,
@@ -0,0 +1,43 @@
1
+ @use 'surface' as *;
2
+
3
+ /**
4
+ * AnchoredMenu — the row menu, portalled out of everything that would clip it.
5
+ *
6
+ * It renders into `document.body`, which means it is OUTSIDE the page's own
7
+ * token block: a `--vs-line` declared on `.page` does not inherit to a sibling
8
+ * of `<div id="app">`. So this is the one module in the set that reads the app's
9
+ * tokens directly and offers no page-level override — a menu is neutral chrome
10
+ * anyway, and a per-page menu skin was never a thing anybody wanted.
11
+ *
12
+ * `z-index` sits above a page's own furniture and below the modal layer (1051+
13
+ * in this library's stack), because a menu must not float over the sheet a
14
+ * click on it opens.
15
+ */
16
+
17
+ .scrim {
18
+ position: fixed;
19
+ inset: 0;
20
+ z-index: 900;
21
+ padding: 0;
22
+ background: transparent;
23
+ border: 0;
24
+ cursor: default;
25
+ }
26
+
27
+ .menu {
28
+ @include surface-tokens;
29
+
30
+ position: fixed;
31
+ z-index: 901;
32
+ display: flex;
33
+ flex-direction: column;
34
+ min-width: 11rem;
35
+ max-width: min(20rem, calc(100vw - 1rem));
36
+ padding: 4px;
37
+ background: var(--pr-surface);
38
+ border: 1px solid var(--pr-line);
39
+ border-radius: var(--btn-br, 5px);
40
+ box-shadow:
41
+ 0 1px 2px rgb(0 0 0 / 8%),
42
+ 0 8px 24px -12px rgb(0 0 0 / 35%);
43
+ }