@wtfalch/design 0.3.2 → 0.5.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/dist/components/Command.d.ts +57 -0
- package/dist/components/Command.js +36 -0
- package/dist/components/Icon.d.ts +17 -4
- package/dist/components/Identity.d.ts +40 -0
- package/dist/components/Identity.js +36 -0
- package/dist/components/Menu.d.ts +62 -0
- package/dist/components/Menu.js +20 -0
- package/dist/components/Modal.d.ts +20 -1
- package/dist/components/Modal.js +11 -3
- package/dist/components/Pagination.d.ts +52 -0
- package/dist/components/Pagination.js +101 -0
- package/dist/components/Popover.d.ts +46 -0
- package/dist/components/Popover.js +5 -0
- package/dist/components/ScrollArea.d.ts +66 -0
- package/dist/components/ScrollArea.js +47 -0
- package/dist/components/SplitPane.d.ts +57 -0
- package/dist/components/SplitPane.js +103 -0
- package/dist/components/Toggle.js +26 -11
- package/dist/components/iconNames.d.ts +1 -1
- package/dist/components/iconNames.js +25 -0
- package/dist/components/icons.js +97 -0
- package/dist/components/initials.d.ts +36 -0
- package/dist/components/initials.js +51 -0
- package/dist/components/pageWindow.d.ts +21 -0
- package/dist/components/pageWindow.js +46 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +22 -0
- package/dist/styles/index.css +792 -0
- package/dist/tf.css +792 -0
- package/dist/valet.css +792 -0
- package/package.json +10 -13
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box that scrolls, and says so.
|
|
3
|
+
*
|
|
4
|
+
* **A list that overflows with no mark at its edge reads as a list that ended.**
|
|
5
|
+
* That is the bug this exists to stop. A thread list showing eleven of four
|
|
6
|
+
* hundred conversations looks exactly like a mailbox with eleven conversations
|
|
7
|
+
* in it: the content runs to the bottom edge of its box and stops, and on a
|
|
8
|
+
* trackpad there is no scrollbar to contradict it. People do not scroll things
|
|
9
|
+
* they have no reason to believe continue.
|
|
10
|
+
*
|
|
11
|
+
* So the edges are drawn. A soft fade appears at whichever end has more behind
|
|
12
|
+
* it and goes when that end is reached, which means the affordance is *absent*
|
|
13
|
+
* exactly when the list really has ended -- the one case where a permanent
|
|
14
|
+
* gradient would lie.
|
|
15
|
+
*
|
|
16
|
+
* **The scrollbar is the theme's, not the platform's.** Same argument as
|
|
17
|
+
* `Select`: an overlay scrollbar drawn by macOS in the platform's grey, over a
|
|
18
|
+
* Night panel, is the one part of the surface the design does not reach. It is
|
|
19
|
+
* also 15px of white on Windows, which is the widest single element in a mail
|
|
20
|
+
* sidebar. `scrollbar-color` covers Firefox and `::-webkit-scrollbar` the rest;
|
|
21
|
+
* both are in `scrollarea.css`.
|
|
22
|
+
*
|
|
23
|
+
* **`overscroll-behavior: contain`**, because a scroll that reaches the end of
|
|
24
|
+
* a pane and carries on into the page behind it is how a thread list scrolls
|
|
25
|
+
* the whole application away. In a split view that is disorienting rather than
|
|
26
|
+
* merely untidy.
|
|
27
|
+
*
|
|
28
|
+
* **Pad the content, not the box.** Padding on the scroller itself insets the
|
|
29
|
+
* fade and the scrollbar along with everything else, so the gradient stops
|
|
30
|
+
* short of the sides and the bar floats in from the edge; and vertical padding
|
|
31
|
+
* on a box whose rows already have their own reads as extra space above the
|
|
32
|
+
* first item, because it is. Put the inset on the child.
|
|
33
|
+
*
|
|
34
|
+
* Not a virtualiser. Ten thousand rows still cost ten thousand nodes; this
|
|
35
|
+
* decides how the box behaves, not how much is in it.
|
|
36
|
+
*/
|
|
37
|
+
export interface Props {
|
|
38
|
+
/** Which way it scrolls. `y` is the common case and the default; `both` is
|
|
39
|
+
* for a table too wide as well as too tall. */
|
|
40
|
+
axis?: 'x' | 'y' | 'both';
|
|
41
|
+
/**
|
|
42
|
+
* Draw the fade at a vertical edge with more behind it. On by default.
|
|
43
|
+
*
|
|
44
|
+
* Vertical only: the misreading is "the list ended", which is a claim about
|
|
45
|
+
* the bottom edge. A word sliced in half at the right edge is already its
|
|
46
|
+
* own affordance, so `axis="x"` draws no fade whatever this says.
|
|
47
|
+
*
|
|
48
|
+
* Turn it off where the content ends in something solid -- a sticky footer,
|
|
49
|
+
* cards on a coloured ground -- because the gradient is mixed towards
|
|
50
|
+
* `--panel` and over anything else it reads as a smudge.
|
|
51
|
+
*/
|
|
52
|
+
fade?: boolean;
|
|
53
|
+
/** Removes the visible scrollbar and keeps every other behaviour, for a
|
|
54
|
+
* strip that is scrolled by a control beside it rather than by dragging. */
|
|
55
|
+
hideBar?: boolean;
|
|
56
|
+
className?: string;
|
|
57
|
+
children: React.ReactNode;
|
|
58
|
+
/** The scrolling element, for a caller that has to drive it -- scrolling a
|
|
59
|
+
* newly selected row into view, or restoring a position. */
|
|
60
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
61
|
+
/** Names the region when it is one a screen reader should be able to reach
|
|
62
|
+
* directly. A scroll box that is keyboard-focusable owes an accessible
|
|
63
|
+
* name; one that is not owes nothing, so this is optional on purpose. */
|
|
64
|
+
label?: string;
|
|
65
|
+
}
|
|
66
|
+
export default function ScrollArea({ axis, fade, hideBar, className, children, ref, label, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
export default function ScrollArea({ axis = 'y', fade = true, hideBar = false, className, children, ref, label, }) {
|
|
4
|
+
const own = useRef(null);
|
|
5
|
+
/* The edge state lives in data attributes rather than React state.
|
|
6
|
+
|
|
7
|
+
This runs on every scroll frame. Setting state there re-renders the
|
|
8
|
+
subtree -- which, for the thing this is usually wrapped around, is the
|
|
9
|
+
whole list -- sixty times a second while a finger is on the trackpad.
|
|
10
|
+
Writing two attributes touches one element and never re-renders, and CSS
|
|
11
|
+
reads them. */
|
|
12
|
+
const measure = useCallback(() => {
|
|
13
|
+
const el = own.current;
|
|
14
|
+
if (!el)
|
|
15
|
+
return;
|
|
16
|
+
const room = 1;
|
|
17
|
+
el.dataset.top = String(el.scrollTop > room);
|
|
18
|
+
el.dataset.bottom = String(el.scrollTop + el.clientHeight < el.scrollHeight - room);
|
|
19
|
+
el.dataset.left = String(el.scrollLeft > room);
|
|
20
|
+
el.dataset.right = String(el.scrollLeft + el.clientWidth < el.scrollWidth - room);
|
|
21
|
+
}, []);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = own.current;
|
|
24
|
+
if (!el)
|
|
25
|
+
return;
|
|
26
|
+
measure();
|
|
27
|
+
el.addEventListener('scroll', measure, { passive: true });
|
|
28
|
+
/* Content arriving is the case a scroll listener alone misses: a mailbox
|
|
29
|
+
that loads its second page grows the box without anyone scrolling, and
|
|
30
|
+
the bottom fade has to appear for content nobody has touched. */
|
|
31
|
+
const resize = new ResizeObserver(measure);
|
|
32
|
+
resize.observe(el);
|
|
33
|
+
for (const child of Array.from(el.children))
|
|
34
|
+
resize.observe(child);
|
|
35
|
+
return () => {
|
|
36
|
+
el.removeEventListener('scroll', measure);
|
|
37
|
+
resize.disconnect();
|
|
38
|
+
};
|
|
39
|
+
}, [measure]);
|
|
40
|
+
return (_jsx("div", { ref: (node) => {
|
|
41
|
+
own.current = node;
|
|
42
|
+
if (typeof ref === 'function')
|
|
43
|
+
ref(node);
|
|
44
|
+
else if (ref)
|
|
45
|
+
ref.current = node;
|
|
46
|
+
}, className: `scroller axis-${axis}${fade ? ' faded' : ''}${hideBar ? ' barless' : ''}${className ? ` ${className}` : ''}`, ...(label ? { tabIndex: 0, role: 'region', 'aria-label': label } : {}), children: children }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two panes and a handle between them.
|
|
3
|
+
*
|
|
4
|
+
* A mail client is a list beside a message, a forum is threads beside a
|
|
5
|
+
* thread, and in both the right width depends on the reader rather than on us:
|
|
6
|
+
* a wide list to scan senders, a narrow one to give the message room. So it is
|
|
7
|
+
* theirs to set, and it has to survive a reload or setting it was a waste of
|
|
8
|
+
* their time.
|
|
9
|
+
*
|
|
10
|
+
* **The handle is a control, not a decoration, and that is the part everybody
|
|
11
|
+
* skips.** Nearly every split view on the web is a `<div>` with a mousedown
|
|
12
|
+
* listener: no role, no tab stop, no way to move it without a pointer. The
|
|
13
|
+
* ARIA pattern for this is `separator` with a value, which makes it a real
|
|
14
|
+
* widget -- focusable, announced as "splitter, 30 percent", and moved with the
|
|
15
|
+
* arrow keys. Home and End take it to its limits, and Enter collapses the
|
|
16
|
+
* first pane and restores it, which is the thing a mouse does by dragging to
|
|
17
|
+
* the edge.
|
|
18
|
+
*
|
|
19
|
+
* **The size is a percentage of the container**, not pixels, because the
|
|
20
|
+
* window is resized more often than the split is. A pixel width chosen on a
|
|
21
|
+
* wide monitor is most of a laptop screen.
|
|
22
|
+
*
|
|
23
|
+
* **The panes are `min-width: 0`.** Without it a flex child refuses to shrink
|
|
24
|
+
* below the intrinsic width of its content, so one long unbroken subject line
|
|
25
|
+
* silently pins the list open and the handle stops halfway with no explanation.
|
|
26
|
+
* That is in `splitpane.css` and it is the reason the file exists.
|
|
27
|
+
*/
|
|
28
|
+
export interface Props {
|
|
29
|
+
/** Exactly two: the first pane and the second. */
|
|
30
|
+
children: [React.ReactNode, React.ReactNode];
|
|
31
|
+
/** `row` puts them side by side with a vertical handle. `column` stacks
|
|
32
|
+
* them. */
|
|
33
|
+
direction?: 'row' | 'column';
|
|
34
|
+
/** The first pane's share, as a percentage, before anyone moves it. */
|
|
35
|
+
defaultSize?: number;
|
|
36
|
+
/** How small and how large the first pane may get, as percentages. The
|
|
37
|
+
* limits are the design's: a list narrower than its own row content is not
|
|
38
|
+
* a smaller list, it is a broken one. */
|
|
39
|
+
min?: number;
|
|
40
|
+
max?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Remember the size under this key, per browser.
|
|
43
|
+
*
|
|
44
|
+
* Without it the handle resets on every reload, which makes moving it feel
|
|
45
|
+
* like it did not work. Storage can throw -- a private window, a browser set
|
|
46
|
+
* to block site data -- so a failure to remember is silent and the default
|
|
47
|
+
* stands.
|
|
48
|
+
*/
|
|
49
|
+
storageKey?: string;
|
|
50
|
+
/** Names the handle. "Sidebar width", not "splitter": the name is announced
|
|
51
|
+
* and should say what moving it does. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** Called as the handle moves, with the first pane's percentage. */
|
|
54
|
+
onResize?: (size: number) => void;
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
export default function SplitPane({ children, direction, defaultSize, min, max, storageKey, label, onResize, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
3
|
+
function clamp(value, min, max) {
|
|
4
|
+
return Math.min(max, Math.max(min, value));
|
|
5
|
+
}
|
|
6
|
+
function remembered(key, fallback) {
|
|
7
|
+
if (!key)
|
|
8
|
+
return fallback;
|
|
9
|
+
try {
|
|
10
|
+
const stored = window.localStorage.getItem(`design.split.${key}`);
|
|
11
|
+
const parsed = stored === null ? Number.NaN : Number.parseFloat(stored);
|
|
12
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export default function SplitPane({ children, direction = 'row', defaultSize = 30, min = 15, max = 70, storageKey, label, onResize, className, }) {
|
|
19
|
+
const [first, second] = children;
|
|
20
|
+
const frame = useRef(null);
|
|
21
|
+
const firstPaneId = useId();
|
|
22
|
+
const [size, setSize] = useState(() => clamp(remembered(storageKey, defaultSize), min, max));
|
|
23
|
+
/* Where the pane was before Enter collapsed it, so Enter puts it back where
|
|
24
|
+
it was rather than at the default. Collapsing and restoring should be the
|
|
25
|
+
same gesture undone, not a reset. */
|
|
26
|
+
const restore = useRef(size);
|
|
27
|
+
const move = useCallback((next) => {
|
|
28
|
+
const bounded = clamp(next, min, max);
|
|
29
|
+
setSize(bounded);
|
|
30
|
+
onResize?.(bounded);
|
|
31
|
+
if (!storageKey)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
window.localStorage.setItem(`design.split.${storageKey}`, String(bounded));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A browser that will not remember is not a reason to refuse to resize.
|
|
38
|
+
}
|
|
39
|
+
}, [min, max, onResize, storageKey]);
|
|
40
|
+
/* Pointer events rather than mouse events, so a pen and a touch drag work
|
|
41
|
+
with the same code; and pointer capture, so a drag that leaves the window
|
|
42
|
+
-- which is what dragging to the edge *is* -- keeps sending moves instead
|
|
43
|
+
of freezing the handle where the cursor left. */
|
|
44
|
+
const onPointerDown = (event) => {
|
|
45
|
+
if (event.button !== 0)
|
|
46
|
+
return;
|
|
47
|
+
const box = frame.current?.getBoundingClientRect();
|
|
48
|
+
if (!box)
|
|
49
|
+
return;
|
|
50
|
+
const handle = event.currentTarget;
|
|
51
|
+
handle.setPointerCapture(event.pointerId);
|
|
52
|
+
const onMove = (e) => {
|
|
53
|
+
const along = direction === 'row' ? e.clientX - box.left : e.clientY - box.top;
|
|
54
|
+
const total = direction === 'row' ? box.width : box.height;
|
|
55
|
+
if (total > 0)
|
|
56
|
+
move((along / total) * 100);
|
|
57
|
+
};
|
|
58
|
+
const onUp = (e) => {
|
|
59
|
+
handle.releasePointerCapture(e.pointerId);
|
|
60
|
+
handle.removeEventListener('pointermove', onMove);
|
|
61
|
+
handle.removeEventListener('pointerup', onUp);
|
|
62
|
+
handle.removeEventListener('pointercancel', onUp);
|
|
63
|
+
document.body.classList.remove('splitting');
|
|
64
|
+
};
|
|
65
|
+
handle.addEventListener('pointermove', onMove);
|
|
66
|
+
handle.addEventListener('pointerup', onUp);
|
|
67
|
+
handle.addEventListener('pointercancel', onUp);
|
|
68
|
+
/* The cursor is set on the body for the duration, because a drag that
|
|
69
|
+
passes over a text pane otherwise flickers to an I-beam the whole way
|
|
70
|
+
across. */
|
|
71
|
+
document.body.classList.add('splitting');
|
|
72
|
+
};
|
|
73
|
+
useEffect(() => () => document.body.classList.remove('splitting'), []);
|
|
74
|
+
const onKeyDown = (event) => {
|
|
75
|
+
const back = direction === 'row' ? 'ArrowLeft' : 'ArrowUp';
|
|
76
|
+
const forward = direction === 'row' ? 'ArrowRight' : 'ArrowDown';
|
|
77
|
+
const step = event.shiftKey ? 10 : 2;
|
|
78
|
+
if (event.key === back)
|
|
79
|
+
move(size - step);
|
|
80
|
+
else if (event.key === forward)
|
|
81
|
+
move(size + step);
|
|
82
|
+
else if (event.key === 'Home')
|
|
83
|
+
move(min);
|
|
84
|
+
else if (event.key === 'End')
|
|
85
|
+
move(max);
|
|
86
|
+
else if (event.key === 'Enter') {
|
|
87
|
+
if (size <= min)
|
|
88
|
+
move(restore.current);
|
|
89
|
+
else {
|
|
90
|
+
restore.current = size;
|
|
91
|
+
move(min);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
return;
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
};
|
|
98
|
+
return (_jsxs("div", { ref: frame, className: `split dir-${direction}${className ? ` ${className}` : ''}`, style: { '--split': `${size}%` }, children: [_jsx("div", { className: "split-pane split-first", id: firstPaneId, children: first }), _jsx("div", { role: "separator", tabIndex: 0, "aria-label": label, "aria-orientation": direction === 'row' ? 'vertical' : 'horizontal', "aria-controls": firstPaneId, "aria-valuenow": Math.round(size), "aria-valuemin": min, "aria-valuemax": max, className: "split-handle", onPointerDown: onPointerDown, onKeyDown: onKeyDown,
|
|
99
|
+
/* Back to where it started. A drag has no undo, and the size is
|
|
100
|
+
remembered, so without this a mis-drag is permanent until you get it
|
|
101
|
+
right by hand. */
|
|
102
|
+
onDoubleClick: () => move(defaultSize), children: _jsx("span", { className: "split-grip", "aria-hidden": "true" }) }), _jsx("div", { className: "split-pane split-second", children: second })] }));
|
|
103
|
+
}
|
|
@@ -65,6 +65,16 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
|
|
|
65
65
|
is stopped at the track a tap on the knob no longer reaches the input by
|
|
66
66
|
itself (measured, in `keyboard.spec.ts`).
|
|
67
67
|
|
|
68
|
+
**The knob goes where the pointer is, not where the pointer has been.**
|
|
69
|
+
This measured the drag as a delta from the knob's resting side, which made
|
|
70
|
+
a click that drifted a few pixels answer the opposite of the click: press
|
|
71
|
+
the far side of an off switch, wander seven pixels, and the delta is about
|
|
72
|
+
zero, so it resolves to off -- it refuses the very thing you pressed. And
|
|
73
|
+
it refuses *silently*, because the knob is back where it started and
|
|
74
|
+
nothing on screen says a gesture was even seen. Reading the pointer's
|
|
75
|
+
position on the track instead means a press and a press-with-a-wobble give
|
|
76
|
+
the same answer, which is the one the pointer is over.
|
|
77
|
+
|
|
68
78
|
`onChange` is called once per gesture, or not at all if the knob was put
|
|
69
79
|
back where it started -- a consumer that saves on change must not see a
|
|
70
80
|
drag as two saves. The keyboard is untouched: the input is still the
|
|
@@ -126,9 +136,16 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
|
|
|
126
136
|
const [held, setHeld] = useState(false);
|
|
127
137
|
const gesture = useRef(null);
|
|
128
138
|
const swallowClick = useRef(false);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
139
|
+
/** Where the knob would sit, 0 to 1, if its centre were under `x`. The
|
|
140
|
+
* 2px and the knob's width are the inset `toggle.css` draws it at:
|
|
141
|
+
* `left: calc(2px + var(--knob-x) * var(--knob-travel))`. */
|
|
142
|
+
const position = (track, x) => {
|
|
143
|
+
const box = track.getBoundingClientRect();
|
|
144
|
+
const knobWidth = KNOB[size];
|
|
145
|
+
const travel = box.width - knobWidth - 4;
|
|
146
|
+
if (travel <= 0)
|
|
147
|
+
return 0;
|
|
148
|
+
return Math.min(1, Math.max(0, (x - box.left - 2 - knobWidth / 2) / travel));
|
|
132
149
|
};
|
|
133
150
|
return (_jsxs(Switch, { ref: rowRef, className: `switch-row switch-${size}${className ? ` ${className}` : ''}`, isSelected: shown, onChange: commit, isDisabled: disabled,
|
|
134
151
|
/* Read-only, not disabled, while a request is out: focus stays where it
|
|
@@ -143,7 +160,6 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
|
|
|
143
160
|
id: e.pointerId,
|
|
144
161
|
startX: e.clientX,
|
|
145
162
|
startY: e.clientY,
|
|
146
|
-
from: shown ? 1 : 0,
|
|
147
163
|
moved: false,
|
|
148
164
|
};
|
|
149
165
|
setHeld(true);
|
|
@@ -154,16 +170,15 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
|
|
|
154
170
|
if (!g.moved) {
|
|
155
171
|
const dx = e.clientX - g.startX;
|
|
156
172
|
const dy = e.clientY - g.startY;
|
|
157
|
-
/* Mostly sideways and past the slop, or it is still a press.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
173
|
+
/* Mostly sideways and past the slop, or it is still a press. The
|
|
174
|
+
slop is what keeps a click a click: under it nothing is drawn
|
|
175
|
+
and nothing moves, so a hand that is not quite still does not
|
|
176
|
+
turn a press into a drag. */
|
|
161
177
|
if (Math.abs(dx) < SLOP || Math.abs(dx) <= Math.abs(dy))
|
|
162
178
|
return;
|
|
163
179
|
g.moved = true;
|
|
164
|
-
g.startX = e.clientX;
|
|
165
180
|
}
|
|
166
|
-
setKnob(position(e.currentTarget,
|
|
181
|
+
setKnob(position(e.currentTarget, e.clientX));
|
|
167
182
|
}, onPointerUp: (e) => {
|
|
168
183
|
const g = gesture.current;
|
|
169
184
|
if (!g || e.pointerId !== g.id)
|
|
@@ -172,7 +187,7 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
|
|
|
172
187
|
setHeld(false);
|
|
173
188
|
setKnob(null);
|
|
174
189
|
swallowClick.current = true;
|
|
175
|
-
const on = g.moved ? position(e.currentTarget,
|
|
190
|
+
const on = g.moved ? position(e.currentTarget, e.clientX) > 0.5 : !shown;
|
|
176
191
|
if (on !== shown)
|
|
177
192
|
commit(on);
|
|
178
193
|
}, onPointerCancel: () => {
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
* derived from this, so the type, the glyph table and the gallery cannot
|
|
15
15
|
* disagree.
|
|
16
16
|
*/
|
|
17
|
-
export declare const ICON_NAMES: readonly ["chat", "settings", "refresh", "minimize", "code", "wifi", "wifi-off", "check", "close", "info", "warning", "error", "expand", "download", "image", "bolt", "speaker", "mic", "back", "spinner", "folder", "book", "eye", "eye-off", "palette", "stars", "wrench", "cloud", "file"];
|
|
17
|
+
export declare const ICON_NAMES: readonly ["chat", "settings", "refresh", "minimize", "code", "wifi", "wifi-off", "check", "close", "info", "warning", "error", "expand", "download", "image", "bolt", "speaker", "mic", "back", "spinner", "folder", "book", "eye", "eye-off", "palette", "stars", "wrench", "cloud", "file", "mail", "mail-open", "pen", "send", "trash", "paperclip", "star", "star-filled", "search", "more", "menu", "reply", "forward"];
|
|
18
18
|
export type IconName = (typeof ICON_NAMES)[number];
|
|
@@ -57,4 +57,29 @@ export const ICON_NAMES = [
|
|
|
57
57
|
// is the wrong noun: you are handing over one file, not opening a place
|
|
58
58
|
// that holds several.
|
|
59
59
|
'file',
|
|
60
|
+
// A mail client's vocabulary, added for `@wtfalch/email`'s mailbox. The set
|
|
61
|
+
// had no noun for a message, a folder to file one in, or any of the four
|
|
62
|
+
// things you do to one, so the mailbox was drawing Inbox with `chat`, Sent
|
|
63
|
+
// with `download` and Deleted Items with `close` -- three right shapes for
|
|
64
|
+
// three wrong words. Measured the same way as the rest; see the `Glyph`
|
|
65
|
+
// docstring in `Icon.tsx`.
|
|
66
|
+
'mail',
|
|
67
|
+
'mail-open',
|
|
68
|
+
'pen',
|
|
69
|
+
'send',
|
|
70
|
+
'trash',
|
|
71
|
+
'paperclip',
|
|
72
|
+
// `star` is a rating or a flagged message; `stars` above it is the
|
|
73
|
+
// sparkles, and means generated. One letter apart and unrelated, which is
|
|
74
|
+
// worth the comment: reach for the wrong one and it still compiles.
|
|
75
|
+
'star',
|
|
76
|
+
'star-filled',
|
|
77
|
+
'search',
|
|
78
|
+
'more',
|
|
79
|
+
'menu',
|
|
80
|
+
// Diagonal arrows, which is what every minimal set draws reply and forward
|
|
81
|
+
// as. Pepicons has no curved corner arrow, and a horizontal `arrow-left`
|
|
82
|
+
// reads as going back rather than answering.
|
|
83
|
+
'reply',
|
|
84
|
+
'forward',
|
|
60
85
|
];
|
package/dist/components/icons.js
CHANGED
|
@@ -250,4 +250,101 @@ export const ICONS = {
|
|
|
250
250
|
'M11 3h-1a4 4 0 0 0-3.874 3H6a4 4 0 1 0 0 8h8a4 4 0 0 0 .899-7.899A4 4 0 0 0 11 3M6.901 7l.193-.75A3 3 0 0 1 10 4h1c1.405 0 2.614.975 2.924 2.325l.14.61l.61.141A3.001 3.001 0 0 1 14 13H6a3 3 0 1 1 0-6z',
|
|
251
251
|
],
|
|
252
252
|
},
|
|
253
|
+
/* The mail set. Every one of these is a Pepicons Pencil glyph measured the
|
|
254
|
+
way the paragraph above describes: bounding box from `getBBox()`,
|
|
255
|
+
re-centred, grown to 78% fill, magnification clamped to 0.85x-1.45x of
|
|
256
|
+
its own 20-unit grid. `reply` and `forward` sit on the upper clamp, as
|
|
257
|
+
`close` does -- small drawings that would otherwise come out at twice
|
|
258
|
+
the ink weight of their neighbours. */
|
|
259
|
+
mail: {
|
|
260
|
+
view: '0.38 0.38 19.23 19.23',
|
|
261
|
+
d: [
|
|
262
|
+
'M17 4H3a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h14a.5.5 0 0 0 .5-.5v-11A.5.5 0 0 0 17 4M3.5 15V5h13v10z',
|
|
263
|
+
'm17.324 4.88l-7.045 6a.5.5 0 0 1-.65-.001l-6.956-6A.5.5 0 0 1 3 4h14a.5.5 0 0 1 .324.88M15.642 5H4.345l5.612 4.841z',
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
'mail-open': {
|
|
267
|
+
view: '-0.26 -0.26 20.51 20.51',
|
|
268
|
+
d: [
|
|
269
|
+
'M2.5 8a.5.5 0 0 1 .5.5V17h14V8.5a.5.5 0 0 1 1 0v9a.5.5 0 0 1-.5.5h-15a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5',
|
|
270
|
+
'M3 5.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v4.67a.5.5 0 0 1-.223.416l-6.5 4.33a.5.5 0 0 1-.554 0l-6.5-4.33A.5.5 0 0 1 3 10.17zM4 6v3.902l6 3.997l6-3.997V6z',
|
|
271
|
+
'M9.723 2.084a.5.5 0 0 1 .554 0l4.5 3l-.554.832L10 3.101L5.777 5.916l-.554-.832zm7.131 5.062l1 1l-.708.708l-1-1zm-13 .708l-1 1l-.708-.708l1-1zM6.75 8A.25.25 0 0 1 7 7.75h6a.25.25 0 1 1 0 .5H7A.25.25 0 0 1 6.75 8m.5 2a.25.25 0 0 1 .25-.25h5a.25.25 0 1 1 0 .5h-5a.25.25 0 0 1-.25-.25',
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
pen: {
|
|
275
|
+
view: '1.02 -0.92 20.09 20.09',
|
|
276
|
+
d: [
|
|
277
|
+
'M3.944 11.79a.5.5 0 0 1 .141-.277L14.163 1.435a.5.5 0 0 1 .707 0l3.89 3.89a.5.5 0 0 1 0 .706L8.68 16.11a.5.5 0 0 1-.277.14l-4.595.706a.5.5 0 0 1-.57-.57zm.964.314l-.577 3.76l3.759-.578l9.609-9.608l-3.183-3.182z',
|
|
278
|
+
'm15.472 8.173l-3.537-3.53l.707-.708l3.536 3.53z',
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
send: {
|
|
282
|
+
view: '-2.09 -1.79 21.9 21.9',
|
|
283
|
+
d: [
|
|
284
|
+
'M.874 7.454L8.697 9.5l2.803 7.868a.5.5 0 0 0 .95-.026l4.746-16.085a.5.5 0 0 0-.655-.61L.826 6.502a.5.5 0 0 0 .048.952m1.783-.567l13.296-4.954l-4.027 13.652l-2.376-6.67a.5.5 0 0 0-.344-.315z',
|
|
285
|
+
'm16 1.293l.707.707L9 9.707L8.293 9z',
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
trash: {
|
|
289
|
+
view: '0.74 1.01 18.52 18.52',
|
|
290
|
+
d: [
|
|
291
|
+
'M8.5 14.999a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm2 0a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm2 0a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm-1-10.5h-3a1.501 1.501 0 0 1 3-.001',
|
|
292
|
+
'M4.5 4.999a.5.5 0 1 1 0-1h11a.5.5 0 0 1 0 1z',
|
|
293
|
+
'M14.5 5.5h-9A.5.5 0 0 0 5 6v11a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V6a.5.5 0 0 0-.5-.5M6 16.5v-10h8v10z',
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
paperclip: {
|
|
297
|
+
view: '-0.99 -0.41 21.47 21.47',
|
|
298
|
+
d: [
|
|
299
|
+
'M9.455 3.188a4 4 0 0 1 5.79 5.521l-5.349 5.608a2.25 2.25 0 0 1-3.258-3.106l4.485-4.706a.5.5 0 1 1 .724.69L7.362 11.9a1.25 1.25 0 0 0 1.81 1.726l5.349-5.608a3 3 0 1 0-4.342-4.141L5.348 8.944a.5.5 0 1 1-.724-.69z',
|
|
300
|
+
'M4.463 16.391c-.439-.419-.824-1.056-1.145-1.758c-.71-1.552-.17-3.383 1.097-4.712L8.8 5.325a.5.5 0 0 0-.724-.69L3.692 9.231c-1.455 1.526-2.214 3.783-1.284 5.818c.342.747.791 1.518 1.365 2.066c.566.54 1.342.947 2.094 1.251c2.095.849 4.337-.039 5.791-1.593l5.692-6.081a.5.5 0 1 0-.73-.684l-5.692 6.082c-1.267 1.354-3.089 1.996-4.686 1.35c-.704-.286-1.346-.636-1.779-1.049',
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
star: {
|
|
304
|
+
view: '-0.25 -0.51 20.51 20.51',
|
|
305
|
+
d: [
|
|
306
|
+
'M10 2a.5.5 0 0 1 .435.253l2.425 4.274l4.745 1.023a.5.5 0 0 1 .27.82l-3.253 3.667l.51 4.911a.5.5 0 0 1-.703.507L10 15.448l-4.429 2.007a.5.5 0 0 1-.704-.507l.51-4.91l-3.25-3.668a.5.5 0 0 1 .269-.82L7.14 6.527l2.425-4.274A.5.5 0 0 1 10 2m0 1.513L7.9 7.215a.5.5 0 0 1-.33.242l-4.128.89l2.83 3.191a.5.5 0 0 1 .123.384l-.443 4.263l3.842-1.74a.5.5 0 0 1 .412 0l3.842 1.74l-.443-4.263a.5.5 0 0 1 .123-.384l2.83-3.191l-4.128-.89a.5.5 0 0 1-.33-.242z',
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
'star-filled': {
|
|
310
|
+
view: '-1.03 -1.3 22.07 22.07',
|
|
311
|
+
d: [
|
|
312
|
+
'm10 15.97l-4.295 1.915a1 1 0 0 1-1.402-1.018l.494-4.677L1.65 8.698a1 1 0 0 1 .535-1.647l4.6-.976L9.134 2a1 1 0 0 1 1.732 0l2.35 4.074l4.6.976a1 1 0 0 1 .535 1.647l-3.148 3.494l.494 4.676a1 1 0 0 1-1.402 1.018z',
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
search: {
|
|
316
|
+
view: '1.62 1.66 15.87 15.87',
|
|
317
|
+
d: [
|
|
318
|
+
'M4.828 4.828A5 5 0 1 0 11.9 11.9a5 5 0 0 0-7.07-7.07m6.364 6.364a4 4 0 1 1-5.656-5.657a4 4 0 0 1 5.656 5.657',
|
|
319
|
+
'M11.192 12.627a1 1 0 0 1 1.415-1.414l2.828 2.829a1 1 0 1 1-1.414 1.414z',
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
more: {
|
|
323
|
+
view: '2.24 2.24 16.03 16.03',
|
|
324
|
+
d: [
|
|
325
|
+
'M14 10.25a1.25 1.25 0 1 1 2.5 0a1.25 1.25 0 0 1-2.5 0m-5 0a1.25 1.25 0 1 1 2.5 0a1.25 1.25 0 0 1-2.5 0m-5 0a1.249 1.249 0 1 1 2.5 0a1.25 1.25 0 1 1-2.5 0',
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
menu: {
|
|
329
|
+
view: '-0.26 0.24 20.51 20.51',
|
|
330
|
+
d: [
|
|
331
|
+
'M2 8.5a.5.5 0 0 1 .5-.5h11.308a.5.5 0 0 1 0 1H2.5a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1h-15a.5.5 0 0 1-.5-.5m0 8a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1h-15a.5.5 0 0 1-.5-.5m0 4a.5.5 0 0 1 .5-.5h11.308a.5.5 0 0 1 0 1H2.5a.5.5 0 0 1-.5-.5',
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
reply: {
|
|
335
|
+
view: '2.87 2.87 13.79 13.79',
|
|
336
|
+
d: [
|
|
337
|
+
'M5.948 12.145a.5.5 0 0 1-.453-.543l.471-5.186a.5.5 0 0 1 .996.09l-.471 5.186a.5.5 0 0 1-.543.453',
|
|
338
|
+
'M12.148 5.945a.5.5 0 0 1-.453.543L6.51 6.96a.5.5 0 0 1-.09-.996l5.185-.472a.5.5 0 0 1 .543.453',
|
|
339
|
+
'M6.647 6.643a.5.5 0 0 1 .707 0l6.535 6.536a.5.5 0 1 1-.707.707L6.646 7.351a.5.5 0 0 1 0-.708',
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
forward: {
|
|
343
|
+
view: '3.34 2.87 13.79 13.79',
|
|
344
|
+
d: [
|
|
345
|
+
'M7.852 5.952a.5.5 0 0 1 .543-.453l5.186.472a.5.5 0 0 1-.09.996l-5.186-.472a.5.5 0 0 1-.453-.543',
|
|
346
|
+
'M14.052 12.152a.5.5 0 0 1-.543-.453l-.472-5.185a.5.5 0 0 1 .996-.09l.472 5.185a.5.5 0 0 1-.453.543',
|
|
347
|
+
'M13.354 6.65a.5.5 0 0 1 0 .708l-6.536 6.535a.5.5 0 0 1-.707-.707l6.535-6.536a.5.5 0 0 1 .707 0',
|
|
348
|
+
],
|
|
349
|
+
},
|
|
253
350
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an identity disc is drawn: its two letters and its hue.
|
|
3
|
+
*
|
|
4
|
+
* **Its own module so `Identity.tsx` stays a Fast Refresh boundary.** A
|
|
5
|
+
* component module that exports a value beside its component loses the
|
|
6
|
+
* boundary, and editing it re-runs every importer instead of swapping the
|
|
7
|
+
* component in place. `iconNames.ts` and `tourMarker.ts` exist for the same
|
|
8
|
+
* reason.
|
|
9
|
+
*
|
|
10
|
+
* Exported from the package as well, because anything else colouring by
|
|
11
|
+
* sender -- a thread list's left rule, a chart of who writes most -- has to
|
|
12
|
+
* agree with the discs or the second cue contradicts the first.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Up to two initials.
|
|
16
|
+
*
|
|
17
|
+
* From the name's first and last word, which is right for "Ada Lovelace" and
|
|
18
|
+
* for "Ada Byron King Lovelace"; from the address's local part when there is
|
|
19
|
+
* no name, which is most machine senders. Split on whitespace only --
|
|
20
|
+
* splitting on punctuation turns "O'Brien" into "OB" and "Smith-Jones" into
|
|
21
|
+
* "SJ", which are worse than the single letter they replace.
|
|
22
|
+
*/
|
|
23
|
+
export declare function initialsOf(name: string | null | undefined, address: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* A hue from the address.
|
|
26
|
+
*
|
|
27
|
+
* A cheap, stable string hash. Not a cryptographic one and not trying to be:
|
|
28
|
+
* the requirement is that the same address gives the same number everywhere
|
|
29
|
+
* and on every machine, which any deterministic function satisfies, and that
|
|
30
|
+
* neighbouring addresses do not land on the same hue, which multiplying by an
|
|
31
|
+
* odd prime handles well enough.
|
|
32
|
+
*
|
|
33
|
+
* Case- and whitespace-insensitive, because `Ada@Example.com ` and
|
|
34
|
+
* `ada@example.com` are one person and two colours would say they were two.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hueOf(address: string): number;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an identity disc is drawn: its two letters and its hue.
|
|
3
|
+
*
|
|
4
|
+
* **Its own module so `Identity.tsx` stays a Fast Refresh boundary.** A
|
|
5
|
+
* component module that exports a value beside its component loses the
|
|
6
|
+
* boundary, and editing it re-runs every importer instead of swapping the
|
|
7
|
+
* component in place. `iconNames.ts` and `tourMarker.ts` exist for the same
|
|
8
|
+
* reason.
|
|
9
|
+
*
|
|
10
|
+
* Exported from the package as well, because anything else colouring by
|
|
11
|
+
* sender -- a thread list's left rule, a chart of who writes most -- has to
|
|
12
|
+
* agree with the discs or the second cue contradicts the first.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Up to two initials.
|
|
16
|
+
*
|
|
17
|
+
* From the name's first and last word, which is right for "Ada Lovelace" and
|
|
18
|
+
* for "Ada Byron King Lovelace"; from the address's local part when there is
|
|
19
|
+
* no name, which is most machine senders. Split on whitespace only --
|
|
20
|
+
* splitting on punctuation turns "O'Brien" into "OB" and "Smith-Jones" into
|
|
21
|
+
* "SJ", which are worse than the single letter they replace.
|
|
22
|
+
*/
|
|
23
|
+
export function initialsOf(name, address) {
|
|
24
|
+
const words = (name ?? '').trim().split(/\s+/).filter(Boolean);
|
|
25
|
+
if (words.length > 0) {
|
|
26
|
+
const first = words[0]?.[0] ?? '';
|
|
27
|
+
const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : '';
|
|
28
|
+
return (first + last).toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
const local = address.split('@')[0] ?? address;
|
|
31
|
+
return (local[0] ?? '?').toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A hue from the address.
|
|
35
|
+
*
|
|
36
|
+
* A cheap, stable string hash. Not a cryptographic one and not trying to be:
|
|
37
|
+
* the requirement is that the same address gives the same number everywhere
|
|
38
|
+
* and on every machine, which any deterministic function satisfies, and that
|
|
39
|
+
* neighbouring addresses do not land on the same hue, which multiplying by an
|
|
40
|
+
* odd prime handles well enough.
|
|
41
|
+
*
|
|
42
|
+
* Case- and whitespace-insensitive, because `Ada@Example.com ` and
|
|
43
|
+
* `ada@example.com` are one person and two colours would say they were two.
|
|
44
|
+
*/
|
|
45
|
+
export function hueOf(address) {
|
|
46
|
+
let hash = 0;
|
|
47
|
+
for (const character of address.trim().toLowerCase()) {
|
|
48
|
+
hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % 360;
|
|
49
|
+
}
|
|
50
|
+
return hash;
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which page buttons a pager draws.
|
|
3
|
+
*
|
|
4
|
+
* **Its own module so `Pagination.tsx` stays a Fast Refresh boundary**, the
|
|
5
|
+
* same rule `iconNames.ts` and `initials.ts` exist under.
|
|
6
|
+
*
|
|
7
|
+
* Never more than seven slots, with the first and last always present and a
|
|
8
|
+
* gap standing in for the run that is elided. A pager that lists twenty-six
|
|
9
|
+
* pages is wider than the table above it; one that lists only the current
|
|
10
|
+
* page's neighbours loses the jump to the end, which is the second most
|
|
11
|
+
* common thing anybody does with one.
|
|
12
|
+
*
|
|
13
|
+
* The width is held at seven even near an end, so the row does not change size
|
|
14
|
+
* as you move through it -- otherwise the button under the cursor shifts
|
|
15
|
+
* between clicks, which is how somebody lands two pages from where they meant.
|
|
16
|
+
*
|
|
17
|
+
* The gap is where the pager puts its "go to page" field, because that is
|
|
18
|
+
* exactly where the pages you cannot see are. What the gap *does* is the
|
|
19
|
+
* component's business; this only says where one goes.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pageWindow(current: number, pages: number): (number | 'gap')[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which page buttons a pager draws.
|
|
3
|
+
*
|
|
4
|
+
* **Its own module so `Pagination.tsx` stays a Fast Refresh boundary**, the
|
|
5
|
+
* same rule `iconNames.ts` and `initials.ts` exist under.
|
|
6
|
+
*
|
|
7
|
+
* Never more than seven slots, with the first and last always present and a
|
|
8
|
+
* gap standing in for the run that is elided. A pager that lists twenty-six
|
|
9
|
+
* pages is wider than the table above it; one that lists only the current
|
|
10
|
+
* page's neighbours loses the jump to the end, which is the second most
|
|
11
|
+
* common thing anybody does with one.
|
|
12
|
+
*
|
|
13
|
+
* The width is held at seven even near an end, so the row does not change size
|
|
14
|
+
* as you move through it -- otherwise the button under the cursor shifts
|
|
15
|
+
* between clicks, which is how somebody lands two pages from where they meant.
|
|
16
|
+
*
|
|
17
|
+
* The gap is where the pager puts its "go to page" field, because that is
|
|
18
|
+
* exactly where the pages you cannot see are. What the gap *does* is the
|
|
19
|
+
* component's business; this only says where one goes.
|
|
20
|
+
*/
|
|
21
|
+
export function pageWindow(current, pages) {
|
|
22
|
+
if (pages <= 7)
|
|
23
|
+
return Array.from({ length: pages }, (_, i) => i + 1);
|
|
24
|
+
const near = [current - 1, current, current + 1].filter((p) => p > 1 && p < pages);
|
|
25
|
+
const slots = new Set([1, ...near, pages]);
|
|
26
|
+
/* Near an end there is only one gap instead of two, so the run has to be one
|
|
27
|
+
longer to keep the row at seven. Four rather than three: 1 2 3 4 5 … 26,
|
|
28
|
+
not 1 2 3 4 … 26, which is six slots and a row that changes width as you
|
|
29
|
+
leave the first page. */
|
|
30
|
+
if (current <= 3)
|
|
31
|
+
for (const p of [2, 3, 4, 5])
|
|
32
|
+
slots.add(p);
|
|
33
|
+
if (current >= pages - 2) {
|
|
34
|
+
for (const p of [pages - 4, pages - 3, pages - 2, pages - 1])
|
|
35
|
+
slots.add(p);
|
|
36
|
+
}
|
|
37
|
+
const out = [];
|
|
38
|
+
let previous = 0;
|
|
39
|
+
for (const page of [...slots].filter((p) => p >= 1 && p <= pages).sort((a, b) => a - b)) {
|
|
40
|
+
if (previous && page - previous > 1)
|
|
41
|
+
out.push('gap');
|
|
42
|
+
out.push(page);
|
|
43
|
+
previous = page;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|