@gusnips/react 0.9.25 → 0.9.27

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/README.md CHANGED
@@ -350,6 +350,38 @@ It keeps the page and drops only a fragment carrying a credential, because the p
350
350
  worth returning to. `createRequireAuth` uses it; use it anywhere else you record where someone was:
351
351
  a dead-session redirect is the common one.
352
352
 
353
+ ## Keep the email between sign-in screens
354
+
355
+ ```tsx
356
+ import { recallAuthEmail, rememberAuthEmail } from "@gusnips/react";
357
+
358
+ <input
359
+ name="email"
360
+ type="email"
361
+ defaultValue={recallAuthEmail()}
362
+ onChange={(event) => rememberAuthEmail(event.currentTarget.value)}
363
+ />;
364
+ ```
365
+
366
+ Sign in, "use a code instead", forgot password and create account are separate screens, and each
367
+ one used to open with an empty email field. Put those two props on every one of them and the
368
+ address follows the person. Call `forgetAuthEmail()` where your app learns a session exists, so the
369
+ next person at this browser starts empty.
370
+
371
+ It is kept in `localStorage` for 30 minutes after the last keystroke, and never in a URL, where it
372
+ would reach `Referer`, access logs, history and analytics. It is `localStorage` and not
373
+ `sessionStorage` because an emailed link opens in a new tab, and a new tab does not inherit
374
+ `sessionStorage`. That tab is where a person lands on sign-in after confirming an account. Where
375
+ the browser blocks site data, or on React Native, the address lasts in memory until the page or the
376
+ app is left.
377
+
378
+ - Read it once, when the screen opens. It is not a store: nothing re-renders when it changes. On a
379
+ prerendered page, read it after hydration, never while rendering.
380
+ - Leave the field uncontrolled. A `defaultValue` and a change handler are enough. Also call
381
+ `rememberAuthEmail(value)` where you read the field at submit, because some password managers fill
382
+ a form without firing events.
383
+ - Never put a password, a code or a token here.
384
+
353
385
  ## Never dead-end anyone
354
386
 
