@exxatdesignux/ui 0.2.9 → 0.2.11
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/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
- package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
- package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
- package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
- package/consumer-extras/patterns/data-views-pattern.md +12 -4
- package/package.json +4 -1
- package/src/components/ui/banner.tsx +20 -7
- package/src/components/ui/date-picker-field.tsx +3 -3
- package/src/components/ui/dropdown-menu.tsx +17 -6
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/separator.tsx +2 -2
- package/src/components/ui/sidebar.tsx +31 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/globals.css +0 -1
- package/src/index.ts +1 -0
- package/src/lib/date-filter.ts +13 -4
- package/src/lib/dropdown-menu-surface.ts +13 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
- package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
- package/template/.nvmrc +1 -1
- package/template/AGENTS.md +82 -27
- package/template/app/(app)/examples/page.tsx +2 -1
- package/template/app/(app)/help/page.tsx +6 -0
- package/template/app/(app)/layout.tsx +7 -4
- package/template/app/(app)/question-bank/find/page.tsx +12 -0
- package/template/app/(app)/question-bank/layout.tsx +46 -0
- package/template/app/(app)/question-bank/library/page.tsx +11 -0
- package/template/app/(app)/question-bank/list/page.tsx +12 -0
- package/template/app/(app)/question-bank/page.tsx +4 -3
- package/template/app/globals.css +1 -2
- package/template/components/app-sidebar.tsx +51 -13
- package/template/components/ask-leo-composer.tsx +173 -45
- package/template/components/ask-leo-sidebar.tsx +9 -1
- package/template/components/chart-area-interactive.tsx +3 -13
- package/template/components/charts-overview.tsx +33 -6
- package/template/components/collaboration-access-flow.tsx +144 -0
- package/template/components/compliance-page-header.tsx +1 -1
- package/template/components/compliance-table.tsx +2 -2
- package/template/components/dashboard-tabs.tsx +4 -3
- package/template/components/data-list-table-cells.tsx +1 -1
- package/template/components/data-list-table.tsx +1 -1
- package/template/components/data-table/index.tsx +5 -5
- package/template/components/data-table/use-table-state.ts +18 -2
- package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
- package/template/components/data-view-dashboard-charts-team.tsx +8 -5
- package/template/components/data-view-dashboard-charts.tsx +62 -227
- package/template/components/dedicated-search-recents.tsx +96 -0
- package/template/components/dedicated-search-url-composer.tsx +112 -0
- package/template/components/getting-started.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +10 -26
- package/template/components/invite-collaborators-drawer.tsx +453 -0
- package/template/components/key-metrics.tsx +54 -8
- package/template/components/nav-documents.tsx +1 -1
- package/template/components/new-placement-form.tsx +3 -3
- package/template/components/page-header.tsx +76 -59
- package/template/components/placements-board-view.tsx +3 -3
- package/template/components/placements-page-header.tsx +1 -1
- package/template/components/placements-table-columns.tsx +3 -2
- package/template/components/product-switcher.tsx +0 -1
- package/template/components/question-bank-board-view.tsx +35 -47
- package/template/components/question-bank-client.tsx +293 -81
- package/template/components/question-bank-dashboard-charts.tsx +174 -0
- package/template/components/question-bank-favorite-button.tsx +46 -0
- package/template/components/question-bank-hub-client.tsx +436 -0
- package/template/components/question-bank-list-view.tsx +26 -19
- package/template/components/question-bank-new-folder-sheet.tsx +56 -42
- package/template/components/question-bank-os-folder-view.tsx +3 -14
- package/template/components/question-bank-page-header.tsx +85 -53
- package/template/components/question-bank-panel-activator.tsx +3 -4
- package/template/components/question-bank-secondary-nav.tsx +523 -65
- package/template/components/question-bank-table.tsx +125 -343
- package/template/components/secondary-panel.tsx +130 -63
- package/template/components/settings-client.tsx +3 -1
- package/template/components/sidebar-shell.tsx +2 -0
- package/template/components/sites-all-client.tsx +1 -1
- package/template/components/sites-table.tsx +1 -1
- package/template/components/system-banner-slot.tsx +2 -1
- package/template/components/table-properties/drawer.tsx +3 -3
- package/template/components/table-properties/sort-card.tsx +1 -1
- package/template/components/team-page-header.tsx +1 -1
- package/template/components/team-table.tsx +8 -4
- package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
- package/template/components/templates/dedicated-search-results-template.tsx +19 -0
- package/template/components/templates/discovery-hub-template.tsx +273 -0
- package/template/components/templates/list-page.tsx +11 -4
- package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
- package/template/docs/card-vs-rows-pattern.md +36 -0
- package/template/docs/collaboration-access-pattern.md +114 -0
- package/template/docs/data-views-pattern.md +12 -4
- package/template/docs/drawer-vs-dialog-pattern.md +50 -0
- package/template/docs/kpi-strip-max-four-pattern.md +29 -0
- package/template/docs/kpi-trend-pattern.md +43 -0
- package/template/fontawesome-subset.manifest.json +2 -2
- package/template/hooks/use-location-hash.ts +14 -8
- package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
- package/template/lib/ask-leo-route-context.ts +24 -0
- package/template/lib/collaborator-access.ts +92 -0
- package/template/lib/command-menu-config.ts +8 -1
- package/template/lib/command-menu-search-data.ts +11 -8
- package/template/lib/data-list-display-options.ts +1 -1
- package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
- package/template/lib/date-filter.ts +1 -0
- package/template/lib/dedicated-search-recents.ts +76 -0
- package/template/lib/dedicated-search-url.ts +23 -0
- package/template/lib/discovery-hub.ts +15 -0
- package/template/lib/list-status-badges.ts +1 -21
- package/template/lib/mock/navigation.tsx +4 -2
- package/template/lib/mock/placements.ts +9 -9
- package/template/lib/mock/question-bank-folders.ts +7 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
- package/template/lib/mock/question-bank-inspector.ts +1 -2
- package/template/lib/mock/question-bank-kpi.ts +38 -26
- package/template/lib/mock/question-bank.ts +43 -16
- package/template/lib/question-bank-dedicated-search.ts +19 -0
- package/template/lib/question-bank-hub-search.ts +90 -0
- package/template/lib/question-bank-nav.ts +322 -6
- package/template/lib/question-bank-recent-searches.ts +22 -0
- package/template/package.json +1 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Exxat DS — cards vs rows vs lists
|
|
2
|
+
|
|
3
|
+
Use when choosing **card grids**, **`DataTable`**, or **simple list rows** for a surface.
|
|
4
|
+
|
|
5
|
+
## Read first
|
|
6
|
+
|
|
7
|
+
- **`docs/card-vs-rows-pattern.md`**
|
|
8
|
+
- **`.cursor/rules/exxat-card-vs-list-rows.mdc`**, **`exxat-data-tables.mdc`**, **`exxat-board-cards.mdc`**
|
|
9
|
+
- **`exxat-centralized-list-dataset.mdc`** — one `tableState.rows` for table + board + cards
|
|
10
|
+
|
|
11
|
+
## Checklist
|
|
12
|
+
|
|
13
|
+
1. **10+ homogeneous records + compare/sort/filter?** → **`DataTable`** + hub template.
|
|
14
|
+
2. **Kanban / visual tiles / folders?** → **`ListPageBoardCard`** or **`ListPageViewFrame`** + card primitives.
|
|
15
|
+
3. **Medium vertical list without full table chrome?** → List row pattern; **promote** to `DataTable` when density and parity demand it.
|
|
16
|
+
|
|
17
|
+
## MUST NOT
|
|
18
|
+
|
|
19
|
+
- Card wall for the **primary** sortable hub that other hubs implement as **`DataTable`** without product reason.
|
|
20
|
+
- Second mock row source for cards vs table.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: exxat-collaboration-access
|
|
3
|
+
description: Shared hub collaboration — PageHeader face rail, InviteCollaboratorsDrawer, library access vs directory role tags. Use when adding invite people, access roster, or collaboration variant headers.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Exxat DS — collaboration & access
|
|
8
|
+
|
|
9
|
+
**Handbook:** `apps/web/AGENTS.md` §4.7
|
|
10
|
+
**Narrative:** `apps/web/docs/collaboration-access-pattern.md`
|
|
11
|
+
**Cursor rule:** `.cursor/rules/exxat-collaboration-access.mdc`
|
|
12
|
+
|
|
13
|
+
## Wiring checklist
|
|
14
|
+
|
|
15
|
+
1. **`PageHeaderCollaborator`** — `name`, `email`, `access`, optional `roles[]`, avatar fields (`components/page-header.tsx`).
|
|
16
|
+
2. **Mock/API** — `lib/mock/<entity>-header-collaborators.ts`; one shape for header + invite sheet.
|
|
17
|
+
3. **Entity header** — `variant="collaboration"`; **Invite people** first in **⋯ More**; pass **`onAddCollaborator`** and **`onCollaboratorsOpen`**.
|
|
18
|
+
4. **Hub client** — **`CollaborationAccessFlow`** (preferred) or `collaborators` + `inviteOpen` + **`InviteCollaboratorsDrawer`**; on invite, append to **`collaborators`**.
|
|
19
|
+
5. **Access maps** — `lib/collaborator-access.ts` for Owner / Editor / Commenter / Viewer, invite options, and **`COLLABORATION_HEADER_ADD_LABEL`**.
|
|
20
|
+
6. **Header** — empty roster → outline **Add collaborator**; non-empty → face rail; both open the invite sheet.
|
|
21
|
+
7. **Invite sheet** — `InviteCollaboratorsDrawer`: export-style **`Sheet`**, combined email + access menu, grouped roster (name → email → role tags → access badge).
|
|
22
|
+
|
|
23
|
+
## MUST NOT
|
|
24
|
+
|
|
25
|
+
- A second invite control beside a populated face rail.
|
|
26
|
+
- Per-hub access label forks or parallel collaborator types.
|
|
27
|
+
- Toast/snackbar for invite outcomes (**`exxat-no-toast.mdc`**).
|
|
28
|
+
- **`Select`** in **`InputGroupAddon`** for access inside the sheet.
|
|
29
|
+
|
|
30
|
+
## Reference
|
|
31
|
+
|
|
32
|
+
- `components/collaboration-access-flow.tsx`, `components/question-bank-page-header.tsx`, `components/question-bank-client.tsx`
|
|
33
|
+
- `components/invite-collaborators-drawer.tsx`, `components/export-drawer.tsx`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: exxat-dedicated-search-surfaces
|
|
3
|
+
description: >-
|
|
4
|
+
Dedicated search landing vs results split, URL composer, recents storage, and templates
|
|
5
|
+
(generic DedicatedSearch*). Use when adding a route pair like empty ?q= landing + ListPageTemplate results,
|
|
6
|
+
or wiring localStorage recents without hydration mismatches.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Dedicated search surfaces (Exxat DS)
|
|
10
|
+
|
|
11
|
+
## When to use
|
|
12
|
+
|
|
13
|
+
- A hub exposes **two URL states** on the same feature: **landing** (no primary query / empty `?q=`) vs **results** (non-empty query drives `ListPageTemplate` / `DataTable`).
|
|
14
|
+
- You need an **AI-style composer** that **updates the URL** with `router.replace` and **does not** open Ask Leo.
|
|
15
|
+
- You need **recent searches** persisted in `localStorage` **without** SSR/client markup drift.
|
|
16
|
+
|
|
17
|
+
## MUST
|
|
18
|
+
|
|
19
|
+
1. **Hydration-safe recents** — **MUST NOT** read `localStorage` in `useState` initializers. Initial client paint **MUST** match the server (`[]` then `useEffect` sync). See **`DedicatedSearchRecents`**.
|
|
20
|
+
2. **Generic names** — Reusable pieces live under **`DedicatedSearch*`** (`components/dedicated-search-*.tsx`, **`components/templates/dedicated-search-*-template.tsx`**, **`lib/dedicated-search-*.ts`**). Hub code passes **`patchSearchParams`**, **`recents` controller**, copy, and entity table — **MUST NOT** fork parallel “question search landing” components for a second hub.
|
|
21
|
+
3. **Landing shell** — Use **`DedicatedSearchLandingTemplate`** (`ListPageViewFrame` + title + composer slot + optional trailing).
|
|
22
|
+
4. **Results chrome** — Reuse **`DEDICATED_SEARCH_RESULTS_OUTER_CONTENT_CLASSNAME`** on the hub content wrapper and **`DedicatedSearchResultsHeaderChrome`** around page header + composer strip.
|
|
23
|
+
5. **Namespaces recents** — Use **`createDedicatedSearchRecentsController(namespace, legacy?)`** from **`lib/dedicated-search-recents.ts`**. When replacing an existing storage key, pass **`legacy: { storageKey, eventName }`** so users do not lose saved rows.
|
|
24
|
+
|
|
25
|
+
## MUST NOT
|
|
26
|
+
|
|
27
|
+
- Gate render on **`typeof window`** for the **first** paint of recents or landing bodies.
|
|
28
|
+
- Duplicate the landing vertical rhythm (`mt-*` between title, composer, recents) outside the template without updating the template for product-wide consistency.
|
|
29
|
+
|
|
30
|
+
## References (apps/web)
|
|
31
|
+
|
|
32
|
+
| Piece | Path |
|
|
33
|
+
|-------|------|
|
|
34
|
+
| Landing template | `components/templates/dedicated-search-landing-template.tsx` |
|
|
35
|
+
| Results chrome | `components/templates/dedicated-search-results-template.tsx` |
|
|
36
|
+
| URL composer | `components/dedicated-search-url-composer.tsx` |
|
|
37
|
+
| Recents list | `components/dedicated-search-recents.tsx` |
|
|
38
|
+
| Recents storage factory | `lib/dedicated-search-recents.ts` |
|
|
39
|
+
| Optional default `q` patcher | `lib/dedicated-search-url.ts` |
|
|
40
|
+
| Question bank adapter (placeholders + patch) | `lib/question-bank-dedicated-search.ts` |
|
|
41
|
+
| Question bank wiring | `components/question-bank-client.tsx` |
|
|
42
|
+
|
|
43
|
+
## Cursor rule
|
|
44
|
+
|
|
45
|
+
- **`.cursor/rules/exxat-dedicated-search-surfaces.mdc`**
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Exxat DS — drawer vs dialog
|
|
2
|
+
|
|
3
|
+
Use when choosing **Radix `Dialog` / `AlertDialog`** vs **`Sheet` / `Drawer`** vs **route** for a flow.
|
|
4
|
+
|
|
5
|
+
## Read first
|
|
6
|
+
|
|
7
|
+
- **`docs/drawer-vs-dialog-pattern.md`**
|
|
8
|
+
- **`AGENTS.md` §6.4** + **`docs/data-views-pattern.md`** (page vs drawer)
|
|
9
|
+
- **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**, **`exxat-page-vs-drawer.mdc`**
|
|
10
|
+
|
|
11
|
+
## Checklist
|
|
12
|
+
|
|
13
|
+
1. **Must the user see the hub while acting?** Yes → **drawer/sheet** (properties, export, invite). No and short → **dialog**. Long / own URL → **route**.
|
|
14
|
+
2. **Destructive confirm?** Prefer **dialog** (`AlertDialog`) unless the product explicitly keeps context in a drawer with the same safeguards.
|
|
15
|
+
3. **Title + focus** — Dialog/drawer/sheet all need an accessible **title**; restore focus to trigger on close.
|
|
16
|
+
|
|
17
|
+
## Repo references
|
|
18
|
+
|
|
19
|
+
- `TablePropertiesDrawer`, `ExportDrawer`, `InviteCollaboratorsDrawer` — drawer-class.
|
|
20
|
+
- Delete / irreversible — dialog pattern, not toast (**`exxat-no-toast.mdc`**).
|
|
@@ -21,7 +21,7 @@ description: >
|
|
|
21
21
|
- **Stack:** Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui primitives, Font Awesome icons
|
|
22
22
|
- **App root:** `exxat-ds/app/(app)/` — route group that wraps all authenticated pages
|
|
23
23
|
- **Single source of truth:** `exxat-ds/AGENTS.md` for full prose explanations; this skill is the actionable summary
|
|
24
|
-
- **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-accessibility`, `exxat-board-cards` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
|
|
24
|
+
- **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -88,7 +88,7 @@ To add a primary nav item, append to `NAV_PRIMARY`:
|
|
|
88
88
|
| Concern | Pattern |
|
|
89
89
|
|--------|---------|
|
|
90
90
|
| **Product (One / Prism)** | **`ExxatProductLogo`** (`components/exxat-product-logo.tsx`) for the header control and **`ProductSwitcher`** — **not** logo.dev rasters unless product explicitly changes that. |
|
|
91
|
-
| **School/program menu width** | **`DropdownMenuContent`**
|
|
91
|
+
| **School/program menu width** | **`DropdownMenuContent`** defaults to **intrinsic width** (**`min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]`** via **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** in **`@exxatdesignux/ui/lib/dropdown-menu-surface`**) — pure CSS, no **`ResizeObserver`**. The **school / program** switcher still uses an explicit wider surface (**`!w-max min-w-72 max-w-[min(100vw-2rem,28rem)]`**) so dense rows stay readable. |
|
|
92
92
|
| **School/program copy** | **Do not truncate** school or program names in the switcher; wrap (**`break-words`**, **`whitespace-normal`**, **`items-start`** on multi-line rows). The selected-school summary shows **school name + current program**. |
|
|
93
93
|
| **Team switcher trigger** | **`SidebarMenuButton` `size="lg"`** uses **`h-12`** + **`overflow-hidden`**, which **clips** a second line (program). When the sidebar is **expanded** or **mobile**, add **`h-auto min-h-12`** and **`overflow-x-clip overflow-y-visible`**. On **icon rail**, hide label rows with **`group-data-[collapsible=icon]:hidden`** (tooltip still exposes the full string). Icon mode defaults **`size-8` + `p-2`** (~16px inner) **clips** school logos — override **`!size-9`**, **`!p-0`**, **`overflow-visible`**. Omit header **chevrons** next to logos if they look like stray chrome. |
|
|
94
94
|
| **Motion / Animate UI** | [Animate UI](https://animate-ui.com/docs) — open **copy-first** animated components (Motion + Tailwind). This repo uses **`motion/react`** + **`lib/motion-ui.ts`** presets; pull more animations from their registry into `components/` when needed. |
|
|
@@ -130,9 +130,10 @@ ListPageTemplate
|
|
|
130
130
|
### Page vs drawer (actions)
|
|
131
131
|
|
|
132
132
|
- **Drawer / sheet** — Use when the user needs **the current page behind them** *and* a **quick view**, **quick actions**, or a **short step** (e.g. properties, export, glance at a row).
|
|
133
|
+
- **Dialog** — **Blocking** confirm/alert/short choice — **`docs/drawer-vs-dialog-pattern.md`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**.
|
|
133
134
|
- **New page** — Use **otherwise**: **primary**, **long-form**, **multi-step**, or flows that need their **own URL** without the hub visible.
|
|
134
135
|
|
|
135
|
-
Align with **`exxat-ds/AGENTS.md` §6.4**, **`docs/data-views-pattern.md`**, **`.cursor/rules/exxat-page-vs-drawer.mdc`**.
|
|
136
|
+
Align with **`exxat-ds/AGENTS.md` §6.4**, **`docs/data-views-pattern.md`**, **`docs/drawer-vs-dialog-pattern.md`**, **`.cursor/rules/exxat-page-vs-drawer.mdc`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**.
|
|
136
137
|
|
|
137
138
|
---
|
|
138
139
|
|
|
@@ -179,6 +180,25 @@ Align with **`exxat-ds/AGENTS.md` §6.4**, **`docs/data-views-pattern.md`**, **`
|
|
|
179
180
|
- `select` column: `defaultPin: "left"`, `lockPin: true`
|
|
180
181
|
- `actions` column: `defaultPin: "right"`, `lockPin: true`
|
|
181
182
|
|
|
183
|
+
### 5.1 Data table and view-toolbar menus
|
|
184
|
+
|
|
185
|
+
**`DropdownMenuContent`** (from **`@/components/ui/dropdown-menu`**, backed by **`@exxatdesignux/ui`**) applies a **default surface** so **view settings**, **Add view**, **row ⋯**, **column ⋯**, and **filter field** menus get **`min-w-52`**, grow with **`w-max`**, and cap at **`max-w-[min(24rem,calc(100vw-2rem))]`** — all **static Tailwind** (no **`ResizeObserver`** / layout measurement).
|
|
186
|
+
|
|
187
|
+
- **Override** only when the UX needs a fixed rail (e.g. **`className="w-20"`** on the pagination page-size menu, **`w-(--radix-dropdown-menu-trigger-width) min-w-60`** on **`NavUser`**, **`!w-max min-w-72 …`** on the school/program switcher).
|
|
188
|
+
- **Reuse** **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** if you build a custom menu primitive that does not wrap **`DropdownMenuContent`**.
|
|
189
|
+
|
|
190
|
+
### 5.2 KPI trends (`KeyMetrics`, `*-kpi.ts`)
|
|
191
|
+
|
|
192
|
+
**`MetricItem.trend`** must match the **signed change** (arrow direction = truth). **`trendPolarity`** (`higher_is_better` default, **`lower_is_better`**, **`informational`**) controls **tints** and **`aria-label`** — e.g. **low PBI / review flags** rising → `trend: "up"` + **`lower_is_better`** → unfavourable (red), not green. **Doc:** **`docs/kpi-trend-pattern.md`** · **Rule:** **`.cursor/rules/exxat-kpi-trends.mdc`** · **Skill:** **`.cursor/skills/exxat-kpi-trends/SKILL.md`**.
|
|
193
|
+
|
|
194
|
+
### 5.3 KPI count (max four)
|
|
195
|
+
|
|
196
|
+
**`ListPageTemplate`** metrics and **Data tab** key-metrics cards: **≤ 4** `MetricItem` — **`docs/kpi-strip-max-four-pattern.md`**, **`lib/dashboard-layout-merge.ts`**, **`.cursor/rules/exxat-kpi-max-four.mdc`**, **`.cursor/skills/exxat-kpi-max-four/SKILL.md`**.
|
|
197
|
+
|
|
198
|
+
### 5.4 Cards vs table rows
|
|
199
|
+
|
|
200
|
+
Dense comparable hub → **`DataTable`**. Boards / folders / visual browse → **`ListPageBoardCard`** + **`ListPageViewFrame`**. **Doc:** **`docs/card-vs-rows-pattern.md`** · **Rule:** **`.cursor/rules/exxat-card-vs-list-rows.mdc`**.
|
|
201
|
+
|
|
182
202
|
**DataTable must wrap in `<div className="pb-6">`.**
|
|
183
203
|
|
|
184
204
|
---
|
|
@@ -207,7 +227,7 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
|
|
|
207
227
|
</Button>
|
|
208
228
|
</DropdownMenuTrigger>
|
|
209
229
|
</Tip>
|
|
210
|
-
<DropdownMenuContent align="end"
|
|
230
|
+
<DropdownMenuContent align="end">
|
|
211
231
|
<DropdownMenuItem onClick={onExport}>
|
|
212
232
|
<i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
|
|
213
233
|
Export
|
|
@@ -230,6 +250,12 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
|
|
|
230
250
|
- Subtitle: `"{count} items · Last updated now"` format
|
|
231
251
|
- Title uses Ivy Presto (`font-heading` variable) — applied automatically by `PageHeader`
|
|
232
252
|
|
|
253
|
+
### 6.1 Collaboration & access (shared hubs)
|
|
254
|
+
|
|
255
|
+
When a hub is **shared**, use **`PageHeader` `variant="collaboration"`**: **empty roster** → outline **Add collaborator**; **non-empty** → face rail (faces / **`+N`** open the invite sheet). **Invite people** also lives under the entity header **⋯ More** and opens **`InviteCollaboratorsDrawer`** via **`CollaborationAccessFlow`** when possible. Library access (Owner / Editor / Commenter / Viewer) comes from **`lib/collaborator-access.ts`**; directory tags (Faculty, Program coordinator, Director) use **`PageHeaderCollaborator.roles`**.
|
|
256
|
+
|
|
257
|
+
**Handbook:** `apps/web/AGENTS.md` §4.7 · **Doc:** `docs/collaboration-access-pattern.md` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md` · **Reference:** Question bank header + client.
|
|
258
|
+
|
|
233
259
|
---
|
|
234
260
|
|
|
235
261
|
## 7. Navigation: Breadcrumbs vs Back Link
|
|
@@ -281,7 +307,7 @@ Never install new packages or create parallel components. Always use what exists
|
|
|
281
307
|
| Color | CSS design tokens only — no hardcoded hex/rgb |
|
|
282
308
|
| Minimum font size | **`text-xs`** (11px at 16px root via `--text-xs`) or larger — never arbitrary classes below 11px (`AGENTS.md` §8.3) |
|
|
283
309
|
|
|
284
|
-
Before adding any component: search `components/ui/` first. Add a prop/variant to an existing component rather than creating a parallel one.
|
|
310
|
+
Before adding any component: search `components/ui/` first. Add a prop/variant to an existing component rather than creating a parallel one. **If nothing fits** and you need a **new shared primitive or large bespoke widget**, **ask the user** before new files — **`.cursor/rules/exxat-reuse-before-custom.mdc`** (unless the task already approved greenfield).
|
|
285
311
|
|
|
286
312
|
---
|
|
287
313
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Exxat DS — KPI strip (max four)
|
|
2
|
+
|
|
3
|
+
Use when building **`KeyMetrics`** on **`ListPageTemplate`** or **Data tab** key-metrics cards.
|
|
4
|
+
|
|
5
|
+
## Read first
|
|
6
|
+
|
|
7
|
+
- **`docs/kpi-strip-max-four-pattern.md`**
|
|
8
|
+
- **`lib/dashboard-layout-merge.ts`** — `KEY_METRICS_KPI_COUNT_MAX`, `clampKeyMetricsKpiCount`
|
|
9
|
+
- **`.cursor/rules/exxat-kpi-max-four.mdc`**
|
|
10
|
+
|
|
11
|
+
## Checklist
|
|
12
|
+
|
|
13
|
+
1. **`entityKpiMetrics`** returns **at most four** `MetricItem` for those surfaces (or `.slice(0, 4)` after priority sort).
|
|
14
|
+
2. **Extra metrics** → `MetricInsight` description, a chart, or another card — not a fifth tile.
|
|
15
|
+
3. **Dashboard persistence** — Respect clamped `keyMetricsKpiCount` (1–4); never bump max without design approval.
|
|
16
|
+
|
|
17
|
+
## Related
|
|
18
|
+
|
|
19
|
+
- **`docs/kpi-trend-pattern.md`** — deltas and `trendPolarity`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Exxat DS — KPI trend arrows and polarity
|
|
2
|
+
|
|
3
|
+
Use when adding or reviewing **`KeyMetrics`**, **`lib/mock/*-kpi.ts`** helpers, or **`ChartCard`** **`miniMetrics`** / **`kpi-chart`** trends.
|
|
4
|
+
|
|
5
|
+
## Authoritative references
|
|
6
|
+
|
|
7
|
+
- **`apps/web/docs/kpi-trend-pattern.md`** — product table + psychometrics example (PBI).
|
|
8
|
+
- **`.cursor/rules/exxat-kpi-trends.mdc`** — binding MUST/MUST NOT.
|
|
9
|
+
- **`apps/web/components/key-metrics.tsx`** — `MetricTrendPolarity`, `metricTrendTone`, `metricTrendAriaQualifier`, `MetricCell` rendering.
|
|
10
|
+
|
|
11
|
+
## Checklist (new or changed KPI)
|
|
12
|
+
|
|
13
|
+
1. **Delta honest?** `trend` follows the sign of the change vs the comparison period.
|
|
14
|
+
2. **Polarity correct?** If “more” is bad → **`trendPolarity: "lower_is_better"`**. If no value judgment → **`"informational"`** (muted tints).
|
|
15
|
+
3. **Copy contextual?** Label + value + delta tell users *what* moved, not only *how much*.
|
|
16
|
+
4. **Screen readers** — Chip keeps icon + visible delta; `aria-label` reflects favorable / unfavorable wording automatically when polarity is set.
|
|
17
|
+
5. **Chart cards** — When using **`ChartCard`** `miniMetrics`, pass **`trendPolarity`** the same way as on **`MetricItem`**.
|
|
18
|
+
|
|
19
|
+
## Quick examples
|
|
20
|
+
|
|
21
|
+
| Metric | More is… | `trendPolarity` |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Total placements | Better (capacity) | default / `higher_is_better` |
|
|
24
|
+
| Pass rate | Better | `higher_is_better` |
|
|
25
|
+
| Open incidents | Worse | `lower_is_better` |
|
|
26
|
+
| Low PBI / review flags | Worse | `lower_is_better` |
|
|
27
|
+
| Items by type (mix %) | Neutral | `informational` |
|
|
@@ -15,6 +15,7 @@ user-invocable: true
|
|
|
15
15
|
2. **`components/secondary-panel.tsx`** — add **`PANELS["<id>"]`** → panel shell (title, optional search) + secondary nav component.
|
|
16
16
|
3. **Hub client** — mount **`*PanelActivator`** with **`useAutoPanel("<id>")`** (same id) for the lifetime of the route (e.g. `QuestionBankPanelActivator`).
|
|
17
17
|
4. **Data** — keep **one** **`useTableState`** / **`tableState.rows`**; drive scope from **URL** + small helpers (see **`lib/question-bank-nav.ts`**).
|
|
18
|
+
5. **Collapse control** — the nested rail header uses **`collapseActiveSecondaryPanel()`** (angles-left icon), not “close”, so the panel stays dismissed until **`openPanel`** runs again (nav, scope hook, or hub re-entry). Layout effects that auto-call **`openPanel`** must respect **`secondaryPanelAutoReopenSuppressed`** (see **`app/(app)/question-bank/layout.tsx`** + **`SecondaryPanelProvider`**).
|
|
18
19
|
|
|
19
20
|
## MUST NOT
|
|
20
21
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Collaboration & access pattern
|
|
2
|
+
|
|
3
|
+
Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
- A list hub or library is **shared** across people (not a private directory).
|
|
8
|
+
- Users need to see **who has access**, **invite by email**, and assign **library access** (Owner / Editor / Commenter / Viewer).
|
|
9
|
+
- The hub already uses **`ListPageTemplate`** + **`PageHeader`** (or an entity header built on it).
|
|
10
|
+
|
|
11
|
+
**Do not** use this for org-wide **role** administration (Faculty, Program coordinator, Director) as the only story — those are **directory role tags** on people, not library access.
|
|
12
|
+
|
|
13
|
+
## Vocabulary
|
|
14
|
+
|
|
15
|
+
| Concept | Meaning | Source |
|
|
16
|
+
|--------|---------|--------|
|
|
17
|
+
| **Library access** | What someone can do **in this hub** (Owner, Editor, Commenter, Viewer) | `lib/collaborator-access.ts` |
|
|
18
|
+
| **Directory roles** | Org/job tags on a person (Faculty, Program coordinator, Director) | `PageHeaderCollaborator.roles` |
|
|
19
|
+
| **Face rail** | Overlapping avatars in the header when the roster is non-empty | `PageHeader` `variant="collaboration"` |
|
|
20
|
+
| **Empty roster CTA** | Outline **Add collaborator** in the header when `collaborators` is empty | `PageHeader` + `COLLABORATION_HEADER_ADD_LABEL` |
|
|
21
|
+
| **Invite sheet** | Floating right **`Sheet`** for roster + invite form | `InviteCollaboratorsDrawer` |
|
|
22
|
+
| **Hub wiring** | Roster state + invite sheet in one render-prop shell | `CollaborationAccessFlow` |
|
|
23
|
+
|
|
24
|
+
## Header (`PageHeader`)
|
|
25
|
+
|
|
26
|
+
- Set **`variant="collaboration"`**.
|
|
27
|
+
- Pass **`collaborators`** (`PageHeaderCollaborator[]`) and optional **`accessInfo`**.
|
|
28
|
+
- **Non-empty roster** — overlapping **face rail** only; each face and **`+N`** open the invite sheet via **`onCollaboratorsOpen`**.
|
|
29
|
+
- **Empty roster** — outline **`Add collaborator`** (`addCollaboratorLabel`, default **`COLLABORATION_HEADER_ADD_LABEL`**) opens the same sheet.
|
|
30
|
+
- **Invite** also lives under **⋯ More** on the entity page header (first item when `variant="collaboration"`).
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
<CollaborationAccessFlow
|
|
34
|
+
initialCollaborators={QUESTION_BANK_HEADER_COLLABORATORS}
|
|
35
|
+
resourceLabel={hubHeader.title}
|
|
36
|
+
>
|
|
37
|
+
{({ collaborators, openInvite }) => (
|
|
38
|
+
<QuestionBankPageHeader
|
|
39
|
+
variant="collaboration"
|
|
40
|
+
title={hubHeader.title}
|
|
41
|
+
questionCount={count}
|
|
42
|
+
collaborators={collaborators}
|
|
43
|
+
onAddCollaborator={openInvite}
|
|
44
|
+
onCollaboratorsOpen={openInvite}
|
|
45
|
+
onExport={() => setExportOpen(true)}
|
|
46
|
+
showMetrics={showMetrics}
|
|
47
|
+
onToggleMetrics={() => setShowMetrics(v => !v)}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
</CollaborationAccessFlow>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Hub client state
|
|
54
|
+
|
|
55
|
+
- Prefer **`CollaborationAccessFlow`** — owns **`collaborators`**, **`openInvite`**, and **`InviteCollaboratorsDrawer`**; pass **`openInvite`** to **`onAddCollaborator`** / **`onCollaboratorsOpen`**.
|
|
56
|
+
- Without the flow: **`collaborators`** — `useState` seeded from `lib/mock/<entity>-header-collaborators.ts` (or API later); **`inviteOpen`** — boolean; mount **`InviteCollaboratorsDrawer`** beside **`ListPageTemplate`**.
|
|
57
|
+
- On invite success, append to **`collaborators`** so the **face rail** and sheet roster stay aligned.
|
|
58
|
+
- **Change access** — roster menu updates **`collaborators`** via **`onCollaboratorAccessChange`** ( **`CollaborationAccessFlow`** default).
|
|
59
|
+
- **Remove access** — confirm dialog then **`onCollaboratorRemove`**; blocked for the only **Owner**.
|
|
60
|
+
|
|
61
|
+
## `PageHeaderCollaborator`
|
|
62
|
+
|
|
63
|
+
| Field | Use |
|
|
64
|
+
|-------|-----|
|
|
65
|
+
| `id`, `name`, `imageUrl`, `initials` | Face rail + roster row |
|
|
66
|
+
| `email` | Roster (below name); invite form |
|
|
67
|
+
| `access` | Library access badge (Owner … Viewer) |
|
|
68
|
+
| `roles` | Optional **outline** chips (Faculty, Program coordinator, Director) |
|
|
69
|
+
|
|
70
|
+
## Invite sheet (`InviteCollaboratorsDrawer`)
|
|
71
|
+
|
|
72
|
+
Mirror **`ExportDrawer`** chrome: floating **`Sheet`**, no overlay, **`showCloseButton={false}`**, footer **Cancel** / **Send invite** with inline **`Kbd`** (**Esc** / **⏎**), **`Shortcut`** for Enter on the open surface.
|
|
73
|
+
|
|
74
|
+
**Invite field:** one bordered row — email input + **access** menu on the right.
|
|
75
|
+
|
|
76
|
+
- Use **`Select`** with **`SelectGroup`** for access (invite row in **`InputGroupAddon`**, roster row standalone); **`position="popper"`** inside the sheet; **no** toast on success (**§6.5**).
|
|
77
|
+
|
|
78
|
+
**People with access:** one **`rounded-lg border`** list with **`divide-y`** — **not** one card per person.
|
|
79
|
+
|
|
80
|
+
Row order:
|
|
81
|
+
|
|
82
|
+
1. **Name** (`text-sm font-medium`)
|
|
83
|
+
2. **Email** (`text-xs text-muted-foreground`)
|
|
84
|
+
3. **Role tags** (`Badge variant="outline"`) when `roles` is set
|
|
85
|
+
4. Trailing **library access** **`Select`** when the hub wires **`onCollaboratorAccessChange`**; **Remove access** (trash) opens a confirm **`Dialog`** when **`onCollaboratorRemove`** is set. The sole **Owner** cannot be removed or demoted until another owner exists.
|
|
86
|
+
|
|
87
|
+
## Library access constants
|
|
88
|
+
|
|
89
|
+
- Types and invite options: **`lib/collaborator-access.ts`**
|
|
90
|
+
- **`INVITE_COLLABORATOR_ACCESS_OPTIONS`** — Editor / Commenter / Viewer (no Owner on invite)
|
|
91
|
+
- Customize option **descriptions** per hub; keep **values** stable for forms/API
|
|
92
|
+
|
|
93
|
+
## File map
|
|
94
|
+
|
|
95
|
+
| Piece | Path |
|
|
96
|
+
|-------|------|
|
|
97
|
+
| Access types | `lib/collaborator-access.ts` |
|
|
98
|
+
| Hub flow | `components/collaboration-access-flow.tsx` |
|
|
99
|
+
| Collaborator type | `components/page-header.tsx` (`PageHeaderCollaborator`) |
|
|
100
|
+
| Invite sheet | `components/invite-collaborators-drawer.tsx` |
|
|
101
|
+
| Entity header | `components/question-bank-page-header.tsx` |
|
|
102
|
+
| Hub wiring | `components/question-bank-client.tsx` |
|
|
103
|
+
| Demo roster | `lib/mock/question-bank-header-collaborators.ts` |
|
|
104
|
+
|
|
105
|
+
## Checklist (new hub)
|
|
106
|
+
|
|
107
|
+
- [ ] `PageHeader` / entity header uses **`variant="collaboration"`** when the product is shared.
|
|
108
|
+
- [ ] **Empty roster** shows **Add collaborator**; **non-empty** shows face rail; both open the invite sheet.
|
|
109
|
+
- [ ] **Invite people** under **⋯ More**; **`CollaborationAccessFlow`** (or equivalent) owns roster + sheet.
|
|
110
|
+
- [ ] Roster: single bordered list; **name → email → role tags**; access badge on the right.
|
|
111
|
+
- [ ] Invite row: email + access menu; **`FormDescription`** for format; **no** toast.
|
|
112
|
+
- [ ] Labels from **`collaborator-access.ts`**; mock/API rows extend **`PageHeaderCollaborator`** once.
|
|
113
|
+
|
|
114
|
+
**Handbook:** `AGENTS.md` §4.7 · **Rule:** `.cursor/rules/exxat-collaboration-access.mdc` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md`
|
|
@@ -45,7 +45,7 @@ Non-table view branches (e.g. **folder** icon grid, **panel** finder, OS-style f
|
|
|
45
45
|
## Mock data and connected views
|
|
46
46
|
|
|
47
47
|
1. **Put entity rows in `lib/mock/<entity>.ts`** — Export a typed array (e.g. `TeamMember[]`, `Placement[]`) and reuse it from the page client and from KPI helpers.
|
|
48
|
-
2. **KPI / summary helpers** — Add `lib/mock/<entity>-kpi.ts` (or next to the mock file) with pure functions **`entityKpiMetrics(rows: T[])`** and **`entityKpiInsight(rows: T[])`** returning `MetricItem[]` and `MetricInsight` from `@/components/key-metrics`. Drive **both** the template **`metrics`** slot and the **dashboard view** from the **same helpers**, passing **`tableState.rows`** in the table component so filters/search apply everywhere.
|
|
48
|
+
2. **KPI / summary helpers** — Add `lib/mock/<entity>-kpi.ts` (or next to the mock file) with pure functions **`entityKpiMetrics(rows: T[])`** and **`entityKpiInsight(rows: T[])`** returning `MetricItem[]` and `MetricInsight` from `@/components/key-metrics`. Set **`MetricItem.trendPolarity`** when an increase is **not** favorable (see **`docs/kpi-trend-pattern.md`**). Drive **both** the template **`metrics`** slot and the **dashboard view** from the **same helpers**, passing **`tableState.rows`** in the table component so filters/search apply everywhere.
|
|
49
49
|
3. **Single table component** — One component (e.g. `TeamTable`) receives **`members`** (full mock) + **`view`**. It calls **`useTableState(members, columns, …)`** once. Branch on `view`:
|
|
50
50
|
- **`table`** → `DataTable` with that state.
|
|
51
51
|
- **`list` / `board`** → `DataTableToolbar` + list/board UI with **`tableState.rows`**.
|
|
@@ -72,6 +72,12 @@ Non-table view branches (e.g. **folder** icon grid, **panel** finder, OS-style f
|
|
|
72
72
|
|
|
73
73
|
**Import:** `@/components/table-properties` re-exports the drawer and types.
|
|
74
74
|
|
|
75
|
+
## Dropdown menus (view settings, row actions)
|
|
76
|
+
|
|
77
|
+
**`DropdownMenuContent`** in **`@exxatdesignux/ui`** merges a **default surface** (**`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`**: **`min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]`**) so **ListPageTemplate** view settings / Add view, **`DataTable`** filter and column menus, and hub **row ⋯** menus size to their labels (including shortcut hints) without fixed **`w-40` / `w-48`** rails. Sizing is **pure CSS** — do **not** add **`ResizeObserver`** or per-open measurement for standard menus.
|
|
78
|
+
|
|
79
|
+
**Override** when product intent needs a fixed width (e.g. pagination **page size** **`w-20`**, **`NavUser`** trigger-width + **`min-w-60`**, school switcher **`!w-max min-w-72 …`**).
|
|
80
|
+
|
|
75
81
|
## Board UI reuse
|
|
76
82
|
|
|
77
83
|
**Handbook:** **`AGENTS.md` §4.4** — board card shell, badge row, shared status maps, and MUST/MUST NOT. **Cursor:** **`.cursor/rules/exxat-board-cards.mdc`**, skill **`.cursor/skills/exxat-board-cards/SKILL.md`**.
|
|
@@ -138,9 +144,11 @@ Reference: `components/placements-page-header.tsx`, `components/team-page-header
|
|
|
138
144
|
|
|
139
145
|
**When to use a new page (route):** The flow is **primary**, **long-form**, **multi-step**, or should have its **own URL** / bookmark / history **without** the parent page behind it — e.g. full create/edit, wizards, or detail that *is* the task.
|
|
140
146
|
|
|
141
|
-
**Rule of thumb:** **Context + quick** → **drawer**; **otherwise** → **new page**.
|
|
147
|
+
**Rule of thumb:** **Context + quick** → **drawer**; **blocking short choice** → **dialog**; **otherwise** → **new page**.
|
|
148
|
+
|
|
149
|
+
**Modal vs side panel (same route):** When the overlay stays on the same URL, prefer **`docs/drawer-vs-dialog-pattern.md`** and **`.cursor/rules/exxat-drawer-vs-dialog.mdc`** — drawers keep the hub visible; dialogs trap focus for confirms.
|
|
142
150
|
|
|
143
|
-
Canonical rules: **`AGENTS.md` §6.4**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**.
|
|
151
|
+
Canonical rules: **`AGENTS.md` §6.4**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**.
|
|
144
152
|
|
|
145
153
|
---
|
|
146
154
|
|
|
@@ -160,7 +168,7 @@ When a route is a **primary** destination in nav (main hub for an entity) **and*
|
|
|
160
168
|
- [ ] **>10 items** → search, filter, sort, properties (per surface type above).
|
|
161
169
|
- [ ] **Has data to export** → **More** menu with **Export** + shared `ExportDrawer` pattern.
|
|
162
170
|
- [ ] **Primary + large / main hub** → `ListPageTemplate`-style shell where applicable.
|
|
163
|
-
- [ ] **Page vs drawer (§6.4)** — Quick
|
|
171
|
+
- [ ] **Page vs drawer vs dialog (§6.4)** — Quick with parent **context** → drawer/sheet; **blocking** confirm → **dialog**; primary or long flows → **new route** (`docs/drawer-vs-dialog-pattern.md`).
|
|
164
172
|
- [ ] **Primary button** → `Button` default variant (`size="lg"` for parity with Placements), not outline.
|
|
165
173
|
- [ ] **Dashboard view tab** → `KeyMetrics` + shared KPI helpers from **`tableState.rows`**; no duplicate one-off metric cards.
|
|
166
174
|
- [ ] **Data view charts** → `ChartFigure` + `chart-keyboard-selection`; layout persistence via **`data-view-dashboard-storage`** (see `AGENTS.md` §4.3).
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exxatdesignux/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Exxat shared design system (components, hooks, tokens). Monorepo setup: clone repo then pnpm bootstrap at workspace root — see github.com/ExxatDesign/Exxat-DS-Workspace README.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
|
+
},
|
|
6
9
|
"private": false,
|
|
7
10
|
"publishConfig": {
|
|
8
11
|
"access": "public",
|
|
@@ -63,7 +63,8 @@ type BannerVariant = keyof typeof VARIANT_CONFIG
|
|
|
63
63
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
65
|
const systemBannerVariants = cva(
|
|
66
|
-
|
|
66
|
+
// No `overflow-hidden` on root — it clips promo `boxShadow` (glow). Clip `decorativeOverlay` in a nested layer instead.
|
|
67
|
+
"relative isolate flex rounded-lg border text-sm transition-all",
|
|
67
68
|
{
|
|
68
69
|
variants: {
|
|
69
70
|
variant: {
|
|
@@ -186,24 +187,36 @@ export function SystemBanner({
|
|
|
186
187
|
}}
|
|
187
188
|
{...props}
|
|
188
189
|
>
|
|
189
|
-
{decorativeOverlay
|
|
190
|
+
{decorativeOverlay ? (
|
|
191
|
+
<div
|
|
192
|
+
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-lg"
|
|
193
|
+
aria-hidden
|
|
194
|
+
>
|
|
195
|
+
{decorativeOverlay}
|
|
196
|
+
</div>
|
|
197
|
+
) : null}
|
|
190
198
|
{/* Icon */}
|
|
191
199
|
<i
|
|
192
|
-
className={cn(
|
|
200
|
+
className={cn(
|
|
201
|
+
config.prefix,
|
|
202
|
+
icon ?? config.icon,
|
|
203
|
+
"relative z-[1] shrink-0 text-[14px]",
|
|
204
|
+
actionPosition === "bottom" ? "mt-0.5" : "",
|
|
205
|
+
)}
|
|
193
206
|
aria-hidden="true"
|
|
194
207
|
/>
|
|
195
208
|
|
|
196
209
|
{/* Content + inline action */}
|
|
197
210
|
{actionPosition === "inline" ? (
|
|
198
211
|
<>
|
|
199
|
-
<div className="min-w-0 flex-1">
|
|
212
|
+
<div className="relative z-[1] min-w-0 flex-1">
|
|
200
213
|
{title && <span className="font-semibold mr-1.5">{title}</span>}
|
|
201
214
|
<span className="opacity-90">{children}</span>
|
|
202
215
|
</div>
|
|
203
|
-
{actionEl}
|
|
216
|
+
{actionEl ? <span className="relative z-[1] inline-flex shrink-0">{actionEl}</span> : null}
|
|
204
217
|
</>
|
|
205
218
|
) : (
|
|
206
|
-
<div className="min-w-0 flex-1">
|
|
219
|
+
<div className="relative z-[1] min-w-0 flex-1">
|
|
207
220
|
{title && <p className="font-semibold leading-tight mb-0.5">{title}</p>}
|
|
208
221
|
<p className="opacity-90 leading-relaxed">{children}</p>
|
|
209
222
|
{actionEl && <div className="mt-1.5">{actionEl}</div>}
|
|
@@ -218,7 +231,7 @@ export function SystemBanner({
|
|
|
218
231
|
aria-label="Dismiss banner"
|
|
219
232
|
onClick={handleDismiss}
|
|
220
233
|
className={cn(
|
|
221
|
-
"inline-flex size-5 shrink-0 items-center justify-center rounded transition-colors",
|
|
234
|
+
"relative z-[2] inline-flex size-5 shrink-0 items-center justify-center rounded transition-colors",
|
|
222
235
|
actionPosition === "bottom" ? "absolute top-2.5 right-2.5" : "",
|
|
223
236
|
"hover:bg-current/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
224
237
|
)}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
5
|
import { cn } from "../../lib/utils"
|
|
6
|
-
import {
|
|
6
|
+
import { formatDateFromDate } from "../../lib/date-filter"
|
|
7
7
|
import { Button } from "./button"
|
|
8
8
|
import { Calendar } from "./calendar"
|
|
9
9
|
import {
|
|
@@ -59,11 +59,11 @@ export function DatePickerField({
|
|
|
59
59
|
"w-full justify-start text-left font-normal",
|
|
60
60
|
triggerClassName,
|
|
61
61
|
)}
|
|
62
|
-
aria-label={value ?
|
|
62
|
+
aria-label={value ? formatDateFromDate(value) : "Pick a date"}
|
|
63
63
|
>
|
|
64
64
|
<i className={cn(DATE_PICKER_ICON_CLASS, "mr-2 shrink-0 text-muted-foreground")} aria-hidden="true" />
|
|
65
65
|
<span className={cn(!value && "text-muted-foreground")}>
|
|
66
|
-
{value ?
|
|
66
|
+
{value ? formatDateFromDate(value) : "MM/DD/YYYY"}
|
|
67
67
|
</span>
|
|
68
68
|
</Button>
|
|
69
69
|
</PopoverTrigger>
|
|
@@ -4,6 +4,7 @@ import * as React from "react"
|
|
|
4
4
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../lib/utils"
|
|
7
|
+
import { DROPDOWN_MENU_CONTENT_SURFACE_CLASS } from "../../lib/dropdown-menu-surface"
|
|
7
8
|
|
|
8
9
|
function DropdownMenu({
|
|
9
10
|
...props
|
|
@@ -43,7 +44,11 @@ function DropdownMenuContent({
|
|
|
43
44
|
data-slot="dropdown-menu-content"
|
|
44
45
|
sideOffset={sideOffset}
|
|
45
46
|
align={align}
|
|
46
|
-
className={cn(
|
|
47
|
+
className={cn(
|
|
48
|
+
"z-50 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
49
|
+
DROPDOWN_MENU_CONTENT_SURFACE_CLASS,
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
47
52
|
{...props}
|
|
48
53
|
/>
|
|
49
54
|
</DropdownMenuPrimitive.Portal>
|
|
@@ -81,7 +86,7 @@ function DropdownMenuItem({
|
|
|
81
86
|
data-variant={variant}
|
|
82
87
|
asChild={asChild}
|
|
83
88
|
className={cn(
|
|
84
|
-
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
|
89
|
+
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
|
85
90
|
className
|
|
86
91
|
)}
|
|
87
92
|
{...props}
|
|
@@ -247,7 +252,7 @@ function DropdownMenuCheckboxItem({
|
|
|
247
252
|
data-slot="dropdown-menu-checkbox-item"
|
|
248
253
|
data-inset={inset}
|
|
249
254
|
className={cn(
|
|
250
|
-
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
255
|
+
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
251
256
|
className
|
|
252
257
|
)}
|
|
253
258
|
checked={checked}
|
|
@@ -290,7 +295,7 @@ function DropdownMenuRadioItem({
|
|
|
290
295
|
data-slot="dropdown-menu-radio-item"
|
|
291
296
|
data-inset={inset}
|
|
292
297
|
className={cn(
|
|
293
|
-
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
298
|
+
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
294
299
|
className
|
|
295
300
|
)}
|
|
296
301
|
{...props}
|
|
@@ -377,7 +382,7 @@ function DropdownMenuSubTrigger({
|
|
|
377
382
|
suppressHydrationWarning
|
|
378
383
|
data-inset={inset}
|
|
379
384
|
className={cn(
|
|
380
|
-
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
385
|
+
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
|
|
381
386
|
className
|
|
382
387
|
)}
|
|
383
388
|
{...props}
|
|
@@ -395,7 +400,11 @@ function DropdownMenuSubContent({
|
|
|
395
400
|
return (
|
|
396
401
|
<DropdownMenuPrimitive.SubContent
|
|
397
402
|
data-slot="dropdown-menu-sub-content"
|
|
398
|
-
className={cn(
|
|
403
|
+
className={cn(
|
|
404
|
+
"z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
405
|
+
DROPDOWN_MENU_CONTENT_SURFACE_CLASS,
|
|
406
|
+
className,
|
|
407
|
+
)}
|
|
399
408
|
{...props}
|
|
400
409
|
/>
|
|
401
410
|
)
|
|
@@ -420,3 +429,5 @@ export {
|
|
|
420
429
|
Shortcut,
|
|
421
430
|
useShortcut,
|
|
422
431
|
}
|
|
432
|
+
|
|
433
|
+
export { DROPDOWN_MENU_CONTENT_SURFACE_CLASS } from "../../lib/dropdown-menu-surface"
|
|
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
14
14
|
data-slot="input-group"
|
|
15
15
|
role="group"
|
|
16
16
|
className={cn(
|
|
17
|
-
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-
|
|
17
|
+
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-md border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5",
|
|
18
18
|
className
|
|
19
19
|
)}
|
|
20
20
|
{...props}
|