@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.
Files changed (36) hide show
  1. package/README.md +151 -4
  2. package/dist/components/app-shell.js +85 -0
  3. package/dist/components/app-shell.js.map +1 -0
  4. package/dist/components/bottom-tab-bar.js +91 -0
  5. package/dist/components/bottom-tab-bar.js.map +1 -0
  6. package/dist/components/mobile-app-bar.js +51 -0
  7. package/dist/components/mobile-app-bar.js.map +1 -0
  8. package/dist/components/nav-badge.js +61 -0
  9. package/dist/components/nav-badge.js.map +1 -0
  10. package/dist/components/nav-link.js +50 -0
  11. package/dist/components/nav-link.js.map +1 -0
  12. package/dist/components/nav-model.js +55 -0
  13. package/dist/components/nav-model.js.map +1 -0
  14. package/dist/components/page-frame.js +92 -0
  15. package/dist/components/page-frame.js.map +1 -0
  16. package/dist/components/resource-error.js +47 -0
  17. package/dist/components/resource-error.js.map +1 -0
  18. package/dist/components/section-nav.js +86 -0
  19. package/dist/components/section-nav.js.map +1 -0
  20. package/dist/components/settled.js +19 -0
  21. package/dist/components/settled.js.map +1 -0
  22. package/dist/components/shell-icons.js +57 -0
  23. package/dist/components/shell-icons.js.map +1 -0
  24. package/dist/components/shell-nav.js +141 -0
  25. package/dist/components/shell-nav.js.map +1 -0
  26. package/dist/components/system-banner.js +53 -0
  27. package/dist/components/system-banner.js.map +1 -0
  28. package/dist/components/use-shell-nav.js +46 -0
  29. package/dist/components/use-shell-nav.js.map +1 -0
  30. package/dist/generated/button.js +4 -2
  31. package/dist/generated/button.js.map +1 -1
  32. package/dist/index.d.ts +421 -1
  33. package/dist/index.js +12 -0
  34. package/package.json +1 -1
  35. package/styles/theme.css +155 -1
  36. 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 63 bricks)
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 and
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