@exegia/corpora-ui 0.21.0 → 0.22.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 (59) hide show
  1. package/dist-lib/components/blocks/layout.d.ts +4 -1
  2. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +1 -1
  3. package/dist-lib/components/blocks/shell/index.d.ts +0 -12
  4. package/dist-lib/components/composed/logo.d.ts +34 -0
  5. package/dist-lib/components/user-avatar/type.d.ts +10 -2
  6. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +5 -0
  7. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +9 -0
  8. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  9. package/dist-lib/index.d.ts +1 -1
  10. package/dist-lib/index.js +3083 -3229
  11. package/dist-lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/components/blocks/layout.ts +30 -2
  14. package/src/components/blocks/profile/profile-card-block.tsx +1 -1
  15. package/src/components/blocks/shell/index.ts +0 -13
  16. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  17. package/src/components/composed/logo.tsx +159 -0
  18. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +41 -0
  19. package/src/components/user-avatar/component.tsx +38 -8
  20. package/src/components/user-avatar/fallback.tsx +3 -1
  21. package/src/components/user-avatar/type.ts +10 -2
  22. package/src/components/user-avatar/use-user-avatar.ts +38 -2
  23. package/src/components/user-avatar/user-avatar-atom.ts +20 -0
  24. package/src/components/user-avatar/utils.ts +94 -0
  25. package/src/index.ts +1 -1
  26. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  27. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  28. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  29. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  30. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  31. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  32. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  33. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  34. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  35. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  36. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  37. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  38. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  39. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  40. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  41. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  42. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  43. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  44. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  45. package/src/components/blocks/nav/types.ts +0 -65
  46. package/src/components/blocks/nav/utils.ts +0 -4
  47. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  48. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  49. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  50. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  51. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  52. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  53. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  54. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  55. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  56. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  57. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  58. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  59. /package/dist-lib/components/{blocks/nav/__tests__/sidebar-block.test.d.ts → composed/__tests__/logo.test.d.ts} +0 -0
@@ -6,3 +6,97 @@ export function initialsFrom(name: string): string {
6
6
  const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
7
7
  return (first + last).toUpperCase()
8
8
  }
