@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.
@@ -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
+ }
@@ -22,13 +22,13 @@
22
22
  * **Inlined rather than installed.** The npm package ships the whole set, and a
23
23
  * font or a bundle has to load before anything draws -- in an app that opens
24
24
  * offline, on a machine that has just downloaded it, that is a flash of missing
25
- * chrome on the one screen that has to inspire confidence. Nineteen glyphs cost
26
- * nothing and cannot fail to arrive.
25
+ * chrome on the one screen that has to inspire confidence. Forty-two glyphs
26
+ * cost nothing and cannot fail to arrive.
27
27
  *
28
28
  * `currentColor` throughout, so an icon takes the colour of the thing it sits
29
29
  * in and never needs a variant.
30
30
  *
31
- * Source names, if you need to add a twentieth from https://pepicons.com:
31
+ * Source names, if you need to add another from https://pepicons.com:
32
32
  * chat text-bubble
33
33
  * settings gear
34
34
  * refresh arrows-spin
@@ -49,7 +49,20 @@
49
49
  * mic microphone
50
50
  * back arrow-left
51
51
  * spinner arrow-spin
52
- file file
52
+ * file file
53
+ * mail letter
54
+ * mail-open letter-open
55
+ * pen pen
56
+ * send paper-plane
57
+ * trash trash
58
+ * paperclip paperclip
59
+ * star star
60
+ * star-filled star-filled
61
+ * search loop (Pepicons' name for a magnifier)
62
+ * more dots-x
63
+ * menu menu
64
+ * reply arrow-up-left
65
+ * forward arrow-up-right
53
66
  */
54
67
  import type { IconName } from './iconNames.js';
55
68
  export type { IconName };
@@ -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
+ }
@@ -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;
@@ -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: "backdrop", isOpen: true, isDismissable: canClose && dismissOnScrim, isKeyboardDismissDisabled: !canClose, onOpenChange: (open) => {
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
+ }