@exxatdesignux/ui 0.2.8 → 0.2.10
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 +17 -4
- 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/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 +0 -1
|
@@ -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}
|
|
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|
|
10
10
|
type={type}
|
|
11
11
|
data-slot="input"
|
|
12
12
|
className={cn(
|
|
13
|
-
"h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:
|
|
13
|
+
"h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
14
14
|
className
|
|
15
15
|
)}
|
|
16
16
|
{...props}
|
|
@@ -111,7 +111,7 @@ function SelectItem({
|
|
|
111
111
|
<SelectPrimitive.Item
|
|
112
112
|
data-slot="select-item"
|
|
113
113
|
className={cn(
|
|
114
|
-
"relative flex w-full 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 not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
114
|
+
"relative flex w-full 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 not-data-[variant=destructive]:focus:**:text-accent-foreground 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([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
115
115
|
className
|
|
116
116
|
)}
|
|
117
117
|
{...props}
|
|
@@ -17,8 +17,8 @@ function Separator({
|
|
|
17
17
|
decorative={decorative}
|
|
18
18
|
orientation={orientation}
|
|
19
19
|
className={cn(
|
|
20
|
-
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
21
|
-
className
|
|
20
|
+
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
|
21
|
+
className,
|
|
22
22
|
)}
|
|
23
23
|
{...props}
|
|
24
24
|
/>
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
|
20
20
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
21
|
+
/** Matches `useIsMobile` / Tailwind `md:` — do not apply desktop cookie to mobile overlay. */
|
|
22
|
+
const SIDEBAR_COOKIE_VIEWPORT_MQ = "(max-width: 767px)"
|
|
21
23
|
const SIDEBAR_WIDTH = "16rem"
|
|
22
24
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
|
23
25
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
|
@@ -43,6 +45,13 @@ function useSidebar() {
|
|
|
43
45
|
return context
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function readSidebarStateCookie(): boolean | undefined {
|
|
49
|
+
if (typeof document === "undefined") return undefined
|
|
50
|
+
const m = document.cookie.match(new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=(true|false)(?:;|$)`))
|
|
51
|
+
if (!m) return undefined
|
|
52
|
+
return m[1] === "true"
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function SidebarProvider({
|
|
47
56
|
defaultOpen = true,
|
|
48
57
|
open: openProp,
|
|
@@ -63,6 +72,18 @@ function SidebarProvider({
|
|
|
63
72
|
// We use openProp and setOpenProp for control from outside the component.
|
|
64
73
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
|
65
74
|
const open = openProp ?? _open
|
|
75
|
+
|
|
76
|
+
// `setOpen` already persists `sidebar_state` to a cookie on desktop; restore it on mount so
|
|
77
|
+
// full reloads and new tabs keep the rail expanded/collapsed. Skip when controlled or on mobile.
|
|
78
|
+
React.useLayoutEffect(() => {
|
|
79
|
+
if (openProp !== undefined) return
|
|
80
|
+
if (typeof window === "undefined") return
|
|
81
|
+
if (window.matchMedia(SIDEBAR_COOKIE_VIEWPORT_MQ).matches) return
|
|
82
|
+
const fromCookie = readSidebarStateCookie()
|
|
83
|
+
if (fromCookie === undefined) return
|
|
84
|
+
_setOpen(fromCookie)
|
|
85
|
+
}, [openProp])
|
|
86
|
+
|
|
66
87
|
const setOpen = React.useCallback(
|
|
67
88
|
(value: boolean | ((value: boolean) => boolean)) => {
|
|
68
89
|
const openState = typeof value === "function" ? value(open) : value
|
|
@@ -530,8 +551,15 @@ const SidebarMenuButton = React.forwardRef<
|
|
|
530
551
|
data-slot="sidebar-menu-button"
|
|
531
552
|
data-sidebar="menu-button"
|
|
532
553
|
data-size={size}
|
|
533
|
-
data-active={isActive}
|
|
534
|
-
className={cn(
|
|
554
|
+
data-active={isActive || undefined}
|
|
555
|
+
className={cn(
|
|
556
|
+
sidebarMenuButtonVariants({ variant, size }),
|
|
557
|
+
className,
|
|
558
|
+
// `asChild` merges the child (e.g. Next `<Link className="w-full">`) onto this node —
|
|
559
|
+
// plain `w-full` can win over `group-data-[collapsible=icon]:w-8` in the stylesheet and
|
|
560
|
+
// squash the icon rail to a non-square hit target (WCAG 2.5.8).
|
|
561
|
+
"group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:shrink-0",
|
|
562
|
+
)}
|
|
535
563
|
{...props}
|
|
536
564
|
/>
|
|
537
565
|
)
|
|
@@ -683,7 +711,7 @@ function SidebarMenuSubButton({
|
|
|
683
711
|
data-slot="sidebar-menu-sub-button"
|
|
684
712
|
data-sidebar="menu-sub-button"
|
|
685
713
|
data-size={size}
|
|
686
|
-
data-active={isActive}
|
|
714
|
+
data-active={isActive || undefined}
|
|
687
715
|
className={cn(
|
|
688
716
|
"flex h-7 min-w-0 cursor-pointer select-none -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-background data-active:text-foreground data-active:shadow-sm data-active:ring-1 data-active:ring-sidebar-border data-active:hc:border data-active:hc:border-foreground data-active:forced-colors:border data-active:forced-colors:border-[Highlight] [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
|
689
717
|
className
|
|
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
|
7
7
|
<textarea
|
|
8
8
|
data-slot="textarea"
|
|
9
9
|
className={cn(
|
|
10
|
-
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:
|
|
10
|
+
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
11
11
|
className
|
|
12
12
|
)}
|
|
13
13
|
{...props}
|
package/src/globals.css
CHANGED
package/src/index.ts
CHANGED
package/src/lib/date-filter.ts
CHANGED
|
@@ -15,6 +15,15 @@ export function formatDateUS(raw: string | null | undefined): string {
|
|
|
15
15
|
return `${m}/${day}/${y}`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Format a `Date` with local calendar fields as MM/DD/YYYY (avoids UTC drift from `toISOString()`). */
|
|
19
|
+
export function formatDateFromDate(raw: Date | null | undefined): string {
|
|
20
|
+
if (!raw || Number.isNaN(raw.getTime())) return "—"
|
|
21
|
+
const m = String(raw.getMonth() + 1).padStart(2, "0")
|
|
22
|
+
const day = String(raw.getDate()).padStart(2, "0")
|
|
23
|
+
const y = raw.getFullYear()
|
|
24
|
+
return `${m}/${day}/${y}`
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
/**
|
|
19
28
|
* Format a Date (or ISO string) into "MM/DD/YYYY hh:mm AM/PM EST".
|
|
20
29
|
* Time zone label is always appended as the literal string "EST" (display only).
|
|
@@ -45,11 +54,11 @@ export function parseRowDateToYmd(raw: string): string | null {
|
|
|
45
54
|
return `${y}-${m}-${day}`
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
/** Format YYYY-MM-DD for
|
|
57
|
+
/** Format YYYY-MM-DD for filter chip labels (MM/DD/YYYY). */
|
|
49
58
|
export function formatYmdForDisplay(ymd: string): string {
|
|
50
|
-
const d =
|
|
51
|
-
if (
|
|
52
|
-
return d
|
|
59
|
+
const d = ymdToLocalDate(ymd)
|
|
60
|
+
if (!d) return ymd
|
|
61
|
+
return formatDateFromDate(d)
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Local noon to avoid timezone shifting the calendar day. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default surface sizing for product dropdown menus (view settings, row ⋯, column menus, etc.).
|
|
3
|
+
*
|
|
4
|
+
* Uses **pure CSS** (`w-max` + `min-w-*` + `max-w-*`) so width follows labels and shortcuts
|
|
5
|
+
* without **ResizeObserver** or layout thrash.
|
|
6
|
+
*
|
|
7
|
+
* Override when you need a fixed rail, for example:
|
|
8
|
+
* - `className="w-20"` — page-size picker in `DataTablePaginated`
|
|
9
|
+
* - `className="w-(--radix-dropdown-menu-trigger-width) min-w-60"` — account / identity menus
|
|
10
|
+
* - `className="!w-max min-w-72 …"` — very wide school/program switcher
|
|
11
|
+
*/
|
|
12
|
+
export const DROPDOWN_MENU_CONTENT_SURFACE_CLASS =
|
|
13
|
+
"min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]" as const
|
|
@@ -21,6 +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-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`**.
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
@@ -87,7 +88,7 @@ To add a primary nav item, append to `NAV_PRIMARY`:
|
|
|
87
88
|
| Concern | Pattern |
|
|
88
89
|
|--------|---------|
|
|
89
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. |
|
|
90
|
-
| **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. |
|
|
91
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**. |
|
|
92
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. |
|
|
93
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. |
|
|
@@ -178,6 +179,17 @@ Align with **`exxat-ds/AGENTS.md` §6.4**, **`docs/data-views-pattern.md`**, **`
|
|
|
178
179
|
- `select` column: `defaultPin: "left"`, `lockPin: true`
|
|
179
180
|
- `actions` column: `defaultPin: "right"`, `lockPin: true`
|
|
180
181
|
|
|
182
|
+
### 5.1 Data table and view-toolbar menus
|
|
183
|
+
|
|
184
|
+
**`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).
|
|
185
|
+
|
|
186
|
+
- **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).
|
|
187
|
+
- **Reuse** **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** if you build a custom menu primitive that does not wrap **`DropdownMenuContent`**.
|
|
188
|
+
|
|
189
|
+
### 5.2 KPI trends (`KeyMetrics`, `*-kpi.ts`)
|
|
190
|
+
|
|
191
|
+
**`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`**.
|
|
192
|
+
|
|
181
193
|
**DataTable must wrap in `<div className="pb-6">`.**
|
|
182
194
|
|
|
183
195
|
---
|
|
@@ -206,7 +218,7 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
|
|
|
206
218
|
</Button>
|
|
207
219
|
</DropdownMenuTrigger>
|
|
208
220
|
</Tip>
|
|
209
|
-
<DropdownMenuContent align="end"
|
|
221
|
+
<DropdownMenuContent align="end">
|
|
210
222
|
<DropdownMenuItem onClick={onExport}>
|
|
211
223
|
<i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
|
|
212
224
|
Export
|
|
@@ -229,6 +241,12 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
|
|
|
229
241
|
- Subtitle: `"{count} items · Last updated now"` format
|
|
230
242
|
- Title uses Ivy Presto (`font-heading` variable) — applied automatically by `PageHeader`
|
|
231
243
|
|
|
244
|
+
### 6.1 Collaboration & access (shared hubs)
|
|
245
|
+
|
|
246
|
+
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`**.
|
|
247
|
+
|
|
248
|
+
**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.
|
|
249
|
+
|
|
232
250
|
---
|
|
233
251
|
|
|
234
252
|
## 7. Navigation: Breadcrumbs vs Back Link
|
|
@@ -449,9 +467,9 @@ Full checklist in `references/accessibility.md`. Summary of the most-violated ru
|
|
|
449
467
|
|
|
450
468
|
### Icons that communicate information — always have a text alternative
|
|
451
469
|
|
|
452
|
-
This rule covers **every icon that carries meaning**, not only icon-only buttons. FA glyphs, inline SVGs, trend arrows, status dots, chart-legend squares, calendar/clock/pin icons in cells — if the icon tells the user something
|
|
470
|
+
This rule covers **every icon that carries meaning**, not only icon-only buttons. FA glyphs, inline SVGs, avatar placeholders, trend arrows, status dots, chart-legend squares, calendar/clock/pin icons in cells — if the icon **tells the user something**, that something MUST be reachable by screen readers AND discoverable to sighted users who don't recognise the glyph. SC 1.1.1, 3.3.2, 2.4.6.
|
|
453
471
|
|
|
454
|
-
**Case A — Decorative icon next to text that already names it** → icon is `aria-hidden`, no tooltip.
|
|
472
|
+
**Case A — Decorative icon next to text that already names it** → icon is `aria-hidden`, no `aria-label`, no tooltip. The text is the alt.
|
|
455
473
|
|
|
456
474
|
```tsx
|
|
457
475
|
<span className="flex items-center gap-1.5">
|
|
@@ -460,7 +478,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
|
|
|
460
478
|
</span>
|
|
461
479
|
```
|
|
462
480
|
|
|
463
|
-
**Case B — Informational icon standing alone** (calendar = "date range", clock = "updated at", pin = "site", trend arrow, status dot, icon-only chart legend) → MUST pair `role="img"` + `aria-label` with a visible `Tooltip`. Wrapper MUST be keyboard-focusable (`tabIndex={0}`).
|
|
481
|
+
**Case B — Informational icon standing alone** (calendar = "date range", clock = "updated at", pin = "site", cap = "student", trend arrow, status dot, icon-only chart legend) → MUST pair `role="img"` + `aria-label` with a visible `Tooltip`. Wrapper MUST be keyboard-focusable (`tabIndex={0}`).
|
|
464
482
|
|
|
465
483
|
```tsx
|
|
466
484
|
<Tooltip>
|
|
@@ -474,7 +492,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
|
|
|
474
492
|
</Tooltip>
|
|
475
493
|
```
|
|
476
494
|
|
|
477
|
-
**Case C — Interactive icon-only button / link** → MUST pair `aria-label` on the `<button>` with a wrapping `Tooltip`. Inner `<i>` / `<svg>` is `aria-hidden`. Target ≥ 24×24.
|
|
495
|
+
**Case C — Interactive icon-only button / link** (close `×`, chevron, overflow `⋯`, sort, filter-dismiss, copy, Ask Leo toggle, row actions) → MUST pair `aria-label` on the `<button>` with a wrapping `Tooltip`. Inner `<i>` / `<svg>` is `aria-hidden`. Target ≥ 24×24.
|
|
478
496
|
|
|
479
497
|
```tsx
|
|
480
498
|
<Tooltip>
|
|
@@ -490,7 +508,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
|
|
|
490
508
|
</Tooltip>
|
|
491
509
|
```
|
|
492
510
|
|
|
493
|
-
**Decision tree:** adjacent text label? → A. Else interactive? → C. Else → B. When in doubt: add the accessible name + tooltip.
|
|
511
|
+
**Decision tree:** adjacent text label? → A. Else interactive? → C. Else → B. When in doubt: add the accessible name + tooltip. Narrow exception for all cases: a chevron inside a labelled composite (`Select`, `Combobox`) where the parent already carries the name.
|
|
494
512
|
|
|
495
513
|
### Touch targets
|
|
496
514
|
- Minimum **24×24 CSS px** for all interactive controls
|
|
@@ -696,8 +714,8 @@ Copy and complete for every list/table/hub page:
|
|
|
696
714
|
- [ ] All dates: `MM/DD/YYYY` / `MM/DD/YYYY hh:mm AM/PM EST`
|
|
697
715
|
- [ ] All tooltips via `<Tip>` — no `title` attribute
|
|
698
716
|
- [ ] All icons: `aria-hidden="true"`; Ask Leo: `fa-duotone fa-solid fa-star-christmas text-brand`
|
|
699
|
-
- [ ] **Every icon that communicates info has a text alternative** — Case A adjacent label, Case B `role="img"` + `aria-label` + `Tooltip
|
|
700
|
-
- [ ] **`Kbd` inside a `Button` uses `variant="bare"
|
|
717
|
+
- [ ] **Every icon that communicates info has a text alternative** — Case A adjacent label (preferred), Case B `role="img"` + `aria-label` + `Tooltip` (calendar-for-date, status dot, trend arrow, icon-only legend), Case C `aria-label` + `Tooltip` on icon-only buttons; target ≥ 24×24 px. See §12 *Icons that communicate information*.
|
|
718
|
+
- [ ] **`Kbd` inside a `Button` uses `variant="bare"`** (glue chords into one bare kbd); **`Kbd` inside `TooltipContent` uses the default tile** — see §11 Keyboard shortcuts
|
|
701
719
|
- [ ] `DialogTitle`/`SheetTitle`/`DrawerTitle` present on every overlay
|
|
702
720
|
- [ ] `role="tablist"` contains only tab-role children
|
|
703
721
|
- [ ] No new shadcn components, no hardcoded colors, no duplicate component abstractions
|
|
@@ -14,6 +14,7 @@ For **any app screen that shows a browsable, filterable grid of records** (lists
|
|
|
14
14
|
3. **Filters:** Use the shared filter model (filter chips / `FilterFieldDef` and operators) consistent with existing list pages — not one-off filter UIs that bypass the table stack.
|
|
15
15
|
4. **Table properties:** Include **Table properties** via `TablePropertiesDrawer` from `@/components/table-properties/drawer` (or the same toolbar + drawer pattern used on reference pages such as placements / data list). Users must be able to adjust columns, density, and related table settings from one place.
|
|
16
16
|
5. **Active view:** On **`ListPageTemplate`** pages with **table / list / board / dashboard** tabs, **`TablePropertiesDrawer`** **MUST** receive **`currentView`** and **`onViewChange`** (see **`./AGENTS.md` §4.2** and **`.cursor/rules/exxat-table-properties-drawer.mdc`**) so Properties matches the selected view (not table-only copy on Board).
|
|
17
|
+
6. **Dropdown menus:** **`DropdownMenuContent`** uses the shared **`@exxatdesignux/ui`** default (**intrinsic `w-max`**, **`min-w-52`**, capped **`max-w`**) for view settings, row ⋯, column menus, and filter pickers — **pure CSS**, no **`ResizeObserver`**. Override only for deliberate narrow/wide rails (e.g. pagination **`w-20`**, account trigger-width, school switcher **`!w-max min-w-72 …`**). See **`docs/data-views-pattern.md`** (“Dropdown menus”).
|
|
17
18
|
|
|
18
19
|
**Reference implementation:** `components/data-list-table.tsx` (placements) shows how `DataTable`, filters, and `TablePropertiesDrawer` compose together.
|
|
19
20
|
|