355
387
  ```ts
@@ -497,10 +529,155 @@ toast needs no `catch`. Outside a component, `copyText(text)` does the write on
497
529
  The words are yours, and so is the live region: a label that changes needs `aria-live` to be
498
530
  heard.
499
531
 
532
+ ## A busy button a person can see
533
+
534
+ ```tsx
535
+ import { BusyRing, useHeldPending } from "@gusnips/react";
536
+
537
+ function SaveButton({ saving, ...props }: { saving: boolean } & ButtonProps) {
538
+ const busy = useHeldPending(saving);
539
+ return (
540
+ <button {...props} disabled={busy} aria-busy={busy} className="relative rounded-md ...">
541
+ Save
542
+ {busy && <BusyRing />}
543
+ </button>
544
+ );
545
+ }
546
+ ```
547
+
548
+ ```css
549
+ @import "@gusnips/tokens/busy-ring.css";
550
+ ```
551
+
552
+ A refresh that answers in 80 ms turns its pending flag on and off inside a frame. Nothing was
553
+ painted, so the reader pressed "Reload", saw nothing change, and decided the press was ignored.
554
+ A louder spinner cannot fix that, because the spinner never shows. `useHeldPending(pending)`
555
+ keeps the flag on for at least 400 ms once it turns on. It is `true` on the very render
556
+ `pending` turns true, so the busy look is there on the press and not a frame later, and a slow
557
+ request is not made slower. Pass a second argument to change the 400.
558
+
559
+ `<BusyRing />` is a ring that runs round the button's edge. It paints in `currentColor`, so it
560
+ contrasts with every variant in both themes and adds nothing to your palette. The button needs
561
+ `position: relative` and a disabled style that does not dim it (`disabled:opacity-100`), or the
562
+ ring shows at half strength. It copies the button's `border-radius`.
563
+
564
+ Tune it in your own CSS: `--busy-ring-width` (2px), `--busy-ring-duration` (900ms) and
565
+ `--busy-ring-duration-reduced` (2400ms). Under reduced motion the ring turns slower and does not
566
+ stop, because a frozen ring reads as a hang. The `@gusnips/tokens` base rules clamp every
567
+ animation to one pass under reduced motion, and the ring wins that fight on purpose.
568
+
569
+ There is no shared `Button`, so the two lines above live in your button once. That is also the
570
+ place to change the look later: swap the ring for an icon or a label there, or edit the CSS for a
571
+ different ring everywhere.
572
+
573
+ ## Slash commands in a text field
574
+
575
+ A slash command is text like `/help` that picks an action instead of sending a message.
576
+
577
+ ```ts
578
+ import { parseCommand } from "@gusnips/react/commands";
579
+
580
+ parseCommand("/help");
581
+ // { name: "help" }
582
+ ```
583
+
584
+ Names have no leading slash. Give each command other names (`aliases`) and search words (`tags`):
585
+
586
+ ```ts
587
+ const commands = [
588
+ {
589
+ name: "help",
590
+ description: "See commands",
591
+ aliases: ["commands", "shortcuts"],
592
+ tags: ["keyboard", "guide"],
593
+ },
594
+ ];
595
+ ```
596
+
597
+ `/commands` runs `/help`. Searching for `keyboard` finds its menu row, but `/keyboard` is not an
598
+ executable name. `matchCommands(commands, query)` searches names, aliases, tags and descriptions,
599
+ ignoring case and accents. Exact names rank first, then aliases, name prefixes, alias prefixes,
600
+ tags and descriptions. Equal ranks keep registry order; each name gets one row.
601
+
602
+ `resolveCommand(commands, name)` answers `matched`, `unknown` or `ambiguous`. An exact name wins
603
+ over an alias. Two commands sharing an alias are ambiguous, not a reason to run the first one.
604
+
605
+ For a controlled text field, use the hook:
606
+
607
+ ```tsx
608
+ import { useSlashCommands } from "@gusnips/react/commands";
609
+
610
+ const slash = useSlashCommands({
611
+ text,
612
+ commands,
613
+ onChange: setText,
614
+ onExecute: runCommand,
615
+ onError: showCommandError,
616
+ });
617
+
618
+ function submit() {
619
+ if (slash.submit()) return;
620
+ sendMessage(text);
621
+ }
622
+ ```
623
+
624
+ The functions in that example belong to your app. `runCommand(command, arg, altKey)` gets the
625
+ original command object, the argument and whether Alt was held. It owns permissions and action
626
+ errors. Supply only commands allowed in this field, with descriptions and tags in the current
627
+ language. Changing those commands or the language refreshes the menu even if `text` stays the same.
628
+
629
+ Call `slash.handleKeyDown(event)` first in the field's key handler. If it returns `true`, stop
630
+ your own handler. Arrows move the selection; Enter picks it. Tab completes the name without
631
+ running it. Escape hides the menu until the query changes. Shift+Enter stays yours. During IME
632
+ composition (when a keyboard combines keystrokes into a character), the hook lets the browser
633
+ finish that character without running or sending anything.
634
+
635
+ Render the rows in your own menu. Keep focus in the field with the browser's `preventDefault`
636
+ on mouse down; use click for the action so keyboard and screen-reader clicks work too:
637
+
638
+ ```tsx
639
+ return slash.open ? (
640
+ <div id={slash.listId} role="listbox" aria-label="Commands">
641
+ {slash.items.map((command, index) => (
642
+ <button
643
+ key={command.name}
644
+ id={slash.itemId(command)}
645
+ type="button"
646
+ role="option"
647
+ tabIndex={-1}
648
+ aria-selected={slash.index === index}
649
+ onMouseEnter={() => slash.highlight(index)}
650
+ onMouseDown={(event) => event.preventDefault()}
651
+ onClick={(event) => slash.pick(command, event.altKey)}
652
+ >
653
+ /{command.name}: {command.description}
654
+ </button>
655
+ ))}
656
+ </div>
657
+ ) : null;
658
+ ```
659
+
660
+ Give the field `role="combobox"`, `aria-autocomplete="list"`, `aria-expanded={slash.open}`,
661
+ `aria-controls={slash.open ? slash.listId : undefined}` and `aria-activedescendant={slash.activeId}`.
662
+ Translate the menu label too. If there are no rows, explain how to find commands and offer a
663
+ "See commands" button that calls `slash.showAll()`. It opens the full list, even after Escape.
664
+ `onError` is required: show what was not recognized, or which names share an alias, with the
665
+ same way back to the list.
666
+
667
+ Set `takesArg: true` when a command accepts an argument. Selecting it, or submitting its bare
668
+ alias, completes `/name ` without running it. Completion keeps arguments already typed.
669
+ `parseCommand` leaves multiline text as a normal message.
670
+
671
+ Call `submit()` before every normal send, queue or quota check, including button and imperative
672
+ paths. It returns `true` for commands it ran, completed or rejected, so unknown commands never
673
+ silently become model messages. It returns `false` for ordinary text. The hook never sends a
674
+ message, clears a draft or changes attached files; those stay with the app. It also has no timer
675
+ or scheduler: offer a repeat command only when your app has real scheduling behind it.
676
+
500
677
  ## Also here
501
678
 
502
679
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
503
- `i18nInitOptions`, and `cn`.
680
+ `i18nInitOptions`, `useHeldPending` and `BusyRing` (above), and `cn`.
504
681
 
505
682
  Pass `storageWriter: "app"` to `i18nInitOptions` when your own language control writes the stored
506
683
  locale. Leave it out and the detector writes it, which is right until you offer "follow the
@@ -577,6 +754,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
577
754
  | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
578
755
  | `@gusnips/react/contract` | two prerender names | nothing |
579
756
  | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
757
+ | `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
580
758
 
