@exxatdesignux/ui 0.2.15 → 0.2.17

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -0,0 +1,183 @@
1
+ "use client"
2
+
3
+ /**
4
+ * DataRowList — generic vertical-stack list view used by every hub's "list"
5
+ * tab (placements, team, compliance, sites, question-bank, …). Replaces the
6
+ * hand-rolled `<ul …flex-col gap-2 px-4 pb-8 pt-2 lg:px-6> {rows.map(<li>…)}`
7
+ * shell that was duplicated across `*-list-view.tsx` files.
8
+ *
9
+ * Composition over inheritance: callers provide a `renderRow(row)` that
10
+ * returns whatever ListPageBoardCard / link / chip-stack they need — this
11
+ * component owns the chrome (spacing, empty state, virtualization), not the
12
+ * row body.
13
+ *
14
+ * Auto-virtualises with `@tanstack/react-virtual` when the row count meets
15
+ * `virtualizeThreshold` (default 100). Disable by passing `0`.
16
+ */
17
+
18
+ import * as React from "react"
19
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
20
+ import { cn } from "@/lib/utils"
21
+
22
+ const DEFAULT_VIRTUALIZE_THRESHOLD = 100
23
+ const DEFAULT_ESTIMATED_ROW_HEIGHT = 96
24
+ const DEFAULT_OVERSCAN = 8
25
+
26
+ export interface DataRowListProps<TRow> {
27
+ /** The filtered/sorted rows from `tableState.rows` (or wherever). */
28
+ rows: readonly TRow[]
29
+ /** Stable id used as the React `key` and (for virtualizer) the v-key. */
30
+ getRowId: (row: TRow, index: number) => string | number
31
+ /** Render the body of one row. Wrap with `<ListPageBoardCard layout="row">` etc. */
32
+ renderRow: (row: TRow, index: number) => React.ReactNode
33
+ /**
34
+ * Shown when `rows.length === 0`. Strings render as muted body copy; pass
35
+ * a `ReactNode` for richer empty states (illustration, CTA, etc.).
36
+ */
37
+ emptyState?: React.ReactNode
38
+ /**
39
+ * Auto-virtualise when `rows.length >= virtualizeThreshold`. Default 100.
40
+ * Pass `0` to never virtualise (preserves predictable layout for short
41
+ * lists like dashboards / pinned tabs).
42
+ */
43
+ virtualizeThreshold?: number
44
+ /** Hint for the virtualizer; clamps to measured size after first paint. */
45
+ estimatedRowHeight?: number
46
+ /** Override the default container padding / gap if needed. */
47
+ className?: string
48
+ /** Override the per-row `<li>` className (e.g. tighter spacing). */
49
+ rowClassName?: string
50
+ /** `aria-label` for the `<ul>` (screen-reader name for the list). */
51
+ ariaLabel?: string
52
+ }
53
+
54
+ const DEFAULT_OUTER_CLASS = "flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6"
55
+
56
+ export function DataRowList<TRow>(props: DataRowListProps<TRow>) {
57
+ const {
58
+ rows,
59
+ getRowId,
60
+ renderRow,
61
+ emptyState,
62
+ virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD,
63
+ estimatedRowHeight = DEFAULT_ESTIMATED_ROW_HEIGHT,
64
+ className,
65
+ rowClassName,
66
+ ariaLabel,
67
+ } = props
68
+
69
+ if (rows.length === 0) {
70
+ if (emptyState == null) return null
71
+ if (typeof emptyState === "string") {
72
+ return (
73
+ <div className="px-4 py-16 text-center lg:px-6">
74
+ <p className="text-sm text-muted-foreground">{emptyState}</p>
75
+ </div>
76
+ )
77
+ }
78
+ return <div className="px-4 py-16 text-center lg:px-6">{emptyState}</div>
79
+ }
80
+
81
+ if (virtualizeThreshold > 0 && rows.length >= virtualizeThreshold) {
82
+ return (
83
+ <DataRowListVirtualized
84
+ rows={rows}
85
+ getRowId={getRowId}
86
+ renderRow={renderRow}
87
+ estimatedRowHeight={estimatedRowHeight}
88
+ className={className}
89
+ rowClassName={rowClassName}
90
+ ariaLabel={ariaLabel}
91
+ />
92
+ )
93
+ }
94
+
95
+ return (
96
+ <ul aria-label={ariaLabel} className={cn(DEFAULT_OUTER_CLASS, className)}>
97
+ {rows.map((row, i) => (
98
+ <li key={getRowId(row, i)} className={rowClassName}>
99
+ {renderRow(row, i)}
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ )
104
+ }
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // Virtualised variant — keeps the DOM short on long lists (e.g. 1000+ rows).
108
+ // Uses `useWindowVirtualizer` so the page scroll drives row recycling; this
109
+ // is the right tool for hub-level lists (not nested-scroll containers).
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+
112
+ function DataRowListVirtualized<TRow>({
113
+ rows,
114
+ getRowId,
115
+ renderRow,
116
+ estimatedRowHeight,
117
+ className,
118
+ rowClassName,
119
+ ariaLabel,
120
+ }: {
121
+ rows: readonly TRow[]
122
+ getRowId: (row: TRow, index: number) => string | number
123
+ renderRow: (row: TRow, index: number) => React.ReactNode
124
+ estimatedRowHeight: number
125
+ className?: string
126
+ rowClassName?: string
127
+ ariaLabel?: string
128
+ }) {
129
+ const anchorRef = React.useRef<HTMLDivElement | null>(null)
130
+ // `scrollMargin` is read by the virtualizer during render, so it has to
131
+ // be state (not a ref). We measure with `useLayoutEffect` after the first
132
+ // paint and on resize so window-scroll math stays accurate when the page
133
+ // layout shifts (sidebar collapse, banner, etc.).
134
+ const [scrollMargin, setScrollMargin] = React.useState(0)
135
+
136
+ const updateScrollMargin = React.useCallback(() => {
137
+ const el = anchorRef.current
138
+ if (!el) return
139
+ setScrollMargin(el.getBoundingClientRect().top + window.scrollY)
140
+ }, [])
141
+
142
+ React.useLayoutEffect(() => {
143
+ updateScrollMargin()
144
+ window.addEventListener("resize", updateScrollMargin)
145
+ return () => window.removeEventListener("resize", updateScrollMargin)
146
+ }, [updateScrollMargin, rows.length])
147
+
148
+ const virtualizer = useWindowVirtualizer({
149
+ count: rows.length,
150
+ estimateSize: () => estimatedRowHeight,
151
+ overscan: DEFAULT_OVERSCAN,
152
+ scrollMargin,
153
+ getItemKey: i => String(getRowId(rows[i], i)),
154
+ })
155
+
156
+ const totalSize = virtualizer.getTotalSize()
157
+
158
+ return (
159
+ <div ref={anchorRef} className={cn("px-4 pb-8 pt-2 lg:px-6", className)}>
160
+ <ul
161
+ aria-label={ariaLabel}
162
+ className="relative m-0 w-full list-none p-0"
163
+ style={{ height: `${totalSize}px` }}
164
+ >
165
+ {virtualizer.getVirtualItems().map(vr => {
166
+ const row = rows[vr.index]
167
+ if (!row) return null
168
+ return (
169
+ <li
170
+ key={vr.key}
171
+ data-index={vr.index}
172
+ ref={virtualizer.measureElement}
173
+ className={cn("absolute left-0 top-0 w-full pb-2", rowClassName)}
174
+ style={{ transform: `translateY(${vr.start}px)` }}
175
+ >
176
+ {renderRow(row, vr.index)}
177
+ </li>
178
+ )
179
+ })}
180
+ </ul>
181
+ </div>
182
+ )
183
+ }
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * FinderPanelView — Miller-style 3-column split for list-page hubs.
5
5
  *
6
- * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `bg-muted/15` columns,
6
+ * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS`,
7
7
  * shared resizable handles) — see `list-page-split-hub-tokens.ts`.
8
8
  */
9
9
 
@@ -142,7 +142,7 @@ export function FinderGroupStrip({
142
142
  <div
143
143
  role="toolbar"
144
144
  aria-label={ariaLabel}
145
- className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-muted/15 px-2 py-2"
145
+ className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-card px-2 py-2"
146
146
  >
147
147
  {groups.map(group => {
148
148
  const isSelected = group.id === selectedGroupId
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Central exports for list-page data surfaces and shared view chrome.
3
3
  *
4
- * **Pattern:** `ListPageTemplate` + `DataListTable` — one `useTableState`, one toolbar,
4
+ * **Pattern:** `ListPageTemplate` + `PlacementsTable` (or any hub-specific `*-table.tsx`) — one `useTableState`, one toolbar,
5
5
  * table | list | board | dashboard from the same component (`AGENTS.md` §4, `docs/data-views-pattern.md`).
6
6
  *
7
7
  * **View UI:** `ViewSegmentedControl` matches the template’s views toolbar (`bg-muted/60` pills).
8
8
  */
9
9
 
10
- export { DataListTable } from "@/components/data-list-table"
11
- export type { DataListTableProps, DataListTableHandle } from "@/components/data-list-table"
10
+ export { PlacementsTable } from "@/components/placements-table"
11
+ export type { PlacementsTableProps, PlacementsTableHandle } from "@/components/placements-table"
12
12
  export type { PlacementLifecycleTabId } from "@/lib/placement-lifecycle"
13
13
  export type { DataListViewType } from "@/lib/data-list-view"
14
14
  export { DATA_LIST_VIEW_TILES, dataListViewIcon, dataListViewLabel } from "@/lib/data-list-view"
@@ -47,6 +47,25 @@ export {
47
47
  type ListPageTreeColumnHeaderProps,
48
48
  } from "@/components/data-views/list-page-tree-column-header"
49
49
 
50
+ /** VS Code–style outline tree chrome — mirrors shadcn `SidebarMenuSub` (see module doc). */
51
+ export {
52
+ OutlineTreeCollapsibleContentRail,
53
+ OutlineTreeLeafButton,
54
+ OutlineTreeMenu,
55
+ OutlineTreeMenuItem,
56
+ OutlineTreeSub,
57
+ OutlineTreeSubItem,
58
+ OUTLINE_TREE_COLLAPSIBLE_CONTENT_RAIL_CLASS,
59
+ OUTLINE_TREE_CHEVRON_GUIDE_SPACER_CLASS,
60
+ OUTLINE_TREE_SUB_ROW_SHIFT_CLASS,
61
+ type OutlineTreeGuideLayout,
62
+ type OutlineTreeLeafButtonProps,
63
+ type OutlineTreeSurface,
64
+ } from "@/components/data-views/outline-tree-menu"
65
+
66
+ export { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
67
+ export type { QuestionBankFolderTreeBranchProps } from "@/components/data-views/question-bank-folder-tree-branch"
68
+
50
69
  export {
51
70
  LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
52
71
  LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
@@ -84,6 +103,10 @@ export {
84
103
  /** Generic folder icon-grid — reusable across all list hubs. */
85
104
  export { FolderGridView, type FolderGridViewProps } from "@/components/data-views/folder-grid-view"
86
105
 
106
+ /** Generic vertical row list — used by every hub's "list" tab. Composes
107
+ * `ListPageBoardCard layout="row"` via a `renderRow` prop. */
108
+ export { DataRowList, type DataRowListProps } from "@/components/data-views/data-row-list"
109
+
87
110
 
88
111
  /** Unified hub tile + list row surface — see `list-page-board-card.tsx`. */
89
112
  export {
@@ -10,7 +10,7 @@ export interface ListPageSplitDetailsPlaceholderProps {
10
10
  }
11
11
 
12
12
  /**
13
- * Empty right pane for split hubs — matches Question bank tree “Nothing selected”.
13
+ * Empty right pane for split hubs — flat `bg-card` to match Miller / tree columns.
14
14
  */
15
15
  export function ListPageSplitDetailsPlaceholder({
16
16
  title = "Nothing selected",
@@ -20,11 +20,11 @@ export function ListPageSplitDetailsPlaceholder({
20
20
  return (
21
21
  <div
22
22
  className={cn(
23
- "flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 via-card to-card px-6 py-10 text-center",
23
+ "flex h-full min-h-0 flex-col items-center justify-center bg-card px-6 py-10 text-center",
24
24
  className,
25
25
  )}
26
26
  >
27
- <div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/25">
27
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-card">
28
28
  <i
29
29
  className="fa-light fa-sidebar text-[1.65rem] leading-none text-muted-foreground/70"
30
30
  aria-hidden="true"
@@ -9,7 +9,7 @@ export const LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS =
9
9
 
10
10
  /** Primary column stack (scope list, folder list, record list, …). */
11
11
  export const LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS =
12
- "flex min-h-0 min-w-0 flex-col bg-muted/15"
12
+ "flex min-h-0 min-w-0 flex-col bg-card"
13
13
 
14
14
  /** Right-hand inspector / detail column shell. */
15
15
  export const LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS =
@@ -19,7 +19,7 @@ export function ListPageTreeColumnHeader({
19
19
  className,
20
20
  }: ListPageTreeColumnHeaderProps) {
21
21
  return (
22
- <div className={cn("shrink-0 border-b border-border/50 bg-muted/10 px-3 py-2", className)}>
22
+ <div className={cn("shrink-0 border-b border-border/50 bg-card px-3 py-2", className)}>
23
23
  <div className="flex h-9 items-center justify-between gap-2">
24
24
  <h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</h3>
25
25
  {trailing ? (
@@ -91,12 +91,20 @@ export function OsFolderGlyph({
91
91
  aria-label={!decorative ? label : undefined}
92
92
  aria-hidden={decorative ? true : undefined}
93
93
  >
94
+ {/* Static SVG — `next/image` can't optimize SVGs without
95
+ `dangerouslyAllowSVG`, so we stay on plain <img> but add the same
96
+ loading/decoding hints `next/image` would. Folder grids render many
97
+ of these at once; lazy-loading lets the browser skip off-screen
98
+ glyphs until they scroll near the viewport. */}
99
+ {/* eslint-disable-next-line @next/next/no-img-element -- SVG; next/image can't optimize without dangerouslyAllowSVG */}
94
100
  <img
95
101
  src={OS_FOLDER_GLYPH_SRC}
96
102
  alt=""
97
103
  width={240}
98
104
  height={240}
99
105
  draggable={false}
106
+ loading="lazy"
107
+ decoding="async"
100
108
  className={cn(
101
109
  "h-full w-full object-contain",
102
110
  "transition-[filter] duration-200",
@@ -0,0 +1,157 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Central outline-tree chrome — mirrors shadcn/ui **Sidebar** file-tree structure
5
+ * (`SidebarMenu` → `SidebarMenuItem` + `Collapsible` → `SidebarMenuSub` → rows) without
6
+ * coupling to `useSidebar`.
7
+ *
8
+ * - **`guideLayout="inset"`** — same rhythm as `SidebarMenuSub` (`mx-3.5` + `translate-x-px`).
9
+ * - **`guideLayout="chevronRail"`** — use with **`OutlineTreeCollapsibleContentRail`**: a **`w-6`**
10
+ * spacer lines up the vertical guide with the **horizontal center** of a **`size-8`** chevron
11
+ * when the folder row uses **`px-2`** (8px padding + 16px half of 32px chevron hit target).
12
+ *
13
+ * @see packages/ui/src/components/ui/sidebar.tsx — `SidebarMenuSub`, `SidebarMenuSubItem`
14
+ */
15
+
16
+ import * as React from "react"
17
+ import { CollapsibleContent } from "@/components/ui/collapsible"
18
+ import { cn } from "@/lib/utils"
19
+
20
+ export type OutlineTreeSurface = "sidebar" | "panel"
21
+
22
+ export type OutlineTreeGuideLayout = "inset" | "chevronRail"
23
+
24
+ const outlineTreeSubInsetClass: Record<OutlineTreeSurface, string> = {
25
+ sidebar:
26
+ "mx-3.5 flex min-w-0 list-none translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5 rtl:-translate-x-px",
27
+ panel:
28
+ "mx-3.5 flex min-w-0 list-none translate-x-px flex-col gap-1 border-s border-border/60 px-2.5 py-0.5 rtl:-translate-x-px",
29
+ }
30
+
31
+ const outlineTreeSubChevronRailClass: Record<OutlineTreeSurface, string> = {
32
+ sidebar:
33
+ "flex min-w-0 flex-1 list-none flex-col gap-1 border-s border-sidebar-border py-0.5 ps-2.5",
34
+ panel: "flex min-w-0 flex-1 list-none flex-col gap-1 border-s border-border/60 py-0.5 ps-2.5",
35
+ }
36
+
37
+ /** Pull row content onto the guide line — matches `SidebarMenuSubButton` horizontal nudge (inset layout only). */
38
+ export const OUTLINE_TREE_SUB_ROW_SHIFT_CLASS = "-translate-x-px rtl:translate-x-px"
39
+
40
+ /** `CollapsibleContent` row: spacer width = `px-2` (8px) + half of `size-8` chevron (16px) → guide under chevron center. */
41
+ export const OUTLINE_TREE_COLLAPSIBLE_CONTENT_RAIL_CLASS = "flex min-w-0 w-full"
42
+
43
+ /** Spacer column — keep in sync with folder row `px-2` + `size-8` chevron. */
44
+ export const OUTLINE_TREE_CHEVRON_GUIDE_SPACER_CLASS = "w-6 shrink-0"
45
+
46
+ /** Wrap `OutlineTreeSub` with `guideLayout="chevronRail"` so the vertical border meets the chevron center. */
47
+ export function OutlineTreeCollapsibleContentRail({
48
+ className,
49
+ children,
50
+ ...props
51
+ }: React.ComponentProps<typeof CollapsibleContent>) {
52
+ return (
53
+ <CollapsibleContent
54
+ className={cn(OUTLINE_TREE_COLLAPSIBLE_CONTENT_RAIL_CLASS, className)}
55
+ {...props}
56
+ >
57
+ <div className={OUTLINE_TREE_CHEVRON_GUIDE_SPACER_CLASS} aria-hidden />
58
+ {children}
59
+ </CollapsibleContent>
60
+ )
61
+ }
62
+
63
+ /** Nested list under a folder — vertical guide + indent. */
64
+ export function OutlineTreeSub({
65
+ surface = "panel",
66
+ guideLayout = "inset",
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"ul"> & {
70
+ surface?: OutlineTreeSurface
71
+ guideLayout?: OutlineTreeGuideLayout
72
+ }) {
73
+ return (
74
+ <ul
75
+ data-slot="outline-tree-sub"
76
+ data-guide-layout={guideLayout}
77
+ className={cn(
78
+ guideLayout === "inset" && outlineTreeSubInsetClass[surface],
79
+ guideLayout === "chevronRail" && outlineTreeSubChevronRailClass[surface],
80
+ className,
81
+ )}
82
+ {...props}
83
+ />
84
+ )
85
+ }
86
+
87
+ /** Root or nested branch list — matches `SidebarMenu` spacing. */
88
+ export function OutlineTreeMenu({ className, ...props }: React.ComponentProps<"ul">) {
89
+ return (
90
+ <ul
91
+ data-slot="outline-tree-menu"
92
+ className={cn("flex w-full min-w-0 list-none flex-col gap-0", className)}
93
+ {...props}
94
+ />
95
+ )
96
+ }
97
+
98
+ /** Expandable folder row wrapper — matches `SidebarMenuItem`. */
99
+ export function OutlineTreeMenuItem({ className, ...props }: React.ComponentProps<"li">) {
100
+ return (
101
+ <li
102
+ data-slot="outline-tree-menu-item"
103
+ className={cn("group/menu-item relative min-w-0 w-full list-none", className)}
104
+ {...props}
105
+ />
106
+ )
107
+ }
108
+
109
+ /** Leaf / nested row inside `OutlineTreeSub` — matches `SidebarMenuSubItem`. */
110
+ export function OutlineTreeSubItem({ className, ...props }: React.ComponentProps<"li">) {
111
+ return (
112
+ <li
113
+ data-slot="outline-tree-sub-item"
114
+ className={cn("group/menu-sub-item relative min-w-0 w-full list-none", className)}
115
+ {...props}
116
+ />
117
+ )
118
+ }
119
+
120
+ export interface OutlineTreeLeafButtonProps extends React.ComponentProps<"button"> {
121
+ surface?: OutlineTreeSurface
122
+ isActive?: boolean
123
+ /** Inset `OutlineTreeSub` only — nudge like `SidebarMenuSubButton`. Ignored when parent uses `chevronRail`. */
124
+ subGuideAlign?: boolean
125
+ }
126
+
127
+ /** Selectable leaf row (file / terminal row) — `SidebarMenuSubButton`–aligned rhythm. */
128
+ export function OutlineTreeLeafButton({
129
+ surface = "panel",
130
+ isActive = false,
131
+ subGuideAlign = false,
132
+ className,
133
+ ...props
134
+ }: OutlineTreeLeafButtonProps) {
135
+ return (
136
+ <button
137
+ type="button"
138
+ data-active={isActive || undefined}
139
+ className={cn(
140
+ "flex min-h-8 w-full min-w-0 cursor-pointer select-none items-center gap-2 overflow-hidden rounded-md px-2 text-start text-sm outline-none ring-ring focus-visible:ring-2 focus-visible:ring-inset [&>svg]:size-4 [&>svg]:shrink-0",
141
+ subGuideAlign && OUTLINE_TREE_SUB_ROW_SHIFT_CLASS,
142
+ surface === "panel" &&
143
+ cn(
144
+ "text-foreground hover:bg-muted/50",
145
+ isActive && "bg-accent font-medium text-accent-foreground",
146
+ ),
147
+ surface === "sidebar" &&
148
+ cn(
149
+ "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
150
+ isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
151
+ ),
152
+ className,
153
+ )}
154
+ {...props}
155
+ />
156
+ )
157
+ }