@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 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.0",
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) ? titleInfo : null;
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;