@exxatdesignux/ui 0.2.9 → 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 +1 -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/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
|
@@ -10,7 +10,7 @@ import { SystemBannerSlot } from "@/components/system-banner-slot"
|
|
|
10
10
|
import { CommandMenu } from "@/components/command-menu"
|
|
11
11
|
import { CommandMenuProvider } from "@/contexts/command-menu-context"
|
|
12
12
|
import { buildCommandMenuConfig } from "@/lib/command-menu-config"
|
|
13
|
-
import {
|
|
13
|
+
import { COMMAND_MENU_SEARCH_DATA_GROUPS } from "@/lib/command-menu-search-data"
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Shared app layout:
|
|
@@ -21,14 +21,17 @@ import { getCommandMenuSearchDataGroups } from "@/lib/command-menu-search-data"
|
|
|
21
21
|
* via SystemBannerProvider) — no hardcoded copy here.
|
|
22
22
|
*/
|
|
23
23
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|
24
|
+
const commandMenuConfig = React.useMemo(
|
|
25
|
+
() => buildCommandMenuConfig({ dataGroups: COMMAND_MENU_SEARCH_DATA_GROUPS }),
|
|
26
|
+
[],
|
|
27
|
+
)
|
|
28
|
+
|
|
24
29
|
return (
|
|
25
30
|
<DashboardViewProvider>
|
|
26
31
|
<ChartVariantProvider>
|
|
27
32
|
<AskLeoProvider>
|
|
28
33
|
<SystemBannerProvider>
|
|
29
|
-
<CommandMenuProvider
|
|
30
|
-
value={buildCommandMenuConfig({ dataGroups: getCommandMenuSearchDataGroups() })}
|
|
31
|
-
>
|
|
34
|
+
<CommandMenuProvider value={commandMenuConfig}>
|
|
32
35
|
|
|
33
36
|
<SidebarShell wrapperClassName="flex min-h-svh flex-col">
|
|
34
37
|
{/* ⌘K command palette */}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Suspense } from "react"
|
|
2
|
+
|
|
3
|
+
import { QuestionBankClient } from "@/components/question-bank-client"
|
|
4
|
+
|
|
5
|
+
/** Discovery hub composer results — same hub chrome as the library, distinct from `/question-bank/list`. */
|
|
6
|
+
export default function QuestionBankHubFindPage() {
|
|
7
|
+
return (
|
|
8
|
+
<Suspense fallback={null}>
|
|
9
|
+
<QuestionBankClient />
|
|
10
|
+
</Suspense>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { usePathname } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
import { useSecondaryPanel } from "@/components/secondary-panel"
|
|
7
|
+
import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the nested secondary panel open across library / list / find navigations.
|
|
11
|
+
* The discovery hub (`/question-bank`) is full-width — no secondary bar there.
|
|
12
|
+
*/
|
|
13
|
+
export default function QuestionBankLayout({ children }: { children: React.ReactNode }) {
|
|
14
|
+
const pathname = usePathname()
|
|
15
|
+
const { openPanel, closePanel, activePanel } = useSecondaryPanel()
|
|
16
|
+
const closePanelRef = React.useRef(closePanel)
|
|
17
|
+
const openPanelRef = React.useRef(openPanel)
|
|
18
|
+
closePanelRef.current = closePanel
|
|
19
|
+
openPanelRef.current = openPanel
|
|
20
|
+
|
|
21
|
+
/** Leaving `/question-bank/*` entirely — close nested panel without forcing primary sidebar open (⌘B / cookie). */
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
return () => {
|
|
24
|
+
closePanelRef.current({ mainSidebar: "leave" })
|
|
25
|
+
}
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
/** Only react to route changes — refs carry latest open/close. */
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
const isDiscoveryHubRoot =
|
|
31
|
+
pathname === QUESTION_BANK_ENTRY_PATH || pathname === `${QUESTION_BANK_ENTRY_PATH}/`
|
|
32
|
+
|
|
33
|
+
if (isDiscoveryHubRoot) {
|
|
34
|
+
closePanelRef.current({ mainSidebar: "leave" })
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (activePanel !== "question-bank") {
|
|
39
|
+
openPanelRef.current("question-bank")
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathname + activePanel drive when to ensure panel id
|
|
43
|
+
}, [pathname, activePanel])
|
|
44
|
+
|
|
45
|
+
return children
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Suspense } from "react"
|
|
2
|
+
|
|
3
|
+
import { QuestionBankClient } from "@/components/question-bank-client"
|
|
4
|
+
|
|
5
|
+
/** Question bank list surface — same hub as `/question-bank/library`, optimized for `?q=` search landings. */
|
|
6
|
+
export default function QuestionBankListPage() {
|
|
7
|
+
return (
|
|
8
|
+
<Suspense fallback={null}>
|
|
9
|
+
<QuestionBankClient />
|
|
10
|
+
</Suspense>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Suspense } from "react"
|
|
2
|
-
import { QuestionBankClient } from "@/components/question-bank-client"
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
import { QuestionBankHubClient } from "@/components/question-bank-hub-client"
|
|
4
|
+
|
|
5
|
+
export default function QuestionBankHubPage() {
|
|
5
6
|
return (
|
|
6
7
|
<Suspense fallback={null}>
|
|
7
|
-
<
|
|
8
|
+
<QuestionBankHubClient />
|
|
8
9
|
</Suspense>
|
|
9
10
|
)
|
|
10
11
|
}
|
package/template/app/globals.css
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
@import "tailwindcss";
|
|
12
12
|
@import "tw-animate-css";
|
|
13
|
-
@import "shadcn/tailwind.css";
|
|
14
13
|
|
|
15
|
-
/* Ensure Tailwind scans the shared UI package for utility classes */
|
|
14
|
+
/* Ensure Tailwind scans the shared UI package for utility classes (repo-relative — stable with pnpm + Turbopack). */
|
|
16
15
|
@source "../node_modules/@exxatdesignux/ui/src";
|
|
17
16
|
|
|
18
17
|
/* RTL layout direction support */
|
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
type NavSchool,
|
|
86
86
|
type NavProgram,
|
|
87
87
|
} from "@/lib/mock/navigation"
|
|
88
|
+
import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
|
|
88
89
|
|
|
89
90
|
/** Path segment of a nav URL (strip `#fragment` for matching). */
|
|
90
91
|
function navUrlPath(url: string): string {
|
|
@@ -93,11 +94,42 @@ function navUrlPath(url: string): string {
|
|
|
93
94
|
return i === -1 ? url : url.slice(0, i)
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
/** Hash segment from a nav `href` (no `#`). `null` when the URL has no `#`. */
|
|
98
|
+
function navUrlFragment(url: string): string | null {
|
|
99
|
+
if (!url.includes("#")) return null
|
|
100
|
+
return url.slice(url.indexOf("#") + 1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizedLocationHash(locationHash: string): string {
|
|
104
|
+
if (!locationHash) return ""
|
|
105
|
+
return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
|
|
110
|
+
* When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
|
|
111
|
+
* and require an empty hash for the “default” row (`/settings` with no `#`).
|
|
112
|
+
*/
|
|
113
|
+
function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
|
|
97
114
|
const pathOnly = navUrlPath(url)
|
|
115
|
+
const frag = navUrlFragment(url)
|
|
116
|
+
const h = normalizedLocationHash(locationHash)
|
|
117
|
+
|
|
98
118
|
if (!pathOnly || pathOnly === "#") return false
|
|
99
|
-
|
|
100
|
-
if (
|
|
119
|
+
|
|
120
|
+
if (frag !== null) {
|
|
121
|
+
if (pathOnly === "/") return pathname === "/" && h === frag
|
|
122
|
+
if (pathOnly === "/library") {
|
|
123
|
+
return pathname.startsWith("/library/") && h === frag
|
|
124
|
+
}
|
|
125
|
+
if (pathOnly.startsWith("/library/")) {
|
|
126
|
+
return pathname === pathOnly && h === frag
|
|
127
|
+
}
|
|
128
|
+
return pathname === pathOnly && h === frag
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (pathOnly === "/") return pathname === "/" && h === ""
|
|
132
|
+
if (pathname === pathOnly) return h === ""
|
|
101
133
|
// Design system library — active on hub and detail routes.
|
|
102
134
|
if (pathOnly === "/library") {
|
|
103
135
|
return pathname.startsWith("/library/")
|
|
@@ -116,11 +148,11 @@ function isCollapsibleChildActive(
|
|
|
116
148
|
locationHash: string
|
|
117
149
|
): boolean {
|
|
118
150
|
const children = parent.children
|
|
119
|
-
if (!children?.length) return isNavActive(pathname, child.url)
|
|
151
|
+
if (!children?.length) return isNavActive(pathname, child.url, locationHash)
|
|
120
152
|
|
|
121
153
|
const hasHashChild = children.some(c => c.url.includes("#"))
|
|
122
154
|
if (hasHashChild) {
|
|
123
|
-
const h =
|
|
155
|
+
const h = normalizedLocationHash(locationHash)
|
|
124
156
|
const childHash = child.url.includes("#") ? child.url.split("#")[1] : ""
|
|
125
157
|
if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
|
|
126
158
|
return h === ""
|
|
@@ -131,7 +163,7 @@ function isCollapsibleChildActive(
|
|
|
131
163
|
return false
|
|
132
164
|
}
|
|
133
165
|
|
|
134
|
-
if (!isNavActive(pathname, child.url)) return false
|
|
166
|
+
if (!isNavActive(pathname, child.url, locationHash)) return false
|
|
135
167
|
|
|
136
168
|
const urls = children.map(c => c.url)
|
|
137
169
|
const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
|
|
@@ -204,12 +236,12 @@ function SidebarNavChildLink({
|
|
|
204
236
|
*/
|
|
205
237
|
function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
|
|
206
238
|
const locationHash = useLocationHash()
|
|
207
|
-
const isActive = isNavActive(pathname, item.url)
|
|
239
|
+
const isActive = isNavActive(pathname, item.url, locationHash)
|
|
208
240
|
const isAnyChildActive =
|
|
209
241
|
item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
|
|
210
242
|
const { state, isMobile } = useSidebar()
|
|
211
243
|
const { openPanel } = useSecondaryPanel()
|
|
212
|
-
const [open, setOpen]
|
|
244
|
+
const [open, setOpen] = React.useState(false)
|
|
213
245
|
const [flyoutOpen, setFlyoutOpen] = React.useState(false)
|
|
214
246
|
const flyoutTitleId = React.useId()
|
|
215
247
|
const iconRailCollapsed = state === "collapsed" && !isMobile
|
|
@@ -368,7 +400,8 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
|
|
|
368
400
|
}
|
|
369
401
|
|
|
370
402
|
function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: string }) {
|
|
371
|
-
const { openPanel } = useSecondaryPanel()
|
|
403
|
+
const { openPanel, closePanel } = useSecondaryPanel()
|
|
404
|
+
const locationHash = useLocationHash()
|
|
372
405
|
return (
|
|
373
406
|
<>
|
|
374
407
|
{items.map(item => {
|
|
@@ -379,7 +412,7 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
|
|
|
379
412
|
return <CollapsibleNavItem key={item.key} item={item} pathname={pathname} />
|
|
380
413
|
}
|
|
381
414
|
|
|
382
|
-
const isActive = isNavActive(pathname, item.url)
|
|
415
|
+
const isActive = isNavActive(pathname, item.url, locationHash)
|
|
383
416
|
const itemPath = navUrlPath(item.url)
|
|
384
417
|
return (
|
|
385
418
|
<SidebarMenuItem key={item.key}>
|
|
@@ -400,7 +433,11 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
|
|
|
400
433
|
!item.url.includes("#")
|
|
401
434
|
) {
|
|
402
435
|
e.preventDefault()
|
|
403
|
-
|
|
436
|
+
if (itemPath === QUESTION_BANK_ENTRY_PATH) {
|
|
437
|
+
closePanel({ mainSidebar: "leave" })
|
|
438
|
+
} else {
|
|
439
|
+
openPanel(item.secondaryPanel)
|
|
440
|
+
}
|
|
404
441
|
}
|
|
405
442
|
}}
|
|
406
443
|
>
|
|
@@ -465,6 +502,7 @@ function SidebarNavSecondaryItems({
|
|
|
465
502
|
pathname: string
|
|
466
503
|
}) {
|
|
467
504
|
const mod = useModKeyLabel()
|
|
505
|
+
const locationHash = useLocationHash()
|
|
468
506
|
return (
|
|
469
507
|
<>
|
|
470
508
|
{items.map((item) => {
|
|
@@ -473,7 +511,7 @@ function SidebarNavSecondaryItems({
|
|
|
473
511
|
!item.opensCommandMenu &&
|
|
474
512
|
Boolean(pathOnly) &&
|
|
475
513
|
pathOnly !== "#" &&
|
|
476
|
-
isNavActive(pathname, item.url)
|
|
514
|
+
isNavActive(pathname, item.url, locationHash)
|
|
477
515
|
|
|
478
516
|
return (
|
|
479
517
|
<SidebarMenuItem key={item.key}>
|
|
@@ -767,7 +805,7 @@ function ProductLogoButton() {
|
|
|
767
805
|
</TooltipContent>
|
|
768
806
|
</Tooltip>
|
|
769
807
|
|
|
770
|
-
<DropdownMenuContent
|
|
808
|
+
<DropdownMenuContent align="start" side="right" sideOffset={8}>
|
|
771
809
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
772
810
|
Switch product
|
|
773
811
|
</DropdownMenuLabel>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as React from "react"
|
|
9
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
|
|
9
10
|
|
|
10
11
|
import { Button } from "@/components/ui/button"
|
|
11
12
|
import {
|
|
@@ -29,20 +30,80 @@ export interface AskLeoComposerProps {
|
|
|
29
30
|
/** Called with trimmed message after send (composer clears afterward). */
|
|
30
31
|
onSubmit?: (message: string) => void
|
|
31
32
|
placeholder?: string
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* When non-empty and the field is empty (single-line / collapsed), cycles these as an overlaid hint
|
|
35
|
+
* with a soft crossfade. Native `placeholder` is suppressed while the overlay shows.
|
|
36
|
+
*/
|
|
37
|
+
animatedPlaceholders?: string[]
|
|
38
|
+
/** Milliseconds between animated placeholder phrases. Default 4200. */
|
|
39
|
+
animatedPlaceholderIntervalMs?: number
|
|
40
|
+
/**
|
|
41
|
+
* When `2`, animated hints can wrap to two lines instead of a single truncated line (e.g. example hub queries).
|
|
42
|
+
* Default `1` matches the original pill composer behavior.
|
|
43
|
+
*/
|
|
44
|
+
animatedPlaceholderMaxLines?: 1 | 2
|
|
45
|
+
/**
|
|
46
|
+
* `attachments` — plus menu + file picker (default). `ai-mark` — Leo-style icon only (e.g. question bank hub).
|
|
47
|
+
*/
|
|
48
|
+
leadingSlot?: "attachments" | "ai-mark"
|
|
49
|
+
/** Accessible name for the textarea (paired with `htmlFor`). */
|
|
50
|
+
inputLabel?: string
|
|
51
|
+
/** `aria-label` on the submit control when the field has text. */
|
|
52
|
+
submitButtonAriaLabel?: string
|
|
53
|
+
/**
|
|
54
|
+
* `send` — paper plane (chat / Ask Leo). `search` — magnifying glass (question bank hub + dedicated search).
|
|
55
|
+
*/
|
|
56
|
+
submitAppearance?: "send" | "search"
|
|
33
57
|
/** Lets the parent swap pill vs card chrome when the field grows (multiline / long text). */
|
|
34
58
|
onExpandedChange?: (expanded: boolean) => void
|
|
59
|
+
className?: string
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoComposerProps>(
|
|
38
63
|
function AskLeoComposer(
|
|
39
|
-
{
|
|
64
|
+
{
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
onSubmit,
|
|
68
|
+
placeholder = "Ask Leo anything…",
|
|
69
|
+
className,
|
|
70
|
+
onExpandedChange,
|
|
71
|
+
animatedPlaceholders,
|
|
72
|
+
animatedPlaceholderIntervalMs = 4200,
|
|
73
|
+
animatedPlaceholderMaxLines = 1,
|
|
74
|
+
leadingSlot = "attachments",
|
|
75
|
+
inputLabel = "Message to Leo",
|
|
76
|
+
submitButtonAriaLabel = "Send message",
|
|
77
|
+
submitAppearance = "send",
|
|
78
|
+
},
|
|
40
79
|
forwardedRef,
|
|
41
80
|
) {
|
|
42
81
|
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
82
|
+
const reduceMotion = useReducedMotion()
|
|
43
83
|
const fieldId = React.useId()
|
|
84
|
+
const phrases = React.useMemo(
|
|
85
|
+
() => (animatedPlaceholders ?? []).map(s => s.trim()).filter(Boolean),
|
|
86
|
+
[animatedPlaceholders],
|
|
87
|
+
)
|
|
88
|
+
const [phraseIndex, setPhraseIndex] = React.useState(0)
|
|
89
|
+
const showAnimatedPlaceholder = phrases.length > 0 && !value.trim() && !isExpanded
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (!showAnimatedPlaceholder) return
|
|
93
|
+
const id = window.setInterval(() => {
|
|
94
|
+
setPhraseIndex(i => (i + 1) % phrases.length)
|
|
95
|
+
}, animatedPlaceholderIntervalMs)
|
|
96
|
+
return () => window.clearInterval(id)
|
|
97
|
+
}, [showAnimatedPlaceholder, phrases.length, animatedPlaceholderIntervalMs])
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (!showAnimatedPlaceholder) setPhraseIndex(0)
|
|
101
|
+
}, [showAnimatedPlaceholder])
|
|
44
102
|
|
|
103
|
+
const reportedExpandedRef = React.useRef<boolean | undefined>(undefined)
|
|
45
104
|
React.useEffect(() => {
|
|
105
|
+
if (reportedExpandedRef.current === isExpanded) return
|
|
106
|
+
reportedExpandedRef.current = isExpanded
|
|
46
107
|
onExpandedChange?.(isExpanded)
|
|
47
108
|
}, [isExpanded, onExpandedChange])
|
|
48
109
|
const innerRef = React.useRef<HTMLTextAreaElement>(null)
|
|
@@ -92,13 +153,15 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
|
|
|
92
153
|
<div className={cn("min-w-0 w-full", className)}>
|
|
93
154
|
<form onSubmit={handleSubmit} className="group/composer min-w-0 w-full" noValidate>
|
|
94
155
|
<label htmlFor={fieldId} className="sr-only">
|
|
95
|
-
|
|
156
|
+
{inputLabel}
|
|
96
157
|
</label>
|
|
97
|
-
|
|
158
|
+
{leadingSlot === "attachments" ? (
|
|
159
|
+
<input ref={fileInputRef} type="file" multiple className="sr-only" onChange={() => {}} />
|
|
160
|
+
) : null}
|
|
98
161
|
|
|
99
162
|
<div
|
|
100
163
|
className={cn(
|
|
101
|
-
"min-w-0 w-full cursor-text overflow-hidden border border-border
|
|
164
|
+
"min-w-0 w-full cursor-text overflow-hidden border border-[color:var(--control-border)] bg-card transition-[border-radius,padding] duration-200 ease-out",
|
|
102
165
|
isExpanded
|
|
103
166
|
? "rounded-2xl px-2 py-2 shadow-none grid [grid-template-columns:minmax(0,1fr)] [grid-template-rows:auto_1fr_auto] [grid-template-areas:'header'_'primary'_'footer']"
|
|
104
167
|
: "rounded-full px-1 py-0.5 shadow-none grid [grid-template-columns:auto_minmax(0,1fr)_auto] [grid-template-rows:minmax(0,auto)] [grid-template-areas:'header_header_header'_'leading_primary_trailing'_'._footer_.']",
|
|
@@ -111,65 +174,119 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
|
|
|
111
174
|
})}
|
|
112
175
|
style={{ gridArea: "primary" }}
|
|
113
176
|
>
|
|
114
|
-
<div className="max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
|
|
177
|
+
<div className="relative max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
|
|
115
178
|
<Textarea
|
|
116
179
|
id={fieldId}
|
|
117
180
|
ref={setTextareaRef}
|
|
118
181
|
value={value}
|
|
119
182
|
onChange={handleTextareaChange}
|
|
120
183
|
onKeyDown={handleKeyDown}
|
|
121
|
-
placeholder={placeholder}
|
|
184
|
+
placeholder={showAnimatedPlaceholder ? " " : placeholder}
|
|
122
185
|
autoComplete="off"
|
|
123
186
|
className={cn(
|
|
124
|
-
"min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-foreground
|
|
187
|
+
"min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 md:text-sm dark:bg-transparent",
|
|
125
188
|
!isExpanded && "min-h-[1.25rem] py-0",
|
|
189
|
+
showAnimatedPlaceholder && "placeholder:text-transparent",
|
|
126
190
|
)}
|
|
127
191
|
rows={1}
|
|
128
192
|
/>
|
|
193
|
+
{showAnimatedPlaceholder ? (
|
|
194
|
+
<div
|
|
195
|
+
className={cn(
|
|
196
|
+
"pointer-events-none absolute inset-x-0 top-0 flex overflow-hidden",
|
|
197
|
+
animatedPlaceholderMaxLines === 2
|
|
198
|
+
? "min-h-[2.5rem] items-start"
|
|
199
|
+
: "min-h-[1.25rem] items-center",
|
|
200
|
+
)}
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
>
|
|
203
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
204
|
+
<motion.span
|
|
205
|
+
key={phraseIndex}
|
|
206
|
+
initial={{ opacity: 0, y: reduceMotion ? 0 : 3 }}
|
|
207
|
+
animate={{ opacity: 1, y: 0 }}
|
|
208
|
+
exit={{ opacity: 0, y: reduceMotion ? 0 : -3 }}
|
|
209
|
+
transition={{ duration: reduceMotion ? 0 : 0.32, ease: [0.22, 1, 0.36, 1] }}
|
|
210
|
+
className={cn(
|
|
211
|
+
"block w-full text-start text-sm leading-5 text-muted-foreground",
|
|
212
|
+
animatedPlaceholderMaxLines === 2
|
|
213
|
+
? "line-clamp-2 whitespace-normal break-words"
|
|
214
|
+
: "truncate",
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{phrases[phraseIndex]}
|
|
218
|
+
</motion.span>
|
|
219
|
+
</AnimatePresence>
|
|
220
|
+
</div>
|
|
221
|
+
) : null}
|
|
129
222
|
</div>
|
|
130
223
|
</div>
|
|
131
224
|
|
|
132
225
|
<div className={cn("flex shrink-0 items-center", { hidden: isExpanded })} style={{ gridArea: "leading" }}>
|
|
133
|
-
|
|
226
|
+
{leadingSlot === "ai-mark" ? (
|
|
134
227
|
<Tooltip>
|
|
135
228
|
<TooltipTrigger asChild>
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</
|
|
229
|
+
<span
|
|
230
|
+
tabIndex={0}
|
|
231
|
+
role="img"
|
|
232
|
+
aria-label="AI search"
|
|
233
|
+
className="flex size-8 shrink-0 items-center justify-center rounded-full text-brand outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
|
|
234
|
+
>
|
|
235
|
+
<i
|
|
236
|
+
className="fa-light fa-star-christmas text-base text-[color:var(--brand-color-dark)] dark:text-[color:var(--brand-color-light)]"
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
/>
|
|
239
|
+
</span>
|
|
147
240
|
</TooltipTrigger>
|
|
148
|
-
<TooltipContent side="top" sideOffset={6} className="
|
|
149
|
-
|
|
241
|
+
<TooltipContent side="top" sideOffset={6} className="text-xs">
|
|
242
|
+
AI search
|
|
150
243
|
</TooltipContent>
|
|
151
244
|
</Tooltip>
|
|
245
|
+
) : (
|
|
246
|
+
<DropdownMenu>
|
|
247
|
+
<Tooltip>
|
|
248
|
+
<TooltipTrigger asChild>
|
|
249
|
+
<DropdownMenuTrigger asChild>
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
variant="ghost"
|
|
253
|
+
size="icon"
|
|
254
|
+
className="size-8 shrink-0 rounded-full hover:bg-accent"
|
|
255
|
+
aria-label="Add attachments"
|
|
256
|
+
>
|
|
257
|
+
<i className="fa-light fa-plus text-base text-muted-foreground" aria-hidden="true" />
|
|
258
|
+
</Button>
|
|
259
|
+
</DropdownMenuTrigger>
|
|
260
|
+
</TooltipTrigger>
|
|
261
|
+
<TooltipContent side="top" sideOffset={6} className="max-w-xs text-xs">
|
|
262
|
+
Add photos, files, and more
|
|
263
|
+
</TooltipContent>
|
|
264
|
+
</Tooltip>
|
|
152
265
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
266
|
+
<DropdownMenuContent align="start" className="max-w-xs rounded-2xl p-1.5">
|
|
267
|
+
<DropdownMenuGroup className="space-y-1">
|
|
268
|
+
<DropdownMenuItem
|
|
269
|
+
className="flex items-center gap-2 rounded-md"
|
|
270
|
+
onClick={() => fileInputRef.current?.click()}
|
|
271
|
+
>
|
|
272
|
+
<i className="fa-light fa-paperclip w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
|
|
273
|
+
Add photos & files
|
|
274
|
+
</DropdownMenuItem>
|
|
275
|
+
<DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
|
|
276
|
+
<i className="fa-light fa-robot w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
|
|
277
|
+
Agent mode
|
|
278
|
+
</DropdownMenuItem>
|
|
279
|
+
<DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
|
|
280
|
+
<i
|
|
281
|
+
className="fa-light fa-magnifying-glass w-4 shrink-0 text-center opacity-60"
|
|
282
|
+
aria-hidden="true"
|
|
283
|
+
/>
|
|
284
|
+
Deep Research
|
|
285
|
+
</DropdownMenuItem>
|
|
286
|
+
</DropdownMenuGroup>
|
|
287
|
+
</DropdownMenuContent>
|
|
288
|
+
</DropdownMenu>
|
|
289
|
+
)}
|
|
173
290
|
</div>
|
|
174
291
|
|
|
175
292
|
<div
|
|
@@ -197,12 +314,23 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
|
|
|
197
314
|
{value.trim() ? (
|
|
198
315
|
<Tooltip>
|
|
199
316
|
<TooltipTrigger asChild>
|
|
200
|
-
<Button
|
|
201
|
-
|
|
317
|
+
<Button
|
|
318
|
+
type="submit"
|
|
319
|
+
size="icon"
|
|
320
|
+
className="size-8 shrink-0 rounded-full"
|
|
321
|
+
aria-label={submitButtonAriaLabel}
|
|
322
|
+
>
|
|
323
|
+
<i
|
|
324
|
+
className={cn(
|
|
325
|
+
"text-base",
|
|
326
|
+
submitAppearance === "search" ? "fa-light fa-magnifying-glass" : "fa-light fa-paper-plane-top",
|
|
327
|
+
)}
|
|
328
|
+
aria-hidden="true"
|
|
329
|
+
/>
|
|
202
330
|
</Button>
|
|
203
331
|
</TooltipTrigger>
|
|
204
332
|
<TooltipContent side="top" sideOffset={6} className="text-xs">
|
|
205
|
-
|
|
333
|
+
{submitButtonAriaLabel}
|
|
206
334
|
</TooltipContent>
|
|
207
335
|
</Tooltip>
|
|
208
336
|
) : null}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as React from "react"
|
|
10
|
+
import dynamic from "next/dynamic"
|
|
10
11
|
import { usePathname } from "next/navigation"
|
|
11
12
|
import { AnimatePresence, motion } from "motion/react"
|
|
12
13
|
import { cn } from "@/lib/utils"
|
|
@@ -24,7 +25,14 @@ import { useSidebar } from "@/components/ui/sidebar"
|
|
|
24
25
|
import { StatusBadge } from "@/components/ui/status-badge"
|
|
25
26
|
import { AiThinkingOverlay } from "@/components/ui/ai-thinking-surface"
|
|
26
27
|
import { LeoTypingDots } from "@/components/leo-typing-dots"
|
|
27
|
-
|
|
28
|
+
|
|
29
|
+
const LeoIcon = dynamic(
|
|
30
|
+
() => import("@/components/ui/leo-icon").then(m => m.LeoIcon),
|
|
31
|
+
{
|
|
32
|
+
ssr: false,
|
|
33
|
+
loading: () => <div className="size-20" aria-hidden="true" />,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
28
36
|
import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
29
37
|
import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context"
|
|
30
38
|
import { isEditableTarget } from "@/lib/editable-target"
|
|
@@ -4,6 +4,7 @@ import * as React from "react"
|
|
|
4
4
|
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
|
5
5
|
|
|
6
6
|
import { useIsMobile } from "@/hooks/use-mobile"
|
|
7
|
+
import { formatDateUS } from "@/lib/date-filter"
|
|
7
8
|
import {
|
|
8
9
|
Card,
|
|
9
10
|
CardAction,
|
|
@@ -247,24 +248,13 @@ export function ChartAreaInteractive() {
|
|
|
247
248
|
axisLine={false}
|
|
248
249
|
tickMargin={8}
|
|
249
250
|
minTickGap={32}
|
|
250
|
-
tickFormatter={(value) =>
|
|
251
|
-
const date = new Date(value)
|
|
252
|
-
return date.toLocaleDateString("en-US", {
|
|
253
|
-
month: "short",
|
|
254
|
-
day: "numeric",
|
|
255
|
-
})
|
|
256
|
-
}}
|
|
251
|
+
tickFormatter={(value) => formatDateUS(String(value))}
|
|
257
252
|
/>
|
|
258
253
|
<ChartTooltip
|
|
259
254
|
cursor={false}
|
|
260
255
|
content={
|
|
261
256
|
<ChartTooltipContent
|
|
262
|
-
labelFormatter={(value) =>
|
|
263
|
-
return new Date(value).toLocaleDateString("en-US", {
|
|
264
|
-
month: "short",
|
|
265
|
-
day: "numeric",
|
|
266
|
-
})
|
|
267
|
-
}}
|
|
257
|
+
labelFormatter={(value) => formatDateUS(String(value))}
|
|
268
258
|
indicator="dot"
|
|
269
259
|
/>
|
|
270
260
|
}
|