@visns-studio/visns-components 6.20.0 → 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.
- package/package.json +1 -1
- package/src/components/generic/GenericIndex.jsx +9 -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/sms/smsHelpers.js +58 -0
- package/src/components/styles/AnchoredMenu.module.scss +43 -0
- package/src/components/styles/BandedList.module.scss +278 -0
- package/src/components/styles/FactsBand.module.scss +108 -0
- package/src/components/styles/PageHeader.module.scss +98 -0
- package/src/components/styles/ScrollRegion.module.scss +20 -0
- package/src/components/styles/StatStrip.module.scss +183 -0
- package/src/components/styles/TabStrip.module.scss +96 -0
- package/src/components/styles/_surface.scss +49 -0
- package/src/index.js +47 -0
|
@@ -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
|
+
}
|