@exxatdesignux/ui 0.2.16 → 0.2.18
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 +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- 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-kpi-flat-band/SKILL.md +38 -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 +19 -0
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -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 +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- 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 +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- 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 +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- 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 +19 -133
- 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} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -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-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- 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/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 +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* SidebarShell — SidebarProvider with layout-aware widths.
|
|
5
5
|
* Desktop expanded/collapsed is persisted in the `sidebar_state` cookie by `@exxatdesignux/ui`
|
|
6
|
-
* `SidebarProvider` (read on mount + write on toggle).
|
|
6
|
+
* `SidebarProvider` (read on mount + write on toggle). `(app)/layout` passes
|
|
7
|
+
* `defaultOpen` from the same cookie on the server so SSR matches the first client paint.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import * as React from "react"
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import * as React from "react"
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
PageBreadcrumbBack,
|
|
17
|
+
PageBreadcrumbTrail,
|
|
18
|
+
type PageBreadcrumbBackProps,
|
|
19
|
+
type PageBreadcrumbTrailItem,
|
|
20
|
+
} from "@/components/page-breadcrumb-trail"
|
|
16
21
|
import { Separator } from "@/components/ui/separator"
|
|
17
22
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
|
18
23
|
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
@@ -25,19 +30,26 @@ import { AskLeoToggle } from "@/components/ask-leo-sidebar"
|
|
|
25
30
|
import { useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
26
31
|
import { cn } from "@/lib/utils"
|
|
27
32
|
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
href?: string
|
|
31
|
-
}
|
|
33
|
+
export type BreadcrumbItem = PageBreadcrumbTrailItem
|
|
34
|
+
export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
|
|
32
35
|
|
|
33
36
|
export interface SiteHeaderProps {
|
|
34
|
-
/** Current page title (last breadcrumb segment) */
|
|
37
|
+
/** Current page title (last breadcrumb segment in trail mode). */
|
|
35
38
|
title?: string
|
|
36
39
|
/** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
|
|
37
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
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
export function SiteHeader({
|
|
48
|
+
export function SiteHeader({
|
|
49
|
+
title = "Dashboard",
|
|
50
|
+
breadcrumbs,
|
|
51
|
+
back,
|
|
52
|
+
}: SiteHeaderProps) {
|
|
41
53
|
const mod = useModKeyLabel()
|
|
42
54
|
const [isStuck, setIsStuck] = React.useState(false)
|
|
43
55
|
|
|
@@ -51,7 +63,13 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
|
|
|
51
63
|
return (
|
|
52
64
|
<div
|
|
53
65
|
className={cn(
|
|
54
|
-
|
|
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",
|
|
55
73
|
isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
|
|
56
74
|
)}
|
|
57
75
|
>
|
|
@@ -78,29 +96,16 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
|
|
|
78
96
|
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
|
|
79
97
|
/>
|
|
80
98
|
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</Link>
|
|
92
|
-
) : (
|
|
93
|
-
<span className="font-sans text-sm text-muted-foreground tracking-normal">
|
|
94
|
-
{crumb.label}
|
|
95
|
-
</span>
|
|
96
|
-
)}
|
|
97
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
|
|
98
|
-
</span>
|
|
99
|
-
))}
|
|
100
|
-
<span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
|
|
101
|
-
{title}
|
|
102
|
-
</span>
|
|
103
|
-
</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
|
+
)}
|
|
104
109
|
|
|
105
110
|
<div className="ml-auto shrink-0">
|
|
106
111
|
<AskLeoToggle />
|
|
@@ -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
|
}
|
|
@@ -200,6 +200,10 @@ export const SitesTable = React.forwardRef<
|
|
|
200
200
|
tableState.setSheetOpen(true)
|
|
201
201
|
},
|
|
202
202
|
}),
|
|
203
|
+
// `tableState` is freshly returned each render by useTableState; depending
|
|
204
|
+
// on it would re-create the imperative handle on every render. Only the
|
|
205
|
+
// React setter is needed (and is referentially stable).
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
207
|
[tableState.setSheetOpen],
|
|
204
208
|
)
|
|
205
209
|
|
|
@@ -151,6 +151,24 @@ export function TablePropertiesDrawerButton({
|
|
|
151
151
|
sortKey,
|
|
152
152
|
} = state
|
|
153
153
|
|
|
154
|
+
// Sheet is portaled; keep latest handlers so sort/filter/conditional edits are not lost.
|
|
155
|
+
const stateRef = React.useRef(state)
|
|
156
|
+
stateRef.current = state
|
|
157
|
+
const ruleHandlersRef = React.useRef({
|
|
158
|
+
onAddConditionalRule,
|
|
159
|
+
onRemoveConditionalRule,
|
|
160
|
+
onUpdateConditionalRule,
|
|
161
|
+
onDisplayOptionsChange,
|
|
162
|
+
onPaginationChange,
|
|
163
|
+
})
|
|
164
|
+
ruleHandlersRef.current = {
|
|
165
|
+
onAddConditionalRule,
|
|
166
|
+
onRemoveConditionalRule,
|
|
167
|
+
onUpdateConditionalRule,
|
|
168
|
+
onDisplayOptionsChange,
|
|
169
|
+
onPaginationChange,
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
return (
|
|
155
173
|
<>
|
|
156
174
|
{extraActions}
|
|
@@ -185,42 +203,42 @@ export function TablePropertiesDrawerButton({
|
|
|
185
203
|
rowHeight={rowHeight}
|
|
186
204
|
onRowHeightChange={setRowHeight}
|
|
187
205
|
pagination={pagination}
|
|
188
|
-
onPaginationChange={
|
|
206
|
+
onPaginationChange={v => ruleHandlersRef.current.onPaginationChange?.(v)}
|
|
189
207
|
activeFilters={activeFilters}
|
|
190
|
-
onAddFilter={fieldKey => addFilter(fieldKey, true)}
|
|
191
|
-
onUpdateFilter={updateFilter}
|
|
192
|
-
onRemoveFilter={removeFilter}
|
|
193
|
-
getFilterConnector={getConnector}
|
|
194
|
-
onToggleFilterConnector={toggleConnector}
|
|
208
|
+
onAddFilter={fieldKey => stateRef.current.addFilter(fieldKey, true)}
|
|
209
|
+
onUpdateFilter={(id, patch) => stateRef.current.updateFilter(id, patch)}
|
|
210
|
+
onRemoveFilter={id => stateRef.current.removeFilter(id)}
|
|
211
|
+
getFilterConnector={leftId => stateRef.current.getConnector(leftId)}
|
|
212
|
+
onToggleFilterConnector={leftId => stateRef.current.toggleConnector(leftId)}
|
|
195
213
|
filterBarVisible={filterBarVisible}
|
|
196
|
-
onFilterBarVisibleChange={setFilterBarVisible}
|
|
214
|
+
onFilterBarVisibleChange={v => stateRef.current.setFilterBarVisible(v)}
|
|
197
215
|
drawerExpandedFilters={drawerExpandedFilters}
|
|
198
|
-
onDrawerExpandedFiltersChange={setDrawerExpandedFilters}
|
|
216
|
+
onDrawerExpandedFiltersChange={stateRef.current.setDrawerExpandedFilters}
|
|
199
217
|
totalRows={totalRows}
|
|
200
218
|
filteredRows={rows.length}
|
|
201
219
|
sortRules={sortRules}
|
|
202
|
-
onSortRulesChange={setSortRules}
|
|
203
|
-
onAddSortRule={addSortRule}
|
|
204
|
-
onRemoveSortRule={removeSortRule}
|
|
205
|
-
onToggleSortDir={toggleSortDir}
|
|
220
|
+
onSortRulesChange={rules => stateRef.current.setSortRules(rules)}
|
|
221
|
+
onAddSortRule={fieldKey => stateRef.current.addSortRule(fieldKey)}
|
|
222
|
+
onRemoveSortRule={id => stateRef.current.removeSortRule(id)}
|
|
223
|
+
onToggleSortDir={id => stateRef.current.toggleSortDir(id)}
|
|
206
224
|
colOrder={colOrder}
|
|
207
|
-
onColOrderChange={setColOrder}
|
|
225
|
+
onColOrderChange={order => stateRef.current.setColOrder(order)}
|
|
208
226
|
hiddenCols={hiddenCols}
|
|
209
|
-
onToggleColVisibility={toggleColVisibility}
|
|
210
|
-
onMoveCol={moveCol}
|
|
227
|
+
onToggleColVisibility={key => stateRef.current.toggleColVisibility(key)}
|
|
228
|
+
onMoveCol={(key, dir) => stateRef.current.moveCol(key, dir)}
|
|
211
229
|
groupBy={groupBy}
|
|
212
|
-
onGroupByChange={setGroupBy}
|
|
230
|
+
onGroupByChange={key => stateRef.current.setGroupBy(key)}
|
|
213
231
|
primarySortKey={sortKey}
|
|
214
232
|
conditionalRules={conditionalRules}
|
|
215
|
-
onAddConditionalRule={onAddConditionalRule}
|
|
216
|
-
onRemoveConditionalRule={onRemoveConditionalRule}
|
|
217
|
-
onUpdateConditionalRule={onUpdateConditionalRule}
|
|
233
|
+
onAddConditionalRule={rule => ruleHandlersRef.current.onAddConditionalRule(rule)}
|
|
234
|
+
onRemoveConditionalRule={id => ruleHandlersRef.current.onRemoveConditionalRule(id)}
|
|
235
|
+
onUpdateConditionalRule={(id, patch) => ruleHandlersRef.current.onUpdateConditionalRule(id, patch)}
|
|
218
236
|
filterFields={filterFields}
|
|
219
237
|
lifecycleTabLabel={lifecycleTabLabel}
|
|
220
238
|
fieldDefinitions={fieldDefinitions}
|
|
221
239
|
resolveColumnLabel={resolveColumnLabel}
|
|
222
240
|
displayOptions={displayOptions}
|
|
223
|
-
onDisplayOptionsChange={onDisplayOptionsChange}
|
|
241
|
+
onDisplayOptionsChange={patch => ruleHandlersRef.current.onDisplayOptionsChange(patch)}
|
|
224
242
|
currentView={currentView}
|
|
225
243
|
onViewChange={onViewChange}
|
|
226
244
|
boardGroupByColumnOptions={boardGroupByColumnOptions}
|
|
@@ -117,6 +117,9 @@ export interface TablePropertiesDrawerProps {
|
|
|
117
117
|
|
|
118
118
|
type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
|
|
119
119
|
|
|
120
|
+
/** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
|
|
121
|
+
const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
|
|
122
|
+
|
|
120
123
|
export function TablePropertiesDrawer({
|
|
121
124
|
open,
|
|
122
125
|
onOpenChange,
|
|
@@ -243,14 +246,14 @@ export function TablePropertiesDrawer({
|
|
|
243
246
|
: "—"
|
|
244
247
|
|
|
245
248
|
return (
|
|
246
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
249
|
+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
|
247
250
|
<SheetContent
|
|
248
251
|
side="right"
|
|
249
252
|
showCloseButton={false}
|
|
250
253
|
showOverlay={false}
|
|
251
254
|
// w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
|
|
252
255
|
// 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"
|
|
256
|
+
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
257
|
style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
|
|
255
258
|
>
|
|
256
259
|
|
|
@@ -462,7 +465,7 @@ export function TablePropertiesDrawer({
|
|
|
462
465
|
>
|
|
463
466
|
<SelectValue />
|
|
464
467
|
</SelectTrigger>
|
|
465
|
-
<SelectContent align="end">
|
|
468
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
466
469
|
{boardGroupByColumnOptions.map(o => (
|
|
467
470
|
<SelectItem key={o.key} value={o.key}>
|
|
468
471
|
{o.label}
|
|
@@ -533,7 +536,7 @@ export function TablePropertiesDrawer({
|
|
|
533
536
|
<SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
|
|
534
537
|
<SelectValue />
|
|
535
538
|
</SelectTrigger>
|
|
536
|
-
<SelectContent align="end">
|
|
539
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
537
540
|
<SelectItem value="1">1 line</SelectItem>
|
|
538
541
|
<SelectItem value="2">2 lines</SelectItem>
|
|
539
542
|
<SelectItem value="3">3 lines</SelectItem>
|
|
@@ -659,7 +662,7 @@ export function TablePropertiesDrawer({
|
|
|
659
662
|
{[
|
|
660
663
|
{ icon: "fa-circle-1", text: "Click \"Add filter\" below" },
|
|
661
664
|
{ icon: "fa-circle-2", text: "Choose a field to filter by" },
|
|
662
|
-
{ icon: "fa-circle-3", text: "Pick
|
|
665
|
+
{ icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
|
|
663
666
|
].map(step => (
|
|
664
667
|
<div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
665
668
|
<i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
|
|
@@ -726,7 +729,7 @@ export function TablePropertiesDrawer({
|
|
|
726
729
|
|
|
727
730
|
{/* Add filter + Remove all */}
|
|
728
731
|
<div className="flex items-center gap-2 pt-2">
|
|
729
|
-
<DropdownMenu>
|
|
732
|
+
<DropdownMenu modal={false}>
|
|
730
733
|
<DropdownMenuTrigger asChild>
|
|
731
734
|
<Button
|
|
732
735
|
type="button"
|
|
@@ -737,11 +740,11 @@ export function TablePropertiesDrawer({
|
|
|
737
740
|
Add filter
|
|
738
741
|
</Button>
|
|
739
742
|
</DropdownMenuTrigger>
|
|
740
|
-
<DropdownMenuContent align="start">
|
|
743
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
741
744
|
<DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
|
|
742
745
|
<DropdownMenuSeparator />
|
|
743
746
|
{filterFields.map(f => (
|
|
744
|
-
<DropdownMenuItem key={f.key}
|
|
747
|
+
<DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
|
|
745
748
|
<i className={`fa-light ${f.icon}`} aria-hidden="true" />
|
|
746
749
|
{f.label}
|
|
747
750
|
</DropdownMenuItem>
|
|
@@ -833,7 +836,7 @@ export function TablePropertiesDrawer({
|
|
|
833
836
|
|
|
834
837
|
{/* Add sort + Remove all */}
|
|
835
838
|
<div className="flex items-center gap-2 pt-2">
|
|
836
|
-
<DropdownMenu>
|
|
839
|
+
<DropdownMenu modal={false}>
|
|
837
840
|
<DropdownMenuTrigger asChild>
|
|
838
841
|
<Button
|
|
839
842
|
type="button"
|
|
@@ -844,11 +847,11 @@ export function TablePropertiesDrawer({
|
|
|
844
847
|
Add sort
|
|
845
848
|
</Button>
|
|
846
849
|
</DropdownMenuTrigger>
|
|
847
|
-
<DropdownMenuContent align="start">
|
|
850
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
848
851
|
<DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
|
|
849
852
|
<DropdownMenuSeparator />
|
|
850
853
|
{sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
|
|
851
|
-
<DropdownMenuItem key={col.key}
|
|
854
|
+
<DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
|
|
852
855
|
<i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
|
|
853
856
|
{col.label}
|
|
854
857
|
</DropdownMenuItem>
|
|
@@ -1052,7 +1055,7 @@ function ConditionalRulesPanel({
|
|
|
1052
1055
|
)}
|
|
1053
1056
|
|
|
1054
1057
|
<div className="flex items-center gap-2 pt-2">
|
|
1055
|
-
<DropdownMenu>
|
|
1058
|
+
<DropdownMenu modal={false}>
|
|
1056
1059
|
<DropdownMenuTrigger asChild>
|
|
1057
1060
|
<Button
|
|
1058
1061
|
type="button"
|
|
@@ -1063,13 +1066,13 @@ function ConditionalRulesPanel({
|
|
|
1063
1066
|
Add rule
|
|
1064
1067
|
</Button>
|
|
1065
1068
|
</DropdownMenuTrigger>
|
|
1066
|
-
<DropdownMenuContent align="start">
|
|
1069
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
1067
1070
|
<DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
|
|
1068
1071
|
<DropdownMenuSeparator />
|
|
1069
1072
|
{filterFields.map(f => (
|
|
1070
1073
|
<DropdownMenuItem
|
|
1071
1074
|
key={f.key}
|
|
1072
|
-
|
|
1075
|
+
onSelect={() => onAdd({
|
|
1073
1076
|
fieldKey: f.key,
|
|
1074
1077
|
operator: f.operators[0],
|
|
1075
1078
|
values: [],
|
|
@@ -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 {
|
|
@@ -154,7 +155,7 @@ function TeamFinderDetail({
|
|
|
154
155
|
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
155
156
|
</dt>
|
|
156
157
|
<dd className="text-[13px]">
|
|
157
|
-
<a href={
|
|
158
|
+
<a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
|
|
158
159
|
</dd>
|
|
159
160
|
</div>
|
|
160
161
|
<div className="flex flex-col gap-0.5">
|
|
@@ -277,7 +278,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
277
278
|
operators: ["contains", "not_contains"],
|
|
278
279
|
},
|
|
279
280
|
cell: row => (
|
|
280
|
-
<a href={
|
|
281
|
+
<a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
|
|
281
282
|
{row.email}
|
|
282
283
|
</a>
|
|
283
284
|
),
|
|
@@ -341,7 +342,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
341
342
|
</Button>
|
|
342
343
|
</DropdownMenuTrigger>
|
|
343
344
|
<DropdownMenuContent align="end">
|
|
344
|
-
<DropdownMenuItem onClick={() => window.open(
|
|
345
|
+
<DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
|
|
345
346
|
<i className="fa-light fa-envelope" aria-hidden="true" />
|
|
346
347
|
Email
|
|
347
348
|
</DropdownMenuItem>
|
|
@@ -501,6 +502,10 @@ export const TeamTable = React.forwardRef<
|
|
|
501
502
|
openPropertiesDrawer: () => {
|
|
502
503
|
tableState.setSheetOpen(true)
|
|
503
504
|
},
|
|
505
|
+
// `tableState` is freshly returned each render by useTableState; depending on
|
|
506
|
+
// it would re-create the imperative handle on every render. Only the React
|
|
507
|
+
// setter is needed (and is referentially stable).
|
|
508
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
509
|
}), [tableState.setSheetOpen])
|
|
505
510
|
|
|
506
511
|
const teamPanelFinderGroups = React.useMemo(
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
Shortcut,
|
|
48
48
|
} from "@/components/ui/dropdown-menu"
|
|
49
49
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
50
|
-
import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
|
|
50
|
+
import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "@/lib/data-list-view"
|
|
51
51
|
import {
|
|
52
52
|
createListPageEditViewHandler,
|
|
53
53
|
type OpenTablePropertiesHandle,
|
|
@@ -273,13 +273,16 @@ export function ListPageTemplate({
|
|
|
273
273
|
|
|
274
274
|
return (
|
|
275
275
|
<>
|
|
276
|
-
{!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
276
|
+
{!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => {
|
|
277
|
+
const keys = dataListViewAddShortcut(i)
|
|
278
|
+
return keys ? (
|
|
279
|
+
<Shortcut
|
|
280
|
+
key={v.type}
|
|
281
|
+
keys={keys}
|
|
282
|
+
onInvoke={() => addView(v.type)}
|
|
283
|
+
/>
|
|
284
|
+
) : null
|
|
285
|
+
})}
|
|
283
286
|
{activeTab && !hideViewsToolbar && (
|
|
284
287
|
<>
|
|
285
288
|
<Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
|
|
@@ -480,7 +483,7 @@ export function ListPageTemplate({
|
|
|
480
483
|
{VIEW_TYPES.map((v, i) => (
|
|
481
484
|
<DropdownMenuItem
|
|
482
485
|
key={v.type}
|
|
483
|
-
shortcut={i
|
|
486
|
+
shortcut={dataListViewAddShortcut(i)}
|
|
484
487
|
onSelect={() => addView(v.type)}
|
|
485
488
|
>
|
|
486
489
|
<i className={`fa-light ${v.icon}`} aria-hidden="true" />
|
|
@@ -15,7 +15,7 @@ export interface NestedSecondaryPanelShellProps {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Shared chrome for a nested hub rail — full width vs icon rail.
|
|
18
|
-
* Fill uses `--secondary-panel-bg`
|
|
18
|
+
* Fill uses `--secondary-panel-bg` — one step lighter than `--sidebar` (elevation 1).
|
|
19
19
|
*/
|
|
20
20
|
export function NestedSecondaryPanelShell({
|
|
21
21
|
open,
|
|
@@ -34,10 +34,16 @@ 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-sidebar-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,
|