@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,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;
@@ -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;