581
759
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
582
760
  point.** An optional peer the barrel imports anyway is not optional: it is a required one whose
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The ring that runs round a busy control's edge. Put it inside the control while it is busy.
3
+ *
4
+ * It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
5
+ * once. The control needs `position: relative` and the radius it already has (the ring copies it),
6
+ * and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
7
+ *
8
+ * Why a component for one `span`: the class name and the markup are one contract. Changing how
9
+ * busy looks, a second element or another shape, is then one edit here and one release, not one
10
+ * edit in every product's button.
11
+ */
12
+ export declare function BusyRing(): import("react").JSX.Element;
13
+ //# sourceMappingURL=busy-ring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"busy-ring.d.ts","sourceRoot":"","sources":["../src/busy-ring.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,gCAEvB"}
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The ring that runs round a busy control's edge. Put it inside the control while it is busy.
4
+ *
5
+ * It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
6
+ * once. The control needs `position: relative` and the radius it already has (the ring copies it),
7
+ * and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
8
+ *
9
+ * Why a component for one `span`: the class name and the markup are one contract. Changing how
10
+ * busy looks, a second element or another shape, is then one edit here and one release, not one
11
+ * edit in every product's button.
12
+ */
13
+ export function BusyRing() {
14
+ return _jsx("span", { "aria-hidden": "true", className: "busy-ring" });
15
+ }
16
+ //# sourceMappingURL=busy-ring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"busy-ring.js","sourceRoot":"","sources":["../src/busy-ring.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,QAAQ;IACtB,OAAO,8BAAkB,MAAM,EAAC,SAAS,EAAC,WAAW,GAAG,CAAC;AAC3D,CAAC"}
@@ -0,0 +1,80 @@
1
+ export interface SlashCommand {
2
+ /** One token, without the leading slash. */
3
+ name: string;
4
+ description: string;
5
+ /** Other executable names. Tags only help search. */
6
+ aliases: readonly string[];
7
+ tags: readonly string[];
8
+ takesArg?: boolean;
9
+ }
10
+ export interface ParsedCommand {
11
+ name: string;
12
+ /** Undefined before a space; empty after a space with no argument yet. */
13
+ arg?: string;
14
+ }
15
+ /** Multiline text and a slash inside a message are not commands. */
16
+ export declare function parseCommand(text: string): ParsedCommand | null;
17
+ export type CommandResolution<T extends SlashCommand = SlashCommand> = {
18
+ kind: "matched";
19
+ command: T;
20
+ } | {
21
+ kind: "unknown";
22
+ name: string;
23
+ } | {
24
+ kind: "ambiguous";
25
+ name: string;
26
+ commands: readonly T[];
27
+ };
28
+ /** Exact names win over aliases. Search tags never execute an action. */
29
+ export declare function resolveCommand<T extends SlashCommand>(commands: readonly T[], name: string): CommandResolution<T>;
30
+ /** One row per name, in registry order when two results have the same rank. */
31
+ export declare function matchCommands<T extends SlashCommand>(commands: readonly T[], query: string): T[];
32
+ /** Replace only the name, keeping the argument already typed. */
33
+ export declare function completeCommand(command: SlashCommand, text: string): string;
34
+ export type CommandProblem<T extends SlashCommand = SlashCommand> = Exclude<CommandResolution<T>, {
35
+ kind: "matched";
36
+ }>;
37
+ export interface CommandOptions<T extends SlashCommand> {
38
+ text: string;
39
+ /** Supply only commands allowed here, with current translated descriptions and tags. */
40
+ commands: readonly T[];
41
+ onChange: (text: string) => void;
42
+ /** The app owns authorization, errors, draft cleanup and attachments. */
43
+ onExecute: (command: T, arg: string | undefined, altKey: boolean) => void;
44
+ /** Required: show a way back to the available commands, never send this text to the model. */
45
+ onError: (problem: CommandProblem<T>) => void;
46
+ }
47
+ /** Structural so React events and small test events both fit. */
48
+ export interface CommandKeyEvent {
49
+ key: string;
50
+ shiftKey?: boolean;
51
+ altKey?: boolean;
52
+ ctrlKey?: boolean;
53
+ metaKey?: boolean;
54
+ isComposing?: boolean;
55
+ keyCode?: number;
56
+ nativeEvent?: {
57
+ isComposing?: boolean;
58
+ keyCode?: number;
59
+ };
60
+ preventDefault: () => void;
61
+ }
62
+ export interface SlashCommandMenu<T extends SlashCommand = SlashCommand> {
63
+ parsed: ParsedCommand | null;
64
+ open: boolean;
65
+ items: readonly T[];
66
+ index: number;
67
+ active: T | undefined;
68
+ listId: string;
69
+ itemId: (command: T) => string;
70
+ activeId: string | undefined;
71
+ highlight: (index: number) => void;
72
+ close: () => void;
73
+ showAll: () => void;
74
+ pick: (command: T, altKey?: boolean) => void;
75
+ submit: (altKey?: boolean) => boolean;
76
+ handleKeyDown: (event: CommandKeyEvent) => boolean;
77
+ }
78
+ /** A controlled textarea menu. No UI, browser globals, timers or attachment state. */
79
+ export declare function useSlashCommands<T extends SlashCommand>({ text, commands, onChange, onExecute, onError, }: CommandOptions<T>): SlashCommandMenu<T>;
80
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,192 @@
1
+ import { useId, useState } from "react";
2
+ /** Multiline text and a slash inside a message are not commands. */
3
+ export function parseCommand(text) {
4
+ if (/[\r\n\u{2028}\u{2029}]/u.test(text))
5
+ return null;
6
+ const match = /^\s*\/(\S*)(?:[^\S\r\n]+(.*))?$/u.exec(text);
7
+ if (!match)
8
+ return null;
9
+ const name = (match[1] ?? "").toLowerCase();
10
+ return match[2] === undefined ? { name } : { name, arg: match[2] };
11
+ }
12
+ /** Exact names win over aliases. Search tags never execute an action. */
13
+ export function resolveCommand(commands, name) {
14
+ const token = name.toLowerCase();
15
+ if (!token)
16
+ return { kind: "unknown", name };
17
+ let matches = commands.filter((command) => command.name.toLowerCase() === token);
18
+ if (matches.length === 0) {
19
+ matches = commands.filter((command) => command.aliases.some((alias) => alias.toLowerCase() === token));
20
+ }
21
+ if (matches.length > 1)
22
+ return { kind: "ambiguous", name, commands: matches };
23
+ const command = matches[0];
24
+ return command ? { kind: "matched", command } : { kind: "unknown", name };
25
+ }
26
+ function searchText(text) {
27
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
28
+ }
29
+ function rank(command, query) {
30
+ if (!query)
31
+ return 0;
32
+ const name = searchText(command.name);
33
+ const aliases = command.aliases.map(searchText);
34
+ if (name === query)
35
+ return 0;
36
+ if (aliases.includes(query))
37
+ return 1;
38
+ if (name.startsWith(query))
39
+ return 2;
40
+ if (aliases.some((alias) => alias.startsWith(query)))
41
+ return 3;
42
+ if (command.tags.some((tag) => searchText(tag).includes(query)))
43
+ return 4;
44
+ if (searchText(command.description).includes(query))
45
+ return 5;
46
+ return Infinity;
47
+ }
48
+ /** One row per name, in registry order when two results have the same rank. */
49
+ export function matchCommands(commands, query) {
50
+ const token = searchText(query.trim());
51
+ const names = new Set();
52
+ return commands
53
+ .filter((command) => {
54
+ const name = command.name.toLowerCase();
55
+ if (names.has(name))
56
+ return false;
57
+ names.add(name);
58
+ return true;
59
+ })
60
+ .map((command) => ({ command, rank: rank(command, token) }))
61
+ .filter((match) => Number.isFinite(match.rank))
62
+ .sort((a, b) => a.rank - b.rank)
63
+ .map((match) => match.command);
64
+ }
65
+ /** Replace only the name, keeping the argument already typed. */
66
+ export function completeCommand(command, text) {
67
+ const arg = parseCommand(text)?.arg;
68
+ if (arg !== undefined)
69
+ return `/${command.name} ${arg}`;
70
+ return `/${command.name}${command.takesArg ? " " : ""}`;
71
+ }
72
+ /** A controlled textarea menu. No UI, browser globals, timers or attachment state. */
73
+ export function useSlashCommands({ text, commands, onChange, onExecute, onError, }) {
74
+ const parsed = parseCommand(text);
75
+ const resolution = parsed ? resolveCommand(commands, parsed.name) : null;
76
+ const editingName = parsed !== null && (parsed.arg === undefined || resolution?.kind !== "matched");
77
+ const items = editingName ? matchCommands(commands, parsed.name) : [];
78
+ const key = JSON.stringify([text, items.map((command) => command.name)]);
79
+ const [state, setState] = useState({ key, text, index: 0, deliberate: false, dismissed: false });
80
+ // Reset selection on a changed result set, but keep Escape's dismissal until the draft changes.
81
+ if (state.key !== key) {
82
+ setState({
83
+ key,
84
+ text,
85
+ index: 0,
86
+ deliberate: false,
87
+ dismissed: state.text === text && state.dismissed,
88
+ });
89
+ }
90
+ const listId = `${useId()}-commands`;
91
+ const open = editingName && !state.dismissed;
92
+ const active = open ? items[state.index] : undefined;
93
+ const itemId = (command) => `${listId}-${encodeURIComponent(command.name)}`;
94
+ function close() {
95
+ setState((current) => ({ ...current, dismissed: true }));
96
+ }
97
+ function showAll() {
98
+ setState((current) => ({ ...current, index: 0, deliberate: false, dismissed: false }));
99
+ onChange("/");
100
+ }
101
+ function highlight(index) {
102
+ if (!items[index])
103
+ return;
104
+ setState((current) => ({ ...current, index, deliberate: true }));
105
+ }
106
+ function pick(command, altKey = false) {
107
+ // Re-resolve against current capabilities, not a descriptor retained by a caller.
108
+ const selected = resolveCommand(commands, command.name);
109
+ if (selected.kind !== "matched") {
110
+ onError(selected);
111
+ return;
112
+ }
113
+ if (selected.command.takesArg || parsed?.arg) {
114
+ onChange(completeCommand(selected.command, text));
115
+ return;
116
+ }
117
+ onExecute(selected.command, parsed?.arg, altKey);
118
+ }
119
+ /** Call before the normal send/queue/quota path, including button and imperative submits. */
120
+ function submit(altKey = false) {
121
+ if (!parsed || !resolution)
122
+ return false;
123
+ if (resolution.kind !== "matched") {
124
+ onError(resolution);
125
+ return true;
126
+ }
127
+ if (resolution.command.takesArg &&
128
+ parsed.arg === undefined &&
129
+ parsed.name !== resolution.command.name.toLowerCase()) {
130
+ onChange(completeCommand(resolution.command, text));
131
+ return true;
132
+ }
133
+ onExecute(resolution.command, parsed.arg, altKey);
134
+ return true;
135
+ }
136
+ /** Call first. True means skip your own key handler; IME still gets the browser's default. */
137
+ function handleKeyDown(event) {
138
+ const native = event.nativeEvent ?? event;
139
+ if (native.isComposing || native.keyCode === 229)
140
+ return true;
141
+ if (event.shiftKey)
142
+ return false;
143
+ if (open && event.key === "Escape") {
144
+ event.preventDefault();
145
+ close();
146
+ return true;
147
+ }
148
+ if (open && items.length > 0 && !event.ctrlKey && !event.metaKey) {
149
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
150
+ event.preventDefault();
151
+ const step = event.key === "ArrowDown" ? 1 : -1;
152
+ highlight((state.index + step + items.length) % items.length);
153
+ return true;
154
+ }
155
+ if (active && (event.key === "Enter" || event.key === "Tab")) {
156
+ event.preventDefault();
157
+ if (event.key === "Tab") {
158
+ onChange(completeCommand(active, text));
159
+ }
160
+ else if (resolution?.kind === "ambiguous" && !state.deliberate) {
161
+ onError(resolution);
162
+ }
163
+ else {
164
+ pick(active, event.altKey);
165
+ }
166
+ return true;
167
+ }
168
+ }
169
+ if (parsed && event.key === "Enter") {
170
+ event.preventDefault();
171
+ return submit(event.altKey);
172
+ }
173
+ return false;
174
+ }
175
+ return {
176
+ parsed,
177
+ open,
178
+ items,
179
+ index: state.index,
180
+ active,
181
+ listId,
182
+ itemId,
183
+ activeId: active ? itemId(active) : undefined,
184
+ highlight,
185
+ close,
186
+ showAll,
187
+ pick,
188
+ submit,
189
+ handleKeyDown,
190
+ };
191
+ }
192
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.js","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAkBxC,oEAAoE;AACpE,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,yBAAyB,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtD,MAAM,KAAK,GAAG,kCAAkC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5D,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAC5C,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AACrE,CAAC;AAOD,yEAAyE;AACzE,MAAM,UAAU,cAAc,CAC5B,QAAsB,EACtB,IAAY;IAEZ,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACjC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC7C,IAAI,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC,CAAC;IACjF,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CACpC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC,CAC/D,CAAC;IACJ,CAAC;IACD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;IAC9E,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC3B,OAAO,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;AAC5E,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;AACpE,CAAC;AAED,SAAS,IAAI,CAAC,OAAqB,EAAE,KAAa;IAChD,IAAI,CAAC,KAAK;QAAE,OAAO,CAAC,CAAC;IACrB,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IAChD,IAAI,IAAI,KAAK,KAAK;QAAE,OAAO,CAAC,CAAC;IAC7B,IAAI,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACrC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC/D,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1E,IAAI,UAAU,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAC9D,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,aAAa,CAAyB,QAAsB,EAAE,KAAa;IACzF,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;IACvC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,OAAO,QAAQ;SACZ,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE;QAClB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;QACxC,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QAClC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;SACD,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;SAC3D,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;SAC9C,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC;SAC/B,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;AACnC,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,eAAe,CAAC,OAAqB,EAAE,IAAY;IACjE,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC;IACpC,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,IAAI,OAAO,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC;IACxD,OAAO,IAAI,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;AAC1D,CAAC;AAgDD,sFAAsF;AACtF,MAAM,UAAU,gBAAgB,CAAyB,EACvD,IAAI,EACJ,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,OAAO,GACW;IAClB,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,UAAU,GAAG,MAAM,CAAC,CAAC,CAAC,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACzE,MAAM,WAAW,GACf,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,KAAK,SAAS,IAAI,UAAU,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC;IAClF,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACtE,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACzE,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;IACjG,gGAAgG;IAChG,IAAI,KAAK,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;QACtB,QAAQ,CAAC;YACP,GAAG;YACH,IAAI;YACJ,KAAK,EAAE,CAAC;YACR,UAAU,EAAE,KAAK;YACjB,SAAS,EAAE,KAAK,CAAC,IAAI,KAAK,IAAI,IAAI,KAAK,CAAC,SAAS;SAClD,CAAC,CAAC;IACL,CAAC;IAED,MAAM,MAAM,GAAG,GAAG,KAAK,EAAE,WAAW,CAAC;IACrC,MAAM,IAAI,GAAG,WAAW,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC;IAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACrD,MAAM,MAAM,GAAG,CAAC,OAAU,EAAE,EAAE,CAAC,GAAG,MAAM,IAAI,kBAAkB,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;IAE/E,SAAS,KAAK;QACZ,QAAQ,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAC3D,CAAC;IAED,SAAS,OAAO;QACd,QAAQ,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;QACvF,QAAQ,CAAC,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,SAAS,SAAS,CAAC,KAAa;QAC9B,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;YAAE,OAAO;QAC1B,QAAQ,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IACnE,CAAC;IAED,SAAS,IAAI,CAAC,OAAU,EAAE,MAAM,GAAG,KAAK;QACtC,kFAAkF;QAClF,MAAM,QAAQ,GAAG,cAAc,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACxD,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAChC,OAAO,CAAC,QAAQ,CAAC,CAAC;YAClB,OAAO;QACT,CAAC;QACD,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,IAAI,MAAM,EAAE,GAAG,EAAE,CAAC;YAC7C,QAAQ,CAAC,eAAe,CAAC,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;YAClD,OAAO;QACT,CAAC;QACD,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;IAED,6FAA6F;IAC7F,SAAS,MAAM,CAAC,MAAM,GAAG,KAAK;QAC5B,IAAI,CAAC,MAAM,IAAI,CAAC,UAAU;YAAE,OAAO,KAAK,CAAC;QACzC,IAAI,UAAU,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAClC,OAAO,CAAC,UAAU,CAAC,CAAC;YACpB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IACE,UAAU,CAAC,OAAO,CAAC,QAAQ;YAC3B,MAAM,CAAC,GAAG,KAAK,SAAS;YACxB,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,EACrD,CAAC;YACD,QAAQ,CAAC,eAAe,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;YACpD,OAAO,IAAI,CAAC;QACd,CAAC;QACD,SAAS,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAClD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8FAA8F;IAC9F,SAAS,aAAa,CAAC,KAAsB;QAC3C,MAAM,MAAM,GAAG,KAAK,CAAC,WAAW,IAAI,KAAK,CAAC;QAC1C,IAAI,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,OAAO,KAAK,GAAG;YAAE,OAAO,IAAI,CAAC;QAC9D,IAAI,KAAK,CAAC,QAAQ;YAAE,OAAO,KAAK,CAAC;QACjC,IAAI,IAAI,IAAI,KAAK,CAAC,GAAG,KAAK,QAAQ,EAAE,CAAC;YACnC,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,KAAK,EAAE,CAAC;YACR,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;YACjE,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;gBACzD,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,KAAK,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;gBAChD,SAAS,CAAC,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC;gBAC9D,OAAO,IAAI,CAAC;YACd,CAAC;YACD,IAAI,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,EAAE,CAAC;gBAC7D,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,IAAI,KAAK,CAAC,GAAG,KAAK,KAAK,EAAE,CAAC;oBACxB,QAAQ,CAAC,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;gBAC1C,CAAC;qBAAM,IAAI,UAAU,EAAE,IAAI,KAAK,WAAW,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC;oBACjE,OAAO,CAAC,UAAU,CAAC,CAAC;gBACtB,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBAC7B,CAAC;gBACD,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;QACD,IAAI,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,OAAO,EAAE,CAAC;YACpC,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,OAAO,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC9B,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO;QACL,MAAM;QACN,IAAI;QACJ,KAAK;QACL,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,MAAM;QACN,MAAM;QACN,MAAM;QACN,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7C,SAAS;QACT,KAAK;QACL,OAAO;QACP,IAAI;QACJ,MAAM;QACN,aAAa;KACd,CAAC;AACJ,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `pending`, kept on for at least `minMs` once it turns on.
3
+ *
4
+ * It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
5
+ * busy state shows on the press and not a frame later. A slow request is not made slower: the
6
+ * hold only fills the gap when the request ended early, and ends within one timer tick of it
7
+ * otherwise.
8
+ *
9
+ * 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
10
+ * look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
11
+ * what is shown, never what the request does.
12
+ */
13
+ export declare function useHeldPending(pending: boolean, minMs?: number): boolean;
14
+ //# sourceMappingURL=held-pending.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"held-pending.d.ts","sourceRoot":"","sources":["../src/held-pending.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,SAAM,GAAG,OAAO,CAmBrE"}
@@ -0,0 +1,36 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ // A refresh that answers in 80 ms flips its `pending` flag on and off inside a frame or two. The
3
+ // reader pressed the button, nothing on screen changed, and the page looks like it ignored them.
4
+ // A louder spinner does not fix that, because the spinner is never painted. The flag itself has
5
+ // to stay on long enough to be seen.
6
+ /**
7
+ * `pending`, kept on for at least `minMs` once it turns on.
8
+ *
9
+ * It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
10
+ * busy state shows on the press and not a frame later. A slow request is not made slower: the
11
+ * hold only fills the gap when the request ended early, and ends within one timer tick of it
12
+ * otherwise.
13
+ *
14
+ * 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
15
+ * look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
16
+ * what is shown, never what the request does.
17
+ */
18
+ export function useHeldPending(pending, minMs = 400) {
19
+ const [held, setHeld] = useState(false);
20
+ const startedAt = useRef(0);
21
+ // Raised while rendering, not in an effect, so there is no frame where `pending` is true and the
22
+ // hold is not. It only ever turns on here. The timer below is the one place it turns off.
23
+ if (pending && !held)
24
+ setHeld(true);
25
+ useEffect(() => {
26
+ if (pending) {
27
+ startedAt.current = Date.now();
28
+ return;
29
+ }
30
+ const remaining = Math.max(0, startedAt.current + minMs - Date.now());
31
+ const timer = setTimeout(() => setHeld(false), remaining);
32
+ return () => clearTimeout(timer);
33
+ }, [pending, minMs]);
34
+ return pending || held;
35
+ }
36
+ //# sourceMappingURL=held-pending.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"held-pending.js","sourceRoot":"","sources":["../src/held-pending.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEpD,iGAAiG;AACjG,iGAAiG;AACjG,gGAAgG;AAChG,qCAAqC;AAErC;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,OAAgB,EAAE,KAAK,GAAG,GAAG;IAC1D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IAE5B,iGAAiG;IACjG,0FAA0F;IAC1F,IAAI,OAAO,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,EAAE,CAAC;YACZ,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC/B,OAAO;QACT,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QACtE,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC,CAAC;QAC1D,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;IAErB,OAAO,OAAO,IAAI,IAAI,CAAC;AACzB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { cn } from "./cn.ts";
2
2
  export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
