@eifi1/ui-kit 0.8.1 → 0.9.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/README.md +4 -0
- package/dist/components/amount-input.d.ts +2 -0
- package/dist/components/calculator.d.ts +2 -0
- package/dist/components/chip.d.ts +27 -5
- package/dist/components/chip.js +6 -2
- package/dist/components/chip.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +69 -3
- package/dist/components/date-picker.js +152 -65
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/disclosure.d.ts +11 -2
- package/dist/components/disclosure.js +10 -4
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/floating-panel.d.ts +29 -2
- package/dist/components/floating-panel.js +16 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/number-field.d.ts +2 -0
- package/dist/components/number-input.d.ts +2 -0
- package/dist/components/numpad-sheet.d.ts +2 -0
- package/dist/components/progress-bar.d.ts +16 -2
- package/dist/components/progress-bar.js +4 -2
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/ui.d.ts +37 -14
- package/dist/components/ui.js +28 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -0
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
- package/dist/i18n/locales/de-CH.d.ts +2 -0
- package/dist/i18n/locales/de-informal.d.ts +2 -0
- package/dist/i18n/locales/de.d.ts +2 -0
- package/dist/i18n/locales/de.js +10 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -0
- package/dist/i18n/locales/es.js +10 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -0
- package/dist/i18n/locales/fr.js +10 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -0
- package/dist/i18n/locales/hu.js +10 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -0
- package/dist/i18n/locales/it.js +10 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -0
- package/dist/i18n/locales/zh.js +10 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/search/command-palette.d.ts +49 -1
- package/dist/search/command-palette.js +132 -29
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +141 -0
- package/dist/search/global-search.js +277 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +2 -0
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +2 -1
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/package.json +12 -3
- package/src/components/chip.tsx +36 -6
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +328 -117
- package/src/components/disclosure.tsx +24 -8
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/ui.tsx +88 -37
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -0
- package/src/i18n/locales/de.ts +10 -0
- package/src/i18n/locales/es.ts +10 -0
- package/src/i18n/locales/fr.ts +10 -0
- package/src/i18n/locales/hu.ts +10 -0
- package/src/i18n/locales/it.ts +10 -0
- package/src/i18n/locales/zh.ts +10 -0
- package/src/index.ts +4 -0
- package/src/search/command-palette.tsx +200 -27
- package/src/search/global-search.tsx +484 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +11 -1
|
@@ -144,8 +144,17 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
144
144
|
* settings or analysis page. `bare` draws nothing: a leading chevron that turns
|
|
145
145
|
* down, for an inline "Show 3 hidden accounts" inside something that already has
|
|
146
146
|
* its own surface.
|
|
147
|
+
*
|
|
148
|
+
* `menu` is a row of a `HoverMenu`: the top bar's menu-item look
|
|
149
|
+
* (`TOPBAR_MENU_ITEM_CLASS` — full-width, `px-3 py-2`, regular weight, square, the
|
|
150
|
+
* hover wash with the text colour left alone), an INSET focus ring so the menu's
|
|
151
|
+
* clipping edge cannot cut it off, the chevron at the end (`chevronPosition`
|
|
152
|
+
* defaults to `end` here) and a body with no padding of its own, so the sub-list's
|
|
153
|
+
* rows sit flush like the rows around them. keksdose's account menu opens its
|
|
154
|
+
* language sub-list this way, and on `bare` it took four header overrides and a
|
|
155
|
+
* body one to get there (account-menu.tsx).
|
|
147
156
|
*/
|
|
148
|
-
variant?: "card" | "bare";
|
|
157
|
+
variant?: "card" | "bare" | "menu";
|
|
149
158
|
/**
|
|
150
159
|
* Where a `bare` disclosure draws its chevron. `start` (default) is the leading
|
|
151
160
|
* chevron that turns from the reading direction to down. `end` puts it at the far
|
|
@@ -153,7 +162,7 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
153
162
|
* account menu, whose language sub-list reads "Language 🇩🇪 ⌄" with the chevron
|
|
154
163
|
* after the flag, like every other menu row with a sub-list. With `trailing`, the
|
|
155
164
|
* chevron follows it, as on a card. A `card` always has it at the end, so this is
|
|
156
|
-
* ignored there.
|
|
165
|
+
* ignored there. A `menu` defaults to `end`, like the menu rows around it.
|
|
157
166
|
*/
|
|
158
167
|
chevronPosition?: "start" | "end";
|
|
159
168
|
/**
|
|
@@ -230,7 +239,7 @@ export function Disclosure({
|
|
|
230
239
|
defaultOpen = false,
|
|
231
240
|
onOpenChange,
|
|
232
241
|
variant = "card",
|
|
233
|
-
chevronPosition
|
|
242
|
+
chevronPosition,
|
|
234
243
|
headingAs: Heading,
|
|
235
244
|
keepMounted,
|
|
236
245
|
disabled,
|
|
@@ -247,8 +256,10 @@ export function Disclosure({
|
|
|
247
256
|
const open = controlled ?? own;
|
|
248
257
|
const bodyId = useId();
|
|
249
258
|
const card = variant === "card";
|
|
250
|
-
|
|
251
|
-
|
|
259
|
+
const menu = variant === "menu";
|
|
260
|
+
// The card's chevron trails always; a menu row's unless asked otherwise; a bare
|
|
261
|
+
// one only when asked to.
|
|
262
|
+
const chevronAtEnd = card || (chevronPosition ?? (menu ? "end" : "start")) === "end";
|
|
252
263
|
const triggerOnly = controls !== undefined;
|
|
253
264
|
// The card's header squares its lower corners only when a body opens under it.
|
|
254
265
|
const joined = open && !triggerOnly;
|
|
@@ -280,7 +291,7 @@ export function Disclosure({
|
|
|
280
291
|
cn(
|
|
281
292
|
"min-w-0 flex-1 after:absolute after:inset-0 after:content-['']",
|
|
282
293
|
"focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]",
|
|
283
|
-
card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : "after:rounded-sm",
|
|
294
|
+
card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : !menu && "after:rounded-sm",
|
|
284
295
|
)
|
|
285
296
|
: "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
286
297
|
card
|
|
@@ -289,7 +300,12 @@ export function Disclosure({
|
|
|
289
300
|
hasTrailing ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
|
|
290
301
|
joined && "rounded-b-none",
|
|
291
302
|
)
|
|
292
|
-
:
|
|
303
|
+
: menu
|
|
304
|
+
? // TOPBAR_MENU_ITEM_CLASS's row, spelled out rather than imported: a
|
|
305
|
+
// component does not reach up into the shell. A test holds the two to
|
|
306
|
+
// the same classes.
|
|
307
|
+
"items-center justify-between gap-3 px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] focus-visible:ring-inset"
|
|
308
|
+
: "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
293
309
|
headerClassName,
|
|
294
310
|
)}
|
|
295
311
|
>
|
|
@@ -337,7 +353,7 @@ export function Disclosure({
|
|
|
337
353
|
)}
|
|
338
354
|
{!triggerOnly && (
|
|
339
355
|
<Collapse id={bodyId} open={open} keepMounted={keepMounted}>
|
|
340
|
-
<div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
|
|
356
|
+
<div className={cn(card ? "space-y-3 px-4 pb-4" : !menu && "space-y-2 pt-2", bodyClassName)}>{children}</div>
|
|
341
357
|
</Collapse>
|
|
342
358
|
)}
|
|
343
359
|
</div>
|
|
@@ -65,6 +65,30 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
|
|
|
65
65
|
* clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
|
|
66
66
|
*/
|
|
67
67
|
offset?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the button carries the browser's own tooltip (`title`, the `label` unless a
|
|
70
|
+
* `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a
|
|
71
|
+
* mouse user with no other way to learn what it is. `false` renders no `title` at all:
|
|
72
|
+
* keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a
|
|
73
|
+
* native `title=` anywhere — so a flag, not `title=""`, which the scan would still
|
|
74
|
+
* see. An empty `title` renders no attribute either, for the caller who has no scan.
|
|
75
|
+
*
|
|
76
|
+
* Not flipped to off by default: a FAB wrapped in nothing would lose the only hover
|
|
77
|
+
* hint it has, silently, in every app already on it. And the kit cannot wrap itself
|
|
78
|
+
* in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the
|
|
79
|
+
* button is `fixed` and portalled — so the one-tooltip caller wraps it.
|
|
80
|
+
*/
|
|
81
|
+
nativeTitle?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for
|
|
84
|
+
* a surface disc — the glyph in brand on the quiet brand fill when on, the secondary
|
|
85
|
+
* text colour when off — the "on" look of `IconButton`'s `pressed` and of a selected
|
|
86
|
+
* Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles
|
|
87
|
+
* (feedback-page's "awaiting only", the /transactions pending and upcoming toggles),
|
|
88
|
+
* which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same
|
|
89
|
+
* in both states; `aria-pressed` already says which one it is in.
|
|
90
|
+
*/
|
|
91
|
+
pressed?: boolean;
|
|
68
92
|
ref?: Ref<HTMLButtonElement>;
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -90,12 +114,18 @@ export function FloatingActionButton({
|
|
|
90
114
|
icon,
|
|
91
115
|
corner = "bottom-end",
|
|
92
116
|
offset = "1rem",
|
|
117
|
+
nativeTitle = true,
|
|
118
|
+
pressed,
|
|
93
119
|
className,
|
|
94
120
|
style,
|
|
95
121
|
type = "button",
|
|
122
|
+
title,
|
|
96
123
|
...rest
|
|
97
124
|
}: FloatingActionButtonProps) {
|
|
98
125
|
const [marker, dir] = usePortalDir();
|
|
126
|
+
const toggle = pressed !== undefined;
|
|
127
|
+
// An empty title is "no tooltip", not an attribute with nothing in it.
|
|
128
|
+
const nativeTip = nativeTitle && title !== "" ? (title ?? label) : undefined;
|
|
99
129
|
return (
|
|
100
130
|
<>
|
|
101
131
|
<span ref={marker} hidden />
|
|
@@ -106,11 +136,20 @@ export function FloatingActionButton({
|
|
|
106
136
|
type={type}
|
|
107
137
|
dir={dir}
|
|
108
138
|
aria-label={label}
|
|
109
|
-
|
|
139
|
+
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
140
|
+
title={nativeTip}
|
|
110
141
|
style={{ ...cornerStyle(corner, offset), ...style }}
|
|
111
142
|
className={cn(
|
|
112
143
|
"fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
|
|
113
|
-
"
|
|
144
|
+
"shadow-lg transition-colors",
|
|
145
|
+
!toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
|
|
146
|
+
// A toggle sits on the page's surface — a solid brand disc would read as
|
|
147
|
+
// "on" in both states — and says "on" in the brand family.
|
|
148
|
+
toggle && "border border-[var(--border)]",
|
|
149
|
+
pressed === false &&
|
|
150
|
+
"bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
|
|
151
|
+
pressed === true &&
|
|
152
|
+
"border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
|
|
114
153
|
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
|
|
115
154
|
"[&_svg]:size-6",
|
|
116
155
|
className,
|
|
@@ -141,6 +180,9 @@ export interface FloatingPanelProps {
|
|
|
141
180
|
/** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
|
|
142
181
|
* From `md` up the card sits above the FAB, so it moves with it. */
|
|
143
182
|
offset?: string;
|
|
183
|
+
/** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
|
|
184
|
+
* `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
|
|
185
|
+
fabNativeTitle?: boolean;
|
|
144
186
|
/** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
|
|
145
187
|
closeLabel?: string;
|
|
146
188
|
/**
|
|
@@ -196,6 +238,7 @@ export function FloatingPanel({
|
|
|
196
238
|
onOpenChange,
|
|
197
239
|
corner = "bottom-end",
|
|
198
240
|
offset = "1rem",
|
|
241
|
+
fabNativeTitle,
|
|
199
242
|
closeLabel,
|
|
200
243
|
initialFocus,
|
|
201
244
|
className,
|
|
@@ -264,6 +307,7 @@ export function FloatingPanel({
|
|
|
264
307
|
icon={fabIcon}
|
|
265
308
|
corner={corner}
|
|
266
309
|
offset={offset}
|
|
310
|
+
nativeTitle={fabNativeTitle}
|
|
267
311
|
aria-expanded={open}
|
|
268
312
|
aria-controls={open ? panelId : undefined}
|
|
269
313
|
onClick={() => {
|
|
@@ -3,8 +3,30 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the
|
|
8
|
+
* names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what
|
|
9
|
+
* came in or went out is money, and painting it `success` / `warning` said "good" and
|
|
10
|
+
* "careful" about a figure that is neither. keksdose's report meters (food-group-card,
|
|
11
|
+
* drilldown-modal) sit beside amounts that are already in the money colours.
|
|
12
|
+
*/
|
|
13
|
+
export type ProgressBarTone =
|
|
14
|
+
| "brand"
|
|
15
|
+
| "neutral"
|
|
16
|
+
| "success"
|
|
17
|
+
| "warning"
|
|
18
|
+
| "danger"
|
|
19
|
+
| "info"
|
|
20
|
+
| "income"
|
|
21
|
+
| "expense";
|
|
22
|
+
/**
|
|
23
|
+
* Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a
|
|
24
|
+
* letter because the letters are taken in order — an `xs` at 6px would be THICKER than
|
|
25
|
+
* `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's
|
|
26
|
+
* track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a
|
|
27
|
+
* line of text where 8px outweighs the text.
|
|
28
|
+
*/
|
|
29
|
+
export type ProgressBarSize = "sm" | "slim" | "md" | "lg";
|
|
8
30
|
|
|
9
31
|
const FILL: Record<ProgressBarTone, string> = {
|
|
10
32
|
brand: "bg-[var(--brand)]",
|
|
@@ -13,9 +35,11 @@ const FILL: Record<ProgressBarTone, string> = {
|
|
|
13
35
|
warning: "bg-[var(--warning)]",
|
|
14
36
|
danger: "bg-[var(--danger)]",
|
|
15
37
|
info: "bg-[var(--info)]",
|
|
38
|
+
income: "bg-[var(--money-income)]",
|
|
39
|
+
expense: "bg-[var(--money-expense)]",
|
|
16
40
|
};
|
|
17
41
|
|
|
18
|
-
const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
|
|
42
|
+
const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", slim: "h-1.5", md: "h-2", lg: "h-3" };
|
|
19
43
|
|
|
20
44
|
export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
21
45
|
/** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
|
package/src/components/ui.tsx
CHANGED
|
@@ -154,27 +154,50 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
154
154
|
};
|
|
155
155
|
|
|
156
156
|
// A tone re-colours the glyph without changing what the variant draws around it.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
157
|
+
// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
|
|
158
|
+
// QUIET is placeholder grey until the pointer or focus arrives, then the tone's
|
|
159
|
+
// family; TONED wears the tone's colour at rest. The defaults are the looks each tone
|
|
160
|
+
// had before `quiet` existed: `danger` is quiet — an action in every row of a list
|
|
161
|
+
// must not shout from every row, so the red arrives only on the one row you are about
|
|
162
|
+
// to act on — while `warning` and `info` are toned, because they mark the ONE thing
|
|
163
|
+
// on the row to notice (keksdose's "needs review" flag, its reconcile action at
|
|
164
|
+
// accounts-page:867), and quiet grey would hide the very thing they point out.
|
|
165
|
+
// `muted` is quiet by definition and has no toned look; `default` has no tone.
|
|
166
|
+
export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
|
|
167
|
+
|
|
168
|
+
type ColouredTone = "danger" | "warning" | "info";
|
|
169
|
+
|
|
170
|
+
const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
|
|
171
|
+
danger: {
|
|
172
|
+
quiet:
|
|
173
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
|
|
174
|
+
toned: "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
|
|
175
|
+
},
|
|
176
|
+
warning: {
|
|
177
|
+
quiet:
|
|
178
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus:ring-[var(--warning-border)]",
|
|
179
|
+
toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus:ring-[var(--warning-border)]",
|
|
180
|
+
},
|
|
181
|
+
info: {
|
|
182
|
+
quiet:
|
|
183
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
|
|
184
|
+
toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const ICON_BUTTON_MUTED =
|
|
189
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
|
|
190
|
+
|
|
191
|
+
/** Which tones sit quiet at rest when `quiet` is left out. */
|
|
192
|
+
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = { danger: true, warning: false, info: false };
|
|
193
|
+
|
|
194
|
+
function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
|
|
195
|
+
if (tone === "default") return "";
|
|
196
|
+
if (tone === "muted") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);
|
|
197
|
+
const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];
|
|
198
|
+
const look = ICON_BUTTON_TONES[tone];
|
|
199
|
+
return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;
|
|
200
|
+
}
|
|
178
201
|
|
|
179
202
|
// A disabled button must not answer the pointer. The hover classes above are plain
|
|
180
203
|
// `hover:` (so a caller's `className="hover:…"` still replaces them through
|
|
@@ -194,11 +217,9 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
|
|
|
194
217
|
overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
|
|
195
218
|
};
|
|
196
219
|
|
|
197
|
-
// The
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
danger: "disabled:hover:text-[var(--text-placeholder)]",
|
|
201
|
-
};
|
|
220
|
+
// The quiet looks change the glyph on hover, so they pin their resting glyph the
|
|
221
|
+
// same way. A toned look keeps its glyph on hover and needs no pin.
|
|
222
|
+
const ICON_BUTTON_QUIET_DISABLED_REST = "disabled:hover:text-[var(--text-placeholder)]";
|
|
202
223
|
|
|
203
224
|
// `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
|
|
204
225
|
// "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
|
|
@@ -229,8 +250,26 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
229
250
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
230
251
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
231
252
|
* wants attention. `info`: sky at rest — a notice-worthy but harmless action
|
|
232
|
-
* (keksdose's reconcile). Default: the variant's own colours.
|
|
233
|
-
tone
|
|
253
|
+
* (keksdose's reconcile). Default: the variant's own colours. See `quiet` for
|
|
254
|
+
* turning a coloured tone's resting look the other way. */
|
|
255
|
+
tone?: IconButtonTone;
|
|
256
|
+
/**
|
|
257
|
+
* Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:
|
|
258
|
+
* `true` is placeholder grey at rest and the tone's colour on hover and focus;
|
|
259
|
+
* `false` wears the tone's colour at rest. Left out, each tone keeps its own
|
|
260
|
+
* default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by
|
|
261
|
+
* definition) and `default` (no tone).
|
|
262
|
+
*
|
|
263
|
+
* `quiet={false}` on `danger` is for a destructive action that stands ALONE, where
|
|
264
|
+
* hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one
|
|
265
|
+
* "delete all" and a touch screen that never hovers, so it painted rose by hand.
|
|
266
|
+
* `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated
|
|
267
|
+
* down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an
|
|
268
|
+
* `emphasis` scale: there are exactly two resting looks, every coloured tone has
|
|
269
|
+
* both, and which one fits is a question about the SITE (alone or repeated, touch or
|
|
270
|
+
* pointer), not about the tone — so it is one switch the family shares.
|
|
271
|
+
*/
|
|
272
|
+
quiet?: boolean;
|
|
234
273
|
/**
|
|
235
274
|
* Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
|
|
236
275
|
* (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
|
|
@@ -269,7 +308,7 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
272
|
-
{ variant = "ghost", size = "md", tone = "default", pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
|
|
311
|
+
{ variant = "ghost", size = "md", tone = "default", quiet, pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
|
|
273
312
|
ref,
|
|
274
313
|
) {
|
|
275
314
|
return (
|
|
@@ -293,8 +332,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
293
332
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
294
333
|
variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
|
|
295
334
|
ICON_BUTTON_DISABLED_REST[variant],
|
|
296
|
-
|
|
297
|
-
ICON_BUTTON_TONES_DISABLED_REST[tone],
|
|
335
|
+
iconButtonToneClass(tone, quiet),
|
|
298
336
|
pressed && ICON_BUTTON_PRESSED,
|
|
299
337
|
className,
|
|
300
338
|
)}
|
|
@@ -1334,13 +1372,24 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1334
1372
|
);
|
|
1335
1373
|
}
|
|
1336
1374
|
|
|
1337
|
-
export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
1375
|
+
export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
|
|
1338
1376
|
/** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
|
|
1339
1377
|
* every empty state in three apps look like the same thing — so there is no
|
|
1340
1378
|
* `children` slot to put arbitrary content in. The two slots below are the only
|
|
1341
1379
|
* other things an empty state has turned out to need, and each has a fixed place. */
|
|
1342
|
-
title
|
|
1343
|
-
|
|
1380
|
+
/** Nodes rather than strings, so a title can carry a code, a link or an emphasised
|
|
1381
|
+
* word — keksdose's error boundary shows the error's name in `<code>` — while the
|
|
1382
|
+
* box still sets the type. */
|
|
1383
|
+
title: ReactNode;
|
|
1384
|
+
hint?: ReactNode;
|
|
1385
|
+
/**
|
|
1386
|
+
* Render the title as a heading of this level. Left out it is a `<div>`, as before:
|
|
1387
|
+
* most empty states are a message inside a section that already has its heading.
|
|
1388
|
+
* keksdose's error boundary IS the page when it trips, and its `<h2>` went missing
|
|
1389
|
+
* when it moved onto EmptyState — so the page's heading outline lost the one line
|
|
1390
|
+
* that says what happened. Only the element changes; the look is the box's.
|
|
1391
|
+
*/
|
|
1392
|
+
headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
|
|
1344
1393
|
/** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
|
|
1345
1394
|
* offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
|
|
1346
1395
|
* muted, so five call sites cannot pick five sizes; hidden from assistive tech,
|
|
@@ -1353,7 +1402,8 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
1353
1402
|
action?: ReactNode;
|
|
1354
1403
|
}
|
|
1355
1404
|
|
|
1356
|
-
export function EmptyState({ title, hint, icon, action, className, ...rest }: EmptyStateProps) {
|
|
1405
|
+
export function EmptyState({ title, hint, icon, action, headingAs, className, ...rest }: EmptyStateProps) {
|
|
1406
|
+
const Title = headingAs ?? "div";
|
|
1357
1407
|
return (
|
|
1358
1408
|
<div
|
|
1359
1409
|
{...rest}
|
|
@@ -1367,8 +1417,9 @@ export function EmptyState({ title, hint, icon, action, className, ...rest }: Em
|
|
|
1367
1417
|
{icon}
|
|
1368
1418
|
</div>
|
|
1369
1419
|
)}
|
|
1370
|
-
|
|
1371
|
-
|
|
1420
|
+
{/* `text-sm`: a heading keeps the box's size, not whatever a stylesheet gives h2. */}
|
|
1421
|
+
<Title className="text-sm font-medium text-[var(--text-secondary)]">{title}</Title>
|
|
1422
|
+
{hint != null && hint !== false && hint !== "" && <div className="mt-1 text-xs">{hint}</div>}
|
|
1372
1423
|
{action != null && (
|
|
1373
1424
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
|
|
1374
1425
|
)}
|
|
@@ -81,9 +81,48 @@ interface PushedEntry {
|
|
|
81
81
|
href: string;
|
|
82
82
|
/** Its overlay is gone but the entry is not — someone above owes this pop. */
|
|
83
83
|
dead: boolean;
|
|
84
|
+
/** The router's `idx` on this entry when the marker went on — see {@link locate}. */
|
|
85
|
+
idx: number | undefined;
|
|
86
|
+
/** The router's `key` copied from the page entry beneath ours. */
|
|
87
|
+
key: string | undefined;
|
|
88
|
+
/** {@link routeOf} the address the marker was placed at. */
|
|
89
|
+
route: string;
|
|
84
90
|
}
|
|
85
91
|
const pushed: PushedEntry[] = [];
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Entries of ours that a ROUTER NAVIGATION made from inside the overlay has left
|
|
95
|
+
* behind — ours, still in the history, but no longer on top and no longer anyone's.
|
|
96
|
+
*
|
|
97
|
+
* Nothing can take an entry out from under a newer one, so they cannot be unwound;
|
|
98
|
+
* what can be done is to not let the user land on one. A traversal that comes to
|
|
99
|
+
* rest on a buried entry is carried straight on over the whole run of them, in the
|
|
100
|
+
* direction it was going (see `skipBuried`): Back from the new page lands on the page
|
|
101
|
+
* the overlay was opened from, Forward from there lands on the new page, and no press
|
|
102
|
+
* is spent on an entry with nothing on screen for it.
|
|
103
|
+
*
|
|
104
|
+
* `below` / `above` count the buried entries of the same run on either side. The
|
|
105
|
+
* router's `idx` tells the direction apart — except after a REPLACE navigation, when
|
|
106
|
+
* the page above the run shares the run's `idx`; the two keys decide there.
|
|
107
|
+
*/
|
|
108
|
+
interface BuriedEntry {
|
|
109
|
+
idx: number;
|
|
110
|
+
/** The `idx` of the router entry that buried the run: higher after a push, the
|
|
111
|
+
* same after a replace. */
|
|
112
|
+
aboveIdx: number;
|
|
113
|
+
/** The key of the page entry directly beneath the run. */
|
|
114
|
+
belowKey: string | undefined;
|
|
115
|
+
/** The key of the router entry that buried the run, when it did. */
|
|
116
|
+
aboveKey: string | undefined;
|
|
117
|
+
below: number;
|
|
118
|
+
above: number;
|
|
119
|
+
}
|
|
120
|
+
const buried = new Map<string, BuriedEntry>();
|
|
121
|
+
|
|
122
|
+
/** The router `{idx, key}` of the entry the last traversal (or burial) left us on —
|
|
123
|
+
* what `skipBuried` compares against to know which way the user was going. */
|
|
124
|
+
let lastSeen: { idx: number | undefined; key: string | undefined } | null = null;
|
|
125
|
+
|
|
87
126
|
/** Entries a real Back press already consumed. Their overlay's cleanup still has to
|
|
88
127
|
* run, and it must not mistake "my entry is not current" for "my entry is buried"
|
|
89
128
|
* — it has no entry left at all. */
|
|
@@ -140,7 +179,12 @@ function runUnwind(): void {
|
|
|
140
179
|
const job = unwind;
|
|
141
180
|
unwind = null;
|
|
142
181
|
if (!job) return;
|
|
143
|
-
if (currentSentinel() !== job.id || currentHref() !== job.href)
|
|
182
|
+
if (currentSentinel() !== job.id || currentHref() !== job.href) {
|
|
183
|
+
// A router navigation landed between the cleanup and this task: the entries
|
|
184
|
+
// are under it (or were replaced by it) rather than simply forgotten.
|
|
185
|
+
if (taggedSentinel() === null) buryUnder(job.entries);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
144
188
|
pendingProgrammatic += 1;
|
|
145
189
|
window.history.go(-job.entries.length);
|
|
146
190
|
}
|
|
@@ -149,6 +193,120 @@ function currentHref(): string {
|
|
|
149
193
|
return typeof window === "undefined" ? "" : window.location.href;
|
|
150
194
|
}
|
|
151
195
|
|
|
196
|
+
/** The router's own `{idx, key}` on the current entry, where a router has put them. */
|
|
197
|
+
function routerMark(): { idx: number | undefined; key: string | undefined } {
|
|
198
|
+
const state = typeof window !== "undefined" ? (window.history.state as unknown) : null;
|
|
199
|
+
if (!state || typeof state !== "object") return { idx: undefined, key: undefined };
|
|
200
|
+
const { idx, key } = state as Record<string, unknown>;
|
|
201
|
+
return { idx: typeof idx === "number" ? idx : undefined, key: typeof key === "string" ? key : undefined };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The part of an address that names the PAGE — the query string left out.
|
|
206
|
+
*
|
|
207
|
+
* A `setSearchParams(…, { replace: true })` rewrites the query on the entry the
|
|
208
|
+
* overlay is standing on; that is a wipe to repair, not a move. Anything else changing
|
|
209
|
+
* is a navigation. Under a hash router the page lives in the hash (`#/table?f.q=x`), so
|
|
210
|
+
* a hash that starts with `#/` counts up to its own `?`; an ordinary `#fragment` does
|
|
211
|
+
* not name a page and is left out.
|
|
212
|
+
*/
|
|
213
|
+
function routeOf(href: string): string {
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(href);
|
|
216
|
+
const hashRoute = url.hash.startsWith("#/") ? url.hash.split("?")[0] : "";
|
|
217
|
+
return url.origin + url.pathname + hashRoute;
|
|
218
|
+
} catch {
|
|
219
|
+
return href;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Is the untagged entry we are standing on still `p`'s — merely wiped by the router —
|
|
225
|
+
* or an entry the router NAVIGATED to from inside the overlay?
|
|
226
|
+
*
|
|
227
|
+
* The router's `idx` is the one per-entry fact that separates the two, because it is
|
|
228
|
+
* the one the router itself keeps straight: a replace leaves it as it was, a push adds
|
|
229
|
+
* one. Our own push copies the page's state, so our entry carries the page's `idx` and
|
|
230
|
+
* a router push on top of it carries one more. The key would not do — every replace
|
|
231
|
+
* mints a new one, so the wipe this repair exists for would already fail it. Neither
|
|
232
|
+
* would the address alone: a push to the address we are already at is still a push.
|
|
233
|
+
*
|
|
234
|
+
* `idx` does not see one move, the router REPLACING our entry with a different page
|
|
235
|
+
* (same `idx`), so the page named by the address must also be unchanged. Where there is
|
|
236
|
+
* no `idx` to compare (no router, or one that keeps none) the address is all there is.
|
|
237
|
+
*/
|
|
238
|
+
function isStillOn(p: PushedEntry): boolean {
|
|
239
|
+
if (routeOf(currentHref()) !== p.route) return false;
|
|
240
|
+
const { idx } = routerMark();
|
|
241
|
+
return idx === undefined || p.idx === undefined || idx === p.idx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The router has navigated while our entries were on top: record which of `records`
|
|
246
|
+
* (ours, oldest first, ending with the top one) it buried and which it replaced, and
|
|
247
|
+
* drop them from `pushed` — none of them can be unwound any more. Returns whether it
|
|
248
|
+
* recognised a navigation at all; when it cannot tell (no router `idx` to go by), it
|
|
249
|
+
* leaves everything as it was and the caller falls back to marking the entry dead.
|
|
250
|
+
*/
|
|
251
|
+
function buryUnder(records: PushedEntry[]): boolean {
|
|
252
|
+
const top = records[records.length - 1];
|
|
253
|
+
const here = routerMark();
|
|
254
|
+
if (!top || top.idx === undefined || here.idx === undefined) return false;
|
|
255
|
+
let run: PushedEntry[];
|
|
256
|
+
if (here.idx > top.idx) {
|
|
257
|
+
run = [];
|
|
258
|
+
for (let i = records.length - 1; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);
|
|
259
|
+
} else if (here.idx === top.idx && !isStillOn(top)) {
|
|
260
|
+
// Replaced: the top entry IS the new page now, and gone as ours. Those beneath it
|
|
261
|
+
// are buried under it.
|
|
262
|
+
run = [];
|
|
263
|
+
for (let i = records.length - 2; i >= 0 && records[i].idx === top.idx; i -= 1) run.unshift(records[i]);
|
|
264
|
+
} else {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
run.forEach((r, i) => {
|
|
268
|
+
buried.set(r.id, {
|
|
269
|
+
idx: top.idx as number,
|
|
270
|
+
aboveIdx: here.idx as number,
|
|
271
|
+
belowKey: run[0].key,
|
|
272
|
+
aboveKey: here.key,
|
|
273
|
+
below: i,
|
|
274
|
+
above: run.length - 1 - i,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
for (const r of records) forgetPushed(r.id);
|
|
278
|
+
lastSeen = here;
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A traversal came to rest on one of our buried entries: carry it on over the run, the
|
|
284
|
+
* way it was going. Back from above (a higher `idx`, or the very entry that buried us)
|
|
285
|
+
* continues down to the page beneath; Forward from that page continues up. A landing
|
|
286
|
+
* from anywhere this cannot place is left alone — one idle press is the price of not
|
|
287
|
+
* guessing, and a wrong guess would move the user somewhere they did not ask to go.
|
|
288
|
+
*/
|
|
289
|
+
function skipBuried(from: typeof lastSeen): void {
|
|
290
|
+
const tag = taggedSentinel();
|
|
291
|
+
const rec = tag !== null ? buried.get(tag) : undefined;
|
|
292
|
+
if (!rec || !from || stack.some((e) => e.id === tag)) return;
|
|
293
|
+
let steps = 0;
|
|
294
|
+
if ((from.idx !== undefined && from.idx > rec.idx) || (from.key !== undefined && from.key === rec.aboveKey)) {
|
|
295
|
+
steps = -(rec.below + 1);
|
|
296
|
+
} else if (
|
|
297
|
+
from.idx !== undefined &&
|
|
298
|
+
// Below the run, or level with it when nothing above it is (a push buried it),
|
|
299
|
+
// or level with it and provably the page beneath (a replace buried it). The
|
|
300
|
+
// router's first entry has no key at all, so an absent key is a key here.
|
|
301
|
+
(from.idx < rec.idx || (from.idx === rec.idx && (rec.aboveIdx > rec.idx || from.key === rec.belowKey)))
|
|
302
|
+
) {
|
|
303
|
+
steps = rec.above + 1;
|
|
304
|
+
}
|
|
305
|
+
if (steps === 0) return;
|
|
306
|
+
pendingProgrammatic += 1;
|
|
307
|
+
window.history.go(steps);
|
|
308
|
+
}
|
|
309
|
+
|
|
152
310
|
function forgetPushed(id: string): void {
|
|
153
311
|
const at = pushed.findIndex((p) => p.id === id);
|
|
154
312
|
if (at >= 0) pushed.splice(at, 1);
|
|
@@ -210,7 +368,14 @@ function currentSentinel(): string | null {
|
|
|
210
368
|
// belief left over from an overlay that is long gone — a tab that has since
|
|
211
369
|
// navigated, a test file that ran another case — would be stamped onto a
|
|
212
370
|
// stranger's entry, and the next cleanup would traverse off it.
|
|
213
|
-
|
|
371
|
+
//
|
|
372
|
+
// And only an entry that is still the one the marker was placed on. A row that
|
|
373
|
+
// NAVIGATES (a palette result, a dialog's link) has the router push the new page
|
|
374
|
+
// before the overlay's cleanup runs; that entry carries no tag either, and
|
|
375
|
+
// re-stamping it made the cleanup "unwind" the navigation itself — the page
|
|
376
|
+
// changed and changed straight back. See `isStillOn` for how the two are told apart.
|
|
377
|
+
const record = pushed.find((p) => p.id === standing);
|
|
378
|
+
if (!record || !isStillOn(record)) {
|
|
214
379
|
standing = null;
|
|
215
380
|
return null;
|
|
216
381
|
}
|
|
@@ -223,10 +388,17 @@ function handlePop() {
|
|
|
223
388
|
// entry we have landed on before anything below consults it, or the repair in
|
|
224
389
|
// `currentSentinel` would stamp the entry we just left onto the one we are on.
|
|
225
390
|
standing = taggedSentinel();
|
|
391
|
+
const from = lastSeen;
|
|
392
|
+
lastSeen = routerMark();
|
|
226
393
|
if (pendingProgrammatic > 0) {
|
|
227
394
|
pendingProgrammatic -= 1;
|
|
228
395
|
return;
|
|
229
396
|
}
|
|
397
|
+
closeOnPop();
|
|
398
|
+
skipBuried(from);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function closeOnPop() {
|
|
230
402
|
// Nothing open → an ordinary navigation, which is none of our business.
|
|
231
403
|
const top = stack[stack.length - 1];
|
|
232
404
|
if (!top) return;
|
|
@@ -290,7 +462,8 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
|
290
462
|
window.history.replaceState(marked, "");
|
|
291
463
|
// The abandoned ones beneath come back owed; ours takes the top slot.
|
|
292
464
|
for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
|
|
293
|
-
|
|
465
|
+
const was = inherited[inherited.length - 1];
|
|
466
|
+
pushed.push({ id, href: currentHref(), dead: false, idx: was.idx, key: was.key, route: was.route });
|
|
294
467
|
standing = id;
|
|
295
468
|
} else {
|
|
296
469
|
// A push DESTROYS every entry ahead of the one we are on, so anything we
|
|
@@ -304,7 +477,8 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
|
304
477
|
// Preserve react-router's own `{usr,key,idx}` — we only tack a marker on, and
|
|
305
478
|
// the URL is unchanged, so the router treats popping this as a no-op re-render.
|
|
306
479
|
window.history.pushState(marked, "");
|
|
307
|
-
|
|
480
|
+
const page = routerMark();
|
|
481
|
+
pushed.push({ id, href: currentHref(), dead: false, idx: page.idx, key: page.key, route: routeOf(currentHref()) });
|
|
308
482
|
standing = id;
|
|
309
483
|
}
|
|
310
484
|
return () => {
|
|
@@ -320,6 +494,9 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
|
320
494
|
// a SIBLING overlay's sentinel landed on top, that overlay is about to unwind
|
|
321
495
|
// and is the one that can take ours with it.
|
|
322
496
|
if (currentSentinel() !== id) {
|
|
497
|
+
// A router navigation from inside the overlay: nothing to unwind — the
|
|
498
|
+
// entries it left beneath the new page are skipped over instead.
|
|
499
|
+
if (mine >= 0 && taggedSentinel() === null && buryUnder(pushed.slice(0, pushed.length))) return;
|
|
323
500
|
if (mine >= 0) pushed[mine].dead = true;
|
|
324
501
|
return;
|
|
325
502
|
}
|