@exegia/corpora-ui 0.20.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.
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/layout.d.ts +4 -1
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -12
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
- package/dist-lib/components/composed/logo.d.ts +34 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +53 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +6 -3
- package/dist-lib/index.js +3732 -2670
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +15 -12
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/layout.ts +30 -2
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -13
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
- package/src/components/user-avatar/component.tsx +151 -22
- package/src/components/user-avatar/fallback.tsx +3 -1
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +58 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +180 -0
- package/src/components/user-avatar/user-avatar-atom.ts +218 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +35 -3
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
4
|
+
/**
|
|
5
|
+
* The library's one and only state provider.
|
|
6
|
+
*
|
|
7
|
+
* Mount it once at the app root. Every library state hook then works
|
|
8
|
+
* anywhere below it — there are no per-component providers to nest.
|
|
9
|
+
*
|
|
10
|
+
* One provider suffices because library state lives in module-level Jotai
|
|
11
|
+
* atom families keyed by an instance id, not in a context created per
|
|
12
|
+
* component. A new stateful component adds atoms to that module scope; it
|
|
13
|
+
* never adds a provider. Consumer code therefore stays exactly as it is as
|
|
14
|
+
* the library grows.
|
|
15
|
+
*
|
|
16
|
+
* Without this provider the hooks still run, which is the trap: Jotai falls
|
|
17
|
+
* back to its implicit default store, and that store is NOT `exegiaStore`,
|
|
18
|
+
* so imperative access would read a different store than the one the
|
|
19
|
+
* components render from. Mount the provider.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* export function App() {
|
|
23
|
+
* return (
|
|
24
|
+
* <ExegiaProvider sound>
|
|
25
|
+
* <Routes />
|
|
26
|
+
* </ExegiaProvider>
|
|
27
|
+
* )
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import * as React from "react"
|
|
32
|
+
import { Provider, useStore } from "jotai"
|
|
33
|
+
|
|
34
|
+
import { ThemeProvider } from "@/components/theme-provider"
|
|
35
|
+
import { bindSounds } from "@/lib/sound"
|
|
36
|
+
import { exegiaStore } from "./store"
|
|
37
|
+
import type { ExegiaStore } from "./store"
|
|
38
|
+
|
|
39
|
+
/** Options forwarded to the library's `ThemeProvider`. */
|
|
40
|
+
export interface ExegiaThemeOptions {
|
|
41
|
+
defaultTheme?: "dark" | "light" | "system"
|
|
42
|
+
storageKey?: string
|
|
43
|
+
disableTransitionOnChange?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ExegiaProviderProps {
|
|
47
|
+
children: React.ReactNode
|
|
48
|
+
/** Store to mount. Defaults to the library's `exegiaStore`. */
|
|
49
|
+
store?: ExegiaStore
|
|
50
|
+
/** Opt into interaction sound. Off unless the app asks for it. */
|
|
51
|
+
sound?: boolean
|
|
52
|
+
/** Mount `ThemeProvider` with these options. Omit it and none is mounted —
|
|
53
|
+
* it reads `localStorage` in its state initializer, so an unconditional
|
|
54
|
+
* mount would break SSR consumers, and `useTheme` already throws without
|
|
55
|
+
* one. */
|
|
56
|
+
theme?: ExegiaThemeOptions
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ExegiaProvider({
|
|
60
|
+
children,
|
|
61
|
+
store,
|
|
62
|
+
sound = false,
|
|
63
|
+
theme,
|
|
64
|
+
}: ExegiaProviderProps): React.ReactElement {
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
// Idempotent, and deliberately not at module scope: opting into sound is
|
|
67
|
+
// the consuming app's decision, not an import side effect.
|
|
68
|
+
if (sound) bindSounds()
|
|
69
|
+
}, [sound])
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Provider store={store ?? exegiaStore}>
|
|
73
|
+
{theme ? <ThemeProvider {...theme}>{children}</ThemeProvider> : children}
|
|
74
|
+
</Provider>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The store mounted by the nearest `ExegiaProvider` above the caller. */
|
|
79
|
+
export const useExegiaStore = useStore
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createStore } from "jotai"
|
|
2
|
+
|
|
3
|
+
/** A Jotai store as created by `createStore()`. */
|
|
4
|
+
export type ExegiaStore = ReturnType<typeof createStore>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The library's default store — the one `ExegiaProvider` mounts unless it is
|
|
8
|
+
* handed another. Exported so non-React code (a router guard, a command
|
|
9
|
+
* palette, a test) can read and write library state imperatively:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* exegiaStore.get(treeExpandedIdsAtom("app-nav"))
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* A single module-level store is wrong for SSR — state would leak between
|
|
16
|
+
* requests — so render-per-request apps pass a fresh `createStore()` to
|
|
17
|
+
* `ExegiaProvider` instead.
|
|
18
|
+
*/
|
|
19
|
+
export const exegiaStore: ExegiaStore = createStore()
|
|
@@ -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,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,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,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,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
|
-
}
|