@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,57 @@
|
|
|
1
|
+
import type { IconName } from './iconNames.js';
|
|
2
|
+
/**
|
|
3
|
+
* Type what you want to do.
|
|
4
|
+
*
|
|
5
|
+
* The keyboard's front door: one field over the application, a list that
|
|
6
|
+
* narrows as you type, Enter to run the highlighted thing. It exists because
|
|
7
|
+
* the alternative to a palette is a menu bar, and a menu bar makes every
|
|
8
|
+
* action cost a guess about which of six menus somebody filed it under.
|
|
9
|
+
*
|
|
10
|
+
* **It is a search field over a listbox, not a text input with a div under
|
|
11
|
+
* it.** That distinction is the whole accessibility story here. React Aria's
|
|
12
|
+
* `Autocomplete` keeps focus in the field while the arrows move the *selection*
|
|
13
|
+
* in the list, sets `aria-activedescendant` so a screen reader reads the
|
|
14
|
+
* highlighted row without focus leaving the input, and wires `aria-controls`
|
|
15
|
+
* between the two. Hand-rolled palettes move real focus into the list, which
|
|
16
|
+
* means typing another letter goes nowhere and the whole thing dead-ends for
|
|
17
|
+
* anybody not using a mouse.
|
|
18
|
+
*
|
|
19
|
+
* **Filtering is substring, and deliberately not fuzzy.** A fuzzy match on a
|
|
20
|
+
* short list is a list that reorders itself under your hands: you type one
|
|
21
|
+
* more letter, the thing you were about to press moves, and you run the wrong
|
|
22
|
+
* command. `contains` with the locale's collation -- so `resume` finds
|
|
23
|
+
* `Résumé` -- keeps the order stable and the surprises out.
|
|
24
|
+
*
|
|
25
|
+
* **The empty state says the query.** "No commands match 'archve'" is a typo
|
|
26
|
+
* somebody can see; "No results" is a dead end that looks like a broken build.
|
|
27
|
+
*/
|
|
28
|
+
export interface Command {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
/** What running it does, when the name does not say. */
|
|
32
|
+
description?: string;
|
|
33
|
+
icon?: IconName;
|
|
34
|
+
/** Shown, never bound. The app owns the binding. */
|
|
35
|
+
shortcut?: string;
|
|
36
|
+
/** Words that should find it but are not in its name -- "trash" for Delete,
|
|
37
|
+
* "folder" for Mailbox. Searched, never displayed. */
|
|
38
|
+
keywords?: string[];
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
onRun?: () => void;
|
|
41
|
+
}
|
|
42
|
+
export interface Group {
|
|
43
|
+
title: string;
|
|
44
|
+
commands: Command[];
|
|
45
|
+
}
|
|
46
|
+
export interface Props {
|
|
47
|
+
open: boolean;
|
|
48
|
+
onOpenChange: (open: boolean) => void;
|
|
49
|
+
/** Grouped, because a flat list of forty commands is a list nobody reads
|
|
50
|
+
* the bottom of. */
|
|
51
|
+
groups: Group[];
|
|
52
|
+
/** The field's placeholder and its accessible name. */
|
|
53
|
+
placeholder?: string;
|
|
54
|
+
label?: string;
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
export default function Command({ open, onOpenChange, groups, placeholder, label, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Modal as AriaModal, Autocomplete, Dialog, Header, Input, Keyboard, ListBox, ListBoxItem, ListBoxSection, ModalOverlay, SearchField, Text, useFilter, } from 'react-aria-components';
|
|
4
|
+
import Icon from './Icon.js';
|
|
5
|
+
export default function Command({ open, onOpenChange, groups, placeholder = 'Search commands…', label = 'Command palette', className, }) {
|
|
6
|
+
const [query, setQuery] = useState('');
|
|
7
|
+
/* The locale's collation, not `toLowerCase().includes()`: that answers no
|
|
8
|
+
for `Résumé` when you type `resume`, and for every language whose casing
|
|
9
|
+
is not English's. */
|
|
10
|
+
const { contains } = useFilter({ sensitivity: 'base' });
|
|
11
|
+
const matches = (command) => query === '' ||
|
|
12
|
+
contains(command.label, query) ||
|
|
13
|
+
(command.description !== undefined && contains(command.description, query)) ||
|
|
14
|
+
(command.keywords ?? []).some((word) => contains(word, query));
|
|
15
|
+
const shown = groups
|
|
16
|
+
.map((group) => ({ ...group, commands: group.commands.filter(matches) }))
|
|
17
|
+
.filter((group) => group.commands.length > 0);
|
|
18
|
+
const empty = shown.length === 0;
|
|
19
|
+
return (_jsx(ModalOverlay, { className: "cmd-scrim", isOpen: open, onOpenChange: (next) => {
|
|
20
|
+
onOpenChange(next);
|
|
21
|
+
/* Cleared on close, not on open: a palette that reopens with the last
|
|
22
|
+
query still in it shows a filtered list somebody has to notice and
|
|
23
|
+
delete before they can search for anything else. */
|
|
24
|
+
if (!next)
|
|
25
|
+
setQuery('');
|
|
26
|
+
}, isDismissable: true, children: _jsx(AriaModal, { className: `cmd${className ? ` ${className}` : ''}`, children: _jsx(Dialog, { className: "cmd-body", "aria-label": label, children: ({ close }) => (_jsxs(Autocomplete, { inputValue: query, onInputChange: setQuery, filter: () => true, children: [_jsxs(SearchField, { className: "cmd-field", "aria-label": label, autoFocus: true, children: [_jsx(Icon, { name: "chat", className: "cmd-mark" }), _jsx(Input, { className: "cmd-input", placeholder: placeholder })] }), empty ? (
|
|
27
|
+
/* The query, quoted. A palette that says "No results" to a
|
|
28
|
+
typo looks like a palette that is broken. */
|
|
29
|
+
_jsxs("p", { className: "cmd-empty", children: ["Nothing matches ", _jsx("strong", { children: query }), "."] })) : (_jsx(ListBox, { className: "cmd-list", "aria-label": label, selectionMode: "none", children: shown.map((group) => (_jsxs(ListBoxSection, { className: "cmd-group", children: [_jsx(Header, { className: "cmd-group-title", children: group.title }), group.commands.map((command) => (_jsxs(ListBoxItem, { id: command.id, className: "cmd-item", textValue: command.label, isDisabled: command.disabled, onAction: () => {
|
|
30
|
+
command.onRun?.();
|
|
31
|
+
/* Closing is this component's job, not every
|
|
32
|
+
caller's. Forty `onRun`s that each remember to
|
|
33
|
+
close is thirty-nine chances to forget. */
|
|
34
|
+
close();
|
|
35
|
+
}, children: [command.icon && _jsx(Icon, { name: command.icon, className: "cmd-icon" }), _jsxs("span", { className: "cmd-text", children: [_jsx(Text, { slot: "label", className: "cmd-label", children: command.label }), command.description && (_jsx(Text, { slot: "description", className: "cmd-desc", children: command.description }))] }), command.shortcut && (_jsx(Keyboard, { className: "cmd-key", children: command.shortcut }))] }, command.id)))] }, group.title))) }))] })) }) }) }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person, said in one line.
|
|
3
|
+
*
|
|
4
|
+
* A name, an address and a coloured disc with their initials. It is a mail
|
|
5
|
+
* client's most repeated object -- every row of a thread list, every header of
|
|
6
|
+
* a message, every chip in a composer's To field -- and until there is one of
|
|
7
|
+
* these it is written by hand at each of those, slightly differently, and the
|
|
8
|
+
* initials are wrong in a different way at every site.
|
|
9
|
+
*
|
|
10
|
+
* **The colour is derived from the address, not chosen.** The same person is
|
|
11
|
+
* the same colour in the list, in the header and in the composer, across
|
|
12
|
+
* reloads and across machines, with nothing stored. That consistency is the
|
|
13
|
+
* only thing the colour is for: it is a second, weaker cue that two rows are
|
|
14
|
+
* from the same sender, which is worth something when scanning and worth
|
|
15
|
+
* nothing if it changes.
|
|
16
|
+
*
|
|
17
|
+
* **The disc is `aria-hidden` and the initials are never read out.** "A L" is
|
|
18
|
+
* not a name, and a screen reader that announces it before the name has made
|
|
19
|
+
* every row of the list longer to listen to for no information at all.
|
|
20
|
+
*
|
|
21
|
+
* **Initials come from the name when there is one, and from the address when
|
|
22
|
+
* there is not.** A contact with no display name is common -- most machine
|
|
23
|
+
* senders have none -- and falling through to the first letter of the local
|
|
24
|
+
* part beats an empty disc or a `?`.
|
|
25
|
+
*/
|
|
26
|
+
export interface Props {
|
|
27
|
+
/** The display name, when the message carried one. */
|
|
28
|
+
name?: string | null;
|
|
29
|
+
address: string;
|
|
30
|
+
/** How much to show. `chip` is the composer's pill, `line` is a list row,
|
|
31
|
+
* `full` puts the address under the name for a message header. */
|
|
32
|
+
kind?: 'chip' | 'line' | 'full';
|
|
33
|
+
size?: 'sm' | 'md';
|
|
34
|
+
/** Something after the name -- a time, a count, a `Pill`. */
|
|
35
|
+
aside?: React.ReactNode;
|
|
36
|
+
/** For the composer: shows a remove button and calls this. */
|
|
37
|
+
onRemove?: () => void;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
export default function Identity({ name, address, kind, size, aside, onRemove, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A person, said in one line.
|
|
4
|
+
*
|
|
5
|
+
* A name, an address and a coloured disc with their initials. It is a mail
|
|
6
|
+
* client's most repeated object -- every row of a thread list, every header of
|
|
7
|
+
* a message, every chip in a composer's To field -- and until there is one of
|
|
8
|
+
* these it is written by hand at each of those, slightly differently, and the
|
|
9
|
+
* initials are wrong in a different way at every site.
|
|
10
|
+
*
|
|
11
|
+
* **The colour is derived from the address, not chosen.** The same person is
|
|
12
|
+
* the same colour in the list, in the header and in the composer, across
|
|
13
|
+
* reloads and across machines, with nothing stored. That consistency is the
|
|
14
|
+
* only thing the colour is for: it is a second, weaker cue that two rows are
|
|
15
|
+
* from the same sender, which is worth something when scanning and worth
|
|
16
|
+
* nothing if it changes.
|
|
17
|
+
*
|
|
18
|
+
* **The disc is `aria-hidden` and the initials are never read out.** "A L" is
|
|
19
|
+
* not a name, and a screen reader that announces it before the name has made
|
|
20
|
+
* every row of the list longer to listen to for no information at all.
|
|
21
|
+
*
|
|
22
|
+
* **Initials come from the name when there is one, and from the address when
|
|
23
|
+
* there is not.** A contact with no display name is common -- most machine
|
|
24
|
+
* senders have none -- and falling through to the first letter of the local
|
|
25
|
+
* part beats an empty disc or a `?`.
|
|
26
|
+
*/
|
|
27
|
+
import { hueOf, initialsOf } from './initials.js';
|
|
28
|
+
export default function Identity({ name, address, kind = 'line', size = 'md', aside, onRemove, className, }) {
|
|
29
|
+
const initials = initialsOf(name, address);
|
|
30
|
+
const shown = name?.trim() || address;
|
|
31
|
+
return (_jsxs("span", { className: `ident kind-${kind} size-${size}${className ? ` ${className}` : ''}`,
|
|
32
|
+
/* The hue is a variable and the stylesheet decides what to do with it:
|
|
33
|
+
a `background` written here would be a colour the theme cannot reach,
|
|
34
|
+
and the discs would stay saturated on Paper. */
|
|
35
|
+
style: { '--ident-hue': hueOf(address) }, children: [_jsx("span", { className: "ident-disc", "aria-hidden": "true", children: initials }), _jsxs("span", { className: "ident-text", children: [_jsx("span", { className: "ident-name", children: shown }), kind === 'full' && shown !== address && _jsx("span", { className: "ident-address", children: address })] }), aside && _jsx("span", { className: "ident-aside", children: aside }), onRemove && (_jsx("button", { type: "button", className: "ident-remove", onClick: onRemove, "aria-label": `Remove ${shown}`, children: _jsx("svg", { viewBox: "0 0 24 24", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", d: "M6 6l12 12M18 6L6 18" }) }) }))] }));
|
|
36
|
+
}
|
|
@@ -4,6 +4,14 @@ import DOMPurify from 'dompurify';
|
|
|
4
4
|
*
|
|
5
5
|
* Sanitised without exception: this is text produced by a model, which may be
|
|
6
6
|
* echoing content it read from a file, so it is never trusted HTML.
|
|
7
|
+
*
|
|
8
|
+
* **On the server, the text itself.** DOMPurify sanitises through a DOM, and a
|
|
9
|
+
* server rendering React has none: `sanitize` is not even a function there,
|
|
10
|
+
* and the first app to server-render a comment found out. Rather than a second
|
|
11
|
+
* sanitiser for the server, which would be two behaviours for one string, the
|
|
12
|
+
* server (and the first client paint, so hydration agrees) renders the text
|
|
13
|
+
* escaped by React, and the sanitised HTML takes its place once mounted. What
|
|
14
|
+
* reaches a browser is never HTML that DOMPurify has not seen.
|
|
7
15
|
*/
|
|
8
16
|
export default function Markdown({ text, sanitize, className, }: {
|
|
9
17
|
text: string;
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import DOMPurify from 'dompurify';
|
|
3
3
|
import { marked } from 'marked';
|
|
4
|
-
import { useMemo } from 'react';
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
5
|
marked.setOptions({ gfm: true, breaks: true });
|
|
6
6
|
/**
|
|
7
7
|
* Render model output as markdown.
|
|
8
8
|
*
|
|
9
9
|
* Sanitised without exception: this is text produced by a model, which may be
|
|
10
10
|
* echoing content it read from a file, so it is never trusted HTML.
|
|
11
|
+
*
|
|
12
|
+
* **On the server, the text itself.** DOMPurify sanitises through a DOM, and a
|
|
13
|
+
* server rendering React has none: `sanitize` is not even a function there,
|
|
14
|
+
* and the first app to server-render a comment found out. Rather than a second
|
|
15
|
+
* sanitiser for the server, which would be two behaviours for one string, the
|
|
16
|
+
* server (and the first client paint, so hydration agrees) renders the text
|
|
17
|
+
* escaped by React, and the sanitised HTML takes its place once mounted. What
|
|
18
|
+
* reaches a browser is never HTML that DOMPurify has not seen.
|
|
11
19
|
*/
|
|
12
20
|
export default function Markdown({ text, sanitize, className, }) {
|
|
21
|
+
const [mounted, setMounted] = useState(false);
|
|
22
|
+
useEffect(() => setMounted(true), []);
|
|
13
23
|
const html = useMemo(() => {
|
|
14
|
-
if (!text)
|
|
15
|
-
return
|
|
24
|
+
if (!text || !mounted)
|
|
25
|
+
return null;
|
|
16
26
|
const raw = marked.parse(text, { async: false });
|
|
17
27
|
return DOMPurify.sanitize(raw, {
|
|
18
28
|
// No iframes, no forms, no event handlers -- prose, code and tables only.
|
|
@@ -20,10 +30,15 @@ export default function Markdown({ text, sanitize, className, }) {
|
|
|
20
30
|
FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick'],
|
|
21
31
|
...sanitize,
|
|
22
32
|
});
|
|
23
|
-
}, [text, sanitize]);
|
|
33
|
+
}, [text, mounted, sanitize]);
|
|
24
34
|
if (!text)
|
|
25
35
|
return null;
|
|
26
|
-
|
|
36
|
+
const classes = `md${className ? ` ${className}` : ''}`;
|
|
37
|
+
if (html === null) {
|
|
38
|
+
// The server, and the client until its first effect: the words, escaped.
|
|
39
|
+
return (_jsx("div", { className: classes, "data-md": "plain", children: _jsx("p", { children: text }) }));
|
|
40
|
+
}
|
|
41
|
+
return (_jsx("div", { className: classes,
|
|
27
42
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: the string is DOMPurify output, with the config above.
|
|
28
43
|
dangerouslySetInnerHTML: { __html: html } }));
|
|
29
44
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Placement } from 'react-aria-components';
|
|
2
|
+
import type { IconName } from './iconNames.js';
|
|
3
|
+
/**
|
|
4
|
+
* A list of things you can do to something.
|
|
5
|
+
*
|
|
6
|
+
* Not `Select`. That is a *value* -- one of a set, one of them currently true,
|
|
7
|
+
* and the control shows which. A menu is a set of *verbs*, none of them a
|
|
8
|
+
* state, and nothing is selected when it closes. Building one out of the other
|
|
9
|
+
* gets you a listbox that announces "selected" after somebody archives a
|
|
10
|
+
* message, which is a small lie told by a screen reader at the exact moment
|
|
11
|
+
* accuracy matters.
|
|
12
|
+
*
|
|
13
|
+
* **Destructive items are marked and last.** `danger` tints the row, and the
|
|
14
|
+
* caller puts it below a separator. Delete beside Reply, in the same weight,
|
|
15
|
+
* one row apart, is a design that is going to lose somebody's mail. Same
|
|
16
|
+
* vocabulary as `DangerZone`, one surface smaller.
|
|
17
|
+
*
|
|
18
|
+
* **Shortcuts are shown, not hidden.** An item that also has a key binding
|
|
19
|
+
* says so on its right; that is the only place most people will ever learn the
|
|
20
|
+
* binding exists, and a menu is where they are already looking.
|
|
21
|
+
*
|
|
22
|
+
* The keyboard, typeahead, submenu timing, outside press and focus restore are
|
|
23
|
+
* React Aria's. The submenu delay in particular is worth not rewriting: a
|
|
24
|
+
* submenu that closes the instant the pointer leaves its parent row is
|
|
25
|
+
* unusable with a mouse, because the diagonal path to it passes over the row
|
|
26
|
+
* below.
|
|
27
|
+
*/
|
|
28
|
+
export interface Item {
|
|
29
|
+
id: string;
|
|
30
|
+
label: React.ReactNode;
|
|
31
|
+
/** A second line, for an action whose consequence is not obvious from its
|
|
32
|
+
* name. Most items should not have one. */
|
|
33
|
+
description?: string;
|
|
34
|
+
icon?: IconName;
|
|
35
|
+
/** The key binding, written the way the platform writes it. Shown, never
|
|
36
|
+
* bound -- this draws the reminder, the app owns the shortcut. */
|
|
37
|
+
shortcut?: string;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Tints the row and gives it the destructive colour. For the one item that
|
|
40
|
+
* cannot be undone. */
|
|
41
|
+
danger?: boolean;
|
|
42
|
+
/** A rule above this item. Groups a menu without giving each group a name. */
|
|
43
|
+
separated?: boolean;
|
|
44
|
+
onAction?: () => void;
|
|
45
|
+
/** Items under this one, opened by hovering or by the right arrow. */
|
|
46
|
+
items?: Item[];
|
|
47
|
+
}
|
|
48
|
+
export interface Section {
|
|
49
|
+
/** The group's name. A section with no title is a `separated` item's job. */
|
|
50
|
+
title: string;
|
|
51
|
+
items: Item[];
|
|
52
|
+
}
|
|
53
|
+
export interface Props {
|
|
54
|
+
/** What opens it. */
|
|
55
|
+
trigger: React.ReactNode;
|
|
56
|
+
items: (Item | Section)[];
|
|
57
|
+
placement?: Placement;
|
|
58
|
+
/** Names the menu for a screen reader. */
|
|
59
|
+
label: string;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
export default function Menu({ trigger, items, placement, label, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Menu as AriaMenu, Popover as AriaPopover, Header, Keyboard, MenuItem, MenuSection, MenuTrigger, Separator, SubmenuTrigger, Text, } from 'react-aria-components';
|
|
3
|
+
import Icon from './Icon.js';
|
|
4
|
+
function isSection(entry) {
|
|
5
|
+
return 'title' in entry && Array.isArray(entry.items);
|
|
6
|
+
}
|
|
7
|
+
function renderItem(item) {
|
|
8
|
+
const row = (_jsxs(MenuItem, { id: item.id, className: `menu-item${item.danger ? ' danger' : ''}`, isDisabled: item.disabled, onAction: item.onAction,
|
|
9
|
+
/* Typeahead needs a string, and `label` may be a node. Without this,
|
|
10
|
+
typing the first letter of an item whose label is markup matches
|
|
11
|
+
nothing and the menu looks broken. */
|
|
12
|
+
textValue: typeof item.label === 'string' ? item.label : item.id, children: [item.icon && _jsx(Icon, { name: item.icon, className: "menu-icon" }), _jsxs("span", { className: "menu-text", children: [_jsx(Text, { slot: "label", className: "menu-label", children: item.label }), item.description && (_jsx(Text, { slot: "description", className: "menu-desc", children: item.description }))] }), item.shortcut && _jsx(Keyboard, { className: "menu-key", children: item.shortcut }), item.items && (_jsx("svg", { className: "menu-more", viewBox: "0 0 24 24", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m9 6 6 6-6 6" }) }))] }, item.id));
|
|
13
|
+
const body = item.items ? (_jsxs(SubmenuTrigger, { children: [row, _jsx(AriaPopover, { className: "menu-sheet", children: _jsx(AriaMenu, { className: "menu-list", children: item.items.map(renderItem) }) })] }, item.id)) : (row);
|
|
14
|
+
if (!item.separated)
|
|
15
|
+
return body;
|
|
16
|
+
return [_jsx(Separator, { className: "menu-rule" }, `${item.id}-rule`), body];
|
|
17
|
+
}
|
|
18
|
+
export default function Menu({ trigger, items, placement = 'bottom start', label, className, }) {
|
|
19
|
+
return (_jsxs(MenuTrigger, { children: [trigger, _jsx(AriaPopover, { className: `menu-sheet${className ? ` ${className}` : ''}`, placement: placement, offset: 6, children: _jsx(AriaMenu, { className: "menu-list", "aria-label": label, children: items.map((entry) => isSection(entry) ? (_jsxs(MenuSection, { className: "menu-section", children: [_jsx(Header, { className: "menu-section-title", children: entry.title }), entry.items.map(renderItem)] }, entry.title)) : (renderItem(entry))) }) })] }));
|
|
20
|
+
}
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* this -- same trap, same restore, stricter about the scrim, because for "may
|
|
23
23
|
* this applet write to your files" a stray click on the background is a way of
|
|
24
24
|
* answering by accident.
|
|
25
|
+
*
|
|
26
|
+
* **A sheet is this with `edge` set, not a second component.** A drawer from
|
|
27
|
+
* the side of the window differs from a window in the middle of it by where it
|
|
28
|
+
* is anchored and which way it slides -- and in nothing else. Same focus trap,
|
|
29
|
+
* same restore, same scrim, same header, body and footer. Writing a `Sheet`
|
|
30
|
+
* that duplicates all of that to change two CSS properties is how a design
|
|
31
|
+
* system ends up with two windows that drift: one of them gets the fix and
|
|
32
|
+
* nobody notices which.
|
|
25
33
|
*/
|
|
26
|
-
export default function Modal({ title, description, subtitle, head, children, footer, footerClass, onClose, closeDisabled, width, bodyClass, className, dismissOnScrim, closeButton, labelledBy, }: {
|
|
34
|
+
export default function Modal({ title, description, subtitle, head, children, footer, footerClass, onClose, closeDisabled, width, edge, bodyClass, className, dismissOnScrim, closeButton, labelledBy, }: {
|
|
27
35
|
/** The window's name. Rendered as the heading and announced on open. */
|
|
28
36
|
title?: React.ReactNode;
|
|
29
37
|
/** A line under the title, saying what the window is for. The same slot
|
|
@@ -45,6 +53,17 @@ export default function Modal({ title, description, subtitle, head, children, fo
|
|
|
45
53
|
/** While something is saving, closing would abandon it mid-flight. */
|
|
46
54
|
closeDisabled?: boolean;
|
|
47
55
|
width?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Anchor it to an edge of the window and slide it in from there, rather
|
|
58
|
+
* than centring it. This is the sheet.
|
|
59
|
+
*
|
|
60
|
+
* `right` and `left` run the full height at `width`; `bottom` runs the full
|
|
61
|
+
* width and is as tall as its content, which is the shape a phone expects.
|
|
62
|
+
* Use one where the window is a *side panel* on the thing behind it --
|
|
63
|
+
* message details, a filter pane -- and leave it off where the window
|
|
64
|
+
* replaces what is behind it.
|
|
65
|
+
*/
|
|
66
|
+
edge?: 'left' | 'right' | 'bottom';
|
|
48
67
|
/** For a body that is not a single column -- Settings' rail and pane. */
|
|
49
68
|
bodyClass?: string;
|
|
50
69
|
className?: string;
|
package/dist/components/Modal.js
CHANGED
|
@@ -23,6 +23,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
23
23
|
* this -- same trap, same restore, stricter about the scrim, because for "may
|
|
24
24
|
* this applet write to your files" a stray click on the background is a way of
|
|
25
25
|
* answering by accident.
|
|
26
|
+
*
|
|
27
|
+
* **A sheet is this with `edge` set, not a second component.** A drawer from
|
|
28
|
+
* the side of the window differs from a window in the middle of it by where it
|
|
29
|
+
* is anchored and which way it slides -- and in nothing else. Same focus trap,
|
|
30
|
+
* same restore, same scrim, same header, body and footer. Writing a `Sheet`
|
|
31
|
+
* that duplicates all of that to change two CSS properties is how a design
|
|
32
|
+
* system ends up with two windows that drift: one of them gets the fix and
|
|
33
|
+
* nobody notices which.
|
|
26
34
|
*/
|
|
27
35
|
import { useEffect, useId, useRef } from 'react';
|
|
28
36
|
import { Modal as AriaModal, Dialog, Heading, ModalOverlay } from 'react-aria-components';
|
|
@@ -30,7 +38,7 @@ import Button from './Button.js';
|
|
|
30
38
|
import Icon from './Icon.js';
|
|
31
39
|
/* The trap is the same obligation wherever it applies, and the studio needs it
|
|
32
40
|
without being shaped like this, so it lives in a hook rather than here. */
|
|
33
|
-
export default function Modal({ title, description, subtitle, head, children, footer, footerClass, onClose, closeDisabled = false, width, bodyClass, className, dismissOnScrim = true, closeButton = true, labelledBy, }) {
|
|
41
|
+
export default function Modal({ title, description, subtitle, head, children, footer, footerClass, onClose, closeDisabled = false, width, edge, bodyClass, className, dismissOnScrim = true, closeButton = true, labelledBy, }) {
|
|
34
42
|
const headingId = useId();
|
|
35
43
|
const canClose = Boolean(onClose) && !closeDisabled;
|
|
36
44
|
/* React Aria owns the three things every hand-built modal here got wrong.
|
|
@@ -57,10 +65,10 @@ export default function Modal({ title, description, subtitle, head, children, fo
|
|
|
57
65
|
useEffect(() => {
|
|
58
66
|
dialogRef.current?.setAttribute('aria-modal', 'true');
|
|
59
67
|
});
|
|
60
|
-
return (_jsx(ModalOverlay, { className:
|
|
68
|
+
return (_jsx(ModalOverlay, { className: `backdrop${edge ? ` sheeted from-${edge}` : ''}`, isOpen: true, isDismissable: canClose && dismissOnScrim, isKeyboardDismissDisabled: !canClose, onOpenChange: (open) => {
|
|
61
69
|
if (!open && canClose)
|
|
62
70
|
onClose?.();
|
|
63
|
-
}, children: _jsx(AriaModal, { className: `modal${className ? ` ${className}` : ''}`, style: width ? { width } : undefined, children: _jsxs(Dialog, { ref: dialogRef, className: "modal-dialog", "aria-labelledby": labelledBy ?? (title ? headingId : undefined), children: [(title || head || (onClose && closeButton)) && (_jsxs("header", { className: "modal-head", children: [title && (
|
|
71
|
+
}, children: _jsx(AriaModal, { className: `modal${edge ? ` sheet from-${edge}` : ''}${className ? ` ${className}` : ''}`, style: width ? { width } : undefined, children: _jsxs(Dialog, { ref: dialogRef, className: "modal-dialog", "aria-labelledby": labelledBy ?? (title ? headingId : undefined), children: [(title || head || (onClose && closeButton)) && (_jsxs("header", { className: "modal-head", children: [title && (
|
|
64
72
|
/* `Heading` is what React Aria labels the dialog by, and it
|
|
65
73
|
renders an `<h2>` -- which the browser gives its own margins
|
|
66
74
|
and its own size (17px above and below, 21px type). The old
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving through a list that does not fit.
|
|
3
|
+
*
|
|
4
|
+
* **It counts in items, not in pages**, because that is what the server
|
|
5
|
+
* answers and what the reader asks. A JMAP query returns a position, a limit
|
|
6
|
+
* and a total; a mailbox is "51–100 of 1,284", not "page 2 of 26". Page
|
|
7
|
+
* numbers are this component's arithmetic, done once here rather than at every
|
|
8
|
+
* call site -- which is where the off-by-one lives, and where it becomes an
|
|
9
|
+
* empty last page.
|
|
10
|
+
*
|
|
11
|
+
* **The count is the point, and it is said out loud.** "51–100 of 1,284" tells
|
|
12
|
+
* you how far in you are and how much is left; two arrows tell you neither. A
|
|
13
|
+
* pager with no count is a pager you navigate by feel.
|
|
14
|
+
*
|
|
15
|
+
* **A total is optional, because a server may refuse to count.** JMAP's
|
|
16
|
+
* `calculateTotal` is a request, not a promise, and a large mailbox is exactly
|
|
17
|
+
* where it gets declined. Without one this shows the range and keeps Next
|
|
18
|
+
* enabled while a full page came back, which is the only honest thing it can
|
|
19
|
+
* do: a page shorter than the limit is the end.
|
|
20
|
+
*
|
|
21
|
+
* **The ellipsis is a field, not punctuation.** It stands for the pages you
|
|
22
|
+
* cannot see, so it is where you say which one you want: type a number, press
|
|
23
|
+
* Enter, and you are there. Drawn as a ghost -- no border, no background, the
|
|
24
|
+
* `…` as its placeholder -- so at rest the row looks exactly like a row of
|
|
25
|
+
* page buttons with an elision in it, and it becomes a control when you touch
|
|
26
|
+
* it. Without this, reaching page 17 of 26 is eleven presses of Next, and the
|
|
27
|
+
* middle of a row of live buttons is a dead spot.
|
|
28
|
+
*
|
|
29
|
+
* **`nav` with a name**, so a screen reader can jump to it and so two pagers
|
|
30
|
+
* on a page are distinguishable. The current page's button is
|
|
31
|
+
* `aria-current="page"`, which is what tells a reader where they are without
|
|
32
|
+
* relying on the colour that says it visually.
|
|
33
|
+
*/
|
|
34
|
+
export interface Props {
|
|
35
|
+
/** Index of the first item shown, counting from zero -- the same number the
|
|
36
|
+
* query was given, so caller and component never disagree about the origin. */
|
|
37
|
+
position: number;
|
|
38
|
+
/** How many are shown per page. */
|
|
39
|
+
limit: number;
|
|
40
|
+
/** How many there are in total, when the server was willing to say. */
|
|
41
|
+
total?: number;
|
|
42
|
+
/** How many actually came back. A short page is the end of the list, which
|
|
43
|
+
* is how Next is decided when there is no total. */
|
|
44
|
+
count?: number;
|
|
45
|
+
onChange: (position: number) => void;
|
|
46
|
+
/** Names this pager. "Mailbox pages", not "Pagination". */
|
|
47
|
+
label: string;
|
|
48
|
+
/** What is being counted, for the summary: "of 1,284 messages". */
|
|
49
|
+
unit?: string;
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
export default function Pagination({ position, limit, total, count, onChange, label, unit, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Moving through a list that does not fit.
|
|
4
|
+
*
|
|
5
|
+
* **It counts in items, not in pages**, because that is what the server
|
|
6
|
+
* answers and what the reader asks. A JMAP query returns a position, a limit
|
|
7
|
+
* and a total; a mailbox is "51–100 of 1,284", not "page 2 of 26". Page
|
|
8
|
+
* numbers are this component's arithmetic, done once here rather than at every
|
|
9
|
+
* call site -- which is where the off-by-one lives, and where it becomes an
|
|
10
|
+
* empty last page.
|
|
11
|
+
*
|
|
12
|
+
* **The count is the point, and it is said out loud.** "51–100 of 1,284" tells
|
|
13
|
+
* you how far in you are and how much is left; two arrows tell you neither. A
|
|
14
|
+
* pager with no count is a pager you navigate by feel.
|
|
15
|
+
*
|
|
16
|
+
* **A total is optional, because a server may refuse to count.** JMAP's
|
|
17
|
+
* `calculateTotal` is a request, not a promise, and a large mailbox is exactly
|
|
18
|
+
* where it gets declined. Without one this shows the range and keeps Next
|
|
19
|
+
* enabled while a full page came back, which is the only honest thing it can
|
|
20
|
+
* do: a page shorter than the limit is the end.
|
|
21
|
+
*
|
|
22
|
+
* **The ellipsis is a field, not punctuation.** It stands for the pages you
|
|
23
|
+
* cannot see, so it is where you say which one you want: type a number, press
|
|
24
|
+
* Enter, and you are there. Drawn as a ghost -- no border, no background, the
|
|
25
|
+
* `…` as its placeholder -- so at rest the row looks exactly like a row of
|
|
26
|
+
* page buttons with an elision in it, and it becomes a control when you touch
|
|
27
|
+
* it. Without this, reaching page 17 of 26 is eleven presses of Next, and the
|
|
28
|
+
* middle of a row of live buttons is a dead spot.
|
|
29
|
+
*
|
|
30
|
+
* **`nav` with a name**, so a screen reader can jump to it and so two pagers
|
|
31
|
+
* on a page are distinguishable. The current page's button is
|
|
32
|
+
* `aria-current="page"`, which is what tells a reader where they are without
|
|
33
|
+
* relying on the colour that says it visually.
|
|
34
|
+
*/
|
|
35
|
+
import { useState } from 'react';
|
|
36
|
+
import { pageWindow } from './pageWindow.js';
|
|
37
|
+
/** Group digits so a five-figure count can be read at a glance. */
|
|
38
|
+
const group = (n) => n.toLocaleString('en-GB');
|
|
39
|
+
/**
|
|
40
|
+
* The gap: a field you type a page into.
|
|
41
|
+
*
|
|
42
|
+
* Its own state, because a window can hold two of these and they are
|
|
43
|
+
* independent -- one for the pages before the current run and one for the
|
|
44
|
+
* pages after it. Hoisting the draft into `Pagination` would mean tracking
|
|
45
|
+
* which of the two is being edited, for no gain.
|
|
46
|
+
*
|
|
47
|
+
* Empty when it is not being typed into, so the placeholder shows and it
|
|
48
|
+
* reads as an elision. Escape and blur abandon; only Enter commits, because a
|
|
49
|
+
* pager that navigates while you are still typing takes you to page 1 on the
|
|
50
|
+
* way to page 17.
|
|
51
|
+
*/
|
|
52
|
+
function JumpField({ pages, onGo }) {
|
|
53
|
+
const [draft, setDraft] = useState('');
|
|
54
|
+
const commit = () => {
|
|
55
|
+
const wanted = Number.parseInt(draft, 10);
|
|
56
|
+
setDraft('');
|
|
57
|
+
if (!Number.isFinite(wanted))
|
|
58
|
+
return;
|
|
59
|
+
// Clamped rather than refused: somebody typing 400 into a 26-page list
|
|
60
|
+
// wants the end, and an error message here would be a bigger interruption
|
|
61
|
+
// than the thing it is guarding.
|
|
62
|
+
onGo(Math.min(pages, Math.max(1, wanted)));
|
|
63
|
+
};
|
|
64
|
+
return (_jsx("input", { className: "pager-jump", type: "number", inputMode: "numeric", min: 1, max: pages, value: draft, placeholder: "\u2026",
|
|
65
|
+
/* The width is set from how many digits the largest page has, so the
|
|
66
|
+
row does not change size when the field is typed into -- the same
|
|
67
|
+
reason `pageWindow` holds seven slots at the ends. */
|
|
68
|
+
style: { '--digits': String(pages).length }, "aria-label": `Go to page, 1 to ${pages}`, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
|
|
69
|
+
if (event.key === 'Enter') {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
commit();
|
|
72
|
+
event.currentTarget.blur();
|
|
73
|
+
}
|
|
74
|
+
else if (event.key === 'Escape') {
|
|
75
|
+
setDraft('');
|
|
76
|
+
event.currentTarget.blur();
|
|
77
|
+
}
|
|
78
|
+
}, onBlur: () => setDraft('') }));
|
|
79
|
+
}
|
|
80
|
+
function Chevron({ back }) {
|
|
81
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: back ? 'm15 6-6 6 6 6' : 'm9 6 6 6-6 6' }) }));
|
|
82
|
+
}
|
|
83
|
+
export default function Pagination({ position, limit, total, count, onChange, label, unit = 'items', className, }) {
|
|
84
|
+
const page = Math.floor(position / limit) + 1;
|
|
85
|
+
const pages = total === undefined ? undefined : Math.max(1, Math.ceil(total / limit));
|
|
86
|
+
const shown = count ?? limit;
|
|
87
|
+
const first = shown === 0 ? 0 : position + 1;
|
|
88
|
+
const last = position + shown;
|
|
89
|
+
const canGoBack = position > 0;
|
|
90
|
+
/* With a total, the last page is known. Without one, a full page means there
|
|
91
|
+
is probably more and a short one means there is not -- which is wrong
|
|
92
|
+
exactly once, on a list whose length is a multiple of the limit, and costs
|
|
93
|
+
one empty page rather than a hidden remainder. */
|
|
94
|
+
const canGoOn = pages === undefined ? shown >= limit : page < pages;
|
|
95
|
+
return (_jsxs("nav", { className: `pager${className ? ` ${className}` : ''}`, "aria-label": label, children: [_jsx("p", { className: "pager-count", children: shown === 0 ? (`No ${unit}`) : (_jsxs(_Fragment, { children: [_jsxs("strong", { children: [group(first), "\u2013", group(last)] }), total === undefined ? ` ${unit}` : ` of ${group(total)} ${unit}`] })) }), _jsxs("div", { className: "pager-controls", children: [_jsx("button", { type: "button", className: "pager-step", onClick: () => onChange(Math.max(0, position - limit)), disabled: !canGoBack, "aria-label": "Previous page", children: _jsx(Chevron, { back: true }) }), pages !== undefined && (_jsx("ol", { className: "pager-pages", children: pageWindow(page, pages).map((slot, index, slots) => slot === 'gap' ? (
|
|
96
|
+
/* Keyed by the page it follows rather than by its index: a
|
|
97
|
+
window holds at most two gaps and they elide different runs,
|
|
98
|
+
and an index key makes React reuse the wrong one -- which
|
|
99
|
+
would carry a half-typed page number from one to the other. */
|
|
100
|
+
_jsx("li", { children: _jsx(JumpField, { pages: pages, onGo: (wanted) => onChange((wanted - 1) * limit) }) }, `gap-after-${slots[index - 1]}`)) : (_jsx("li", { children: _jsx("button", { type: "button", className: `pager-page${slot === page ? ' on' : ''}`, "aria-current": slot === page ? 'page' : undefined, "aria-label": `Page ${slot}`, onClick: () => onChange((slot - 1) * limit), children: slot }) }, slot))) })), _jsx("button", { type: "button", className: "pager-step", onClick: () => onChange(position + limit), disabled: !canGoOn, "aria-label": "Next page", children: _jsx(Chevron, {}) })] })] }));
|
|
101
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Placement } from 'react-aria-components';
|
|
2
|
+
/**
|
|
3
|
+
* A small surface anchored to the thing that opened it.
|
|
4
|
+
*
|
|
5
|
+
* The middle term between `Tooltip` and `Modal`, and the distinction is what
|
|
6
|
+
* it contains rather than how big it is. A tooltip *says* something and you
|
|
7
|
+
* cannot touch it. A modal takes the application away until it is answered. A
|
|
8
|
+
* popover holds controls -- a filter, a colour, a signature picker -- and the
|
|
9
|
+
* page behind it stays live, because the whole point is to adjust something
|
|
10
|
+
* and watch it change.
|
|
11
|
+
*
|
|
12
|
+
* **It is a dialog, not a div.** Focus moves into it on open, comes back to the
|
|
13
|
+
* trigger on close, Escape closes it and an outside press closes it. Every one
|
|
14
|
+
* of those is what people expect from something that appeared over the page,
|
|
15
|
+
* and every one is missing from the `position: absolute` panel that gets
|
|
16
|
+
* written instead. The panel is worse in a way that is hard to see and easy to
|
|
17
|
+
* hit: tab out of it and you are behind it, operating the page it is covering.
|
|
18
|
+
*
|
|
19
|
+
* **It flips.** `placement` is a preference, not an instruction. React Aria
|
|
20
|
+
* measures the space and moves the surface to the other side when the edge is
|
|
21
|
+
* near, which is the failure the hand-rolled version always ships with -- it
|
|
22
|
+
* works everywhere except at the bottom of the window, where the content
|
|
23
|
+
* appears off screen.
|
|
24
|
+
*
|
|
25
|
+
* Uncontrolled by default: hand it a trigger and children, and it opens and
|
|
26
|
+
* closes itself. `open`/`onOpenChange` are for the case where something else
|
|
27
|
+
* has to close it -- a keyboard shortcut, a route change.
|
|
28
|
+
*/
|
|
29
|
+
export interface Props {
|
|
30
|
+
/** What opens it. Any focusable element; a `Button` is the usual one. */
|
|
31
|
+
trigger: React.ReactNode;
|
|
32
|
+
/** The contents. A render function receives `close` for a surface whose own
|
|
33
|
+
* controls dismiss it -- picking a colour, applying a filter. */
|
|
34
|
+
children: React.ReactNode | ((close: () => void) => React.ReactNode);
|
|
35
|
+
/** Which side it prefers. It will use another if this one does not fit. */
|
|
36
|
+
placement?: Placement;
|
|
37
|
+
/** Names the surface. A dialog owes an accessible name; without one a
|
|
38
|
+
* screen reader announces "dialog" and nothing else. */
|
|
39
|
+
label: string;
|
|
40
|
+
/** Gap between the trigger and the surface. */
|
|
41
|
+
offset?: number;
|
|
42
|
+
open?: boolean;
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
export default function Popover({ trigger, children, placement, label, offset, open, onOpenChange, className, }: Props): import("react").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Popover as AriaPopover, Dialog, DialogTrigger, } from 'react-aria-components';
|
|
3
|
+
export default function Popover({ trigger, children, placement = 'bottom start', label, offset = 6, open, onOpenChange, className, }) {
|
|
4
|
+
return (_jsxs(DialogTrigger, { isOpen: open, onOpenChange: onOpenChange, children: [trigger, _jsx(AriaPopover, { className: `pop${className ? ` ${className}` : ''}`, placement: placement, offset: offset, children: _jsx(Dialog, { className: "pop-body", "aria-label": label, children: ({ close }) => (typeof children === 'function' ? children(close) : children) }) })] }));
|
|
5
|
+
}
|