3
+ export { BusyRing } from "./busy-ring.tsx";
4
+ export { useHeldPending } from "./held-pending.ts";
3
5
  export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
4
6
  export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
5
7
  export { ApiError, isAbortError } from "./api-error.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@
31
31
  // identical in source.
32
32
  export { cn } from "./cn.js";
33
33
  export { copyText, useCopy } from "./copy.js";
34
+ export { BusyRing } from "./busy-ring.js";
35
+ export { useHeldPending } from "./held-pending.js";
34
36
  export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
35
37
  export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.js";
36
38
  export { ApiError, isAbortError } from "./api-error.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
package/dist/ui/menu.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Menu as Primitive } from "@base-ui/react/menu";
2
2
  import type { ReactNode } from "react";
3
- export declare const Menu: <Payload>(props: Primitive.Root.Props<Payload>) => React.JSX.Element;
3
+ export declare const Menu: typeof Primitive.Root;
4
4
  export declare const MenuTrigger: Primitive.Trigger;
5
5
  export declare const MenuPortal: import("react").ForwardRefExoticComponent<Omit<import("@base-ui/react").ContextMenuPortalProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
6
6
  export declare const MenuPositioner: import("react").ForwardRefExoticComponent<Omit<import("@base-ui/react").MenuPositionerProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;