@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
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.21.0",
|
|
97
97
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
98
98
|
"main": "src/index.js",
|
|
99
99
|
"files": [
|
|
@@ -301,6 +301,12 @@ function GenericIndex({
|
|
|
301
301
|
themeType,
|
|
302
302
|
userProfile,
|
|
303
303
|
showTitle = true,
|
|
304
|
+
/* Whether the embedded "N total" count (and tableInfo headings) render
|
|
305
|
+
when showTitle is off. Defaults on — normally it is the only place
|
|
306
|
+
the row count prints above the fold. A host that embeds the grid
|
|
307
|
+
under its own summary strip is already printing that figure and
|
|
308
|
+
turns this off rather than saying the same number twice. */
|
|
309
|
+
showTotal = true,
|
|
304
310
|
tabLayout = 'rail',
|
|
305
311
|
}) {
|
|
306
312
|
const gridRef = useRef(null);
|
|
@@ -813,7 +819,9 @@ function GenericIndex({
|
|
|
813
819
|
// An empty box is worth printing inside the title bar (it holds the bar's
|
|
814
820
|
// right-hand column open) and not worth printing anywhere else.
|
|
815
821
|
const embeddedInfo =
|
|
816
|
-
!showTitle && (total > 0 || hasHeadings)
|
|
822
|
+
!showTitle && showTotal && (total > 0 || hasHeadings)
|
|
823
|
+
? titleInfo
|
|
824
|
+
: null;
|
|
817
825
|
|
|
818
826
|
// Lay the filter menu out as a horizontal strip rather than a rail. The
|
|
819
827
|
// conditions are written out in `shouldUnderlineTabs`, which is where they
|
|
@@ -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;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import useContainedHeight from './useContainedHeight';
|
|
4
|
+
import styles from '../styles/BandedList.module.scss';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The container a run of bands sits in.
|
|
8
|
+
*
|
|
9
|
+
* `stack` is a column of independent bands with air between them. `card` is one
|
|
10
|
+
* card holding all of them, for `flush` bands — a continuous list broken up by
|
|
11
|
+
* dividers rather than a set of separate panels.
|
|
12
|
+
*
|
|
13
|
+
* `scroll` makes the LIST scroll instead of the page, so the summary strip, the
|
|
14
|
+
* search box and the view tabs above it stay on the screen while four hundred
|
|
15
|
+
* rows move underneath. Its height is measured, not declared — see
|
|
16
|
+
* `useContainedHeight`. Pair it with `sticky` on the bands so their headings
|
|
17
|
+
* survive the scroll, and remember that a scrolling region CLIPS: a row's `⋯`
|
|
18
|
+
* popover has to be an `AnchoredMenu`, which portals out of it.
|
|
19
|
+
*
|
|
20
|
+
* The same treatment for a list this component does not draw — an embedded data
|
|
21
|
+
* grid, say — is `ScrollRegion`, which is the identical mechanism without the
|
|
22
|
+
* bands.
|
|
23
|
+
*
|
|
24
|
+
* @param {'stack'|'card'} variant
|
|
25
|
+
* @param {boolean} scroll Contain the scrolling to the rows.
|
|
26
|
+
* @param {number} gutter Space to leave under the region, in pixels.
|
|
27
|
+
* @param {*} remeasure Anything whose change alters what is above it.
|
|
28
|
+
*/
|
|
29
|
+
const BandedList = ({
|
|
30
|
+
variant = 'stack',
|
|
31
|
+
scroll = false,
|
|
32
|
+
gutter = 24,
|
|
33
|
+
remeasure = null,
|
|
34
|
+
className,
|
|
35
|
+
style,
|
|
36
|
+
children,
|
|
37
|
+
...rest
|
|
38
|
+
}) => {
|
|
39
|
+
const ref = useRef(null);
|
|
40
|
+
const height = useContainedHeight(ref, gutter, remeasure, scroll);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={[
|
|
46
|
+
styles.board,
|
|
47
|
+
variant === 'card' ? styles.boardCard : null,
|
|
48
|
+
scroll ? styles.scroll : null,
|
|
49
|
+
className,
|
|
50
|
+
]
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.join(' ')}
|
|
53
|
+
style={
|
|
54
|
+
scroll && height !== null ? { ...style, maxHeight: height } : style
|
|
55
|
+
}
|
|
56
|
+
{...rest}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const TONES = {
|
|
64
|
+
danger: styles.toneDanger,
|
|
65
|
+
warn: styles.toneWarn,
|
|
66
|
+
neutral: styles.toneNeutral,
|
|
67
|
+
quiet: styles.toneQuiet,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One band: a heading with a count, a note or a subtotal on the right, an
|
|
72
|
+
* urgency on the left edge, and the host's own rows underneath.
|
|
73
|
+
*
|
|
74
|
+
* `collapseWhenEmpty` is the behaviour worth knowing about. A board built from a
|
|
75
|
+
* FIXED list of bands — overdue, this week, this month, later — has to be able
|
|
76
|
+
* to draw only the ones that have something in them, or a quiet week renders as
|
|
77
|
+
* five headings over nothing. Pass `count` and the band takes itself off the
|
|
78
|
+
* page at zero. A band that genuinely wants to say "none" sets
|
|
79
|
+
* `collapseWhenEmpty={false}` and puts the sentence in its children.
|
|
80
|
+
*
|
|
81
|
+
* @param {'card'|'plain'|'flush'} variant See BandedList.module.scss.
|
|
82
|
+
* @param {'danger'|'warn'|'neutral'|'quiet'} tone The left edge. `card` only.
|
|
83
|
+
* @param {node} title The heading.
|
|
84
|
+
* @param {number} count Drawn beside the heading, and the thing
|
|
85
|
+
* `collapseWhenEmpty` reads.
|
|
86
|
+
* @param {node} meta Right-hand slot — a subtotal, a chip.
|
|
87
|
+
* @param {node} note Right-hand small print, after `meta`.
|
|
88
|
+
* @param {boolean} sticky Pins the heading while its rows scroll.
|
|
89
|
+
* @param {string} titleAs Element for the heading. `h2` by default;
|
|
90
|
+
* a letter divider in a filing drawer is not
|
|
91
|
+
* a heading and should say `span`.
|
|
92
|
+
*/
|
|
93
|
+
const Band = ({
|
|
94
|
+
variant = 'card',
|
|
95
|
+
tone = 'neutral',
|
|
96
|
+
title,
|
|
97
|
+
count,
|
|
98
|
+
meta,
|
|
99
|
+
note,
|
|
100
|
+
sticky = false,
|
|
101
|
+
collapseWhenEmpty = true,
|
|
102
|
+
titleAs: TitleElement = 'h2',
|
|
103
|
+
className,
|
|
104
|
+
headClassName,
|
|
105
|
+
children,
|
|
106
|
+
...rest
|
|
107
|
+
}) => {
|
|
108
|
+
if (collapseWhenEmpty && count === 0) return null;
|
|
109
|
+
|
|
110
|
+
const hasHead =
|
|
111
|
+
title !== undefined ||
|
|
112
|
+
count !== undefined ||
|
|
113
|
+
meta !== undefined ||
|
|
114
|
+
note !== undefined;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<section
|
|
118
|
+
className={[styles.band, styles[variant], TONES[tone], className]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(' ')}
|
|
121
|
+
{...rest}
|
|
122
|
+
>
|
|
123
|
+
{hasHead && (
|
|
124
|
+
<header
|
|
125
|
+
className={[
|
|
126
|
+
styles.head,
|
|
127
|
+
sticky ? styles.headSticky : null,
|
|
128
|
+
headClassName,
|
|
129
|
+
]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join(' ')}
|
|
132
|
+
>
|
|
133
|
+
<TitleElement className={styles.title}>
|
|
134
|
+
{title}
|
|
135
|
+
{count !== undefined && count !== null && (
|
|
136
|
+
<span className={styles.count}>{count}</span>
|
|
137
|
+
)}
|
|
138
|
+
</TitleElement>
|
|
139
|
+
|
|
140
|
+
{(meta !== undefined && meta !== null) ||
|
|
141
|
+
(note !== undefined && note !== null) ? (
|
|
142
|
+
<span className={styles.meta}>
|
|
143
|
+
{meta}
|
|
144
|
+
{note ? (
|
|
145
|
+
<span className={styles.note}>{note}</span>
|
|
146
|
+
) : null}
|
|
147
|
+
</span>
|
|
148
|
+
) : null}
|
|
149
|
+
</header>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{children}
|
|
153
|
+
</section>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export { Band };
|
|
158
|
+
export default BandedList;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import styles from '../styles/FactsBand.module.scss';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The band of facts that sits under a record's title and stays there.
|
|
7
|
+
*
|
|
8
|
+
* A detail screen built out of tabs has one structural problem: the answer to
|
|
9
|
+
* "who do I ring / is anything on fire here" is on the fourth tab, and opening
|
|
10
|
+
* any other tab takes it off the screen. The band is the fix — the handful of
|
|
11
|
+
* facts somebody opened the record FOR, above the strip, unaffected by which tab
|
|
12
|
+
* is showing.
|
|
13
|
+
*
|
|
14
|
+
* IT SHOULD ALWAYS RENDER ITS CONTAINER, including while loading and when the
|
|
15
|
+
* fetch fails — hence `state`. A band that returns null makes the tab strip jump
|
|
16
|
+
* by 70px on every page load, and in a layout that orders its children by
|
|
17
|
+
* position (the CRM's customer record does exactly this) a missing band promotes
|
|
18
|
+
* the tab strip into the title's place and draws the page upside down.
|
|
19
|
+
*
|
|
20
|
+
* @param {node} state Renders one full-width sentence instead of the
|
|
21
|
+
* cells — "Loading…", or why it could not load.
|
|
22
|
+
* @param {string} minWidth Narrowest a cell may get before the grid drops a
|
|
23
|
+
* column. Writes `--vs-facts-min` inline; pass this
|
|
24
|
+
* OR set the property from a stylesheet, not both.
|
|
25
|
+
* @param {string} columns A whole `grid-template-columns` value, for what
|
|
26
|
+
* auto-fit cannot express. Writes
|
|
27
|
+
* `--vs-facts-columns`.
|
|
28
|
+
*/
|
|
29
|
+
const FactsBand = ({
|
|
30
|
+
state,
|
|
31
|
+
minWidth,
|
|
32
|
+
columns,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
children,
|
|
36
|
+
...rest
|
|
37
|
+
}) => {
|
|
38
|
+
const vars = { ...style };
|
|
39
|
+
|
|
40
|
+
if (minWidth) vars['--vs-facts-min'] = minWidth;
|
|
41
|
+
if (columns) vars['--vs-facts-columns'] = columns;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={[styles.band, className].filter(Boolean).join(' ')}
|
|
46
|
+
style={vars}
|
|
47
|
+
{...rest}
|
|
48
|
+
>
|
|
49
|
+
{state !== undefined && state !== null ? (
|
|
50
|
+
<span className={styles.state}>{state}</span>
|
|
51
|
+
) : (
|
|
52
|
+
children
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const TONES = {
|
|
59
|
+
accent: styles.toneAccent,
|
|
60
|
+
warn: styles.toneWarn,
|
|
61
|
+
danger: styles.toneDanger,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One cell of the band.
|
|
66
|
+
*
|
|
67
|
+
* `value` and `figure` are the two ways a fact reads, and a cell takes one or
|
|
68
|
+
* the other: a name, a suburb or a phone number is TEXT (clipped, semibold, and
|
|
69
|
+
* a link when `href` is given), while a count is a FIGURE (large, tabular, so
|
|
70
|
+
* the eye finds the numbers across the row). Passing both is legal and draws
|
|
71
|
+
* both, which is occasionally what a cell wants; passing `children` replaces the
|
|
72
|
+
* lot for the cells that are genuinely bespoke.
|
|
73
|
+
*
|
|
74
|
+
* @param {node} label Small caps over the fact.
|
|
75
|
+
* @param {node} value Read as text. Becomes an `<a>` when `href` is set.
|
|
76
|
+
* @param {node} figure Read as a number.
|
|
77
|
+
* @param {node} note The line under it.
|
|
78
|
+
* @param {'accent'|'warn'|'danger'} tone Colours the figure or the value.
|
|
79
|
+
* @param {string} href Makes the value a link — `tel:`, `mailto:`, a route.
|
|
80
|
+
*/
|
|
81
|
+
const Fact = ({
|
|
82
|
+
label,
|
|
83
|
+
value,
|
|
84
|
+
figure,
|
|
85
|
+
note,
|
|
86
|
+
tone,
|
|
87
|
+
href,
|
|
88
|
+
className,
|
|
89
|
+
valueClassName,
|
|
90
|
+
noteClassName,
|
|
91
|
+
children,
|
|
92
|
+
...rest
|
|
93
|
+
}) => {
|
|
94
|
+
const ValueElement = href ? 'a' : 'span';
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<span
|
|
98
|
+
className={[styles.cell, className].filter(Boolean).join(' ')}
|
|
99
|
+
{...rest}
|
|
100
|
+
>
|
|
101
|
+
{label !== undefined && label !== null && (
|
|
102
|
+
<span className={styles.label}>{label}</span>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{children ?? (
|
|
106
|
+
<>
|
|
107
|
+
{figure !== undefined && figure !== null && (
|
|
108
|
+
<span
|
|
109
|
+
className={[styles.figure, TONES[tone], valueClassName]
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join(' ')}
|
|
112
|
+
>
|
|
113
|
+
{figure}
|
|
114
|
+
</span>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{value !== undefined && value !== null && (
|
|
118
|
+
<ValueElement
|
|
119
|
+
className={[
|
|
120
|
+
styles.value,
|
|
121
|
+
figure === undefined || figure === null
|
|
122
|
+
? TONES[tone]
|
|
123
|
+
: null,
|
|
124
|
+
valueClassName,
|
|
125
|
+
]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join(' ')}
|
|
128
|
+
{...(href ? { href } : {})}
|
|
129
|
+
>
|
|
130
|
+
{value}
|
|
131
|
+
</ValueElement>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{note !== undefined && note !== null && note !== '' && (
|
|
135
|
+
<span
|
|
136
|
+
className={[styles.note, noteClassName]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join(' ')}
|
|
139
|
+
>
|
|
140
|
+
{note}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</span>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export { Fact };
|
|
150
|
+
export default FactsBand;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import styles from '../styles/PageHeader.module.scss';
|
|
4
|
+
|
|
5
|
+
const ALIGN = {
|
|
6
|
+
center: styles.alignCenter,
|
|
7
|
+
start: styles.alignStart,
|
|
8
|
+
end: styles.alignEnd,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The row at the top of a screen: what this page is, and what you can do to it.
|
|
13
|
+
*
|
|
14
|
+
* Six screens in the CRM had written this out by hand and had already drifted —
|
|
15
|
+
* three alignments, two title sizes, two different paddings, and one of them was
|
|
16
|
+
* missing the `nav { min-width: 0 }` that stops a breadcrumb shoving the buttons
|
|
17
|
+
* off the row. It is one component now.
|
|
18
|
+
*
|
|
19
|
+
* The left side takes either the title/subtitle pair or `children`, and pages
|
|
20
|
+
* that lead with a breadcrumb pass it as `children`. The title's size is a token
|
|
21
|
+
* (`--vs-header-title-size`) rather than a prop: a dashboard that wants a 1.4rem
|
|
22
|
+
* heading wants it on every one of its screens, which is a stylesheet decision,
|
|
23
|
+
* not a per-call one.
|
|
24
|
+
*
|
|
25
|
+
* @param {node} title Rendered as the page's `h1`.
|
|
26
|
+
* @param {node} subtitle One line under it.
|
|
27
|
+
* @param {node} back Rendered above the title — a "‹ All clients" link.
|
|
28
|
+
* @param {node} actions Hard right. Buttons, a picker, a refresh.
|
|
29
|
+
* @param {node} children Replaces the title/subtitle block outright.
|
|
30
|
+
* @param {'center'|'start'|'end'} align How the two halves line up.
|
|
31
|
+
* @param {boolean} gutter The quarter-rem of air a breadcrumb sits in.
|
|
32
|
+
* @param {boolean} sticky Pins the row while the page scrolls.
|
|
33
|
+
* @param {string} titleAs Element for the title. `h1` by default — pass
|
|
34
|
+
* something else on a page that already has one.
|
|
35
|
+
*/
|
|
36
|
+
const PageHeader = ({
|
|
37
|
+
title,
|
|
38
|
+
subtitle,
|
|
39
|
+
back,
|
|
40
|
+
actions,
|
|
41
|
+
align = 'center',
|
|
42
|
+
gutter = false,
|
|
43
|
+
sticky = false,
|
|
44
|
+
titleAs: TitleElement = 'h1',
|
|
45
|
+
className,
|
|
46
|
+
mainClassName,
|
|
47
|
+
actionsClassName,
|
|
48
|
+
children,
|
|
49
|
+
...rest
|
|
50
|
+
}) => (
|
|
51
|
+
<div
|
|
52
|
+
className={[
|
|
53
|
+
styles.header,
|
|
54
|
+
ALIGN[align],
|
|
55
|
+
gutter ? styles.gutter : null,
|
|
56
|
+
sticky ? styles.sticky : null,
|
|
57
|
+
className,
|
|
58
|
+
]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(' ')}
|
|
61
|
+
{...rest}
|
|
62
|
+
>
|
|
63
|
+
<div className={[styles.main, mainClassName].filter(Boolean).join(' ')}>
|
|
64
|
+
{back}
|
|
65
|
+
|
|
66
|
+
{children ?? (
|
|
67
|
+
<>
|
|
68
|
+
{title !== undefined && title !== null && (
|
|
69
|
+
<TitleElement className={styles.title}>{title}</TitleElement>
|
|
70
|
+
)}
|
|
71
|
+
{subtitle !== undefined && subtitle !== null && (
|
|
72
|
+
<p className={styles.subtitle}>{subtitle}</p>
|
|
73
|
+
)}
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{actions ? (
|
|
79
|
+
<div
|
|
80
|
+
className={[styles.actions, actionsClassName]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' ')}
|
|
83
|
+
>
|
|
84
|
+
{actions}
|
|
85
|
+
</div>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export default PageHeader;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import useContainedHeight from './useContainedHeight';
|
|
4
|
+
import styles from '../styles/ScrollRegion.module.scss';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A region that scrolls so the page does not.
|
|
8
|
+
*
|
|
9
|
+
* Wrap the LIST half of a screen whose top half is chrome — a summary strip, a
|
|
10
|
+
* search box, a row of view tabs — and the chrome stays on the screen while the
|
|
11
|
+
* rows move under it. On the tablet this CRM is driven on that is the whole
|
|
12
|
+
* difference between filtering a list and hunting for the filter.
|
|
13
|
+
*
|
|
14
|
+
* The height is measured rather than declared; see `useContainedHeight` for why
|
|
15
|
+
* a `calc(100vh - 22rem)` is wrong on half the states of a real page.
|
|
16
|
+
*
|
|
17
|
+
* TWO THINGS IT DOES NOT DO, both on purpose:
|
|
18
|
+
*
|
|
19
|
+
* It does not set `overscroll-behavior`. Trapping the scroll at the end of the
|
|
20
|
+
* list is what makes a long page feel broken on a touchscreen; when this
|
|
21
|
+
* region has finished, the page should carry on.
|
|
22
|
+
*
|
|
23
|
+
* It does not clip popovers for you. A `⋯` menu inside a scroll region is
|
|
24
|
+
* inside a clipping ancestor and WILL be cut off — use `AnchoredMenu`, which
|
|
25
|
+
* renders through a portal and is not affected.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} gutter Space to leave beneath the region, in pixels.
|
|
28
|
+
* @param {*} remeasure Anything whose change alters what is above it.
|
|
29
|
+
*/
|
|
30
|
+
const ScrollRegion = ({
|
|
31
|
+
gutter = 24,
|
|
32
|
+
remeasure = null,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
children,
|
|
36
|
+
...rest
|
|
37
|
+
}) => {
|
|
38
|
+
const ref = useRef(null);
|
|
39
|
+
const height = useContainedHeight(ref, gutter, remeasure);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
className={[styles.region, className].filter(Boolean).join(' ')}
|
|
45
|
+
style={height === null ? style : { ...style, maxHeight: height }}
|
|
46
|
+
{...rest}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default ScrollRegion;
|