@wtfalch/design 0.3.1 → 0.4.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/Identity.d.ts +40 -0
- package/dist/components/Identity.js +36 -0
- package/dist/components/Markdown.d.ts +8 -0
- package/dist/components/Markdown.js +20 -5
- 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/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 +1 -1
|
@@ -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: () => {
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,26 +26,52 @@ export type { Props as ButtonProps } from './components/Button.js';
|
|
|
26
26
|
export { default as Callout } from './components/Callout.js';
|
|
27
27
|
export { default as Card } from './components/Card.js';
|
|
28
28
|
export { default as Checkbox } from './components/Checkbox.js';
|
|
29
|
+
/** Type what you want to do: the keyboard's front door over the application. */
|
|
30
|
+
export { default as Command } from './components/Command.js';
|
|
31
|
+
export type { Command as CommandItem, Group as CommandGroup } from './components/Command.js';
|
|
29
32
|
export { DangerAction, default as DangerZone } from './components/DangerZone.js';
|
|
30
33
|
export { default as Dialog } from './components/Dialog.js';
|
|
31
34
|
export { default as Empty } from './components/Empty.js';
|
|
32
35
|
export { default as Field } from './components/Field.js';
|
|
33
36
|
export type { FieldWiring } from './components/Field.js';
|
|
34
37
|
export { default as Icon } from './components/Icon.js';
|
|
38
|
+
/** A person in one line -- initials, name, address -- and the two pure
|
|
39
|
+
* functions behind the disc, exported so a caller colouring something else
|
|
40
|
+
* by sender agrees with it. */
|
|
41
|
+
export { default as Identity } from './components/Identity.js';
|
|
42
|
+
export { hueOf, initialsOf } from './components/initials.js';
|
|
35
43
|
export { ICON_NAMES } from './components/iconNames.js';
|
|
36
44
|
export type { IconName } from './components/iconNames.js';
|
|
37
45
|
export { default as Illustration } from './components/Illustration.js';
|
|
38
46
|
export { default as Input } from './components/Input.js';
|
|
39
47
|
export type { Props as InputProps } from './components/Input.js';
|
|
40
48
|
export { default as Markdown } from './components/Markdown.js';
|
|
49
|
+
export { default as Menu } from './components/Menu.js';
|
|
50
|
+
export type { Item as MenuItem, Section as MenuSection } from './components/Menu.js';
|
|
41
51
|
export { default as Modal } from './components/Modal.js';
|
|
52
|
+
/** Moving through a list that does not fit, counted in items rather than
|
|
53
|
+
* pages. `pageWindow` is the elision, exported because it is the arithmetic
|
|
54
|
+
* worth testing on its own. */
|
|
55
|
+
export { default as Pagination } from './components/Pagination.js';
|
|
56
|
+
export { pageWindow } from './components/pageWindow.js';
|
|
42
57
|
export { default as Pill } from './components/Pill.js';
|
|
58
|
+
/** A small surface anchored to what opened it: the middle term between
|
|
59
|
+
* `Tooltip`, which only says something, and `Modal`, which takes the
|
|
60
|
+
* application away. */
|
|
61
|
+
export { default as Popover } from './components/Popover.js';
|
|
43
62
|
export { default as Progress } from './components/Progress.js';
|
|
44
63
|
export { Row, Rows } from './components/Rows.js';
|
|
64
|
+
/** A box that scrolls and says so. */
|
|
65
|
+
export { default as ScrollArea } from './components/ScrollArea.js';
|
|
66
|
+
export type { Props as ScrollAreaProps } from './components/ScrollArea.js';
|
|
45
67
|
export { default as Select } from './components/Select.js';
|
|
46
68
|
export { default as SizeGrid } from './components/SizeGrid.js';
|
|
47
69
|
export { default as Skeleton } from './components/Skeleton.js';
|
|
48
70
|
export { default as Slider } from './components/Slider.js';
|
|
71
|
+
/** Two panes and a handle between them, the handle being a real `separator`
|
|
72
|
+
* widget rather than a div with a mousedown listener. */
|
|
73
|
+
export { default as SplitPane } from './components/SplitPane.js';
|
|
74
|
+
export type { Props as SplitPaneProps } from './components/SplitPane.js';
|
|
49
75
|
export { default as Table } from './components/Table.js';
|
|
50
76
|
export type { Column } from './components/Table.js';
|
|
51
77
|
export { default as Tabs } from './components/Tabs.js';
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ export { default as Button } from './components/Button.js';
|
|
|
24
24
|
export { default as Callout } from './components/Callout.js';
|
|
25
25
|
export { default as Card } from './components/Card.js';
|
|
26
26
|
export { default as Checkbox } from './components/Checkbox.js';
|
|
27
|
+
/** Type what you want to do: the keyboard's front door over the application. */
|
|
28
|
+
export { default as Command } from './components/Command.js';
|
|
27
29
|
/* `DangerAction` is a component -- a row inside the zone -- and was exported as
|
|
28
30
|
a type until the first consumer wrote `<DangerAction>` and TypeScript refused
|
|
29
31
|
it (TS1362). Nothing in this repo renders one, which is how it went unseen. */
|
|
@@ -32,18 +34,38 @@ export { default as Dialog } from './components/Dialog.js';
|
|
|
32
34
|
export { default as Empty } from './components/Empty.js';
|
|
33
35
|
export { default as Field } from './components/Field.js';
|
|
34
36
|
export { default as Icon } from './components/Icon.js';
|
|
37
|
+
/** A person in one line -- initials, name, address -- and the two pure
|
|
38
|
+
* functions behind the disc, exported so a caller colouring something else
|
|
39
|
+
* by sender agrees with it. */
|
|
40
|
+
export { default as Identity } from './components/Identity.js';
|
|
41
|
+
export { hueOf, initialsOf } from './components/initials.js';
|
|
35
42
|
export { ICON_NAMES } from './components/iconNames.js';
|
|
36
43
|
export { default as Illustration } from './components/Illustration.js';
|
|
37
44
|
export { default as Input } from './components/Input.js';
|
|
38
45
|
export { default as Markdown } from './components/Markdown.js';
|
|
46
|
+
export { default as Menu } from './components/Menu.js';
|
|
39
47
|
export { default as Modal } from './components/Modal.js';
|
|
48
|
+
/** Moving through a list that does not fit, counted in items rather than
|
|
49
|
+
* pages. `pageWindow` is the elision, exported because it is the arithmetic
|
|
50
|
+
* worth testing on its own. */
|
|
51
|
+
export { default as Pagination } from './components/Pagination.js';
|
|
52
|
+
export { pageWindow } from './components/pageWindow.js';
|
|
40
53
|
export { default as Pill } from './components/Pill.js';
|
|
54
|
+
/** A small surface anchored to what opened it: the middle term between
|
|
55
|
+
* `Tooltip`, which only says something, and `Modal`, which takes the
|
|
56
|
+
* application away. */
|
|
57
|
+
export { default as Popover } from './components/Popover.js';
|
|
41
58
|
export { default as Progress } from './components/Progress.js';
|
|
42
59
|
export { Row, Rows } from './components/Rows.js';
|
|
60
|
+
/** A box that scrolls and says so. */
|
|
61
|
+
export { default as ScrollArea } from './components/ScrollArea.js';
|
|
43
62
|
export { default as Select } from './components/Select.js';
|
|
44
63
|
export { default as SizeGrid } from './components/SizeGrid.js';
|
|
45
64
|
export { default as Skeleton } from './components/Skeleton.js';
|
|
46
65
|
export { default as Slider } from './components/Slider.js';
|
|
66
|
+
/** Two panes and a handle between them, the handle being a real `separator`
|
|
67
|
+
* widget rather than a div with a mousedown listener. */
|
|
68
|
+
export { default as SplitPane } from './components/SplitPane.js';
|
|
47
69
|
export { default as Table } from './components/Table.js';
|
|
48
70
|
export { default as Tabs } from './components/Tabs.js';
|
|
49
71
|
export { default as Textarea } from './components/Textarea.js';
|