@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.
Files changed (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +1 -1
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -0,0 +1,19 @@
1
+ import { patchQuestionBankUrlSearchParams } from "@/lib/question-bank-nav"
2
+
3
+ /** Rotating example queries for dedicated search composers on list/find surfaces. */
4
+ export const QUESTION_BANK_DEDICATED_SEARCH_PLACEHOLDERS = [
5
+ "all the questions I used in last year's assessment",
6
+ "what did we run for the spring OSCE checkout?",
7
+ "Clinical folder, cardiology tagged, for third-year summative",
8
+ "find QB-2024-0012 and anything like it",
9
+ "that anemia vignette from the shelf-style block",
10
+ "MCQs I still need before next week's diabetes station",
11
+ ] as const
12
+
13
+ export function patchQuestionBankDedicatedSearchParams(
14
+ current: URLSearchParams,
15
+ submittedText: string,
16
+ ): URLSearchParams {
17
+ const t = submittedText.trim()
18
+ return patchQuestionBankUrlSearchParams(current, { q: t || null })
19
+ }
@@ -0,0 +1,90 @@
1
+ import type { DiscoveryHubSearchGroup } from "@/lib/discovery-hub"
2
+ import {
3
+ QUESTION_BANK_ENTRY_PATH,
4
+ QUESTION_BANK_LIBRARY_PATH,
5
+ } from "@/lib/question-bank-nav"
6
+
7
+ export type { DiscoveryHubSearchGroup, DiscoveryHubSearchItem } from "@/lib/discovery-hub"
8
+
9
+ export function buildQuestionBankHubSearchGroups(): DiscoveryHubSearchGroup[] {
10
+ return [
11
+ {
12
+ id: "ai-create",
13
+ heading: "Create with AI",
14
+ items: [
15
+ {
16
+ id: "ai-mcq",
17
+ label: "Draft a multiple-choice question on clinical reasoning",
18
+ keywords: "ai leo create mcq multiple choice clinical reasoning",
19
+ askLeoPrompt:
20
+ "Help me draft a multiple-choice assessment question focused on clinical reasoning. Ask clarifying questions about topic, difficulty, and learning objective before proposing stems and distractors.",
21
+ },
22
+ {
23
+ id: "ai-osce",
24
+ label: "Generate an OSCE checklist from a case vignette",
25
+ keywords: "ai leo osce checklist vignette station",
26
+ askLeoPrompt:
27
+ "I want to generate an OSCE checklist from a case vignette. Walk me through the case details you need, then propose observable behaviors and scoring notes.",
28
+ },
29
+ {
30
+ id: "ai-remediation",
31
+ label: "Turn missed items into remediation questions",
32
+ keywords: "ai leo remediation review missed items",
33
+ askLeoPrompt:
34
+ "Turn a list of missed assessment items into remediation questions. Ask what content area and learner level to target, then suggest short-answer or MCQ follow-ups.",
35
+ },
36
+ {
37
+ id: "ai-bank-outline",
38
+ label: "Outline a new question bank for a course module",
39
+ keywords: "ai leo outline bank module taxonomy folders",
40
+ askLeoPrompt:
41
+ "Help me outline a new question bank for a course module. Suggest folder structure, item types, and a balanced mix of difficulty before we add items.",
42
+ },
43
+ ],
44
+ },
45
+ {
46
+ id: "actions",
47
+ heading: "Quick actions",
48
+ items: [
49
+ {
50
+ id: "browse-library",
51
+ label: "Browse question library",
52
+ keywords: "library table folder panel tree views",
53
+ icon: "fa-light fa-table-list",
54
+ href: QUESTION_BANK_LIBRARY_PATH,
55
+ },
56
+ {
57
+ id: "browse-my",
58
+ label: "Open my questions",
59
+ keywords: "my questions author scope",
60
+ icon: "fa-light fa-user",
61
+ href: `${QUESTION_BANK_LIBRARY_PATH}?scope=my`,
62
+ },
63
+ {
64
+ id: "new-question",
65
+ label: "Create new question",
66
+ keywords: "new question create draft author",
67
+ icon: "fa-light fa-plus",
68
+ askLeoPrompt:
69
+ "Help me create a new assessment question. Start by asking for topic, item type, difficulty, and learning objective, then draft the stem, answer choices, and rationale.",
70
+ },
71
+ {
72
+ id: "hub-entry",
73
+ label: "Question bank home",
74
+ keywords: "home hub search",
75
+ icon: "fa-light fa-house",
76
+ href: QUESTION_BANK_ENTRY_PATH,
77
+ },
78
+ ],
79
+ },
80
+ ]
81
+ }
82
+
83
+ export const QUESTION_BANK_HUB_SEARCH_PLACEHOLDER =
84
+ "Search questions, folders, or describe what you want to create…"
85
+
86
+ export const QUESTION_BANK_HUB_ASK_LEO_PROMPTS = [
87
+ "Draft five MCQs on pharmacology with rationales",
88
+ "Suggest folder names for a new pediatrics bank",
89
+ "Rewrite this stem for clarity and bias-free wording",
90
+ ] as const
@@ -17,14 +17,85 @@ export interface QuestionBankNavState {
17
17
  folderId: string | null
18
18
  }
19
19
 
20
+ export const QUESTION_BANK_ENTRY_PATH = "/question-bank"
21
+
22
+ /** Breadcrumb segment for the discovery hub — links from library / search shells back to `/question-bank`. */
23
+ export const QUESTION_BANK_HUB_BREADCRUMB = {
24
+ label: "Question hub",
25
+ href: QUESTION_BANK_ENTRY_PATH,
26
+ } as const
27
+
28
+ /** List hub with secondary nav, views, and table state. */
29
+ export const QUESTION_BANK_LIBRARY_PATH = "/question-bank/library"
30
+
31
+ /**
32
+ * Same hub as the library (table + panel + tree) but intended for search landings (`?q=`).
33
+ * Keeps secondary panel + nav behavior aligned with {@link QUESTION_BANK_LIBRARY_PATH}.
34
+ */
35
+ export const QUESTION_BANK_LIST_PATH = "/question-bank/list"
36
+
37
+ /**
38
+ * Results from the discovery hub composer — same table stack as {@link QUESTION_BANK_LIST_PATH} but a
39
+ * distinct URL so library “Search” and hub-driven search are not conflated.
40
+ */
41
+ export const QUESTION_BANK_HUB_FIND_PATH = "/question-bank/find"
42
+
43
+ /** @deprecated Use `QUESTION_BANK_LIBRARY_PATH` for scoped library routes. */
44
+ export const QUESTION_BANK_HUB_PATH = QUESTION_BANK_LIBRARY_PATH
45
+
46
+ export const QUESTION_BANK_LIBRARY_HUB_PATHS: readonly string[] = [
47
+ QUESTION_BANK_LIBRARY_PATH,
48
+ QUESTION_BANK_LIST_PATH,
49
+ QUESTION_BANK_HUB_FIND_PATH,
50
+ ]
51
+
52
+ /** Library list search (`/list`) or hub discovery search (`/find`) — both use the dedicated search shell. */
53
+ export function isQuestionBankDedicatedSearchPathname(pathname: string): boolean {
54
+ return pathname === QUESTION_BANK_LIST_PATH || pathname === QUESTION_BANK_HUB_FIND_PATH
55
+ }
56
+
57
+ /** Default secondary-nav selection — All questions (no `scope` query). */
58
+ export const QUESTION_BANK_DEFAULT_NAV: QuestionBankNavState = {
59
+ scope: "all",
60
+ folderId: null,
61
+ }
62
+
63
+ export function isQuestionBankDefaultNav(nav: QuestionBankNavState): boolean {
64
+ return nav.scope === "all" && nav.folderId === null
65
+ }
66
+
20
67
  export function parseQuestionBankNav(searchParams: URLSearchParams): QuestionBankNavState {
21
68
  const raw = (searchParams.get("scope") ?? "all").toLowerCase()
22
69
  if (raw === "my") return { scope: "my", folderId: null }
23
70
  if (raw === "folder") {
71
+ const rawId = searchParams.get("folderId") ?? searchParams.get("folder")
72
+ const folderId = typeof rawId === "string" ? rawId.trim() || null : null
73
+ return { scope: "folder", folderId }
74
+ }
75
+ if (raw === "all") return { ...QUESTION_BANK_DEFAULT_NAV }
76
+ return { ...QUESTION_BANK_DEFAULT_NAV }
77
+ }
78
+
79
+ /** Rewrite invalid or incomplete scope URLs back to the default All questions hub. Preserves list search params when present. */
80
+ export function questionBankCanonicalNavHref(searchParams: URLSearchParams): string | null {
81
+ const raw = searchParams.get("scope")
82
+ if (!raw) return null
83
+ const lowered = raw.toLowerCase()
84
+ const q = searchParams.get("q")?.trim() || null
85
+ const fav = searchParams.get("fav") === "1"
86
+ const deckClinical = searchParams.get("deck") === "clinical"
87
+ const preserved = {
88
+ q,
89
+ ...(fav ? { fav: true as const } : {}),
90
+ ...(deckClinical ? { deck: "clinical" as const } : {}),
91
+ }
92
+ if (lowered === "my") return null
93
+ if (lowered === "folder") {
24
94
  const folderId = searchParams.get("folderId") ?? searchParams.get("folder")
25
- return { scope: "folder", folderId: folderId || null }
95
+ return folderId ? null : questionBankNavHref({ scope: "all", ...preserved })
26
96
  }
27
- return { scope: "all", folderId: null }
97
+ if (lowered === "all") return null
98
+ return questionBankNavHref({ scope: "all", ...preserved })
28
99
  }
29
100
 
30
101
  /** Breadcrumb + title for `SiteHeader` / `PageHeader` (matches secondary nav scopes). */
@@ -39,18 +110,21 @@ export function questionBankHubHeaderModel(
39
110
  ): QuestionBankHubHeaderModel {
40
111
  if (nav.scope === "my") {
41
112
  return {
42
- breadcrumbs: [{ label: "Question bank", href: "/question-bank" }],
113
+ breadcrumbs: [{ label: QUESTION_BANK_HUB_BREADCRUMB.label, href: QUESTION_BANK_HUB_BREADCRUMB.href }],
43
114
  title: "My questions",
44
115
  }
45
116
  }
46
117
  if (nav.scope === "folder" && nav.folderId) {
47
118
  const name = folders.find(f => f.id === nav.folderId)?.name ?? "Folder"
48
119
  return {
49
- breadcrumbs: [{ label: "Question bank", href: "/question-bank" }],
120
+ breadcrumbs: [{ label: QUESTION_BANK_HUB_BREADCRUMB.label, href: QUESTION_BANK_HUB_BREADCRUMB.href }],
50
121
  title: name,
51
122
  }
52
123
  }
53
- return { title: "All questions" }
124
+ return {
125
+ breadcrumbs: [{ label: QUESTION_BANK_HUB_BREADCRUMB.label, href: QUESTION_BANK_HUB_BREADCRUMB.href }],
126
+ title: "All questions",
127
+ }
54
128
  }
55
129
 
56
130
  export function filterQuestionBankItemsByNav(
@@ -71,19 +145,261 @@ export function filterQuestionBankItemsByNav(
71
145
  return items
72
146
  }
73
147
 
148
+ /** Mock folder id for the Favorites bucket (see `DEFAULT_QUESTION_BANK_FOLDERS`). */
149
+ export const QUESTION_BANK_FAVORITES_FOLDER_ID = "fld-favorites"
150
+
151
+ /** Root folder id for the mock “Clinical” tree (`fld-skills-lab` is a child). */
152
+ export const QUESTION_BANK_CLINICAL_ROOT_FOLDER_ID = "fld-clinical"
153
+
154
+ /**
155
+ * Client-side “AI / hub” free-text filter — same scan as `useTableState` toolbar search
156
+ * (`Object.values` → string → lowercase `includes`).
157
+ */
158
+ export function filterQuestionBankItemsByFreeText(items: QuestionBankItem[], q: string): QuestionBankItem[] {
159
+ const t = q.trim()
160
+ if (!t) return items
161
+ const needle = t.toLowerCase()
162
+ return items.filter(row =>
163
+ Object.values(row).some(v => String(v ?? "").toLowerCase().includes(needle)),
164
+ )
165
+ }
166
+
167
+ export function isQuestionBankItemFavorite(item: QuestionBankItem): boolean {
168
+ return item.folderId === QUESTION_BANK_FAVORITES_FOLDER_ID || item.isStarred === true
169
+ }
170
+
171
+ /** When a mock row lives in the Favorites folder, toggling off moves it here (no “original folder” yet). */
172
+ const QUESTION_BANK_UNFAVORITE_FALLBACK_FOLDER_ID = "fld-science"
173
+
174
+ /** Demo toggle for `isStarred` / Favorites folder membership (offline mock only). */
175
+ export function toggleQuestionBankItemFavorite(item: QuestionBankItem): QuestionBankItem {
176
+ if (isQuestionBankItemFavorite(item)) {
177
+ if (item.folderId === QUESTION_BANK_FAVORITES_FOLDER_ID) {
178
+ return {
179
+ ...item,
180
+ folderId: QUESTION_BANK_UNFAVORITE_FALLBACK_FOLDER_ID,
181
+ isStarred: false,
182
+ }
183
+ }
184
+ return { ...item, isStarred: false }
185
+ }
186
+ return { ...item, isStarred: true }
187
+ }
188
+
189
+ export function filterQuestionBankItemsByFavoritesOnly(items: QuestionBankItem[]): QuestionBankItem[] {
190
+ return items.filter(isQuestionBankItemFavorite)
191
+ }
192
+
193
+ /**
194
+ * Mock “Clinical deck” (`deck=clinical`): items under the Clinical folder tree **or**
195
+ * demo topics “Clinical skills” / “Neurology” (so list landings can show a coherent slice).
196
+ */
197
+ export function filterQuestionBankItemsByClinicalDeckMock(
198
+ items: QuestionBankItem[],
199
+ folders: QuestionBankFolder[],
200
+ ): QuestionBankItem[] {
201
+ const inClinicalTree = collectFolderDescendantIds(folders, QUESTION_BANK_CLINICAL_ROOT_FOLDER_ID)
202
+ const topicMatch = new Set(["Clinical skills", "Neurology"])
203
+ return items.filter(
204
+ i => inClinicalTree.has(i.folderId) || topicMatch.has(i.topic),
205
+ )
206
+ }
207
+
208
+ export type QuestionBankLandingFilterState = {
209
+ hubFreeText: string
210
+ favOnly: boolean
211
+ clinicalDeck: boolean
212
+ }
213
+
214
+ /**
215
+ * Nav scope + optional dedicated search routes (`/question-bank/list`, `/question-bank/find`) landing filters (`q`, `fav`, `deck`).
216
+ * When `hubFreeText` is non-empty but matches no rows, hub text is ignored so the scoped list still shows.
217
+ */
218
+ export function applyQuestionBankHubDisplayFilters(
219
+ items: QuestionBankItem[],
220
+ folders: QuestionBankFolder[],
221
+ nav: QuestionBankNavState,
222
+ landing: QuestionBankLandingFilterState | null,
223
+ ): QuestionBankItem[] {
224
+ let rows = filterQuestionBankItemsByNav(items, folders, nav)
225
+ if (!landing) return rows
226
+ let afterText = filterQuestionBankItemsByFreeText(rows, landing.hubFreeText)
227
+ if (landing.hubFreeText.trim() && afterText.length === 0) {
228
+ afterText = rows
229
+ }
230
+ rows = afterText
231
+ if (landing.favOnly) rows = filterQuestionBankItemsByFavoritesOnly(rows)
232
+ if (landing.clinicalDeck) rows = filterQuestionBankItemsByClinicalDeckMock(rows, folders)
233
+ return rows
234
+ }
235
+
236
+ /** True when hub `q` is non-empty but matches no rows under the current nav (before fav/deck). */
237
+ export function questionBankHubTextMatchesNothing(
238
+ items: QuestionBankItem[],
239
+ folders: QuestionBankFolder[],
240
+ nav: QuestionBankNavState,
241
+ landing: QuestionBankLandingFilterState | null,
242
+ ): boolean {
243
+ if (!landing?.hubFreeText.trim()) return false
244
+ const navRows = filterQuestionBankItemsByNav(items, folders, nav)
245
+ return filterQuestionBankItemsByFreeText(navRows, landing.hubFreeText).length === 0
246
+ }
247
+
248
+ export function patchQuestionBankUrlSearchParams(
249
+ sp: URLSearchParams,
250
+ patch: {
251
+ q?: string | null
252
+ fav?: boolean | null
253
+ deckClinical?: boolean | null
254
+ },
255
+ ): URLSearchParams {
256
+ const next = new URLSearchParams(sp.toString())
257
+ if (patch.q !== undefined) {
258
+ const t = patch.q?.trim() ?? ""
259
+ if (t) next.set("q", t)
260
+ else next.delete("q")
261
+ }
262
+ if (patch.fav !== undefined) {
263
+ if (patch.fav) next.set("fav", "1")
264
+ else next.delete("fav")
265
+ }
266
+ if (patch.deckClinical !== undefined) {
267
+ if (patch.deckClinical) next.set("deck", "clinical")
268
+ else next.delete("deck")
269
+ }
270
+ return next
271
+ }
272
+
273
+ /** Build {@link QUESTION_BANK_LIST_PATH} query consistently (nav + hub search + mock toggles). */
274
+ export function questionBankListSearchHref(
275
+ nav: QuestionBankNavState,
276
+ opts: { q?: string | null; fav?: boolean; deckClinical?: boolean },
277
+ ): string {
278
+ return questionBankNavHref({
279
+ scope: nav.scope,
280
+ folderId: nav.folderId,
281
+ searchLanding: true,
282
+ q: opts.q,
283
+ fav: opts.fav,
284
+ deck: opts.deckClinical ? "clinical" : undefined,
285
+ })
286
+ }
287
+
288
+ /** Favorites bucket — same folder scope on library, list, or hub-find; preserves `q` / mock toggles on dedicated search routes. */
289
+ export function questionBankFavoritesFolderHref(pathname: string, currentSearch: URLSearchParams): string {
290
+ const onList = pathname === QUESTION_BANK_LIST_PATH
291
+ const onHubFind = pathname === QUESTION_BANK_HUB_FIND_PATH
292
+ const q = currentSearch.get("q")
293
+ const fav = currentSearch.get("fav") === "1"
294
+ const deckClinical = currentSearch.get("deck") === "clinical"
295
+ return questionBankNavHref({
296
+ scope: "folder",
297
+ folderId: QUESTION_BANK_FAVORITES_FOLDER_ID,
298
+ ...(onList ? { searchLanding: true } : onHubFind ? { hubFind: true } : {}),
299
+ q,
300
+ ...(fav ? { fav: true as const } : {}),
301
+ ...(deckClinical ? { deck: "clinical" as const } : {}),
302
+ })
303
+ }
304
+
305
+ /** “Search” secondary nav — {@link QUESTION_BANK_LIST_PATH} without `?q=` (always the search landing). Preserves `fav` / `deck` when set. */
306
+ export function questionBankSearchLandingNavHref(
307
+ nav: QuestionBankNavState,
308
+ currentSearch: URLSearchParams,
309
+ ): string {
310
+ const listNav: QuestionBankNavState =
311
+ nav.scope === "folder" ? QUESTION_BANK_DEFAULT_NAV : nav
312
+ return questionBankListSearchHref(listNav, {
313
+ fav: currentSearch.get("fav") === "1" ? true : undefined,
314
+ deckClinical: currentSearch.get("deck") === "clinical" ? true : undefined,
315
+ })
316
+ }
317
+
318
+ /** True when the dedicated search shell should show the “Search” nav row as current (not browsing a folder there). */
319
+ export function isQuestionBankSearchNavActive(pathname: string, nav: QuestionBankNavState): boolean {
320
+ if (pathname !== QUESTION_BANK_LIST_PATH && pathname !== QUESTION_BANK_HUB_FIND_PATH) return false
321
+ if (nav.scope === "folder" && nav.folderId) return false
322
+ return true
323
+ }
324
+
325
+ /**
326
+ * Hub scope link that preserves list-only params (`q`, `fav`, `deck`) when the user is already
327
+ * on {@link QUESTION_BANK_LIST_PATH}; folder / “My” targets use the library path per product rule.
328
+ */
329
+ export function questionBankHubScopeHref(
330
+ pathname: string,
331
+ currentSearch: URLSearchParams,
332
+ patch: { scope: QuestionBankNavScope; folderId?: string | null },
333
+ ): string {
334
+ const onList = pathname === QUESTION_BANK_LIST_PATH
335
+ const onHubFind = pathname === QUESTION_BANK_HUB_FIND_PATH
336
+ const q = currentSearch.get("q")
337
+ const fav = currentSearch.get("fav") === "1"
338
+ const deckClinical = currentSearch.get("deck") === "clinical"
339
+ const landingBits = {
340
+ q,
341
+ ...(fav ? { fav: true as const } : {}),
342
+ ...(deckClinical ? { deck: "clinical" as const } : {}),
343
+ }
344
+ if (patch.scope === "my" || (patch.scope === "folder" && patch.folderId)) {
345
+ return questionBankNavHref({
346
+ scope: patch.scope,
347
+ folderId: patch.folderId,
348
+ searchLanding: false,
349
+ })
350
+ }
351
+ if (patch.scope === "all") {
352
+ return questionBankNavHref({
353
+ scope: "all",
354
+ ...(onList ? { searchLanding: true } : onHubFind ? { hubFind: true } : {}),
355
+ ...landingBits,
356
+ })
357
+ }
358
+ return questionBankNavHref({ scope: "all", searchLanding: false })
359
+ }
360
+
74
361
  /** Build `/question-bank` href with optional query + hash (hash without leading `#`). */
75
362
  export function questionBankNavHref(opts: {
76
363
  scope: QuestionBankNavScope
77
364
  folderId?: string | null
78
365
  hash?: string
366
+ /**
367
+ * Hub / panel search string (`?q=`). On {@link QUESTION_BANK_LIST_PATH} and {@link QUESTION_BANK_HUB_FIND_PATH}
368
+ * this filters rows via {@link applyQuestionBankHubDisplayFilters} — **not** the DataTable toolbar
369
+ * (toolbar stays independent on those routes).
370
+ */
371
+ q?: string | null
372
+ /** Mock list filter: `fav=1` — favorites folder or `isStarred` rows. */
373
+ fav?: boolean
374
+ /** Mock list filter: `deck=clinical` — see {@link filterQuestionBankItemsByClinicalDeckMock}. */
375
+ deck?: "clinical" | null
376
+ /**
377
+ * When true, links use {@link QUESTION_BANK_LIST_PATH} (library “Search” in secondary nav).
378
+ * Scoped folder / mine links should use `searchLanding: false` (library path).
379
+ */
380
+ searchLanding?: boolean
381
+ /**
382
+ * When true, links use {@link QUESTION_BANK_HUB_FIND_PATH} (discovery hub composer submit).
383
+ * Do not combine with `searchLanding` — hub find wins if both are set.
384
+ */
385
+ hubFind?: boolean
79
386
  }): string {
80
- const base = "/question-bank"
387
+ const base =
388
+ opts.hubFind === true
389
+ ? QUESTION_BANK_HUB_FIND_PATH
390
+ : opts.searchLanding === true
391
+ ? QUESTION_BANK_LIST_PATH
392
+ : QUESTION_BANK_LIBRARY_PATH
81
393
  const sp = new URLSearchParams()
82
394
  if (opts.scope === "my") sp.set("scope", "my")
83
395
  if (opts.scope === "folder" && opts.folderId) {
84
396
  sp.set("scope", "folder")
85
397
  sp.set("folderId", opts.folderId)
86
398
  }
399
+ const trimmedQ = opts.q?.trim()
400
+ if (trimmedQ) sp.set("q", trimmedQ)
401
+ if (opts.fav) sp.set("fav", "1")
402
+ if (opts.deck === "clinical") sp.set("deck", "clinical")
87
403
  const qs = sp.toString()
88
404
  const h = opts.hash?.replace(/^#/, "")
89
405
  const hashPart = h ? `#${h}` : ""
@@ -0,0 +1,22 @@
1
+ import { createDedicatedSearchRecentsController } from "@/lib/dedicated-search-recents"
2
+
3
+ const controller = createDedicatedSearchRecentsController("question-bank", {
4
+ storageKey: "exxat-ds.question-bank.recent-searches.v1",
5
+ eventName: "exxat-question-bank-recent-searches",
6
+ })
7
+
8
+ export const QUESTION_BANK_RECENT_SEARCHES_EVENT = controller.eventName
9
+
10
+ export const questionBankDedicatedSearchRecents = controller
11
+
12
+ export function readQuestionBankRecentSearches(): string[] {
13
+ return controller.read()
14
+ }
15
+
16
+ export function recordQuestionBankRecentSearch(query: string): void {
17
+ controller.record(query)
18
+ }
19
+
20
+ export function clearQuestionBankRecentSearches(): void {
21
+ controller.clear()
22
+ }
@@ -44,7 +44,6 @@
44
44
  "motion": "^12.38.0",
45
45
  "next": "16.2.4",
46
46
  "next-themes": "^0.4.6",
47
- "radix-ui": "^1.4.3",
48
47
  "react": "^19.2.4",
49
48
  "react-day-picker": "^9.14.0",
50
49
  "react-dom": "^19.2.4",