@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.
- package/CHANGELOG.md +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/mock/navigation.tsx +30 -1
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- package/template/components/command-menu-02.tsx +0 -386
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ✓ SidebarTrigger wrapped in Tooltip — icon-only button (WCAG 4.1.2, 1.1.1)
|
|
7
7
|
* ✓ <header role="banner"> landmark for AT navigation (WCAG 1.3.6)
|
|
8
|
-
* ✓
|
|
8
|
+
* ✓ Sticky at top — when stuck, the rounded breadcrumb sits on the app bg and a
|
|
9
|
+
* bottom separator appears to anchor it; transparent at rest so the rounded
|
|
10
|
+
* corners blend into the inset card.
|
|
9
11
|
* ✓ Uses Inter (font-sans) — Ivy Presto is reserved for PageHeader <h1> only
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
|
-
import
|
|
14
|
+
import * as React from "react"
|
|
15
|
+
import {
|
|
16
|
+
PageBreadcrumbBack,
|
|
17
|
+
PageBreadcrumbTrail,
|
|
18
|
+
type PageBreadcrumbBackProps,
|
|
19
|
+
type PageBreadcrumbTrailItem,
|
|
20
|
+
} from "@/components/page-breadcrumb-trail"
|
|
13
21
|
import { Separator } from "@/components/ui/separator"
|
|
14
22
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
|
15
23
|
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
@@ -20,26 +28,54 @@ import {
|
|
|
20
28
|
} from "@/components/ui/tooltip"
|
|
21
29
|
import { AskLeoToggle } from "@/components/ask-leo-sidebar"
|
|
22
30
|
import { useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
31
|
+
import { cn } from "@/lib/utils"
|
|
23
32
|
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
href?: string
|
|
27
|
-
}
|
|
33
|
+
export type BreadcrumbItem = PageBreadcrumbTrailItem
|
|
34
|
+
export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
|
|
28
35
|
|
|
29
36
|
export interface SiteHeaderProps {
|
|
30
|
-
/** Current page title (last breadcrumb segment) */
|
|
37
|
+
/** Current page title (last breadcrumb segment in trail mode). */
|
|
31
38
|
title?: string
|
|
32
39
|
/** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
|
|
33
40
|
breadcrumbs?: BreadcrumbItem[]
|
|
41
|
+
/**
|
|
42
|
+
* Back-icon variant — parent link only (no `title` segment in the header).
|
|
43
|
+
* Prefer when the page `<h1>` carries the current title (e.g. New question composer).
|
|
44
|
+
*/
|
|
45
|
+
back?: SiteHeaderBackLink
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
export function SiteHeader({
|
|
48
|
+
export function SiteHeader({
|
|
49
|
+
title = "Dashboard",
|
|
50
|
+
breadcrumbs,
|
|
51
|
+
back,
|
|
52
|
+
}: SiteHeaderProps) {
|
|
37
53
|
const mod = useModKeyLabel()
|
|
54
|
+
const [isStuck, setIsStuck] = React.useState(false)
|
|
55
|
+
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
const onScroll = () => setIsStuck(window.scrollY > 0)
|
|
58
|
+
onScroll()
|
|
59
|
+
window.addEventListener("scroll", onScroll, { passive: true })
|
|
60
|
+
return () => window.removeEventListener("scroll", onScroll)
|
|
61
|
+
}, [])
|
|
38
62
|
|
|
39
63
|
return (
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
// Sticky page chrome sits BELOW every Radix overlay (DropdownMenu /
|
|
67
|
+
// Popover / Select / Dialog / Sheet / Tooltip / Drawer all render at
|
|
68
|
+
// z-50). Previously `z-60` here caused the school/product switcher
|
|
69
|
+
// dropdown to open behind the breadcrumb. `z-30` keeps the header
|
|
70
|
+
// above page content (charts, tables, scrolled rows) but below
|
|
71
|
+
// floating overlays.
|
|
72
|
+
"sticky top-0 z-30 transition-colors",
|
|
73
|
+
isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
40
76
|
<header
|
|
41
77
|
role="banner"
|
|
42
|
-
className="flex h-(--header-height) shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
|
|
78
|
+
className="flex h-(--header-height) shrink-0 items-center gap-2 bg-background rounded-t-xl transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
|
|
43
79
|
>
|
|
44
80
|
<div className="flex w-full items-center gap-1 ps-4 pe-2 lg:gap-2 lg:ps-6 lg:pe-2">
|
|
45
81
|
<Tooltip>
|
|
@@ -60,34 +96,22 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
|
|
|
60
96
|
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
|
|
61
97
|
/>
|
|
62
98
|
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</Link>
|
|
74
|
-
) : (
|
|
75
|
-
<span className="font-sans text-sm text-muted-foreground tracking-normal">
|
|
76
|
-
{crumb.label}
|
|
77
|
-
</span>
|
|
78
|
-
)}
|
|
79
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
|
|
80
|
-
</span>
|
|
81
|
-
))}
|
|
82
|
-
<span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
|
|
83
|
-
{title}
|
|
84
|
-
</span>
|
|
85
|
-
</nav>
|
|
99
|
+
{back ? (
|
|
100
|
+
<PageBreadcrumbBack {...back} className="min-w-0 flex-1" />
|
|
101
|
+
) : (
|
|
102
|
+
<PageBreadcrumbTrail
|
|
103
|
+
variant="header"
|
|
104
|
+
items={breadcrumbs}
|
|
105
|
+
currentPage={title}
|
|
106
|
+
className="flex-1"
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
86
109
|
|
|
87
110
|
<div className="ml-auto shrink-0">
|
|
88
111
|
<AskLeoToggle />
|
|
89
112
|
</div>
|
|
90
113
|
</div>
|
|
91
114
|
</header>
|
|
115
|
+
</div>
|
|
92
116
|
)
|
|
93
117
|
}
|
|
@@ -3,45 +3,40 @@
|
|
|
3
3
|
import Link from "next/link"
|
|
4
4
|
import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
|
|
5
5
|
import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
|
|
6
|
+
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
6
7
|
|
|
7
8
|
export function SitesListView({ rows }: { rows: SiteDirectoryRow[] }) {
|
|
8
|
-
if (rows.length === 0) {
|
|
9
|
-
return (
|
|
10
|
-
<div className="px-4 py-16 text-center lg:px-6">
|
|
11
|
-
<p className="text-sm text-muted-foreground">No sites match your search.</p>
|
|
12
|
-
</div>
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
return (
|
|
17
|
-
<
|
|
18
|
-
{rows
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
<DataRowList<SiteDirectoryRow>
|
|
11
|
+
rows={rows}
|
|
12
|
+
getRowId={site => site.id}
|
|
13
|
+
emptyState="No sites match your search."
|
|
14
|
+
ariaLabel="Sites"
|
|
15
|
+
renderRow={site => (
|
|
16
|
+
<Link
|
|
17
|
+
href={site.url}
|
|
18
|
+
className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
19
|
+
>
|
|
20
|
+
<ListPageBoardCard
|
|
21
|
+
layout="row"
|
|
22
|
+
interactive
|
|
23
|
+
rowContainerClassName="flex flex-row items-center gap-3"
|
|
24
|
+
leading={
|
|
25
|
+
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
|
|
26
|
+
<i className="fa-light fa-hospital text-sm" aria-hidden="true" />
|
|
27
|
+
</span>
|
|
28
|
+
}
|
|
29
|
+
rowEnd={
|
|
30
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
31
|
+
}
|
|
23
32
|
>
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
rowEnd={
|
|
34
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
35
|
-
}
|
|
36
|
-
>
|
|
37
|
-
<div className="space-y-0.5">
|
|
38
|
-
<p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
|
|
39
|
-
<p className="truncate text-xs text-muted-foreground">{site.id}</p>
|
|
40
|
-
</div>
|
|
41
|
-
</ListPageBoardCard>
|
|
42
|
-
</Link>
|
|
43
|
-
</li>
|
|
44
|
-
))}
|
|
45
|
-
</ul>
|
|
33
|
+
<div className="space-y-0.5">
|
|
34
|
+
<p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
|
|
35
|
+
<p className="truncate text-xs text-muted-foreground">{site.id}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</ListPageBoardCard>
|
|
38
|
+
</Link>
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
46
41
|
)
|
|
47
42
|
}
|
|
@@ -10,6 +10,7 @@ import Link from "next/link"
|
|
|
10
10
|
import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
|
|
11
11
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
12
12
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
13
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
13
14
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
14
15
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
15
16
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
@@ -193,6 +194,25 @@ export const SitesTable = React.forwardRef<
|
|
|
193
194
|
|
|
194
195
|
const tableState = useTableState<SiteDirectoryRow>(sites, columns, { key: "name", dir: "asc" })
|
|
195
196
|
|
|
197
|
+
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
198
|
+
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
|
|
199
|
+
const lifecycleColumnKeys = React.useMemo(
|
|
200
|
+
() => new Set(columns.map(c => c.key)),
|
|
201
|
+
[columns],
|
|
202
|
+
)
|
|
203
|
+
useTableStateLifecycle({
|
|
204
|
+
namespace: "sites",
|
|
205
|
+
tabId: "main",
|
|
206
|
+
tableState,
|
|
207
|
+
columnKeys: lifecycleColumnKeys,
|
|
208
|
+
extras: { conditionalRules },
|
|
209
|
+
onLoadExtras: e => {
|
|
210
|
+
if (e && Array.isArray(e.conditionalRules)) {
|
|
211
|
+
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
196
216
|
React.useImperativeHandle(
|
|
197
217
|
ref,
|
|
198
218
|
() => ({
|
|
@@ -200,6 +220,10 @@ export const SitesTable = React.forwardRef<
|
|
|
200
220
|
tableState.setSheetOpen(true)
|
|
201
221
|
},
|
|
202
222
|
}),
|
|
223
|
+
// `tableState` is freshly returned each render by useTableState; depending
|
|
224
|
+
// on it would re-create the imperative handle on every render. Only the
|
|
225
|
+
// React setter is needed (and is referentially stable).
|
|
226
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
227
|
[tableState.setSheetOpen],
|
|
204
228
|
)
|
|
205
229
|
|
|
@@ -250,7 +250,7 @@ export function TablePropertiesDrawer({
|
|
|
250
250
|
showOverlay={false}
|
|
251
251
|
// w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
|
|
252
252
|
// so the drawer never overflows horizontally. Use 100svh so height is correct on mobile.
|
|
253
|
-
className="w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
|
|
253
|
+
className="z-[80] w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
|
|
254
254
|
style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
|
|
255
255
|
>
|
|
256
256
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as
|
|
4
|
+
* Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as PlacementsClient).
|
|
5
5
|
* Imports from `@/components/data-views` for shared list-page + view types.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* TeamListView — full-width rows for team roster (same data as DataTable / board).
|
|
5
|
+
* Shell from generic `DataRowList`; row body stays team-specific (avatar,
|
|
6
|
+
* name, role, email, status badge).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import * as React from "react"
|
|
8
9
|
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
9
10
|
import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
|
|
11
|
+
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
10
12
|
import {
|
|
11
13
|
TEAM_MEMBER_STATUS_BADGE_CLASS,
|
|
12
14
|
TEAM_MEMBER_STATUS_ICON,
|
|
@@ -14,42 +16,6 @@ import {
|
|
|
14
16
|
} from "@/lib/list-status-badges"
|
|
15
17
|
import type { TeamMember } from "@/lib/mock/team"
|
|
16
18
|
|
|
17
|
-
function TeamListRow({
|
|
18
|
-
member,
|
|
19
|
-
onRowActivate,
|
|
20
|
-
}: {
|
|
21
|
-
member: TeamMember
|
|
22
|
-
onRowActivate?: (member: TeamMember) => void
|
|
23
|
-
}) {
|
|
24
|
-
return (
|
|
25
|
-
<li>
|
|
26
|
-
<ListPageBoardCard
|
|
27
|
-
layout="row"
|
|
28
|
-
rowContainerClassName="flex flex-row items-center gap-3"
|
|
29
|
-
onClick={onRowActivate ? () => onRowActivate(member) : undefined}
|
|
30
|
-
leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
|
|
31
|
-
rowEnd={
|
|
32
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
33
|
-
<ListHubStatusBadge
|
|
34
|
-
surface="board"
|
|
35
|
-
label={TEAM_MEMBER_STATUS_LABEL[member.status]}
|
|
36
|
-
tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
|
|
37
|
-
icon={TEAM_MEMBER_STATUS_ICON[member.status]}
|
|
38
|
-
/>
|
|
39
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
40
|
-
</div>
|
|
41
|
-
}
|
|
42
|
-
>
|
|
43
|
-
<div className="space-y-0.5">
|
|
44
|
-
<p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
|
|
45
|
-
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
46
|
-
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
|
47
|
-
</div>
|
|
48
|
-
</ListPageBoardCard>
|
|
49
|
-
</li>
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
19
|
export function TeamListView({
|
|
54
20
|
members,
|
|
55
21
|
onRowActivate,
|
|
@@ -57,19 +23,37 @@ export function TeamListView({
|
|
|
57
23
|
members: TeamMember[]
|
|
58
24
|
onRowActivate?: (member: TeamMember) => void
|
|
59
25
|
}) {
|
|
60
|
-
if (members.length === 0) {
|
|
61
|
-
return (
|
|
62
|
-
<div className="px-4 py-16 text-center lg:px-6">
|
|
63
|
-
<p className="text-sm text-muted-foreground">No team members match your filters.</p>
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
26
|
return (
|
|
69
|
-
<
|
|
70
|
-
{members
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
27
|
+
<DataRowList<TeamMember>
|
|
28
|
+
rows={members}
|
|
29
|
+
getRowId={m => m.id}
|
|
30
|
+
emptyState="No team members match your filters."
|
|
31
|
+
ariaLabel="Team members"
|
|
32
|
+
renderRow={member => (
|
|
33
|
+
<ListPageBoardCard
|
|
34
|
+
layout="row"
|
|
35
|
+
rowContainerClassName="flex flex-row items-center gap-3"
|
|
36
|
+
onClick={onRowActivate ? () => onRowActivate(member) : undefined}
|
|
37
|
+
leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
|
|
38
|
+
rowEnd={
|
|
39
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
40
|
+
<ListHubStatusBadge
|
|
41
|
+
surface="board"
|
|
42
|
+
label={TEAM_MEMBER_STATUS_LABEL[member.status]}
|
|
43
|
+
tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
|
|
44
|
+
icon={TEAM_MEMBER_STATUS_ICON[member.status]}
|
|
45
|
+
/>
|
|
46
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
47
|
+
</div>
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
<div className="space-y-0.5">
|
|
51
|
+
<p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
|
|
52
|
+
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
53
|
+
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
|
54
|
+
</div>
|
|
55
|
+
</ListPageBoardCard>
|
|
56
|
+
)}
|
|
57
|
+
/>
|
|
74
58
|
)
|
|
75
59
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
TEAM_MEMBER_STATUS_ICON,
|
|
14
14
|
TEAM_MEMBER_STATUS_LABEL,
|
|
15
15
|
} from "@/lib/list-status-badges"
|
|
16
|
+
import { mailtoHref } from "@/lib/mailto"
|
|
16
17
|
import type { TeamMember } from "@/lib/mock/team"
|
|
17
18
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
18
19
|
import {
|
|
@@ -37,6 +38,7 @@ import type { DataListViewType } from "@/lib/data-list-view"
|
|
|
37
38
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
38
39
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
39
40
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
41
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
40
42
|
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
41
43
|
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
42
44
|
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
@@ -154,7 +156,7 @@ function TeamFinderDetail({
|
|
|
154
156
|
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
155
157
|
</dt>
|
|
156
158
|
<dd className="text-[13px]">
|
|
157
|
-
<a href={
|
|
159
|
+
<a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
|
|
158
160
|
</dd>
|
|
159
161
|
</div>
|
|
160
162
|
<div className="flex flex-col gap-0.5">
|
|
@@ -277,7 +279,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
277
279
|
operators: ["contains", "not_contains"],
|
|
278
280
|
},
|
|
279
281
|
cell: row => (
|
|
280
|
-
<a href={
|
|
282
|
+
<a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
|
|
281
283
|
{row.email}
|
|
282
284
|
</a>
|
|
283
285
|
),
|
|
@@ -341,7 +343,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
341
343
|
</Button>
|
|
342
344
|
</DropdownMenuTrigger>
|
|
343
345
|
<DropdownMenuContent align="end">
|
|
344
|
-
<DropdownMenuItem onClick={() => window.open(
|
|
346
|
+
<DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
|
|
345
347
|
<i className="fa-light fa-envelope" aria-hidden="true" />
|
|
346
348
|
Email
|
|
347
349
|
</DropdownMenuItem>
|
|
@@ -400,6 +402,26 @@ export const TeamTable = React.forwardRef<
|
|
|
400
402
|
|
|
401
403
|
const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
|
|
402
404
|
|
|
405
|
+
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
406
|
+
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle` for
|
|
407
|
+
// the centralised hook; pass `extras` for any non-table state.
|
|
408
|
+
const lifecycleColumnKeys = React.useMemo(
|
|
409
|
+
() => new Set(columns.map(c => c.key)),
|
|
410
|
+
[columns],
|
|
411
|
+
)
|
|
412
|
+
useTableStateLifecycle({
|
|
413
|
+
namespace: "team",
|
|
414
|
+
tabId: "main",
|
|
415
|
+
tableState,
|
|
416
|
+
columnKeys: lifecycleColumnKeys,
|
|
417
|
+
extras: { conditionalRules },
|
|
418
|
+
onLoadExtras: e => {
|
|
419
|
+
if (e && Array.isArray(e.conditionalRules)) {
|
|
420
|
+
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
|
|
403
425
|
const dashboardKpi = React.useMemo(
|
|
404
426
|
() => ({
|
|
405
427
|
metrics: teamKpiMetrics(tableState.rows),
|
|
@@ -501,6 +523,10 @@ export const TeamTable = React.forwardRef<
|
|
|
501
523
|
openPropertiesDrawer: () => {
|
|
502
524
|
tableState.setSheetOpen(true)
|
|
503
525
|
},
|
|
526
|
+
// `tableState` is freshly returned each render by useTableState; depending on
|
|
527
|
+
// it would re-create the imperative handle on every render. Only the React
|
|
528
|
+
// setter is needed (and is referentially stable).
|
|
529
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
530
|
}), [tableState.setSheetOpen])
|
|
505
531
|
|
|
506
532
|
const teamPanelFinderGroups = React.useMemo(
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
5
|
import { ListPageViewFrame } from "@/components/data-views"
|
|
6
|
+
import { DotPattern } from "@/components/ui/dot-pattern"
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
6
8
|
|
|
7
9
|
export interface DedicatedSearchLandingTemplateProps {
|
|
8
10
|
/** Page title — string or rich node (e.g. styled heading). */
|
|
@@ -20,6 +22,67 @@ export interface DedicatedSearchLandingTemplateProps {
|
|
|
20
22
|
const DEFAULT_GUTTER =
|
|
21
23
|
"mx-auto flex min-h-[min(72vh,36rem)] w-full min-w-0 flex-col justify-center gap-0 px-6 py-8 sm:px-8 sm:py-10 md:px-12 md:py-12 lg:px-16"
|
|
22
24
|
|
|
25
|
+
/** Feather into page white / header so the hero never reads as a hard horizontal slab. */
|
|
26
|
+
const HERO_BACKDROP_MASK =
|
|
27
|
+
"[mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)]"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Soft blurred blobs using only Ask Leo surface tints (`--leo-surface-tint-a|b` in `globals.css`).
|
|
31
|
+
*/
|
|
32
|
+
function DedicatedSearchLandingBackdrop() {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
aria-hidden
|
|
36
|
+
className={cn(
|
|
37
|
+
"pointer-events-none absolute inset-0 -z-10 select-none overflow-hidden",
|
|
38
|
+
HERO_BACKDROP_MASK,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
className="absolute -left-[20%] -top-[30%] h-[min(54vmin,27rem)] w-[min(54vmin,27rem)] rounded-full blur-[76px]"
|
|
43
|
+
style={{
|
|
44
|
+
background: "radial-gradient(circle at 42% 36%, var(--leo-surface-tint-b) 0%, transparent 68%)",
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
<div
|
|
48
|
+
className="absolute -right-[12%] top-[2%] h-[min(46vmin,23rem)] w-[min(46vmin,23rem)] rounded-full blur-[68px]"
|
|
49
|
+
style={{
|
|
50
|
+
background: "radial-gradient(circle at 48% 48%, var(--leo-surface-tint-a) 0%, transparent 66%)",
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
<div
|
|
54
|
+
className="absolute bottom-[-16%] left-[14%] h-[min(50vmin,25rem)] w-[min(50vmin,25rem)] rounded-full blur-[84px]"
|
|
55
|
+
style={{
|
|
56
|
+
background: "radial-gradient(circle at 44% 40%, var(--leo-surface-tint-b) 0%, transparent 70%)",
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
<div
|
|
60
|
+
className="absolute bottom-[4%] right-[6%] h-[min(40vmin,20rem)] w-[min(40vmin,20rem)] rounded-full blur-[60px]"
|
|
61
|
+
style={{
|
|
62
|
+
background: "radial-gradient(circle at 52% 44%, var(--leo-surface-tint-a) 0%, transparent 72%)",
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
<div
|
|
66
|
+
className="absolute left-[36%] top-[32%] h-[min(38vmin,19rem)] w-[min(38vmin,19rem)] -translate-x-1/2 rounded-full blur-[74px]"
|
|
67
|
+
style={{
|
|
68
|
+
background: "radial-gradient(circle at 50% 50%, var(--leo-surface-tint-b) 0%, transparent 68%)",
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
{/* Static dot field — same primitive as `AiThinkingOverlay` (no motion here). */}
|
|
73
|
+
<DotPattern
|
|
74
|
+
width={15}
|
|
75
|
+
height={15}
|
|
76
|
+
cr={0.65}
|
|
77
|
+
className={cn(
|
|
78
|
+
"absolute inset-0 opacity-[0.34] mix-blend-multiply dark:opacity-[0.22] dark:mix-blend-soft-light",
|
|
79
|
+
"fill-[color-mix(in_oklch,var(--brand-color)_14%,var(--background))]",
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
23
86
|
/**
|
|
24
87
|
* Centered dedicated-search landing — empty `?q=` shell (hero title + composer + optional trailing).
|
|
25
88
|
*/
|
|
@@ -32,27 +95,30 @@ export function DedicatedSearchLandingTemplate({
|
|
|
32
95
|
gutterClassName = DEFAULT_GUTTER,
|
|
33
96
|
}: DedicatedSearchLandingTemplateProps) {
|
|
34
97
|
return (
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
98
|
+
<div className="relative isolate min-w-0 w-full overflow-hidden">
|
|
99
|
+
<DedicatedSearchLandingBackdrop />
|
|
100
|
+
<ListPageViewFrame
|
|
101
|
+
maxWidthClassName={maxWidthClassName}
|
|
102
|
+
className={cn("relative z-10", frameClassName)}
|
|
103
|
+
gutterClassName={gutterClassName}
|
|
104
|
+
>
|
|
105
|
+
<header className="min-w-0">
|
|
106
|
+
{typeof title === "string" ? (
|
|
107
|
+
<h1
|
|
108
|
+
className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
|
|
109
|
+
style={{ fontFamily: "var(--font-heading)" }}
|
|
110
|
+
>
|
|
111
|
+
{title}
|
|
112
|
+
</h1>
|
|
113
|
+
) : (
|
|
114
|
+
title
|
|
115
|
+
)}
|
|
116
|
+
</header>
|
|
52
117
|
|
|
53
|
-
|
|
118
|
+
<div className="min-w-0 mt-6 sm:mt-8">{composer}</div>
|
|
54
119
|
|
|
55
|
-
|
|
56
|
-
|
|
120
|
+
{trailing ? <div className="min-w-0 mt-10 sm:mt-12 md:mt-14 lg:mt-16">{trailing}</div> : null}
|
|
121
|
+
</ListPageViewFrame>
|
|
122
|
+
</div>
|
|
57
123
|
)
|
|
58
124
|
}
|
|
@@ -320,9 +320,7 @@ export function ListPageTemplate({
|
|
|
320
320
|
const count = getTabCount?.(tab.filterId)
|
|
321
321
|
const tabInner = (
|
|
322
322
|
<>
|
|
323
|
-
{
|
|
324
|
-
<i className={`fa-light ${tab.icon} text-xs`} aria-hidden="true" />
|
|
325
|
-
) : null}
|
|
323
|
+
<i className={cn("fa-light shrink-0 text-xs", tab.icon)} aria-hidden="true" />
|
|
326
324
|
{tab.label}
|
|
327
325
|
{count !== undefined && (
|
|
328
326
|
<span
|
|
@@ -14,8 +14,8 @@ export interface NestedSecondaryPanelShellProps {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Shared chrome for a nested hub rail — full width vs icon rail
|
|
18
|
-
*
|
|
17
|
+
* Shared chrome for a nested hub rail — full width vs icon rail.
|
|
18
|
+
* Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
|
|
19
19
|
*/
|
|
20
20
|
export function NestedSecondaryPanelShell({
|
|
21
21
|
open,
|
|
@@ -34,15 +34,20 @@ export function NestedSecondaryPanelShell({
|
|
|
34
34
|
"transition-[width,margin,opacity] duration-200 ease-linear",
|
|
35
35
|
open
|
|
36
36
|
? cn(
|
|
37
|
-
|
|
37
|
+
// Match the primary sidebar: fill the full viewport height
|
|
38
|
+
// (minus our 0.5rem top + 0.5rem bottom margin from `m-2` →
|
|
39
|
+
// 1rem on desktop where the panel is `md:sticky md:top-2`;
|
|
40
|
+
// 2rem on mobile where the panel scrolls inline and we leave
|
|
41
|
+
// a little more breathing room). No upper cap so tall screens
|
|
42
|
+
// get a fully-extended rail.
|
|
43
|
+
"shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
|
|
38
44
|
compact
|
|
39
|
-
? "w-12 min-w-12 max-w-12 h-[
|
|
40
|
-
: "w-64 min-w-64 max-w-64 h-[
|
|
45
|
+
? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
|
|
46
|
+
: "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
|
|
41
47
|
)
|
|
42
48
|
: "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
|
|
43
49
|
className,
|
|
44
50
|
)}
|
|
45
|
-
style={open ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
|
|
46
51
|
>
|
|
47
52
|
<div
|
|
48
53
|
className={cn(
|