@evinvest/uikit 0.23.0 → 0.25.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 +151 -4
- package/dist/components/app-shell.js +85 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/bottom-tab-bar.js +91 -0
- package/dist/components/bottom-tab-bar.js.map +1 -0
- package/dist/components/mobile-app-bar.js +51 -0
- package/dist/components/mobile-app-bar.js.map +1 -0
- package/dist/components/nav-badge.js +61 -0
- package/dist/components/nav-badge.js.map +1 -0
- package/dist/components/nav-link.js +50 -0
- package/dist/components/nav-link.js.map +1 -0
- package/dist/components/nav-model.js +55 -0
- package/dist/components/nav-model.js.map +1 -0
- package/dist/components/page-frame.js +92 -0
- package/dist/components/page-frame.js.map +1 -0
- package/dist/components/resource-error.js +47 -0
- package/dist/components/resource-error.js.map +1 -0
- package/dist/components/section-nav.js +86 -0
- package/dist/components/section-nav.js.map +1 -0
- package/dist/components/settled.js +19 -0
- package/dist/components/settled.js.map +1 -0
- package/dist/components/shell-icons.js +57 -0
- package/dist/components/shell-icons.js.map +1 -0
- package/dist/components/shell-nav.js +141 -0
- package/dist/components/shell-nav.js.map +1 -0
- package/dist/components/system-banner.js +53 -0
- package/dist/components/system-banner.js.map +1 -0
- package/dist/components/use-shell-nav.js +46 -0
- package/dist/components/use-shell-nav.js.map +1 -0
- package/dist/generated/button.js +4 -2
- package/dist/generated/button.js.map +1 -1
- package/dist/index.d.ts +421 -1
- package/dist/index.js +12 -0
- package/package.json +1 -1
- package/styles/theme.css +155 -1
- package/styles/tokens.css +157 -2
package/README.md
CHANGED
|
@@ -64,7 +64,8 @@ your Tailwind v4 entrypoint — this is the load-bearing part of the kit:
|
|
|
64
64
|
| ink | `ink` `ink-mid` `ink-soft` — hierarchy, loudest first |
|
|
65
65
|
| lines | `border` `input` `ring` |
|
|
66
66
|
| roles | `brand` `primary` `secondary` `positive` `accent-trace` `accent-debug` `accent-info` `accent-warn` `accent-error`, each with `on-*` where it gets filled; `primary-ink` — the primary role as ink on a surface (`#128377` fill under a white `on-primary`, `#2a9d8f` ink) |
|
|
67
|
-
| scalars | `radius` `control-radius` `control-py` `display-scale` `band-py` `page-max` `page-px` `shadow-*` `font-*` |
|
|
67
|
+
| scalars | `radius` `control-radius` `control-py` `display-scale` `band-py` `page-max` `page-px` `shell-rail-w` `shell-tab-bar-h` `shadow-*` `font-*` |
|
|
68
|
+
| motion | `ev-ease-out` `ev-ease-in-out` `ev-dur-fast` `ev-dur-base` `ev-dur-slow` `ev-rise` `ev-stagger` `ev-stagger-section` |
|
|
68
69
|
| charts | `chart-1` … `chart-5` |
|
|
69
70
|
|
|
70
71
|
Custom properties inherit, so a scope class on a `<section>` re-themes its
|
|
@@ -310,6 +311,133 @@ and so does the fill, on every palette and polarity `test/tokens.test.ts`
|
|
|
310
311
|
measures. Controls that sit on the surface (`outline`, `ghost`, inputs) keep
|
|
311
312
|
the `ring` border plus its halo.
|
|
312
313
|
|
|
314
|
+
## App shell
|
|
315
|
+
|
|
316
|
+
The frame a signed-in app sits in — a rail beside the page on a wide screen, a
|
|
317
|
+
tab bar under it on a phone, one page inset and title scale, and the motion
|
|
318
|
+
between screens. It knows nothing about routes, roles or data: the nav is a
|
|
319
|
+
list you build, visibility is a list you filter, and the router comes in as two
|
|
320
|
+
props. TS-only (see [Limitations](#limitations)).
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
// app/(app)/layout.tsx — the layout stays a Server Component; the nav is a client leaf
|
|
324
|
+
import { AppShell } from "@evinvest/uikit";
|
|
325
|
+
import { Nav, Tabs } from "./nav";
|
|
326
|
+
|
|
327
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
328
|
+
return (
|
|
329
|
+
<AppShell rail={<Nav />} tabBar={<Tabs />} banner={<Banners />}>
|
|
330
|
+
{children}
|
|
331
|
+
</AppShell>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
// app/(app)/nav.tsx
|
|
338
|
+
"use client";
|
|
339
|
+
import Link from "next/link";
|
|
340
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
341
|
+
import { ShellNav, BottomTabBar, type NavGroup, type NavItem } from "@evinvest/uikit";
|
|
342
|
+
|
|
343
|
+
const GROUPS: NavGroup[] = [
|
|
344
|
+
{ id: "main", label: "Your account", items: [
|
|
345
|
+
{ id: "home", href: "/", label: "Home", icon: Home },
|
|
346
|
+
{ id: "invest", href: "/invest", label: "Invest", icon: LineChart, match: "exact" },
|
|
347
|
+
] },
|
|
348
|
+
];
|
|
349
|
+
const FOOTER: NavGroup[] = [
|
|
350
|
+
{ id: "me", items: [{ id: "inbox", href: "/notifications", label: "Notifications", icon: Bell, badge: unread }] },
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
export function Nav() {
|
|
354
|
+
const router = useRouter();
|
|
355
|
+
return (
|
|
356
|
+
<ShellNav
|
|
357
|
+
groups={GROUPS}
|
|
358
|
+
footerGroups={FOOTER}
|
|
359
|
+
pathname={usePathname()}
|
|
360
|
+
linkComponent={Link}
|
|
361
|
+
onItemIntent={(item) => router.prefetch(item.href)}
|
|
362
|
+
header={<Logo />}
|
|
363
|
+
labels={{ primary: t("nav.main"), footer: t("nav.account"), badge: (n) => t("nav.unread", { n }) }}
|
|
364
|
+
/>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- **`AppShell`** — slots `rail`, `tabBar`, `banner`, `children`; `breakpoint`
|
|
370
|
+
(`"md"` | `"lg"`, default `lg`) is where the tab bar gives way to the rail.
|
|
371
|
+
Fills `100dvh` less `--ev-shell-offset`; the rail sticks under that offset; the
|
|
372
|
+
column reserves `--shell-tab-bar-h` plus the bottom safe area for the bar.
|
|
373
|
+
`mainProps` reaches the `<main>`. Layout only — no `"use client"`.
|
|
374
|
+
- **`NavItem`** — `{ id, href, label, icon?, badge?, trailing?, external?,
|
|
375
|
+
target?, match?, also?, disabled? }`, shared by the rail and the tab bar.
|
|
376
|
+
`match` is `"prefix"` (default, on a segment boundary; `/` is exact),
|
|
377
|
+
`"exact"`, or `(path) => boolean`; `also` lists more paths the item claims
|
|
378
|
+
(the one tab standing in for Profile, Notifications and Settings). When
|
|
379
|
+
several items claim a path, **one** wins — an exact hit, else the longest
|
|
380
|
+
prefix, else the first — so overlapping items never light two rows.
|
|
381
|
+
`resolveActiveNavItem` / `isNavItemActive` are exported for your own chrome.
|
|
382
|
+
`badge: number` draws a `NavBadge`; any other node is drawn as given.
|
|
383
|
+
`trailing` may be a function of `{ active, pending }`. `external` renders a
|
|
384
|
+
plain `<a>` that is never current.
|
|
385
|
+
- **`ShellNav`** — `groups` + `footerGroups` (pinned to the bottom), `header` /
|
|
386
|
+
`footer` slots, `pathname` + `linkComponent`, `onItemIntent(item)` (hover /
|
|
387
|
+
focus — prefetch there), `onNavigate(item)`, `renderItem(item, state)` to
|
|
388
|
+
replace a row's content (the link, `aria-current` and the marker stay the
|
|
389
|
+
kit's), `labels` `{ primary, footer, badge(n) }`. A plain left click marks
|
|
390
|
+
the row at once (`data-pending`) and the pathname catches up; a modified or
|
|
391
|
+
middle click does not. One marker per group slides between rows on a CSS
|
|
392
|
+
`transform` transition — it is placed by measurement, so until hydration the
|
|
393
|
+
marked row carries the fill itself.
|
|
394
|
+
- **`BottomTabBar`** — `items` (five fit a phone), the same router props,
|
|
395
|
+
callbacks and optimistic mark, `labels` `{ nav, badge(n) }`. One rule slides by
|
|
396
|
+
whole tab widths and fades on a route no tab claims; a numeric `badge` sits
|
|
397
|
+
on the icon's corner.
|
|
398
|
+
- **`NavBadge`** `{ count, max = 99, label(n), variant: "pill" | "corner",
|
|
399
|
+
active }` shows `99+` and speaks the real count; **`NavDot`** `{ tone, label?,
|
|
400
|
+
corner? }` is a presence dot.
|
|
401
|
+
- **`MobileAppBar`** `{ title, back?: { href } | { onClick }, right?,
|
|
402
|
+
linkComponent?, hideFrom = "lg", labels: { back } }` — sticky under
|
|
403
|
+
`--ev-shell-offset`; with `back` the title centres.
|
|
404
|
+
- **`PageFrame`** `{ title?, description?, eyebrow?, actions?, appBar?,
|
|
405
|
+
width: "full" | "content", breakpoint }`, with **`PageHeading`** and
|
|
406
|
+
**`SectionLabel`** (`tone: "muted" | "accent"`). With an `appBar` the heading
|
|
407
|
+
is the wide screen's only. Server-safe.
|
|
408
|
+
- **`SectionNav`** `{ groups: { id, label?, description?, items }[], value,
|
|
409
|
+
onValueChange?, linkComponent?, labels: { nav } }` — a page's own section
|
|
410
|
+
rail (settings panes); items are buttons, or links when they have an `href`.
|
|
411
|
+
- **`ResourceError`** `{ message, variant: "inline" (onRetry?, retrying?) |
|
|
412
|
+
"alert" (title?), labels: { retry } }` and **`SystemBanner`** `{ tone: "warn" |
|
|
413
|
+
"info", title?, icon?, onDismiss?, labels: { dismiss } }` — presentation
|
|
414
|
+
only; what failed and whether a dismissal sticks are yours.
|
|
415
|
+
- **`Settled`** `{ loading, skeleton, children }` — the skeleton → content
|
|
416
|
+
handover: the content fades and rises in after a skeleton was shown, and cuts
|
|
417
|
+
in when the data was there from the start.
|
|
418
|
+
|
|
419
|
+
Every string the kit renders or speaks is a `labels` entry with an English
|
|
420
|
+
default.
|
|
421
|
+
|
|
422
|
+
### Motion without a motion library
|
|
423
|
+
|
|
424
|
+
The shell's motion is CSS in `tokens.css` (from `motion.css`), keyed on data
|
|
425
|
+
attributes, so a Server Component gets it and no client JS runs for it. Every
|
|
426
|
+
rule has a `prefers-reduced-motion` form (fades stay, travel goes).
|
|
427
|
+
|
|
428
|
+
| attribute | what moves |
|
|
429
|
+
|---|---|
|
|
430
|
+
| `data-enter="rise"` | the element fades in and rises `--ev-rise` once; `--enter-delay` postpones it |
|
|
431
|
+
| `data-enter="stagger"` | each direct child does the same, `--enter-step` (default `--ev-stagger`) apart; override a child's slot with `--enter-index` |
|
|
432
|
+
| `[data-slot="settled"][data-state="revealed"]` | `Settled`'s handover |
|
|
433
|
+
| `[data-slot="skeleton"]` | every `Skeleton` fades in before it pulses (`animate-none` turns both off) |
|
|
434
|
+
|
|
435
|
+
The timing is tokens on every theme root, for your own transitions too
|
|
436
|
+
(`ease-(--ev-ease-out) duration-(--ev-dur-base)`): `--ev-ease-out`,
|
|
437
|
+
`--ev-ease-in-out`, `--ev-dur-fast` / `-base` / `-slow` (180 / 280 / 400ms),
|
|
438
|
+
`--ev-rise` (8px), `--ev-stagger` (35ms), `--ev-stagger-section` (50ms). The
|
|
439
|
+
shell's geometry is `--shell-rail-w` (248px) and `--shell-tab-bar-h` (64px).
|
|
440
|
+
|
|
313
441
|
## Usage
|
|
314
442
|
|
|
315
443
|
```tsx
|
|
@@ -356,7 +484,7 @@ element across the two ports.
|
|
|
356
484
|
| overlay placement | inline `position:fixed` + `data-side` | `Portal` + `useFloating` |
|
|
357
485
|
| dismiss / focus trap | full-screen backdrop / native order | `useDismissableLayer` / `useFocusScope` |
|
|
358
486
|
|
|
359
|
-
### Component inventory (all
|
|
487
|
+
### Component inventory (all 73 bricks)
|
|
360
488
|
|
|
361
489
|
- **Tier A — static (23):** badge, button, button-group, card, input, textarea,
|
|
362
490
|
label, field, separator, skeleton, spinner, kbd, table, container, alert,
|
|
@@ -375,6 +503,10 @@ element across the two ports.
|
|
|
375
503
|
/ `Forbidden` / `ServerError` presets (404 / 403 / 500). The kit ships no
|
|
376
504
|
artwork: `Logo` masks whatever `--brand-mark` / `--brand-aspect` the consumer
|
|
377
505
|
declares beside the palette, and the status pages use it for their mark.
|
|
506
|
+
- **App shell — TS-only (10):** app-shell, shell-nav, bottom-tab-bar,
|
|
507
|
+
nav-badge (`NavBadge` / `NavDot`), mobile-app-bar, page-frame (`PageFrame` /
|
|
508
|
+
`PageHeading` / `SectionLabel`), section-nav, resource-error, system-banner,
|
|
509
|
+
settled — see [App shell](#app-shell).
|
|
378
510
|
- **Features — composed screens (1):** terminal — a trading terminal over an
|
|
379
511
|
investment product's shares (`Terminal` grid placing each `TerminalPane` by
|
|
380
512
|
`TerminalArea`; `TerminalTicker` / `TickerStat`; `OrderBook` with
|
|
@@ -388,6 +520,10 @@ landing (TS) sources — e.g. `Badge` keeps cabinet's `success` variant, and
|
|
|
388
520
|
`Button` keeps landing's square icon shape at every size, as the separate `icon`
|
|
389
521
|
boolean it always was in Rust (see [docs/spec/variants.md](../../docs/spec/variants.md)).
|
|
390
522
|
|
|
523
|
+
`size="touch"` is the 44 px pointer-target floor (WCAG 2.5.5) for touch-first
|
|
524
|
+
screens: `min-h-11` with `lg`'s padding, and a 44 × 44 square with `icon`. It is
|
|
525
|
+
a button step only — `Input` and `SelectTrigger` already clear it at `lg`.
|
|
526
|
+
|
|
391
527
|
## Limitations
|
|
392
528
|
|
|
393
529
|
Reproducing everything dep-light means some behaviour is intentionally reduced,
|
|
@@ -468,8 +604,19 @@ measuring needs host-only `web-sys`). Known gaps:
|
|
|
468
604
|
lock-up are the caller's, and each column renders only when it has content.
|
|
469
605
|
The lock-up is composed from `mark` + `brand` (display family) + `tagline`,
|
|
470
606
|
or passed whole as `lockup`. Brand-coloured text reads `primary-ink`.
|
|
471
|
-
- **TS-only for now:** `NativeSelect`, the `Field` id hand-off
|
|
472
|
-
`SelectValue` labels
|
|
607
|
+
- **TS-only for now:** `NativeSelect`, the `Field` id hand-off,
|
|
608
|
+
`SelectValue` labels, and the whole [app shell](#app-shell) (`AppShell`,
|
|
609
|
+
`ShellNav`, `BottomTabBar`, `NavBadge` / `NavDot`, `MobileAppBar`, `PageFrame` /
|
|
610
|
+
`PageHeading` / `SectionLabel`, `SectionNav`, `ResourceError`, `SystemBanner`,
|
|
611
|
+
`Settled`) — no Dioxus twin. Its CSS (the entrances, the markers, the motion
|
|
612
|
+
and shell tokens) ships in the shared sheet, so a Rust app can use the
|
|
613
|
+
`data-enter` contract today.
|
|
614
|
+
- **app shell:** `ShellNav`'s marker is measured (one forced layout read per
|
|
615
|
+
navigation); the tab bar's rule is arithmetic and assumes equal tabs. A
|
|
616
|
+
`data-enter` nested inside another still entering composes both rises (the
|
|
617
|
+
cabinet's motion-library version flattens the inner one); the kit leaves
|
|
618
|
+
nesting to the caller. `linkComponent` is typed loosely (`ElementType`), as
|
|
619
|
+
on `Footer`, so a router with typed hrefs does not fight it.
|
|
473
620
|
- **brand chrome (header / footer / status pages):** TS routes links through an
|
|
474
621
|
optional `linkComponent` (e.g. `next/link`) for soft navigation; Rust renders
|
|
475
622
|
plain `<a>` (a full document load). The Dioxus header drives its scroll state,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
const AT = {
|
|
5
|
+
md: {
|
|
6
|
+
rail: "hidden md:flex",
|
|
7
|
+
tabBar: "md:hidden",
|
|
8
|
+
reserve: "pb-[calc(var(--shell-tab-bar-h)+env(safe-area-inset-bottom,0px))] md:pb-0",
|
|
9
|
+
banner: "md:px-8"
|
|
10
|
+
},
|
|
11
|
+
lg: {
|
|
12
|
+
rail: "hidden lg:flex",
|
|
13
|
+
tabBar: "lg:hidden",
|
|
14
|
+
reserve: "pb-[calc(var(--shell-tab-bar-h)+env(safe-area-inset-bottom,0px))] lg:pb-0",
|
|
15
|
+
banner: "lg:px-8"
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const present = (node) => node !== null && node !== void 0 && node !== false;
|
|
19
|
+
function AppShell({
|
|
20
|
+
rail,
|
|
21
|
+
tabBar,
|
|
22
|
+
banner,
|
|
23
|
+
breakpoint = "lg",
|
|
24
|
+
mainProps,
|
|
25
|
+
className,
|
|
26
|
+
children,
|
|
27
|
+
...props
|
|
28
|
+
}) {
|
|
29
|
+
const at = AT[breakpoint];
|
|
30
|
+
return /* @__PURE__ */ jsxs(
|
|
31
|
+
"div",
|
|
32
|
+
{
|
|
33
|
+
"data-slot": "app-shell",
|
|
34
|
+
"data-breakpoint": breakpoint,
|
|
35
|
+
className: cn(
|
|
36
|
+
"flex min-h-[calc(100dvh-var(--ev-shell-offset,0px))] bg-background",
|
|
37
|
+
className
|
|
38
|
+
),
|
|
39
|
+
...props,
|
|
40
|
+
children: [
|
|
41
|
+
present(rail) && /* @__PURE__ */ jsx(
|
|
42
|
+
"div",
|
|
43
|
+
{
|
|
44
|
+
"data-slot": "app-shell-rail",
|
|
45
|
+
className: cn(
|
|
46
|
+
"sticky top-[var(--ev-shell-offset,0px)] h-[calc(100dvh-var(--ev-shell-offset,0px))] shrink-0 self-start",
|
|
47
|
+
at.rail
|
|
48
|
+
),
|
|
49
|
+
children: rail
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
/* @__PURE__ */ jsxs(
|
|
53
|
+
"div",
|
|
54
|
+
{
|
|
55
|
+
"data-slot": "app-shell-content",
|
|
56
|
+
className: cn("flex min-w-0 flex-1 flex-col", present(tabBar) && at.reserve),
|
|
57
|
+
children: [
|
|
58
|
+
present(banner) && /* @__PURE__ */ jsx(
|
|
59
|
+
"div",
|
|
60
|
+
{
|
|
61
|
+
"data-slot": "app-shell-banner",
|
|
62
|
+
className: cn("flex flex-col gap-2 px-4 pt-4 empty:hidden", at.banner),
|
|
63
|
+
children: banner
|
|
64
|
+
}
|
|
65
|
+
),
|
|
66
|
+
/* @__PURE__ */ jsx("main", { ...mainProps, className: cn("min-w-0 flex-1", mainProps?.className), children })
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
),
|
|
70
|
+
present(tabBar) && /* @__PURE__ */ jsx(
|
|
71
|
+
"div",
|
|
72
|
+
{
|
|
73
|
+
"data-slot": "app-shell-tab-bar",
|
|
74
|
+
className: cn("fixed inset-x-0 bottom-0 z-40", at.tabBar),
|
|
75
|
+
children: tabBar
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
AppShell
|
|
84
|
+
};
|
|
85
|
+
//# sourceMappingURL=app-shell.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/app-shell.tsx"],"sourcesContent":["import * as React from \"react\";\nimport { cn } from \"../lib/cn\";\n\n/** Where the shell swaps the bottom tab bar for the rail. */\nexport type ShellBreakpoint = \"md\" | \"lg\";\n\nexport interface AppShellProps extends React.ComponentProps<\"div\"> {\n /** The desktop rail — a `ShellNav`. Shown from `breakpoint` up. */\n rail?: React.ReactNode;\n /** The phone's tab bar — a `BottomTabBar`. Pinned to the viewport bottom below `breakpoint`. */\n tabBar?: React.ReactNode;\n /** Strips above the page (`SystemBanner`s). An empty slot takes no room. */\n banner?: React.ReactNode;\n /** Default `lg`. */\n breakpoint?: ShellBreakpoint;\n /** Props for the `<main>` the page renders into (an `id` for a skip link, say). */\n mainProps?: React.ComponentProps<\"main\">;\n}\n\n// Tailwind only sees whole class strings, so each breakpoint spells its own.\nconst AT = {\n md: {\n rail: \"hidden md:flex\",\n tabBar: \"md:hidden\",\n reserve: \"pb-[calc(var(--shell-tab-bar-h)+env(safe-area-inset-bottom,0px))] md:pb-0\",\n banner: \"md:px-8\",\n },\n lg: {\n rail: \"hidden lg:flex\",\n tabBar: \"lg:hidden\",\n reserve: \"pb-[calc(var(--shell-tab-bar-h)+env(safe-area-inset-bottom,0px))] lg:pb-0\",\n banner: \"lg:px-8\",\n },\n} as const;\n\nconst present = (node: React.ReactNode) => node !== null && node !== undefined && node !== false;\n\n/**\n * The signed-in app's frame: a rail beside a content column on a wide screen, a\n * bottom tab bar under it on a phone. Layout only — the nav, the banners and the\n * page are slots, so the shell knows nothing about routes, roles or data.\n *\n * Fills the viewport less `--ev-shell-offset` (the room a host shell's own header\n * takes); the rail sticks under that offset at full remaining height and scrolls\n * by itself when it cannot fit. The column reserves the tab bar's height plus\n * the bottom safe area, so the bar never covers content.\n */\nexport function AppShell({\n rail,\n tabBar,\n banner,\n breakpoint = \"lg\",\n mainProps,\n className,\n children,\n ...props\n}: AppShellProps) {\n const at = AT[breakpoint];\n return (\n <div\n data-slot=\"app-shell\"\n data-breakpoint={breakpoint}\n className={cn(\n \"flex min-h-[calc(100dvh-var(--ev-shell-offset,0px))] bg-background\",\n className,\n )}\n {...props}\n >\n {present(rail) && (\n <div\n data-slot=\"app-shell-rail\"\n className={cn(\n \"sticky top-[var(--ev-shell-offset,0px)] h-[calc(100dvh-var(--ev-shell-offset,0px))] shrink-0 self-start\",\n at.rail,\n )}\n >\n {rail}\n </div>\n )}\n <div\n data-slot=\"app-shell-content\"\n className={cn(\"flex min-w-0 flex-1 flex-col\", present(tabBar) && at.reserve)}\n >\n {present(banner) && (\n <div\n data-slot=\"app-shell-banner\"\n className={cn(\"flex flex-col gap-2 px-4 pt-4 empty:hidden\", at.banner)}\n >\n {banner}\n </div>\n )}\n <main {...mainProps} className={cn(\"min-w-0 flex-1\", mainProps?.className)}>\n {children}\n </main>\n </div>\n {present(tabBar) && (\n <div\n data-slot=\"app-shell-tab-bar\"\n className={cn(\"fixed inset-x-0 bottom-0 z-40\", at.tabBar)}\n >\n {tabBar}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"AAqEQ,cAUF,YAVE;AArER,YAAY,WAAW;AACvB,SAAS,UAAU;AAmBnB,MAAM,KAAK;AAAA,EACT,IAAI;AAAA,IACF,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,QAAQ;AAAA,EACV;AAAA,EACA,IAAI;AAAA,IACF,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,QAAQ;AAAA,EACV;AACF;AAEA,MAAM,UAAU,CAAC,SAA0B,SAAS,QAAQ,SAAS,UAAa,SAAS;AAYpF,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,KAAK,GAAG,UAAU;AACxB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,mBAAiB;AAAA,MACjB,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH;AAAA,gBAAQ,IAAI,KACX;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,WAAW;AAAA,cACT;AAAA,cACA,GAAG;AAAA,YACL;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QAEF;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,WAAW,GAAG,gCAAgC,QAAQ,MAAM,KAAK,GAAG,OAAO;AAAA,YAE1E;AAAA,sBAAQ,MAAM,KACb;AAAA,gBAAC;AAAA;AAAA,kBACC,aAAU;AAAA,kBACV,WAAW,GAAG,8CAA8C,GAAG,MAAM;AAAA,kBAEpE;AAAA;AAAA,cACH;AAAA,cAEF,oBAAC,UAAM,GAAG,WAAW,WAAW,GAAG,kBAAkB,WAAW,SAAS,GACtE,UACH;AAAA;AAAA;AAAA,QACF;AAAA,QACC,QAAQ,MAAM,KACb;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,WAAW,GAAG,iCAAiC,GAAG,MAAM;AAAA,YAEvD;AAAA;AAAA,QACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { NavBadge } from "./nav-badge.js";
|
|
6
|
+
import { labelTitle, NavLink } from "./nav-link.js";
|
|
7
|
+
import { resolveActiveNavItem } from "./nav-model.js";
|
|
8
|
+
import { usePendingNavigation } from "./use-shell-nav.js";
|
|
9
|
+
function BottomTabBar({
|
|
10
|
+
items,
|
|
11
|
+
pathname,
|
|
12
|
+
linkComponent,
|
|
13
|
+
onItemIntent,
|
|
14
|
+
onNavigate,
|
|
15
|
+
labels = {},
|
|
16
|
+
className,
|
|
17
|
+
...props
|
|
18
|
+
}) {
|
|
19
|
+
const [pendingId, markPending] = usePendingNavigation(pathname);
|
|
20
|
+
const currentId = (pendingId !== null && items.some((i) => i.id === pendingId) ? pendingId : void 0) ?? resolveActiveNavItem(items, pathname)?.id;
|
|
21
|
+
const at = items.findIndex((i) => i.id === currentId);
|
|
22
|
+
return /* @__PURE__ */ jsxs(
|
|
23
|
+
"nav",
|
|
24
|
+
{
|
|
25
|
+
"data-slot": "tab-bar",
|
|
26
|
+
"aria-label": labels.nav ?? "Main",
|
|
27
|
+
className: cn(
|
|
28
|
+
"relative box-content flex h-(--shell-tab-bar-h) items-center border-t border-border bg-secondary px-2 pb-[env(safe-area-inset-bottom,0px)]",
|
|
29
|
+
className
|
|
30
|
+
),
|
|
31
|
+
...props,
|
|
32
|
+
children: [
|
|
33
|
+
/* @__PURE__ */ jsx(
|
|
34
|
+
"span",
|
|
35
|
+
{
|
|
36
|
+
"data-slot": "tab-bar-marker",
|
|
37
|
+
"data-state": at >= 0 ? "active" : "idle",
|
|
38
|
+
"aria-hidden": true,
|
|
39
|
+
className: "pointer-events-none absolute left-2 top-0 flex justify-center",
|
|
40
|
+
style: {
|
|
41
|
+
width: `calc((100% - 1rem) / ${Math.max(items.length, 1)})`,
|
|
42
|
+
translate: `${Math.max(at, 0) * 100}% 0`
|
|
43
|
+
},
|
|
44
|
+
children: /* @__PURE__ */ jsx("span", { className: "h-0.5 w-10 rounded-full bg-primary-ink" })
|
|
45
|
+
}
|
|
46
|
+
),
|
|
47
|
+
items.map((item) => {
|
|
48
|
+
const Icon = item.icon;
|
|
49
|
+
const active = item.id === currentId;
|
|
50
|
+
return /* @__PURE__ */ jsxs(
|
|
51
|
+
NavLink,
|
|
52
|
+
{
|
|
53
|
+
item,
|
|
54
|
+
current: active,
|
|
55
|
+
pending: item.id === pendingId,
|
|
56
|
+
linkComponent,
|
|
57
|
+
markPending,
|
|
58
|
+
onItemIntent,
|
|
59
|
+
onNavigate,
|
|
60
|
+
className: cn(
|
|
61
|
+
"flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg py-1 font-medium transition-colors",
|
|
62
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary",
|
|
63
|
+
active ? "text-primary-ink" : "text-ink-soft hover:text-ink",
|
|
64
|
+
item.disabled && "cursor-not-allowed opacity-50"
|
|
65
|
+
),
|
|
66
|
+
children: [
|
|
67
|
+
/* @__PURE__ */ jsxs("span", { className: "relative shrink-0", children: [
|
|
68
|
+
Icon ? /* @__PURE__ */ jsx(Icon, { className: "size-5" }) : /* @__PURE__ */ jsx("span", { className: "block size-5" }),
|
|
69
|
+
typeof item.badge === "number" ? /* @__PURE__ */ jsx(
|
|
70
|
+
NavBadge,
|
|
71
|
+
{
|
|
72
|
+
count: item.badge,
|
|
73
|
+
variant: "corner",
|
|
74
|
+
...labels.badge ? { label: labels.badge } : {}
|
|
75
|
+
}
|
|
76
|
+
) : item.badge
|
|
77
|
+
] }),
|
|
78
|
+
/* @__PURE__ */ jsx("span", { className: "w-full truncate text-center text-xs", title: labelTitle(item.label), children: item.label })
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
item.id
|
|
82
|
+
);
|
|
83
|
+
})
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
export {
|
|
89
|
+
BottomTabBar
|
|
90
|
+
};
|
|
91
|
+
//# sourceMappingURL=bottom-tab-bar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/bottom-tab-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { NavBadge } from \"./nav-badge\";\nimport { labelTitle, NavLink, type NavLinkCallbacks } from \"./nav-link\";\nimport { resolveActiveNavItem, type NavItem } from \"./nav-model\";\nimport { usePendingNavigation } from \"./use-shell-nav\";\n\nexport interface BottomTabBarLabels {\n /** Accessible name of the bar. Default `\"Main\"`. */\n nav?: string;\n /** Spoken text of a numeric badge, given the real count. Default `\"{n} new\"`. */\n badge?: (count: number) => string;\n}\n\nexport interface BottomTabBarProps\n extends Omit<React.ComponentProps<\"nav\">, \"children\">,\n NavLinkCallbacks {\n /**\n * The tabs, in order. Five is the most a phone holds — every label gets a\n * fifth of ~390px. A tab standing in for several rail rows lists their paths\n * in `also`. `trailing` is not drawn here; `badge` sits on the icon's corner.\n */\n items: readonly NavItem[];\n pathname: string;\n linkComponent?: React.ElementType;\n labels?: BottomTabBarLabels;\n}\n\n/**\n * The mobile tab bar: equal tabs, one rule that slides between them, and a\n * corner badge. Position-agnostic — `AppShell` pins it to the bottom of the\n * viewport and hides it from its breakpoint up; the bar itself reserves the\n * bottom safe area under its `--shell-tab-bar-h`.\n *\n * The rule is a single node translated by whole tab widths (it is exactly one\n * tab wide, so the two cannot drift); on a route no tab claims it fades out\n * where it stands. That is why each tab is `min-w-0`: a long label must\n * truncate, never widen its tab off the rule's arithmetic.\n */\nexport function BottomTabBar({\n items,\n pathname,\n linkComponent,\n onItemIntent,\n onNavigate,\n labels = {},\n className,\n ...props\n}: BottomTabBarProps) {\n const [pendingId, markPending] = usePendingNavigation(pathname);\n const currentId =\n (pendingId !== null && items.some((i) => i.id === pendingId) ? pendingId : undefined) ??\n resolveActiveNavItem(items, pathname)?.id;\n const at = items.findIndex((i) => i.id === currentId);\n return (\n <nav\n data-slot=\"tab-bar\"\n aria-label={labels.nav ?? \"Main\"}\n className={cn(\n \"relative box-content flex h-(--shell-tab-bar-h) items-center border-t border-border bg-secondary px-2 pb-[env(safe-area-inset-bottom,0px)]\",\n className,\n )}\n {...props}\n >\n <span\n data-slot=\"tab-bar-marker\"\n data-state={at >= 0 ? \"active\" : \"idle\"}\n aria-hidden\n className=\"pointer-events-none absolute left-2 top-0 flex justify-center\"\n // `left-2` + `1rem` restate the bar's `px-2`: the rule is one tab wide.\n style={{\n width: `calc((100% - 1rem) / ${Math.max(items.length, 1)})`,\n translate: `${Math.max(at, 0) * 100}% 0`,\n }}\n >\n <span className=\"h-0.5 w-10 rounded-full bg-primary-ink\" />\n </span>\n {items.map((item) => {\n const Icon = item.icon;\n const active = item.id === currentId;\n return (\n <NavLink\n key={item.id}\n item={item}\n current={active}\n pending={item.id === pendingId}\n linkComponent={linkComponent}\n markPending={markPending}\n onItemIntent={onItemIntent}\n onNavigate={onNavigate}\n className={cn(\n \"flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg py-1 font-medium transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary\",\n active ? \"text-primary-ink\" : \"text-ink-soft hover:text-ink\",\n item.disabled && \"cursor-not-allowed opacity-50\",\n )}\n >\n <span className=\"relative shrink-0\">\n {Icon ? <Icon className=\"size-5\" /> : <span className=\"block size-5\" />}\n {typeof item.badge === \"number\" ? (\n <NavBadge\n count={item.badge}\n variant=\"corner\"\n {...(labels.badge ? { label: labels.badge } : {})}\n />\n ) : (\n item.badge\n )}\n </span>\n <span className=\"w-full truncate text-center text-xs\" title={labelTitle(item.label)}>\n {item.label}\n </span>\n </NavLink>\n );\n })}\n </nav>\n );\n}\n"],"mappings":";AA6EQ,cAsBI,YAtBJ;AA3ER,YAAY,WAAW;AACvB,SAAS,UAAU;AACnB,SAAS,gBAAgB;AACzB,SAAS,YAAY,eAAsC;AAC3D,SAAS,4BAA0C;AACnD,SAAS,4BAA4B;AAkC9B,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS,CAAC;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,CAAC,WAAW,WAAW,IAAI,qBAAqB,QAAQ;AAC9D,QAAM,aACH,cAAc,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,OAAO,SAAS,IAAI,YAAY,WAC3E,qBAAqB,OAAO,QAAQ,GAAG;AACzC,QAAM,KAAK,MAAM,UAAU,CAAC,MAAM,EAAE,OAAO,SAAS;AACpD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,cAAY,OAAO,OAAO;AAAA,MAC1B,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,cAAY,MAAM,IAAI,WAAW;AAAA,YACjC,eAAW;AAAA,YACX,WAAU;AAAA,YAEV,OAAO;AAAA,cACL,OAAO,wBAAwB,KAAK,IAAI,MAAM,QAAQ,CAAC,CAAC;AAAA,cACxD,WAAW,GAAG,KAAK,IAAI,IAAI,CAAC,IAAI,GAAG;AAAA,YACrC;AAAA,YAEA,8BAAC,UAAK,WAAU,0CAAyC;AAAA;AAAA,QAC3D;AAAA,QACC,MAAM,IAAI,CAAC,SAAS;AACnB,gBAAM,OAAO,KAAK;AAClB,gBAAM,SAAS,KAAK,OAAO;AAC3B,iBACE;AAAA,YAAC;AAAA;AAAA,cAEC;AAAA,cACA,SAAS;AAAA,cACT,SAAS,KAAK,OAAO;AAAA,cACrB;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA,WAAW;AAAA,gBACT;AAAA,gBACA;AAAA,gBACA,SAAS,qBAAqB;AAAA,gBAC9B,KAAK,YAAY;AAAA,cACnB;AAAA,cAEA;AAAA,qCAAC,UAAK,WAAU,qBACb;AAAA,yBAAO,oBAAC,QAAK,WAAU,UAAS,IAAK,oBAAC,UAAK,WAAU,gBAAe;AAAA,kBACpE,OAAO,KAAK,UAAU,WACrB;AAAA,oBAAC;AAAA;AAAA,sBACC,OAAO,KAAK;AAAA,sBACZ,SAAQ;AAAA,sBACP,GAAI,OAAO,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;AAAA;AAAA,kBACjD,IAEA,KAAK;AAAA,mBAET;AAAA,gBACA,oBAAC,UAAK,WAAU,uCAAsC,OAAO,WAAW,KAAK,KAAK,GAC/E,eAAK,OACR;AAAA;AAAA;AAAA,YA7BK,KAAK;AAAA,UA8BZ;AAAA,QAEJ,CAAC;AAAA;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { ChevronLeftIcon } from "./shell-icons.js";
|
|
6
|
+
const BACK = "-ml-1 flex size-6 shrink-0 items-center justify-center rounded-md text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
7
|
+
const HIDE_FROM = { md: "md:hidden", lg: "lg:hidden" };
|
|
8
|
+
function MobileAppBar({
|
|
9
|
+
title,
|
|
10
|
+
back,
|
|
11
|
+
right,
|
|
12
|
+
linkComponent,
|
|
13
|
+
hideFrom = "lg",
|
|
14
|
+
labels = {},
|
|
15
|
+
className,
|
|
16
|
+
...props
|
|
17
|
+
}) {
|
|
18
|
+
const backLabel = labels.back ?? "Back";
|
|
19
|
+
const Link = linkComponent ?? "a";
|
|
20
|
+
return /* @__PURE__ */ jsxs(
|
|
21
|
+
"header",
|
|
22
|
+
{
|
|
23
|
+
"data-slot": "mobile-app-bar",
|
|
24
|
+
"data-enter": "rise",
|
|
25
|
+
className: cn(
|
|
26
|
+
"sticky top-[var(--ev-shell-offset,0px)] z-30 flex items-center gap-2 border-b border-border bg-secondary px-4 pb-3.5 pt-4",
|
|
27
|
+
hideFrom && HIDE_FROM[hideFrom],
|
|
28
|
+
className
|
|
29
|
+
),
|
|
30
|
+
...props,
|
|
31
|
+
children: [
|
|
32
|
+
back && ("href" in back ? /* @__PURE__ */ jsx(Link, { href: back.href, "aria-label": backLabel, className: BACK, children: /* @__PURE__ */ jsx(ChevronLeftIcon, { className: "size-6" }) }) : /* @__PURE__ */ jsx("button", { type: "button", onClick: back.onClick, "aria-label": backLabel, className: BACK, children: /* @__PURE__ */ jsx(ChevronLeftIcon, { className: "size-6" }) })),
|
|
33
|
+
/* @__PURE__ */ jsx(
|
|
34
|
+
"h1",
|
|
35
|
+
{
|
|
36
|
+
className: cn(
|
|
37
|
+
"min-w-0 flex-1 truncate font-semibold text-ink",
|
|
38
|
+
back ? "text-center text-base" : "text-lg"
|
|
39
|
+
),
|
|
40
|
+
children: title
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
back && right === void 0 ? /* @__PURE__ */ jsx("span", { className: "size-6 shrink-0", "aria-hidden": true }) : right
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
MobileAppBar
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=mobile-app-bar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/mobile-app-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../lib/cn\";\nimport type { ShellBreakpoint } from \"./app-shell\";\nimport { ChevronLeftIcon } from \"./shell-icons\";\n\nexport interface MobileAppBarLabels {\n /** Accessible name of the back control. Default `\"Back\"`. */\n back?: string;\n}\n\nexport type MobileAppBarBack = { href: string } | { onClick: () => void };\n\nexport interface MobileAppBarProps extends Omit<React.ComponentProps<\"header\">, \"title\"> {\n title: React.ReactNode;\n /** A pushed screen: a back link (`href`) or a back button (`onClick`); centres the title. */\n back?: MobileAppBarBack;\n /** Trailing action — Save, a menu. */\n right?: React.ReactNode;\n linkComponent?: React.ElementType;\n /** Hidden from this breakpoint up, where the page heading takes over. Default `lg`; `false` never hides. */\n hideFrom?: ShellBreakpoint | false;\n labels?: MobileAppBarLabels;\n}\n\n// A bare glyph has no border of its own, so it needs a rounded box for the ring to trace.\nconst BACK =\n \"-ml-1 flex size-6 shrink-0 items-center justify-center rounded-md text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\nconst HIDE_FROM = { md: \"md:hidden\", lg: \"lg:hidden\" } as const;\n\n/**\n * The bar that titles a screen on a phone. A root screen puts the title flush\n * left with an optional action; a pushed one (`back`) centres it between the\n * back control and a matching spacer. Sticks under `--ev-shell-offset` and\n * arrives with the screen (`data-enter=\"rise\"`), one step ahead of a\n * `PageFrame`'s sections.\n */\nexport function MobileAppBar({\n title,\n back,\n right,\n linkComponent,\n hideFrom = \"lg\",\n labels = {},\n className,\n ...props\n}: MobileAppBarProps) {\n const backLabel = labels.back ?? \"Back\";\n const Link = linkComponent ?? \"a\";\n return (\n <header\n data-slot=\"mobile-app-bar\"\n data-enter=\"rise\"\n className={cn(\n \"sticky top-[var(--ev-shell-offset,0px)] z-30 flex items-center gap-2 border-b border-border bg-secondary px-4 pb-3.5 pt-4\",\n hideFrom && HIDE_FROM[hideFrom],\n className,\n )}\n {...props}\n >\n {back &&\n (\"href\" in back ? (\n <Link href={back.href} aria-label={backLabel} className={BACK}>\n <ChevronLeftIcon className=\"size-6\" />\n </Link>\n ) : (\n <button type=\"button\" onClick={back.onClick} aria-label={backLabel} className={BACK}>\n <ChevronLeftIcon className=\"size-6\" />\n </button>\n ))}\n <h1\n className={cn(\n \"min-w-0 flex-1 truncate font-semibold text-ink\",\n back ? \"text-center text-base\" : \"text-lg\",\n )}\n >\n {title}\n </h1>\n {back && right === undefined ? <span className=\"size-6 shrink-0\" aria-hidden /> : right}\n </header>\n );\n}\n"],"mappings":";AAoDI,SAaQ,KAbR;AAlDJ,YAAY,WAAW;AACvB,SAAS,UAAU;AAEnB,SAAS,uBAAuB;AAsBhC,MAAM,OACJ;AAEF,MAAM,YAAY,EAAE,IAAI,aAAa,IAAI,YAAY;AAS9C,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,SAAS,CAAC;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,YAAY,OAAO,QAAQ;AACjC,QAAM,OAAO,iBAAiB;AAC9B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,cAAW;AAAA,MACX,WAAW;AAAA,QACT;AAAA,QACA,YAAY,UAAU,QAAQ;AAAA,QAC9B;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH;AAAA,iBACE,UAAU,OACT,oBAAC,QAAK,MAAM,KAAK,MAAM,cAAY,WAAW,WAAW,MACvD,8BAAC,mBAAgB,WAAU,UAAS,GACtC,IAEA,oBAAC,YAAO,MAAK,UAAS,SAAS,KAAK,SAAS,cAAY,WAAW,WAAW,MAC7E,8BAAC,mBAAgB,WAAU,UAAS,GACtC;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,OAAO,0BAA0B;AAAA,YACnC;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACC,QAAQ,UAAU,SAAY,oBAAC,UAAK,WAAU,mBAAkB,eAAW,MAAC,IAAK;AAAA;AAAA;AAAA,EACpF;AAEJ;","names":[]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
const PILL = "rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums";
|
|
5
|
+
const CORNER = "absolute -right-2.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold leading-none tabular-nums text-on-primary";
|
|
6
|
+
const defaultLabel = (n) => `${n} new`;
|
|
7
|
+
function NavBadge({
|
|
8
|
+
count,
|
|
9
|
+
max = 99,
|
|
10
|
+
label = defaultLabel,
|
|
11
|
+
variant = "pill",
|
|
12
|
+
active = false,
|
|
13
|
+
className,
|
|
14
|
+
...props
|
|
15
|
+
}) {
|
|
16
|
+
if (!(count > 0)) return null;
|
|
17
|
+
return /* @__PURE__ */ jsxs(
|
|
18
|
+
"span",
|
|
19
|
+
{
|
|
20
|
+
"data-slot": "nav-badge",
|
|
21
|
+
"data-variant": variant,
|
|
22
|
+
className: cn(
|
|
23
|
+
variant === "corner" ? CORNER : cn(PILL, active ? "bg-background text-ink" : "bg-primary-ink/15 text-primary-ink"),
|
|
24
|
+
className
|
|
25
|
+
),
|
|
26
|
+
...props,
|
|
27
|
+
children: [
|
|
28
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, children: count > max ? `${max}+` : count }),
|
|
29
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: label(count) })
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const DOT_TONE = {
|
|
35
|
+
primary: "bg-primary-ink",
|
|
36
|
+
positive: "bg-positive",
|
|
37
|
+
warn: "bg-accent-warn",
|
|
38
|
+
error: "bg-accent-error"
|
|
39
|
+
};
|
|
40
|
+
function NavDot({ tone = "primary", label, corner = false, className, ...props }) {
|
|
41
|
+
return /* @__PURE__ */ jsx(
|
|
42
|
+
"span",
|
|
43
|
+
{
|
|
44
|
+
"data-slot": "nav-dot",
|
|
45
|
+
"aria-hidden": label === void 0 ? true : void 0,
|
|
46
|
+
className: cn(
|
|
47
|
+
"inline-block size-2 shrink-0 rounded-full",
|
|
48
|
+
DOT_TONE[tone],
|
|
49
|
+
corner && "absolute -right-1 -top-0.5",
|
|
50
|
+
className
|
|
51
|
+
),
|
|
52
|
+
...props,
|
|
53
|
+
children: label !== void 0 && /* @__PURE__ */ jsx("span", { className: "sr-only", children: label })
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
NavBadge,
|
|
59
|
+
NavDot
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=nav-badge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/nav-badge.tsx"],"sourcesContent":["import * as React from \"react\";\nimport { cn } from \"../lib/cn\";\n\nexport type NavBadgeVariant = \"pill\" | \"corner\";\n\nexport interface NavBadgeProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n count: number;\n /** Above this the badge shows `{max}+`. */\n max?: number;\n /**\n * The spoken text, given the REAL count — the one figure the cap throws away.\n * Default: `\"{n} new\"`.\n */\n label?: (count: number) => string;\n /**\n * `pill` sits in a row after the label (the rail); `corner` is the solid dot on\n * an icon's top-right corner (the tab bar) — its parent must be `relative`.\n */\n variant?: NavBadgeVariant;\n /** A pill on the marked row: inverted so it reads on the marker's fill. */\n active?: boolean;\n}\n\nconst PILL = \"rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums\";\nconst CORNER =\n \"absolute -right-2.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold leading-none tabular-nums text-on-primary\";\n\nconst defaultLabel = (n: number) => `${n} new`;\n\n/**\n * A count on a nav row or tab, capped so a neglected inbox cannot widen the\n * rail. Draws nothing for `0` or less.\n *\n * The visible figure is `aria-hidden` and the spoken one rides in an `sr-only`\n * span rather than an `aria-label`: a label on a plain span is not part of the\n * name the browser computes for the link around it, while text is — so the row\n * reads \"Notifications, 150 new\".\n */\nexport function NavBadge({\n count,\n max = 99,\n label = defaultLabel,\n variant = \"pill\",\n active = false,\n className,\n ...props\n}: NavBadgeProps) {\n if (!(count > 0)) return null;\n return (\n <span\n data-slot=\"nav-badge\"\n data-variant={variant}\n className={cn(\n variant === \"corner\"\n ? CORNER\n : cn(PILL, active ? \"bg-background text-ink\" : \"bg-primary-ink/15 text-primary-ink\"),\n className,\n )}\n {...props}\n >\n <span aria-hidden>{count > max ? `${max}+` : count}</span>\n <span className=\"sr-only\">{label(count)}</span>\n </span>\n );\n}\n\nexport type NavDotTone = \"primary\" | \"positive\" | \"warn\" | \"error\";\n\nconst DOT_TONE: Record<NavDotTone, string> = {\n primary: \"bg-primary-ink\",\n positive: \"bg-positive\",\n warn: \"bg-accent-warn\",\n error: \"bg-accent-error\",\n};\n\nexport interface NavDotProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n tone?: NavDotTone;\n /** Spoken text; without one the dot is decoration and hidden from AT. */\n label?: string;\n /** Pin to an icon's top-right corner (parent `relative`), as the tab bar does. */\n corner?: boolean;\n}\n\n/** A presence dot — \"something here\", no figure. */\nexport function NavDot({ tone = \"primary\", label, corner = false, className, ...props }: NavDotProps) {\n return (\n <span\n data-slot=\"nav-dot\"\n aria-hidden={label === undefined ? true : undefined}\n className={cn(\n \"inline-block size-2 shrink-0 rounded-full\",\n DOT_TONE[tone],\n corner && \"absolute -right-1 -top-0.5\",\n className,\n )}\n {...props}\n >\n {label !== undefined && <span className=\"sr-only\">{label}</span>}\n </span>\n );\n}\n"],"mappings":"AAiDI,SAWE,KAXF;AAjDJ,YAAY,WAAW;AACvB,SAAS,UAAU;AAsBnB,MAAM,OAAO;AACb,MAAM,SACJ;AAEF,MAAM,eAAe,CAAC,MAAc,GAAG,CAAC;AAWjC,SAAS,SAAS;AAAA,EACvB;AAAA,EACA,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,MAAI,EAAE,QAAQ,GAAI,QAAO;AACzB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,gBAAc;AAAA,MACd,WAAW;AAAA,QACT,YAAY,WACR,SACA,GAAG,MAAM,SAAS,2BAA2B,oCAAoC;AAAA,QACrF;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,4BAAC,UAAK,eAAW,MAAE,kBAAQ,MAAM,GAAG,GAAG,MAAM,OAAM;AAAA,QACnD,oBAAC,UAAK,WAAU,WAAW,gBAAM,KAAK,GAAE;AAAA;AAAA;AAAA,EAC1C;AAEJ;AAIA,MAAM,WAAuC;AAAA,EAC3C,SAAS;AAAA,EACT,UAAU;AAAA,EACV,MAAM;AAAA,EACN,OAAO;AACT;AAWO,SAAS,OAAO,EAAE,OAAO,WAAW,OAAO,SAAS,OAAO,WAAW,GAAG,MAAM,GAAgB;AACpG,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,eAAa,UAAU,SAAY,OAAO;AAAA,MAC1C,WAAW;AAAA,QACT;AAAA,QACA,SAAS,IAAI;AAAA,QACb,UAAU;AAAA,QACV;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH,oBAAU,UAAa,oBAAC,UAAK,WAAU,WAAW,iBAAM;AAAA;AAAA,EAC3D;AAEJ;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { anchorTargetProps, isPlainLeftClick } from "./nav-model.js";
|
|
5
|
+
function NavLink({
|
|
6
|
+
item,
|
|
7
|
+
current,
|
|
8
|
+
pending,
|
|
9
|
+
linkComponent,
|
|
10
|
+
markPending,
|
|
11
|
+
onItemIntent,
|
|
12
|
+
onNavigate,
|
|
13
|
+
className,
|
|
14
|
+
title,
|
|
15
|
+
children
|
|
16
|
+
}) {
|
|
17
|
+
if (item.disabled) {
|
|
18
|
+
return /* @__PURE__ */ jsx("span", { "data-slot": "nav-link", "aria-disabled": "true", title, className, children });
|
|
19
|
+
}
|
|
20
|
+
const Comp = item.external ? "a" : linkComponent ?? "a";
|
|
21
|
+
const intent = onItemIntent && (() => onItemIntent(item));
|
|
22
|
+
return /* @__PURE__ */ jsx(
|
|
23
|
+
Comp,
|
|
24
|
+
{
|
|
25
|
+
"data-slot": "nav-link",
|
|
26
|
+
"data-pending": pending ? "true" : void 0,
|
|
27
|
+
href: item.href,
|
|
28
|
+
title,
|
|
29
|
+
"aria-current": current ? "page" : void 0,
|
|
30
|
+
className,
|
|
31
|
+
onPointerEnter: intent,
|
|
32
|
+
onFocus: intent,
|
|
33
|
+
onClick: (e) => {
|
|
34
|
+
if (item.external || !isPlainLeftClick(e)) return;
|
|
35
|
+
markPending(item.id);
|
|
36
|
+
onNavigate?.(item);
|
|
37
|
+
},
|
|
38
|
+
...anchorTargetProps(item),
|
|
39
|
+
children
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
function labelTitle(label) {
|
|
44
|
+
return typeof label === "string" ? label : void 0;
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
NavLink,
|
|
48
|
+
labelTitle
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=nav-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/nav-link.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { anchorTargetProps, isPlainLeftClick, type NavItem } from \"./nav-model\";\n\nexport interface NavLinkCallbacks {\n /** Hover or focus on an item — the moment to prefetch its route. */\n onItemIntent?: ((item: NavItem) => void) | undefined;\n /** A plain left click on an in-app item, after it is marked pending. */\n onNavigate?: ((item: NavItem) => void) | undefined;\n}\n\ninterface NavLinkProps extends NavLinkCallbacks {\n item: NavItem;\n current: boolean;\n pending: boolean;\n linkComponent: React.ElementType | undefined;\n markPending: (id: string) => void;\n className: string;\n title?: string | undefined;\n children: React.ReactNode;\n}\n\n/**\n * The anchor (or the disabled stand-in) under a rail row and a tab: picks the\n * element, wires the optimistic mark and the intent/navigate callbacks, and\n * carries `aria-current`, which is also what the rail's marker measures.\n */\nexport function NavLink({\n item,\n current,\n pending,\n linkComponent,\n markPending,\n onItemIntent,\n onNavigate,\n className,\n title,\n children,\n}: NavLinkProps) {\n if (item.disabled) {\n return (\n <span data-slot=\"nav-link\" aria-disabled=\"true\" title={title} className={className}>\n {children}\n </span>\n );\n }\n const Comp: React.ElementType = item.external ? \"a\" : (linkComponent ?? \"a\");\n const intent = onItemIntent && (() => onItemIntent(item));\n return (\n <Comp\n data-slot=\"nav-link\"\n data-pending={pending ? \"true\" : undefined}\n href={item.href}\n title={title}\n aria-current={current ? \"page\" : undefined}\n className={className}\n onPointerEnter={intent}\n onFocus={intent}\n onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {\n if (item.external || !isPlainLeftClick(e)) return;\n markPending(item.id);\n onNavigate?.(item);\n }}\n {...anchorTargetProps(item)}\n >\n {children}\n </Comp>\n );\n}\n\n/** The row's tooltip: the label itself when it is plain text, so a truncated one stays readable. */\nexport function labelTitle(label: React.ReactNode): string | undefined {\n return typeof label === \"string\" ? label : undefined;\n}\n"],"mappings":";AA0CM;AAxCN,YAAY,WAAW;AACvB,SAAS,mBAAmB,wBAAsC;AAyB3D,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAiB;AACf,MAAI,KAAK,UAAU;AACjB,WACE,oBAAC,UAAK,aAAU,YAAW,iBAAc,QAAO,OAAc,WAC3D,UACH;AAAA,EAEJ;AACA,QAAM,OAA0B,KAAK,WAAW,MAAO,iBAAiB;AACxE,QAAM,SAAS,iBAAiB,MAAM,aAAa,IAAI;AACvD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,gBAAc,UAAU,SAAS;AAAA,MACjC,MAAM,KAAK;AAAA,MACX;AAAA,MACA,gBAAc,UAAU,SAAS;AAAA,MACjC;AAAA,MACA,gBAAgB;AAAA,MAChB,SAAS;AAAA,MACT,SAAS,CAAC,MAA2C;AACnD,YAAI,KAAK,YAAY,CAAC,iBAAiB,CAAC,EAAG;AAC3C,oBAAY,KAAK,EAAE;AACnB,qBAAa,IAAI;AAAA,MACnB;AAAA,MACC,GAAG,kBAAkB,IAAI;AAAA,MAEzB;AAAA;AAAA,EACH;AAEJ;AAGO,SAAS,WAAW,OAA4C;AACrE,SAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;","names":[]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function pathOf(href) {
|
|
2
|
+
const cut = href.search(/[?#]/);
|
|
3
|
+
const path = cut === -1 ? href : href.slice(0, cut);
|
|
4
|
+
return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
5
|
+
}
|
|
6
|
+
function underPrefix(pathname, prefix) {
|
|
7
|
+
if (prefix === "/") return pathname === "/";
|
|
8
|
+
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
9
|
+
}
|
|
10
|
+
const EXACT = Number.MAX_SAFE_INTEGER;
|
|
11
|
+
function navItemMatch(item, pathname) {
|
|
12
|
+
if (item.external || item.disabled) return null;
|
|
13
|
+
const path = pathOf(pathname);
|
|
14
|
+
const own = pathOf(item.href);
|
|
15
|
+
const match = item.match ?? "prefix";
|
|
16
|
+
let score = null;
|
|
17
|
+
if (typeof match === "function") score = match(path) ? own.length : null;
|
|
18
|
+
else if (path === own) score = EXACT;
|
|
19
|
+
else if (match === "prefix" && underPrefix(path, own)) score = own.length;
|
|
20
|
+
for (const extra of item.also ?? []) {
|
|
21
|
+
const p = pathOf(extra);
|
|
22
|
+
if (underPrefix(path, p) && (score === null || p.length > score)) score = p.length;
|
|
23
|
+
}
|
|
24
|
+
return score;
|
|
25
|
+
}
|
|
26
|
+
function isNavItemActive(item, pathname) {
|
|
27
|
+
return navItemMatch(item, pathname) !== null;
|
|
28
|
+
}
|
|
29
|
+
function resolveActiveNavItem(items, pathname) {
|
|
30
|
+
let best;
|
|
31
|
+
let bestScore = -1;
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const score = navItemMatch(item, pathname);
|
|
34
|
+
if (score !== null && score > bestScore) {
|
|
35
|
+
best = item;
|
|
36
|
+
bestScore = score;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return best;
|
|
40
|
+
}
|
|
41
|
+
function isPlainLeftClick(e) {
|
|
42
|
+
return !(e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0);
|
|
43
|
+
}
|
|
44
|
+
function anchorTargetProps(item) {
|
|
45
|
+
if (item.target === void 0) return {};
|
|
46
|
+
return item.target === "_blank" ? { target: item.target, rel: "noopener noreferrer" } : { target: item.target };
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
anchorTargetProps,
|
|
50
|
+
isNavItemActive,
|
|
51
|
+
isPlainLeftClick,
|
|
52
|
+
navItemMatch,
|
|
53
|
+
resolveActiveNavItem
|
|
54
|
+
};
|
|
55
|
+
//# sourceMappingURL=nav-model.js.map
|