9
+
10
+ /** Sampling grid for `measureRimTone`. 16² px is enough to average a rim. */
11
+ const TONE_SAMPLE_SIZE = 16
12
+ /** Pixels this far (in radii) from the centre count as rim — the band the
13
+ * bezel sits on. Matches the ring's mask, which fades in from ~78%. */
14
+ const TONE_RIM_RADIUS = 0.6
15
+
16
+ const toneCache = new Map<string, Promise<number | null>>()
17
+
18
+ /**
19
+ * Average perceived lightness, 0 (black) → 1 (white), of an image's outer
20
+ * band — the pixels the bezel ring overlays — or `null` when it cannot be
21
+ * measured: no DOM/canvas (SSR, test DOM), a decode failure, or a host
22
+ * without CORS headers (the sample needs a CORS-clean image; the avatar's
23
+ * own `<img>` is left untouched, so a non-CORS host still displays).
24
+ * Results are memoised per `src`, so a room of the same face samples once.
25
+ */
26
+ export function measureRimTone(src: string): Promise<number | null> {
27
+ const cached = toneCache.get(src)
28
+ if (cached) return cached
29
+ const promise = new Promise<number | null>((resolve) => {
30
+ if (typeof document === "undefined" || typeof Image === "undefined") {
31
+ resolve(null)
32
+ return
33
+ }
34
+ const image = new Image()
35
+ image.crossOrigin = "anonymous"
36
+ image.decoding = "async"
37
+ image.onerror = () => resolve(null)
38
+ image.onload = () => {
39
+ try {
40
+ const size = TONE_SAMPLE_SIZE
41
+ const canvas = document.createElement("canvas")
42
+ canvas.width = size
43
+ canvas.height = size
44
+ const context = canvas.getContext("2d", { willReadFrequently: true })
45
+ if (!context) {
46
+ resolve(null)
47
+ return
48
+ }
49
+ context.drawImage(image, 0, 0, size, size)
50
+ const { data } = context.getImageData(0, 0, size, size)
51
+ const centre = (size - 1) / 2
52
+ const rim = TONE_RIM_RADIUS * centre
53
+ let sum = 0
54
+ let count = 0
55
+ for (let y = 0; y < size; y++) {
56
+ for (let x = 0; x < size; x++) {
57
+ const dx = x - centre
58
+ const dy = y - centre
59
+ if (dx * dx + dy * dy < rim * rim) continue
60
+ const offset = (y * size + x) * 4
61
+ const r = data[offset] ?? 0
62
+ const g = data[offset + 1] ?? 0
63
+ const b = data[offset + 2] ?? 0
64
+ // Rec. 709 luma on the gamma-encoded channels: close enough to
65
+ // perceived lightness for a "light or dark?" verdict.
66
+ sum += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
67
+ count++
68
+ }
69
+ }
70
+ resolve(count === 0 ? null : sum / count)
71
+ } catch {
72
+ // A tainted canvas throws on getImageData.
73
+ resolve(null)
74
+ }
75
+ }
76
+ image.src = src
77
+ })
78
+ toneCache.set(src, promise)
79
+ // Only a real reading is worth keeping — a failed sample (flaky network,
80
+ // a host that gained CORS headers later) gets another go next mount.
81
+ void promise.then((tone) => {
82
+ if (tone === null) toneCache.delete(src)
83
+ })
84
+ return promise
85
+ }
86
+
87
+ /**
88
+ * Bezel highlight/shadow alphas for an image whose rim lightness is `tone`
89
+ * (0 → 1). Against a dark rim the white highlight is eased off (it glares)
90
+ * and the black shadow pushed up (it has to darken mid-tones to register);
91
+ * against a pale rim the reverse — the highlight can be full (it barely
92
+ * shows) and the shadow is what carries the emboss, so it sits lighter.
93
+ * Linear between; the end points are tuned by eye on a dark and a light
94
+ * photo in both themes.
95
+ */
96
+ export function bezelAlphasForTone(tone: number): { hi: number; lo: number } {
97
+ const t = Math.min(1, Math.max(0, tone))
98
+ return {
99
+ hi: 0.7 + 0.3 * t,
100
+ lo: 0.6 - 0.4 * t,
101
+ }
102
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * from "./components/ui/spinner"
24
24
  export * from "./components/ui/textarea"
25
25
 
26
26
  // components
27
+ export * from "./components/composed/logo"
27
28
  export * from "./components/composed/password-input"
28
29
  export * from "./components/composed/social-providers"
29
30
  export * from "./components/composed/tree"
@@ -69,7 +70,6 @@ export * from "./components/blocks/auth/passkey-manager-block"
69
70
  export * from "./components/blocks/auth/passkey-sign-in-block"
70
71
  export * from "./components/blocks/auth/signup-block"
71
72
  export * from "./components/blocks/auth/update-password-block"
72
- export * from "./components/blocks/nav/sidebar-block"
73
73
  export * from "./components/blocks/nav/sidebar"
74
74
  export * from "./components/blocks/shell"
75
75
  export { default as ShellLayout } from "./components/blocks/shell"
@@ -1,14 +0,0 @@
1
- import { ISidebarBlockProps } from './types';
2
- import * as React from "react";
3
- export type { ISidebarBlockProps as SidebarBlockProps, ISidebarNavItem as SidebarNavItem, ISidebarNavSection as SidebarNavSection, ISidebarNavSubItem as SidebarNavSubItem, } from './types';
4
- /**
5
- * Application sidebar navigation, driven by data rather than composition.
6
- *
7
- * This is the navigation panel only — it claims no page layout, so place it
8
- * beside your own content. It owns its own sidebar context (⌘B toggles it);
9
- * pass `open`/`onOpenChange` to drive the rail, and `openMobile`/
10
- * `onOpenMobileChange` to open the drawer from a header button of your own.
11
- * Compose the underlying `AnimatedPanel*` parts directly when a screen
12
- * needs something this API does not cover.
13
- */
14
- export declare function SidebarBlock({ sections, activeId, onNavigate, header, footer, showTrigger, side, variant, collapsible, open, defaultOpen, onOpenChange, openMobile, defaultOpenMobile, onOpenMobileChange, width, iconWidth, mobileWidth, ariaLabel, className, }: ISidebarBlockProps): React.ReactElement;
@@ -1,3 +0,0 @@
1
- import { IRowProps } from './types';
2
- import * as React from "react";
3
- export declare function SidebarNavRow({ item, activeId, onNavigate }: IRowProps): React.JSX.Element;
@@ -1,60 +0,0 @@
1
- /** A nested link, one level under a top-level entry. */
2
- export interface ISidebarNavSubItem {
3
- id: string;
4
- label: string;
5
- icon?: React.ReactNode;
6
- /** Renders an anchor. Without one the row is a button. */
7
- href?: string;
8
- disabled?: boolean;
9
- target?: "_blank" | "_self" | "_parent" | "_top";
10
- onSelect?: () => void;
11
- }
12
- /** A top-level entry. With `items` it expands instead of navigating. */
13
- export interface ISidebarNavItem extends ISidebarNavSubItem {
14
- /** Trailing hint — a count, a "New" pill. Hidden while collapsed. */
15
- badge?: React.ReactNode;
16
- items?: ISidebarNavSubItem[];
17
- /** Start expanded. Defaults to expanded when one of its children is active. */
18
- defaultOpen?: boolean;
19
- }
20
- /** A titled run of entries. The title hides while the rail is collapsed. */
21
- export interface ISidebarNavSection {
22
- id: string;
23
- label?: string;
24
- items: ISidebarNavItem[];
25
- }
26
- export interface ISidebarBlockProps {
27
- sections: ISidebarNavSection[];
28
- /** `id` of the current entry — matches top-level and nested ids alike. */
29
- activeId?: string;
30
- /** Fires for every selection, after the entry's own `onSelect`. */
31
- onNavigate?: (item: ISidebarNavItem | ISidebarNavSubItem) => void;
32
- /** Brand row above the navigation. */
33
- header?: React.ReactNode;
34
- /** Pinned below the navigation — an account card, a version note. */
35
- footer?: React.ReactNode;
36
- /** Collapse toggle in the header row. */
37
- showTrigger?: boolean;
38
- side?: "left" | "right";
39
- variant?: "sidebar" | "floating" | "inset";
40
- /** How the rail collapses: to icons, off-canvas, or not at all. */
41
- collapsible?: "offcanvas" | "icon" | "none";
42
- open?: boolean;
43
- defaultOpen?: boolean;
44
- onOpenChange?: (open: boolean) => void;
45
- /** Under 768px the panel is a drawer — drive it from your own header. */
46
- openMobile?: boolean;
47
- defaultOpenMobile?: boolean;
48
- onOpenMobileChange?: (open: boolean) => void;
49
- /** Rail widths. CSS lengths, e.g. "16rem". */
50
- width?: string;
51
- iconWidth?: string;
52
- mobileWidth?: string;
53
- ariaLabel?: string;
54
- className?: string;
55
- }
56
- export interface IRowProps {
57
- item: ISidebarNavItem;
58
- activeId?: string;
59
- onNavigate?: (item: ISidebarNavItem | ISidebarNavSubItem) => void;
60
- }
@@ -1 +0,0 @@
1
- export declare function isActive(id: string, activeId?: string): boolean;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarContent: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarFooter: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarGroupContent: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarGroupLabel: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarGroup: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarHeader: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,3 +0,0 @@
1
- import { AnimatedSidebarMenuButtonProps } from './type';
2
- import type * as React from "react";
3
- export declare function AnimatedSidebarMenuButton({ children, icon, badge, href, isActive, ariaExpanded, disabled, closeOnSelect, target, rel, onSelect, className, }: AnimatedSidebarMenuButtonProps): React.JSX.Element;
@@ -1,2 +0,0 @@
1
- import { HTMLMotionProps } from 'motion/react';
2
- export declare const AnimatedSidebarMenuItem: import('react').ForwardRefExoticComponent<Omit<HTMLMotionProps<"li">, "ref"> & import('react').RefAttributes<HTMLLIElement>>;
@@ -1,3 +0,0 @@
1
- import { AnimatedSidebarMenuSubButtonProps } from './type';
2
- import type * as React from "react";
3
- export declare function AnimatedSidebarMenuSubButton({ children, icon, href, isActive, disabled, closeOnSelect, target, rel, onSelect, className, }: AnimatedSidebarMenuSubButtonProps): React.JSX.Element;
@@ -1,2 +0,0 @@
1
- import { HTMLMotionProps } from 'motion/react';
2
- export declare const AnimatedSidebarMenuSubItem: import('react').ForwardRefExoticComponent<Omit<HTMLMotionProps<"li">, "ref"> & import('react').RefAttributes<HTMLLIElement>>;
@@ -1,2 +0,0 @@
1
- import { AnimatedSidebarMenuSubProps } from './type';
2
- export declare const AnimatedSidebarMenuSub: import('react').ForwardRefExoticComponent<Omit<AnimatedSidebarMenuSubProps, "ref"> & import('react').RefAttributes<HTMLUListElement>>;
@@ -1,2 +0,0 @@
1
- import { HTMLAttributes } from 'react';
2
- export declare const AnimatedSidebarMenu: import('react').ForwardRefExoticComponent<HTMLAttributes<HTMLUListElement> & import('react').RefAttributes<HTMLUListElement>>;
@@ -1,125 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test"
2
- import { render, screen } from "@testing-library/react"
3
- import userEvent from "@testing-library/user-event"
4
-
5
- import { SidebarBlock, type SidebarNavSection } from "../sidebar-block"
6
-
7
- const SECTIONS: SidebarNavSection[] = [
8
- {
9
- id: "work",
10
- label: "Research",
11
- items: [
12
- { id: "search", label: "Search" },
13
- {
14
- id: "library",
15
- label: "Library",
16
- items: [
17
- { id: "manuscripts", label: "Manuscripts" },
18
- { id: "codices", label: "Codices" },
19
- ],
20
- },
21
- ],
22
- },
23
- { id: "workspace", items: [{ id: "settings", label: "Settings" }] },
24
- ]
25
-
26
- describe("SidebarBlock", () => {
27
- test("renders the sections and their entries", () => {
28
- render(<SidebarBlock ariaLabel="Main" sections={SECTIONS} />)
29
-
30
- expect(screen.getByRole("navigation", { name: "Main" })).toBeDefined()
31
- expect(screen.getByText("Research")).toBeDefined()
32
- expect(screen.getByRole("button", { name: "Search" })).toBeDefined()
33
- expect(screen.getByRole("button", { name: "Settings" })).toBeDefined()
34
- })
35
-
36
- test("marks the active entry", () => {
37
- render(<SidebarBlock activeId="search" sections={SECTIONS} />)
38
-
39
- expect(
40
- screen
41
- .getByRole("button", { name: "Search" })
42
- .getAttribute("aria-current")
43
- ).toBe("page")
44
- expect(
45
- screen
46
- .getByRole("button", { name: "Settings" })
47
- .getAttribute("aria-current")
48
- ).toBeNull()
49
- })
50
-
51
- test("reports selections through onNavigate, after the entry's onSelect", async () => {
52
- const user = userEvent.setup()
53
- const calls: string[] = []
54
- render(
55
- <SidebarBlock
56
- onNavigate={(item) => calls.push(`navigate:${item.id}`)}
57
- sections={[
58
- {
59
- id: "work",
60
- items: [
61
- {
62
- id: "search",
63
- label: "Search",
64
- onSelect: () => calls.push("select:search"),
65
- },
66
- ],
67
- },
68
- ]}
69
- />
70
- )
71
-
72
- await user.click(screen.getByRole("button", { name: "Search" }))
73
-
74
- expect(calls).toEqual(["select:search", "navigate:search"])
75
- })
76
-
77
- test("an entry with children expands instead of navigating", async () => {
78
- const user = userEvent.setup()
79
- const onNavigate = mock(() => {})
80
- render(<SidebarBlock onNavigate={onNavigate} sections={SECTIONS} />)
81
-
82
- const parent = screen.getByRole("button", { name: "Library" })
83
- expect(parent.getAttribute("aria-expanded")).toBe("false")
84
- expect(screen.queryByRole("button", { name: "Manuscripts" })).toBeNull()
85
-
86
- await user.click(parent)
87
-
88
- expect(
89
- await screen.findByRole("button", { name: "Manuscripts" })
90
- ).toBeDefined()
91
- expect(parent.getAttribute("aria-expanded")).toBe("true")
92
- expect(onNavigate).not.toHaveBeenCalled()
93
- })
94
-
95
- test("starts expanded when one of its children is active", async () => {
96
- render(<SidebarBlock activeId="codices" sections={SECTIONS} />)
97
-
98
- const child = await screen.findByRole("button", { name: "Codices" })
99
- expect(child.getAttribute("aria-current")).toBe("page")
100
- expect(
101
- screen
102
- .getByRole("button", { name: "Library" })
103
- .getAttribute("aria-expanded")
104
- ).toBe("true")
105
- })
106
-
107
- test("renders href entries as links", () => {
108
- render(
109
- <SidebarBlock
110
- sections={[
111
- {
112
- id: "work",
113
- items: [
114
- { id: "docs", label: "Docs", href: "/docs", target: "_blank" },
115
- ],
116
- },
117
- ]}
118
- />
119
- )
120
-
121
- const link = screen.getByRole("link", { name: "Docs" })
122
- expect(link.getAttribute("href")).toBe("/docs")
123
- expect(link.getAttribute("rel")).toBe("noreferrer noopener")
124
- })
125
- })
@@ -1,142 +0,0 @@
1
- "use client"
2
-
3
- import { PanelLeftIcon } from "lucide-react"
4
- import * as React from "react"
5
-
6
- import {
7
- AnimatedPanel as AnimatedSidebar,
8
- AnimatedPanelProvider as AnimatedSidebarProvider,
9
- AnimatedPanelTrigger as AnimatedSidebarTrigger,
10
- AnimatedSidebarContent,
11
- AnimatedSidebarFooter,
12
- AnimatedSidebarGroup,
13
- AnimatedSidebarGroupContent,
14
- AnimatedSidebarGroupLabel,
15
- AnimatedSidebarHeader,
16
- AnimatedSidebarMenu,
17
- } from "@/components/blocks/shell"
18
- import { cn } from "@/lib/utils"
19
- import { SidebarNavRow } from "@/components/blocks/nav/sidebar-nav-row"
20
-
21
- import type { ISidebarBlockProps } from "@/components/blocks/nav/types"
22
-
23
- export type {
24
- ISidebarBlockProps as SidebarBlockProps,
25
- ISidebarNavItem as SidebarNavItem,
26
- ISidebarNavSection as SidebarNavSection,
27
- ISidebarNavSubItem as SidebarNavSubItem,
28
- } from "@/components/blocks/nav/types"
29
-
30
- /**
31
- * Application sidebar navigation, driven by data rather than composition.
32
- *
33
- * This is the navigation panel only — it claims no page layout, so place it
34
- * beside your own content. It owns its own sidebar context (⌘B toggles it);
35
- * pass `open`/`onOpenChange` to drive the rail, and `openMobile`/
36
- * `onOpenMobileChange` to open the drawer from a header button of your own.
37
- * Compose the underlying `AnimatedPanel*` parts directly when a screen
38
- * needs something this API does not cover.
39
- */
40
- export function SidebarBlock({
41
- sections,
42
- activeId,
43
- onNavigate,
44
- header,
45
- footer,
46
- showTrigger = true,
47
- side = "left",
48
- variant = "sidebar",
49
- collapsible = "icon",
50
- open,
51
- defaultOpen,
52
- onOpenChange,
53
- openMobile,
54
- defaultOpenMobile,
55
- onOpenMobileChange,
56
- width,
57
- iconWidth,
58
- mobileWidth,
59
- ariaLabel = "Main",
60
- className,
61
- }: ISidebarBlockProps): React.ReactElement {
62
- return (
63
- <AnimatedSidebarProvider
64
- // The provider ships a full-page flex shell; this block is just the
65
- // panel, so the shell is flattened back to the panel's own box. The
66
- // block is a single panel, so its scalar open props map onto the
67
- // provider's side-keyed records under the block's own `side`.
68
- className="h-full min-h-0 w-auto"
69
- defaultOpen={{ [side]: defaultOpen ?? true }}
70
- defaultOpenMobile={
71
- defaultOpenMobile === undefined ? undefined : { [side]: defaultOpenMobile }
72
- }
73
- onOpenChange={(nextOpen, changedSide) => {
74
- if (changedSide === side) onOpenChange?.(nextOpen)
75
- }}
76
- onOpenMobileChange={(nextOpen, changedSide) => {
77
- if (changedSide === side) onOpenMobileChange?.(nextOpen)
78
- }}
79
- open={open === undefined ? undefined : { [side]: open }}
80
- openMobile={openMobile === undefined ? undefined : { [side]: openMobile }}
81
- style={{
82
- ...(width ? { "--sidebar-width": width } : null),
83
- ...(iconWidth ? { "--sidebar-width-icon": iconWidth } : null),
84
- ...(mobileWidth ? { "--sidebar-width-mobile": mobileWidth } : null),
85
- }}
86
- >
87
- <AnimatedSidebar
88
- ariaLabel={ariaLabel}
89
- // The panel is built for a full-page shell (`h-svh`, so its footer
90
- // hangs below any shorter box). Fill the parent instead — in a
91
- // full-height layout that comes to the same thing.
92
- className={cn("h-full", className)}
93
- collapsible={collapsible}
94
- panelClassName="h-full"
95
- // The panel is navigation and nothing else, so it names a navigation
96
- // landmark rather than the complementary one <aside> implies. (The
97
- // mobile drawer is a dialog and keeps that role.)
98
- role="navigation"
99
- side={side}
100
- variant={variant}
101
- >
102
- {header ||
103
- (showTrigger && (
104
- <AnimatedSidebarHeader>
105
- <div className="flex min-w-0 items-center justify-between gap-2">
106
- {header && <div className="min-w-0 flex-1">{header}</div>}
107
- {showTrigger && (
108
- <AnimatedSidebarTrigger className="text-muted-foreground hover:text-foreground">
109
- <PanelLeftIcon className="size-4.5" />
110
- </AnimatedSidebarTrigger>
111
- )}
112
- </div>
113
- </AnimatedSidebarHeader>
114
- ))}
115
- <AnimatedSidebarContent>
116
- {sections.map((section) => (
117
- <AnimatedSidebarGroup key={section.id}>
118
- {section.label && (
119
- <AnimatedSidebarGroupLabel>
120
- {section.label}
121
- </AnimatedSidebarGroupLabel>
122
- )}
123
- <AnimatedSidebarGroupContent>
124
- <AnimatedSidebarMenu>
125
- {section.items.map((item) => (
126
- <SidebarNavRow
127
- activeId={activeId}
128
- item={item}
129
- key={item.id}
130
- onNavigate={onNavigate}
131
- />
132
- ))}
133
- </AnimatedSidebarMenu>
134
- </AnimatedSidebarGroupContent>
135
- </AnimatedSidebarGroup>
136
- ))}
137
- </AnimatedSidebarContent>
138
- {footer && <AnimatedSidebarFooter>{footer}</AnimatedSidebarFooter>}
139
- </AnimatedSidebar>
140
- </AnimatedSidebarProvider>
141
- )
142
- }
@@ -1,78 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
-
5
- import type { IRowProps, ISidebarNavItem, ISidebarNavSubItem } from "@/components/blocks/nav/types"
6
- import { isActive } from "@/components/blocks/nav/utils"
7
- import {
8
- AnimatedSidebarMenuButton,
9
- AnimatedSidebarMenuItem,
10
- AnimatedSidebarMenuSub,
11
- AnimatedSidebarMenuSubButton,
12
- AnimatedSidebarMenuSubItem,
13
- } from "@/components/blocks/shell"
14
-
15
- export function SidebarNavRow({ item, activeId, onNavigate }: IRowProps) {
16
- const children = item.items ?? []
17
- const hasChildren = children.length > 0
18
- const childActive = children.some((child) => isActive(child.id, activeId))
19
- // null = follow the active child; a boolean = the reader has said otherwise.
20
- const [toggled, setToggled] = React.useState<boolean | null>(null)
21
- const [wasChildActive, setWasChildActive] = React.useState(childActive)
22
-
23
- // Navigating into a nested route from elsewhere reveals its parent, even if
24
- // the reader had collapsed it. Adjusted during render rather than in an
25
- // effect, so the group never paints closed for a frame first.
26
- if (childActive !== wasChildActive) {
27
- setWasChildActive(childActive)
28
- if (childActive) setToggled(null)
29
- }
30
-
31
- const open = toggled ?? item.defaultOpen ?? childActive
32
-
33
- function select(entry: ISidebarNavItem | ISidebarNavSubItem) {
34
- entry.onSelect?.()
35
- onNavigate?.(entry)
36
- }
37
-
38
- return (
39
- <AnimatedSidebarMenuItem>
40
- <AnimatedSidebarMenuButton
41
- ariaExpanded={hasChildren ? open : undefined}
42
- badge={item.badge}
43
- disabled={item.disabled}
44
- href={hasChildren ? undefined : item.href}
45
- icon={item.icon}
46
- isActive={isActive(item.id, activeId)}
47
- onSelect={() => {
48
- if (hasChildren) {
49
- setToggled(!open)
50
- return
51
- }
52
- select(item)
53
- }}
54
- target={item.target}
55
- >
56
- {item.label}
57
- </AnimatedSidebarMenuButton>
58
- {hasChildren ? (
59
- <AnimatedSidebarMenuSub open={open}>
60
- {children.map((child) => (
61
- <AnimatedSidebarMenuSubItem key={child.id}>
62
- <AnimatedSidebarMenuSubButton
63
- disabled={child.disabled}
64
- href={child.href}
65
- icon={child.icon}
66
- isActive={isActive(child.id, activeId)}
67
- onSelect={() => select(child)}
68
- target={child.target}
69
- >
70
- {child.label}
71
- </AnimatedSidebarMenuSubButton>
72
- </AnimatedSidebarMenuSubItem>
73
- ))}
74
- </AnimatedSidebarMenuSub>
75
- ) : null}
76
- </AnimatedSidebarMenuItem>
77
- )
78
- }
@@ -1,65 +0,0 @@
1
-
2
- /** A nested link, one level under a top-level entry. */
3
- export interface ISidebarNavSubItem {
4
- id: string
5
- label: string
6
- icon?: React.ReactNode
7
- /** Renders an anchor. Without one the row is a button. */
8
- href?: string
9
- disabled?: boolean
10
- target?: "_blank" | "_self" | "_parent" | "_top"
11
- onSelect?: () => void
12
- }
13
-
14
- /** A top-level entry. With `items` it expands instead of navigating. */
15
- export interface ISidebarNavItem extends ISidebarNavSubItem {
16
- /** Trailing hint — a count, a "New" pill. Hidden while collapsed. */
17
- badge?: React.ReactNode
18
- items?: ISidebarNavSubItem[]
19
- /** Start expanded. Defaults to expanded when one of its children is active. */
20
- defaultOpen?: boolean
21
- }
22
-
23
- /** A titled run of entries. The title hides while the rail is collapsed. */
24
- export interface ISidebarNavSection {
25
- id: string
26
- label?: string
27
- items: ISidebarNavItem[]
28
- }
29
-
30
- export interface ISidebarBlockProps {
31
- sections: ISidebarNavSection[]
32
- /** `id` of the current entry — matches top-level and nested ids alike. */
33
- activeId?: string
34
- /** Fires for every selection, after the entry's own `onSelect`. */
35
- onNavigate?: (item: ISidebarNavItem | ISidebarNavSubItem) => void
36
- /** Brand row above the navigation. */
37
- header?: React.ReactNode
38
- /** Pinned below the navigation — an account card, a version note. */
39
- footer?: React.ReactNode
40
- /** Collapse toggle in the header row. */
41
- showTrigger?: boolean
42
- side?: "left" | "right"
43
- variant?: "sidebar" | "floating" | "inset"
44
- /** How the rail collapses: to icons, off-canvas, or not at all. */
45
- collapsible?: "offcanvas" | "icon" | "none"
46
- open?: boolean
47
- defaultOpen?: boolean
48
- onOpenChange?: (open: boolean) => void
49
- /** Under 768px the panel is a drawer — drive it from your own header. */
50
- openMobile?: boolean
51
- defaultOpenMobile?: boolean
52
- onOpenMobileChange?: (open: boolean) => void
53
- /** Rail widths. CSS lengths, e.g. "16rem". */
54
- width?: string
55
- iconWidth?: string
56
- mobileWidth?: string
57
- ariaLabel?: string
58
- className?: string
59
- }
60
-
61
- export interface IRowProps {
62
- item: ISidebarNavItem
63
- activeId?: string
64
- onNavigate?: (item: ISidebarNavItem | ISidebarNavSubItem) => void
65
- }
@@ -1,4 +0,0 @@
1
-
2
- export function isActive(id: string, activeId?: string): boolean {
3
- return activeId !== undefined && id === activeId
4
- }