@pilotiq/pilotiq 0.6.1 → 0.7.0

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 (212) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +614 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +104 -29
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Html.d.ts +2 -2
  127. package/dist/schema/Html.d.ts.map +1 -1
  128. package/dist/schema/Html.js +2 -2
  129. package/dist/schema/Html.js.map +1 -1
  130. package/dist/schema/Markdown.d.ts +2 -2
  131. package/dist/schema/Markdown.d.ts.map +1 -1
  132. package/dist/schema/Markdown.js +2 -2
  133. package/dist/schema/Markdown.js.map +1 -1
  134. package/dist/schema/Section.d.ts +16 -0
  135. package/dist/schema/Section.d.ts.map +1 -1
  136. package/dist/schema/Section.js +16 -0
  137. package/dist/schema/Section.js.map +1 -1
  138. package/dist/schema/Wizard.d.ts +45 -0
  139. package/dist/schema/Wizard.d.ts.map +1 -1
  140. package/dist/schema/Wizard.js +50 -0
  141. package/dist/schema/Wizard.js.map +1 -1
  142. package/dist/schema/resolveSchema.d.ts +8 -0
  143. package/dist/schema/resolveSchema.d.ts.map +1 -1
  144. package/dist/schema/resolveSchema.js +70 -1
  145. package/dist/schema/resolveSchema.js.map +1 -1
  146. package/dist/schema/sanitize.d.ts +3 -3
  147. package/dist/schema/sanitize.d.ts.map +1 -1
  148. package/dist/schema/sanitize.js +10 -3
  149. package/dist/schema/sanitize.js.map +1 -1
  150. package/dist/sessionFilters.d.ts.map +1 -1
  151. package/dist/sessionFilters.js +12 -1
  152. package/dist/sessionFilters.js.map +1 -1
  153. package/dist/styles/file-upload.css +13 -0
  154. package/dist/vite.d.ts.map +1 -1
  155. package/dist/vite.js +9 -2
  156. package/dist/vite.js.map +1 -1
  157. package/package.json +6 -4
  158. package/src/Column.test.ts +36 -0
  159. package/src/Column.ts +54 -0
  160. package/src/Page.ts +13 -4
  161. package/src/Pilotiq.ts +109 -0
  162. package/src/Resource.ts +29 -0
  163. package/src/actions/exportFactory.ts +1 -1
  164. package/src/columns/SelectColumn.ts +46 -8
  165. package/src/columns/editableColumns.test.ts +45 -0
  166. package/src/defaultPages.ts +3 -0
  167. package/src/elements/Form.ts +19 -0
  168. package/src/elements/Table.ts +35 -1
  169. package/src/elements/TableGroup.test.ts +111 -0
  170. package/src/elements/TableGroup.ts +135 -0
  171. package/src/elements/dispatchForm.ts +34 -7
  172. package/src/elements/dispatchTable.test.ts +267 -0
  173. package/src/elements/dispatchTable.ts +112 -33
  174. package/src/fields/Field.test.ts +15 -0
  175. package/src/fields/Field.ts +8 -3
  176. package/src/fields/RepeaterField.ts +104 -0
  177. package/src/fields/RepeaterRelationship.test.ts +173 -0
  178. package/src/nestedRelationManagerData.test.ts +21 -0
  179. package/src/orm/modelDefaults.ts +21 -0
  180. package/src/pageData.ts +267 -47
  181. package/src/react/AppShell.tsx +55 -4
  182. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  183. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  184. package/src/react/PendingSuggestionsContext.tsx +172 -0
  185. package/src/react/SchemaRenderer.tsx +504 -95
  186. package/src/react/cells/EditableCell.tsx +11 -2
  187. package/src/react/fields/CheckboxListInput.tsx +23 -2
  188. package/src/react/fields/ColorInput.tsx +22 -2
  189. package/src/react/fields/DateTimeInput.tsx +22 -2
  190. package/src/react/fields/FieldShell.tsx +167 -3
  191. package/src/react/fields/FileUploadInput.tsx +21 -2
  192. package/src/react/fields/KeyValueInput.tsx +32 -2
  193. package/src/react/fields/RadioInput.tsx +23 -2
  194. package/src/react/fields/SelectFieldInput.tsx +25 -2
  195. package/src/react/fields/SliderInput.tsx +20 -2
  196. package/src/react/fields/TagsInput.tsx +20 -2
  197. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  198. package/src/react/index.ts +18 -0
  199. package/src/relationManagerData.test.ts +451 -2
  200. package/src/routes.ts +58 -2
  201. package/src/schema/Html.ts +2 -2
  202. package/src/schema/Markdown.ts +2 -2
  203. package/src/schema/Section.ts +17 -0
  204. package/src/schema/Wizard.ts +67 -0
  205. package/src/schema/containers.test.ts +90 -0
  206. package/src/schema/resolveSchema.test.ts +50 -0
  207. package/src/schema/resolveSchema.ts +79 -1
  208. package/src/schema/sanitize.ts +13 -4
  209. package/src/sessionFilters.test.ts +23 -0
  210. package/src/sessionFilters.ts +11 -1
  211. package/src/styles/file-upload.css +13 -0
  212. package/src/vite.ts +9 -2
@@ -68,6 +68,7 @@ import {
68
68
  CircleIcon, InboxIcon, GripVerticalIcon,
69
69
  ChevronDownIcon, CopyIcon, CheckIcon, XIcon,
70
70
  InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon,
71
+ Columns3Icon,
71
72
  } from 'lucide-react'
72
73
  import type { ComponentType } from 'react'
73
74
  import { useNavigate, type NavigateFn } from './navigate.js'
@@ -2058,16 +2059,60 @@ function SectionRenderer({ el, index }: { el: ElementMeta; index: number }) {
2058
2059
 
2059
2060
  // ─── Wizard (Plan #8) ───────────────────────────────────────
2060
2061
 
2062
+ /**
2063
+ * Resolve the initial active step for `WizardRenderer`. Priority:
2064
+ * 1. URL `?<queryKey>=N` (1-based — wizards expose human-friendly indexes
2065
+ * when `Wizard.persistStepInQueryString()` is enabled).
2066
+ * 2. `localStorage[<storageKey>]` (0-based, set by the persist effect).
2067
+ * 3. `startOnStep` configured on the Wizard.
2068
+ *
2069
+ * SSR-safe: returns `startOnStep` when `window` is undefined.
2070
+ */
2071
+ function readInitialWizardStep(
2072
+ total: number,
2073
+ startOnStep: number,
2074
+ storageKey: string | undefined,
2075
+ queryKey: string | undefined,
2076
+ ): number {
2077
+ if (typeof window === 'undefined') return startOnStep
2078
+ if (queryKey) {
2079
+ try {
2080
+ const raw = new URL(window.location.href).searchParams.get(queryKey)
2081
+ if (raw !== null && raw !== '') {
2082
+ const n = Number(raw) - 1
2083
+ if (Number.isFinite(n) && n >= 0 && n < total) return n
2084
+ }
2085
+ } catch { /* ignore */ }
2086
+ }
2087
+ if (storageKey) {
2088
+ try {
2089
+ const stored = window.localStorage.getItem(storageKey)
2090
+ if (stored !== null) {
2091
+ const n = Number(stored)
2092
+ if (Number.isFinite(n) && n >= 0 && n < total) return n
2093
+ }
2094
+ } catch { /* ignore */ }
2095
+ }
2096
+ return startOnStep
2097
+ }
2098
+
2061
2099
  /**
2062
2100
  * Multi-step form layout. Tracks active step in `useState`, optionally
2063
- * persisted to localStorage. On Next click, POSTs `{ step, values }` to
2064
- * the form's `wizardUrl` (stamped by the route handler when the form
2065
- * has a Wizard descendant). 200 → advance; 422 → stamp inline errors;
2066
- * absent `wizardUrl` → advance immediately (no validation).
2101
+ * persisted to localStorage and/or the URL query string. On Next click,
2102
+ * POSTs `{ step, values }` to the form's `wizardUrl` (stamped by the
2103
+ * route handler when the form has a Wizard descendant). 200 → advance;
2104
+ * 422 → stamp inline errors; absent `wizardUrl` → advance immediately
2105
+ * (no validation).
2067
2106
  *
2068
2107
  * Inactive steps render hidden (display:none) rather than unmounted so
2069
2108
  * controlled inputs preserve their values across step transitions and
2070
2109
  * cross-step `$get` works on the resolved meta.
2110
+ *
2111
+ * Nav buttons honor `Wizard.submitAction() / nextAction() / previousAction()`
2112
+ * — chrome (label / icon / color / size / outlined / iconOnly / tooltip /
2113
+ * disabled rules) carries through to the rendered button while the click
2114
+ * behavior stays hardwired (advance / recede / submit-form). Bare wizards
2115
+ * keep the built-in defaults.
2071
2116
  */
2072
2117
  function WizardRenderer({ el, index }: {
2073
2118
  el: ElementMeta
@@ -2082,26 +2127,22 @@ function WizardRenderer({ el, index }: {
2082
2127
  const startOnStep = Math.max(0, Math.min(Math.max(0, steps.length - 1), Number(el['startOnStep'] ?? 0)))
2083
2128
  const persist = el['persist'] !== false
2084
2129
  const storageKey = persist && formId ? `pilotiq.wizard.${formId}.step` : undefined
2130
+ const queryKey = typeof el['persistStepInQueryString'] === 'string'
2131
+ ? String(el['persistStepInQueryString'])
2132
+ : undefined
2085
2133
 
2086
- const [active, setActive] = useState(startOnStep)
2134
+ const submitActionMeta = el['submitAction'] as ElementMeta | undefined
2135
+ const nextActionMeta = el['nextAction'] as ElementMeta | undefined
2136
+ const previousActionMeta = el['previousAction'] as ElementMeta | undefined
2137
+
2138
+ // Initial-step resolution priority: URL (?<key>=N, 1-based) > localStorage >
2139
+ // startOnStep. URL wins on first paint so deep links land on the right step
2140
+ // before localStorage can override. Lazy initializer — resolution runs once.
2141
+ const [active, setActive] = useState(() => readInitialWizardStep(steps.length, startOnStep, storageKey, queryKey))
2087
2142
  const [advancing, setAdvancing] = useState(false)
2088
2143
  const [advanceError, setAdvanceError] = useState<string | null>(null)
2089
2144
 
2090
- // Hydrate persisted step from localStorage after mount.
2091
- useEffect(() => {
2092
- if (!storageKey) return
2093
- if (typeof window === 'undefined') return
2094
- try {
2095
- const stored = window.localStorage.getItem(storageKey)
2096
- if (stored !== null) {
2097
- const n = Number(stored)
2098
- if (Number.isFinite(n) && n >= 0 && n < steps.length) setActive(n)
2099
- }
2100
- } catch { /* ignore */ }
2101
- // eslint-disable-next-line react-hooks/exhaustive-deps
2102
- }, [storageKey])
2103
-
2104
- // Persist active step changes.
2145
+ // Persist active step changes to localStorage (when enabled).
2105
2146
  useEffect(() => {
2106
2147
  if (!storageKey) return
2107
2148
  if (typeof window === 'undefined') return
@@ -2109,6 +2150,20 @@ function WizardRenderer({ el, index }: {
2109
2150
  catch { /* ignore */ }
2110
2151
  }, [storageKey, active])
2111
2152
 
2153
+ // Mirror active step to the URL via replaceState — purely client-side state
2154
+ // sync, no SPA re-fetch. 1-based externally; cleared when on the first step
2155
+ // so bare URLs don't grow ?step=1 noise.
2156
+ useEffect(() => {
2157
+ if (!queryKey) return
2158
+ if (typeof window === 'undefined') return
2159
+ try {
2160
+ const url = new URL(window.location.href)
2161
+ if (active === 0) url.searchParams.delete(queryKey)
2162
+ else url.searchParams.set(queryKey, String(active + 1))
2163
+ window.history.replaceState(window.history.state, '', url.toString())
2164
+ } catch { /* ignore */ }
2165
+ }, [queryKey, active])
2166
+
2112
2167
  if (steps.length === 0) {
2113
2168
  return (
2114
2169
  <div key={index} className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
@@ -2223,34 +2278,106 @@ function WizardRenderer({ el, index }: {
2223
2278
  )}
2224
2279
 
2225
2280
  {/* Step nav. The Form's Save button (in Heading actions or page chrome)
2226
- stays as-is — only the final step's submit goes through. We expose
2227
- Back / Next here. The final-step "Save" is the form's submit; the
2228
- renderer flips Next → submit-button on the final step. */}
2281
+ stays as-is by default — only the final step's submit goes through
2282
+ the form. `Wizard.submitAction()` opts into a wizard-owned submit
2283
+ button on the final step (use this when the wizard is the entire
2284
+ form and there's no page chrome). `nextAction() / previousAction()`
2285
+ customize the chrome of the built-in Back / Next buttons. */}
2229
2286
  <div className="flex items-center justify-between gap-2">
2230
- <button
2231
- type="button"
2287
+ <WizardNavButton
2288
+ actionMeta={previousActionMeta}
2289
+ fallbackLabel="Back"
2232
2290
  disabled={isFirst || advancing}
2233
2291
  onClick={() => advance(active - 1)}
2234
- className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
2235
- >
2236
- Back
2237
- </button>
2292
+ />
2238
2293
  {isLast
2239
- ? <span className="text-xs text-muted-foreground">Submit the form to finish.</span>
2240
- : <button
2241
- type="button"
2294
+ ? (submitActionMeta
2295
+ ? <WizardNavButton
2296
+ actionMeta={submitActionMeta}
2297
+ fallbackLabel="Submit"
2298
+ type="submit"
2299
+ disabled={advancing}
2300
+ />
2301
+ : <span className="text-xs text-muted-foreground">Submit the form to finish.</span>)
2302
+ : <WizardNavButton
2303
+ actionMeta={nextActionMeta}
2304
+ fallbackLabel={advancing ? 'Validating…' : 'Next'}
2242
2305
  disabled={advancing}
2243
2306
  onClick={() => advance(active + 1)}
2244
- className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
2245
- >
2246
- {advancing ? 'Validating…' : 'Next'}
2247
- </button>
2307
+ />
2248
2308
  }
2249
2309
  </div>
2250
2310
  </div>
2251
2311
  )
2252
2312
  }
2253
2313
 
2314
+ /**
2315
+ * Renders one wizard nav slot (Back / Next / Submit). Falls back to plain
2316
+ * built-in chrome (border button for Back, primary button for Next/Submit)
2317
+ * when no `actionMeta` is supplied; otherwise reads the resolved Action's
2318
+ * chrome (`label / icon / color / size / outlined / iconOnly / tooltip /
2319
+ * disabled`) and applies it to a button whose click is hardwired by the
2320
+ * surrounding wizard. `type="submit"` lets the Submit slot trigger the
2321
+ * surrounding form's onSubmit dispatcher (no `onClick` needed).
2322
+ *
2323
+ * Hidden actions (`.visible(false)` resolved-away) drop the slot entirely
2324
+ * — the resolver returns `undefined` for hidden Action elements, which
2325
+ * arrives here as `actionMeta == null` so we fall through to the default
2326
+ * chrome. Use `Wizard.skippable()` semantics to hide nav buttons when
2327
+ * appropriate; for permanent removal subclass the wizard.
2328
+ */
2329
+ function WizardNavButton({
2330
+ actionMeta,
2331
+ fallbackLabel,
2332
+ type = 'button',
2333
+ disabled,
2334
+ onClick,
2335
+ }: {
2336
+ actionMeta: ElementMeta | undefined
2337
+ fallbackLabel: string
2338
+ type?: 'button' | 'submit'
2339
+ disabled?: boolean
2340
+ onClick?: () => void
2341
+ }) {
2342
+ // Bare default — keep historical chrome for back-compat (un-customized
2343
+ // wizards look identical to before this change).
2344
+ if (!actionMeta) {
2345
+ const isPrimary = type === 'submit' || fallbackLabel !== 'Back'
2346
+ return (
2347
+ <button
2348
+ type={type}
2349
+ disabled={disabled}
2350
+ onClick={onClick}
2351
+ className={isPrimary
2352
+ ? 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
2353
+ : 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed'}
2354
+ >
2355
+ {fallbackLabel}
2356
+ </button>
2357
+ )
2358
+ }
2359
+
2360
+ const ownDisabled = Boolean(actionMeta['disabled'])
2361
+ const label = String(actionMeta['label'] ?? fallbackLabel)
2362
+ const tooltip = actionMeta['tooltip'] ? String(actionMeta['tooltip']) : undefined
2363
+ const iconOnly = Boolean(actionMeta['iconOnly'])
2364
+ const className = actionButtonClass(actionMeta, {})
2365
+ const node = (
2366
+ <button
2367
+ type={type}
2368
+ disabled={disabled || ownDisabled}
2369
+ onClick={onClick}
2370
+ className={`${className} disabled:opacity-50 disabled:cursor-not-allowed`}
2371
+ aria-label={iconOnly ? label : undefined}
2372
+ >
2373
+ {renderActionIcon(actionMeta)}
2374
+ {!iconOnly && <span>{label}</span>}
2375
+ {renderActionBadge(actionMeta)}
2376
+ </button>
2377
+ )
2378
+ return <>{withTooltip(node, tooltip)}</>
2379
+ }
2380
+
2254
2381
  // ─── Top-level dispatch ─────────────────────────────────────
2255
2382
 
2256
2383
  const TEXT_COLOR_CLASSES: Record<string, string> = {
@@ -3477,6 +3604,11 @@ interface TableUrlState {
3477
3604
  * in the dropdown to override `defaultGroup`); `undefined` omits the
3478
3605
  * key entirely so the configured default takes over. */
3479
3606
  group?: string
3607
+ /** Drilled-in group key for `?groupKey=`. `undefined` omits — the
3608
+ * heading is banded (or no group at all); empty string explicitly
3609
+ * clears (used by the chip's × so a stale URL value doesn't return
3610
+ * via foreign-param round-trip). */
3611
+ groupKey?: string
3480
3612
  }
3481
3613
 
3482
3614
  // Mirror of `prefixedKey` in `elements/dispatchTable.ts`. Kept inline so
@@ -3531,6 +3663,7 @@ function buildTableQuery(
3531
3663
  prefixK(prefix, 'page'),
3532
3664
  prefixK(prefix, 'perPage'),
3533
3665
  prefixK(prefix, 'group'),
3666
+ prefixK(prefix, 'groupKey'),
3534
3667
  ...Object.keys(filterValues).map(n => prefixK(prefix, n)),
3535
3668
  ])
3536
3669
  for (const [k, v] of currentParams) {
@@ -3548,6 +3681,11 @@ function buildTableQuery(
3548
3681
  if (merged.sort) params.set(prefixK(prefix, 'sort'), `${merged.sort.column}:${merged.sort.direction}`)
3549
3682
  if (merged.page && merged.page > 1) params.set(prefixK(prefix, 'page'), String(merged.page))
3550
3683
  if (merged.group !== undefined) params.set(prefixK(prefix, 'group'), merged.group)
3684
+ // groupKey is sparse — only writes when the override sets a non-empty
3685
+ // value. Drill-out (chip ×) passes `''` to clear; the foreign-param
3686
+ // dedupe set above already filtered the stale value out, so an empty
3687
+ // override produces a URL without the key.
3688
+ if (merged.groupKey) params.set(prefixK(prefix, 'groupKey'), merged.groupKey)
3551
3689
  const qs = params.toString()
3552
3690
  // Always anchor to a real pathname — Vike's client-side router treats
3553
3691
  // a bare `?qs` href as a fresh URL with empty pathname, which routes
@@ -4882,6 +5020,78 @@ function RecordCellLink({
4882
5020
  )
4883
5021
  }
4884
5022
 
5023
+ /**
5024
+ * "Drilled into <Label>: <Value>" chip above the table when a group
5025
+ * heading has been clicked. The × clears `?<prefix>groupKey=`, returning
5026
+ * the table to its banded view. Real `<a href>` with `useNavigate()`
5027
+ * intercept on plain left-click so cmd-click / middle-click open a
5028
+ * fresh tab (rare but valid for sharing the banded view URL).
5029
+ */
5030
+ function ActiveGroupKeyChip({
5031
+ label, value, displayValue, clearHref, navigate,
5032
+ }: {
5033
+ label: string
5034
+ value: string
5035
+ displayValue: string
5036
+ clearHref: string
5037
+ navigate: NavigateFn
5038
+ }) {
5039
+ const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
5040
+ if (e.button !== 0) return
5041
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
5042
+ e.preventDefault()
5043
+ void navigate(clearHref)
5044
+ }
5045
+ return (
5046
+ <div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
5047
+ <span className="text-muted-foreground">Drilled into</span>
5048
+ <span className="font-medium text-foreground">
5049
+ {label ? `${label}: ` : ''}{displayValue || value}
5050
+ </span>
5051
+ <a
5052
+ href={clearHref}
5053
+ onClick={onClick}
5054
+ aria-label="Clear drill-in"
5055
+ className="ms-auto text-muted-foreground hover:text-foreground"
5056
+ >
5057
+ ×
5058
+ </a>
5059
+ </div>
5060
+ )
5061
+ }
5062
+
5063
+ /**
5064
+ * Group-heading text wrapped in a real `<a href>` that SPA-navs into the
5065
+ * drilled-in URL. Plain left-click intercepts for `useNavigate()`;
5066
+ * cmd/ctrl/shift-click + middle-click fall through to the browser so
5067
+ * "open in new tab" semantics work. Visually inherits the heading
5068
+ * styling — the link adds underline-on-hover affordance without
5069
+ * disturbing the surrounding text-transform / size.
5070
+ */
5071
+ function GroupHeadingLink({
5072
+ href, navigate, children,
5073
+ }: {
5074
+ href: string
5075
+ navigate: NavigateFn
5076
+ children: React.ReactNode
5077
+ }) {
5078
+ const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
5079
+ if (e.button !== 0) return
5080
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
5081
+ e.preventDefault()
5082
+ void navigate(href)
5083
+ }
5084
+ return (
5085
+ <a
5086
+ href={href}
5087
+ onClick={onClick}
5088
+ className="inline-flex items-center gap-1 text-inherit no-underline hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
5089
+ >
5090
+ {children}
5091
+ </a>
5092
+ )
5093
+ }
5094
+
4885
5095
  /**
4886
5096
  * List-page tab strip — Filament-style query shortcuts above the table
4887
5097
  * ("All / Drafts / Published / Archived"). Each trigger is a real `<a>`
@@ -5121,6 +5331,61 @@ function SortByPicker({
5121
5331
  )
5122
5332
  }
5123
5333
 
5334
+ /**
5335
+ * Toolbar dropdown for `Column.toggleable()` columns. Lists every
5336
+ * toggleable column with a checkbox; toggling writes through to a
5337
+ * caller-supplied `onToggle` (the `TableRendererBody` owns the state
5338
+ * + the localStorage round-trip). Mounted only when at least one
5339
+ * column is toggleable.
5340
+ */
5341
+ function ColumnsToggleDropdown({
5342
+ columns, hidden, onToggle,
5343
+ }: {
5344
+ columns: ElementMeta[]
5345
+ hidden: Set<string>
5346
+ onToggle: (name: string, nextHidden: boolean) => void
5347
+ }) {
5348
+ if (columns.length === 0) return null
5349
+ return (
5350
+ <DropdownMenu>
5351
+ <DropdownMenuTrigger
5352
+ render={(props) => (
5353
+ <button
5354
+ {...props}
5355
+ type="button"
5356
+ className="inline-flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground hover:bg-accent"
5357
+ aria-label="Show or hide columns"
5358
+ >
5359
+ <Columns3Icon className="h-4 w-4" aria-hidden="true" />
5360
+ <span>Columns</span>
5361
+ </button>
5362
+ )}
5363
+ />
5364
+ <DropdownMenuContent align="end" className="min-w-[12rem]">
5365
+ {columns.map((col, i) => {
5366
+ const name = String(col['name'] ?? '')
5367
+ const label = String(col['label'] ?? name)
5368
+ const isHidden = hidden.has(name)
5369
+ return (
5370
+ <DropdownMenuItem
5371
+ key={i}
5372
+ // Suppress menu-close so users can toggle multiple columns
5373
+ // without re-opening the dropdown.
5374
+ closeOnClick={false}
5375
+ onClick={() => onToggle(name, !isHidden)}
5376
+ >
5377
+ <span className="inline-flex w-4 items-center justify-center">
5378
+ {!isHidden && <CheckIcon className="h-4 w-4" aria-hidden="true" />}
5379
+ </span>
5380
+ <span>{label}</span>
5381
+ </DropdownMenuItem>
5382
+ )
5383
+ })}
5384
+ </DropdownMenuContent>
5385
+ </DropdownMenu>
5386
+ )
5387
+ }
5388
+
5124
5389
  /**
5125
5390
  * Lookup tables for responsive grid column-counts in `contentLayout:
5126
5391
  * 'cards'`. Tailwind's JIT scanner needs **literal** class strings; we
@@ -5318,6 +5583,11 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5318
5583
  const navigate = useNavigate()
5319
5584
  const children = el.children ?? []
5320
5585
  const columns = children.filter(c => c.type === 'column')
5586
+ // `Column.toggleable()` columns — sourced from the resolved meta. The
5587
+ // user's per-table visibility map is owned + persisted below; the full
5588
+ // `columns` list stays available for the toolbar dropdown so hidden
5589
+ // columns can be re-shown without a roundtrip.
5590
+ const toggleableColumns = columns.filter(c => c['toggleable'] !== undefined)
5321
5591
  // Actions and ActionGroups share placement — both show up in the
5322
5592
  // header/bulk/row toolbars depending on their `placement` field.
5323
5593
  const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
@@ -5326,6 +5596,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5326
5596
  const hasRecordClasses = Boolean(el['recordClasses'])
5327
5597
  const pollInterval = typeof el['pollInterval'] === 'number' ? el['pollInterval'] as number : undefined
5328
5598
  const defaultGroup = typeof el['defaultGroup'] === 'string' ? el['defaultGroup'] as string : undefined
5599
+ const activeGroupKey = typeof el['activeGroupKey'] === 'string' ? el['activeGroupKey'] as string : undefined
5329
5600
  const summaries = el['summaries'] as Record<string, Array<{ kind: string; value: string; label?: string }>> | undefined
5330
5601
  const groupSummaries = el['groupSummaries'] as
5331
5602
  Record<string, Record<string, Array<{ kind: string; value: string; label?: string }>>> | undefined
@@ -5335,6 +5606,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5335
5606
  collapsible?: true
5336
5607
  collapsed?: true
5337
5608
  date?: true
5609
+ scopable?: true
5338
5610
  }> | undefined) ?? []
5339
5611
  // Active group's registered metadata (if any). Falls back to a synth
5340
5612
  // for the bare-column form so the heading row still has a label.
@@ -5348,6 +5620,11 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5348
5620
  })
5349
5621
  : undefined
5350
5622
  const groupColumnLabel = activeGroupMeta?.label
5623
+ // Heading text becomes a real `<a href>` when the active group opts in
5624
+ // via `.scopable()`. Synthesized bare-column groups can't be scopable
5625
+ // (no builder call ran).
5626
+ const groupHeadingScopable = activeGroupMeta !== undefined
5627
+ && (activeGroupMeta as { scopable?: true }).scopable === true
5351
5628
 
5352
5629
  // Auto-refresh: re-visit current URL on a timer so sort/filter/pagination
5353
5630
  // state survives. Pause while the document is hidden — background tabs
@@ -5393,6 +5670,59 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5393
5670
  const perPage = el['perPage'] as number | undefined
5394
5671
  const searchable = Boolean(el['searchable'])
5395
5672
  const currentPath = (el['currentPath'] as string | undefined) ?? ''
5673
+
5674
+ // `Column.toggleable()` user-visibility map. Persisted per-table at
5675
+ // `pilotiq.table.<currentPath>.columns.<name>` ('1' = hidden,
5676
+ // '0' = visible). On first paint, fall back to `meta.toggleable.initiallyHidden`.
5677
+ // SSR returns the meta default — the localStorage hydrate happens
5678
+ // inside the effect so server + first client render match.
5679
+ const columnsVisibilityKey = (name: string): string =>
5680
+ `pilotiq.table.${currentPath}.columns.${name}`
5681
+ const initialHidden = (): Set<string> => {
5682
+ const out = new Set<string>()
5683
+ for (const col of toggleableColumns) {
5684
+ const cfg = col['toggleable'] as { initiallyHidden?: boolean } | undefined
5685
+ if (cfg?.initiallyHidden) out.add(String(col['name']))
5686
+ }
5687
+ return out
5688
+ }
5689
+ const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(initialHidden)
5690
+ useEffect(() => {
5691
+ if (typeof window === 'undefined') return
5692
+ if (toggleableColumns.length === 0) return
5693
+ const next = new Set<string>()
5694
+ for (const col of toggleableColumns) {
5695
+ const name = String(col['name'])
5696
+ const cfg = col['toggleable'] as { initiallyHidden?: boolean } | undefined
5697
+ try {
5698
+ const stored = window.localStorage.getItem(columnsVisibilityKey(name))
5699
+ if (stored === '1') next.add(name)
5700
+ else if (stored === '0') { /* visible */ }
5701
+ else if (cfg?.initiallyHidden) next.add(name)
5702
+ } catch {
5703
+ if (cfg?.initiallyHidden) next.add(name)
5704
+ }
5705
+ }
5706
+ setHiddenColumns(next)
5707
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5708
+ }, [currentPath, toggleableColumns.length])
5709
+ const toggleColumnHidden = (name: string, nextHidden: boolean): void => {
5710
+ setHiddenColumns(prev => {
5711
+ const next = new Set(prev)
5712
+ if (nextHidden) next.add(name)
5713
+ else next.delete(name)
5714
+ if (typeof window !== 'undefined') {
5715
+ try { window.localStorage.setItem(columnsVisibilityKey(name), nextHidden ? '1' : '0') }
5716
+ catch { /* private mode / quota — silent */ }
5717
+ }
5718
+ return next
5719
+ })
5720
+ }
5721
+ // Filtered column list used by every render path (header, body cells,
5722
+ // group + footer summaries, empty-state colSpan). Non-toggleable
5723
+ // columns always survive.
5724
+ const visibleColumns = columns.filter(c => !hiddenColumns.has(String(c['name'])))
5725
+
5396
5726
  // Tier-3 — when the table opts into `Table.queryStringIdentifier(...)`,
5397
5727
  // every URL key (search / sort / page / perPage / group / filter names)
5398
5728
  // gets prefixed with `${id}_` so multiple tables on one page don't
@@ -5474,6 +5804,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5474
5804
  ...(urlGroup !== undefined ? { group: urlGroup }
5475
5805
  : defaultGroup !== undefined ? { group: defaultGroup }
5476
5806
  : {}),
5807
+ ...(activeGroupKey !== undefined ? { groupKey: activeGroupKey } : {}),
5477
5808
  }
5478
5809
 
5479
5810
  // Snapshot active filter values for sort/pagination href construction.
@@ -5485,6 +5816,17 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5485
5816
  if (typeof v === 'string' && v !== '') activeFilters[String(f['name'])] = v
5486
5817
  }
5487
5818
 
5819
+ // Drill-in / drill-out URL builders for the group heading link and the
5820
+ // active-key chip's clear button. Drill-in sets `?<prefix>groupKey=v`
5821
+ // and resets `page`; drill-out clears it. Both round-trip foreign
5822
+ // params (other tables' state) through `buildTableQuery`.
5823
+ const buildGroupKeyHref = (value: string): string => buildTableQuery(
5824
+ state, { groupKey: value, page: 1 }, currentPath, activeFilters, queryPrefix,
5825
+ )
5826
+ const drillOutHref = (): string => buildTableQuery(
5827
+ state, { groupKey: '', page: 1 }, currentPath, activeFilters, queryPrefix,
5828
+ )
5829
+
5488
5830
  // Track which row ids are currently checked. Keyed by id (string), not
5489
5831
  // by index, so pagination and re-renders don't drop selection state.
5490
5832
  const [selected, setSelected] = useState<Set<string>>(() => new Set())
@@ -5634,7 +5976,8 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5634
5976
  // Only modal + collapsible mount a toolbar widget; the always-visible
5635
5977
  // strip modes don't add anything to the header bar.
5636
5978
  const showFiltersInToolbar = hasFilters && (filtersInModal || filtersCollapsible)
5637
- const showHeaderBar = searchable || headerActions.length > 0 || showFiltersInToolbar || hasGroupPicker || hasSortPicker
5979
+ const hasColumnsToggle = toggleableColumns.length > 0
5980
+ const showHeaderBar = searchable || headerActions.length > 0 || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle
5638
5981
  const hasBulkActions = bulkActions.length > 0
5639
5982
  const hasRowActions = rowActions.length > 0
5640
5983
 
@@ -5657,7 +6000,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5657
6000
  !searchActive &&
5658
6001
  currentPage === 1
5659
6002
  const reorderColumnVisible = reorderableColumn !== undefined
5660
- const totalCols = columns.length
6003
+ const totalCols = visibleColumns.length
5661
6004
  + (hasBulkActions ? 1 : 0)
5662
6005
  + (hasRowActions ? 1 : 0)
5663
6006
  + (reorderColumnVisible ? 1 : 0)
@@ -5686,7 +6029,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5686
6029
  )}
5687
6030
  {showHeaderBar && (
5688
6031
  <div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
5689
- {(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker) ? (
6032
+ {(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle) ? (
5690
6033
  <div className="flex items-center gap-2">
5691
6034
  {searchable && (
5692
6035
  <form method="get" action={currentPath || undefined} className="flex items-end gap-2">
@@ -5753,6 +6096,13 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5753
6096
  }}
5754
6097
  />
5755
6098
  )}
6099
+ {toggleableColumns.length > 0 && (
6100
+ <ColumnsToggleDropdown
6101
+ columns={toggleableColumns}
6102
+ hidden={hiddenColumns}
6103
+ onToggle={toggleColumnHidden}
6104
+ />
6105
+ )}
5756
6106
  </div>
5757
6107
  ) : <span />}
5758
6108
  {headerActions.length > 0 && (
@@ -5766,6 +6116,29 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5766
6116
  {hasFilters && filtersAbove && filtersOpen && (
5767
6117
  <FilterStrip filters={filters} prefix={queryPrefix} />
5768
6118
  )}
6119
+ {activeGroupKey !== undefined && (
6120
+ <ActiveGroupKeyChip
6121
+ label={groupColumnLabel ?? defaultGroup ?? ''}
6122
+ value={activeGroupKey}
6123
+ displayValue={(() => {
6124
+ // Prefer a row-resolved `_groupTitle` (server stamped via
6125
+ // `getTitleFromRecordUsing`) so the chip reads the same as
6126
+ // a banded heading. Falls back to the raw bucket key when
6127
+ // no row matched — empty drilled-in pages still show what
6128
+ // they're drilled into.
6129
+ for (const r of rows) {
6130
+ const obj = r as Record<string, unknown>
6131
+ if (String(obj['_groupValue'] ?? '') !== activeGroupKey) continue
6132
+ const t = obj['_groupTitle']
6133
+ if (typeof t === 'string' && t !== '') return t
6134
+ break
6135
+ }
6136
+ return activeGroupKey
6137
+ })()}
6138
+ clearHref={drillOutHref()}
6139
+ navigate={navigate}
6140
+ />
6141
+ )}
5769
6142
  {hasBulkActions && someChecked && (
5770
6143
  <div className="flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
5771
6144
  <span className="text-muted-foreground">
@@ -5809,6 +6182,8 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5809
6182
  toggleGroupCollapsed={toggleGroupCollapsed}
5810
6183
  cardsPerRow={cardsPerRow}
5811
6184
  navigate={navigate}
6185
+ groupHeadingScopable={groupHeadingScopable}
6186
+ buildGroupKeyHref={buildGroupKeyHref}
5812
6187
  />
5813
6188
  ) : (
5814
6189
  <div className="rounded-xl border bg-card overflow-hidden">
@@ -5827,7 +6202,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5827
6202
  />
5828
6203
  </TableHead>
5829
6204
  )}
5830
- {columns.map((col, i) => {
6205
+ {visibleColumns.map((col, i) => {
5831
6206
  const name = String(col['name'] ?? '')
5832
6207
  const label = String(col['label'] ?? name)
5833
6208
  const sortable = Boolean(col['sortable'])
@@ -5931,34 +6306,44 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5931
6306
  colSpan={totalCols}
5932
6307
  className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
5933
6308
  >
5934
- {groupCollapsible ? (
5935
- <button
5936
- type="button"
5937
- className="flex w-full items-center gap-2 text-left"
5938
- onClick={() => toggleGroupCollapsed(groupValue!)}
5939
- aria-expanded={!isInCollapsedGroup}
5940
- >
5941
- <ChevronDownIcon
5942
- className={[
5943
- 'size-4 transition-transform',
5944
- isInCollapsedGroup ? '-rotate-90' : '',
5945
- ].filter(Boolean).join(' ')}
5946
- />
6309
+ {(() => {
6310
+ const drillable = groupHeadingScopable
6311
+ && groupValue !== undefined
6312
+ && groupValue !== ''
6313
+ const headingText = (
5947
6314
  <GroupHeaderText
5948
6315
  label={groupColumnLabel}
5949
6316
  value={groupValue}
5950
6317
  title={groupTitle}
5951
6318
  description={groupDescription}
5952
6319
  />
5953
- </button>
5954
- ) : (
5955
- <GroupHeaderText
5956
- label={groupColumnLabel}
5957
- value={groupValue}
5958
- title={groupTitle}
5959
- description={groupDescription}
5960
- />
5961
- )}
6320
+ )
6321
+ const headingNode = drillable
6322
+ ? <GroupHeadingLink href={buildGroupKeyHref(groupValue!)} navigate={navigate}>{headingText}</GroupHeadingLink>
6323
+ : headingText
6324
+ if (groupCollapsible) {
6325
+ return (
6326
+ <div className="flex w-full items-center gap-2">
6327
+ <button
6328
+ type="button"
6329
+ className="inline-flex items-center"
6330
+ onClick={() => toggleGroupCollapsed(groupValue!)}
6331
+ aria-expanded={!isInCollapsedGroup}
6332
+ aria-label={isInCollapsedGroup ? 'Expand group' : 'Collapse group'}
6333
+ >
6334
+ <ChevronDownIcon
6335
+ className={[
6336
+ 'size-4 transition-transform',
6337
+ isInCollapsedGroup ? '-rotate-90' : '',
6338
+ ].filter(Boolean).join(' ')}
6339
+ />
6340
+ </button>
6341
+ {headingNode}
6342
+ </div>
6343
+ )
6344
+ }
6345
+ return headingNode
6346
+ })()}
5962
6347
  </TableCell>
5963
6348
  </TableRow>
5964
6349
  )}
@@ -5999,7 +6384,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5999
6384
  />
6000
6385
  </TableCell>
6001
6386
  )}
6002
- {columns.map((col, ci) => {
6387
+ {visibleColumns.map((col, ci) => {
6003
6388
  const name = String(col['name'] ?? '')
6004
6389
  const value = recordObj[name]
6005
6390
  const align = col['alignment'] === 'center' ? 'text-center'
@@ -6022,9 +6407,18 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
6022
6407
  : null
6023
6408
  if (EditableComp && editUrl !== undefined) {
6024
6409
  const cellDisabled = col['disabled'] === true || cellDisabledMap?.[name] === true
6410
+ const cellSelectOptionsMap = recordObj['_cellSelectOptions'] as
6411
+ Record<string, Array<{ value: string; label: string }>> | undefined
6412
+ const rowOptions = cellSelectOptionsMap?.[name]
6025
6413
  return (
6026
6414
  <TableCell key={ci} className={`text-sm text-foreground ${align} p-0`} style={widthStyle}>
6027
- <EditableComp url={editUrl} col={col} value={value} disabled={cellDisabled} />
6415
+ <EditableComp
6416
+ url={editUrl}
6417
+ col={col}
6418
+ value={value}
6419
+ disabled={cellDisabled}
6420
+ {...(rowOptions ? { rowOptions } : {})}
6421
+ />
6028
6422
  </TableCell>
6029
6423
  )
6030
6424
  }
@@ -6064,7 +6458,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
6064
6458
  <TableRow key={`group-summary-${id}`} className="bg-muted/20 hover:bg-muted/20">
6065
6459
  {reorderColumnVisible && <TableCell />}
6066
6460
  {hasBulkActions && <TableCell />}
6067
- {columns.map((col, ci) => {
6461
+ {visibleColumns.map((col, ci) => {
6068
6462
  const name = String(col['name'] ?? '')
6069
6463
  const align = col['alignment'] === 'center' ? 'text-center'
6070
6464
  : col['alignment'] === 'end' ? 'text-right'
@@ -6094,7 +6488,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
6094
6488
  <TableRow>
6095
6489
  {reorderColumnVisible && <TableCell />}
6096
6490
  {hasBulkActions && <TableCell />}
6097
- {columns.map((col, ci) => {
6491
+ {visibleColumns.map((col, ci) => {
6098
6492
  const name = String(col['name'] ?? '')
6099
6493
  const align = col['alignment'] === 'center' ? 'text-center'
6100
6494
  : col['alignment'] === 'end' ? 'text-right'
@@ -6174,6 +6568,7 @@ function CardsLayoutBody({
6174
6568
  striped, activeEmpty, EmptyIcon, hasFilterOrSearch,
6175
6569
  defaultGroup, groupColumnLabel, groupCollapsible, collapsedGroups, toggleGroupCollapsed,
6176
6570
  cardsPerRow, navigate,
6571
+ groupHeadingScopable, buildGroupKeyHref,
6177
6572
  }: {
6178
6573
  el: ElementMeta
6179
6574
  columns: ElementMeta[]
@@ -6197,6 +6592,10 @@ function CardsLayoutBody({
6197
6592
  toggleGroupCollapsed: (groupValue: string) => void
6198
6593
  cardsPerRow: Record<string, number> | undefined
6199
6594
  navigate: NavigateFn
6595
+ // Drill-in affordances. Sparse: when `groupHeadingScopable` is false,
6596
+ // the heading renders as before; `buildGroupKeyHref` is unused.
6597
+ groupHeadingScopable?: boolean
6598
+ buildGroupKeyHref?: (value: string) => string
6200
6599
  }) {
6201
6600
  void el // keep prop for future telemetry; silences unused-prop lint
6202
6601
  void columns
@@ -6260,38 +6659,48 @@ function CardsLayoutBody({
6260
6659
  && collapsedGroups[section.groupValue] === true
6261
6660
  return (
6262
6661
  <div key={si} className="flex flex-col gap-3">
6263
- {section.groupValue !== undefined && (
6264
- groupCollapsible ? (
6265
- <button
6266
- type="button"
6267
- className="flex w-full items-center gap-2 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground"
6268
- onClick={() => toggleGroupCollapsed(section.groupValue!)}
6269
- aria-expanded={!collapsed}
6270
- >
6271
- <ChevronDownIcon
6272
- className={[
6273
- 'size-4 transition-transform',
6274
- collapsed ? '-rotate-90' : '',
6275
- ].filter(Boolean).join(' ')}
6276
- />
6277
- <GroupHeaderText
6278
- label={groupColumnLabel}
6279
- value={section.groupValue}
6280
- title={section.title}
6281
- description={section.description}
6282
- />
6283
- </button>
6284
- ) : (
6662
+ {section.groupValue !== undefined && (() => {
6663
+ const drillable = groupHeadingScopable === true
6664
+ && buildGroupKeyHref !== undefined
6665
+ && section.groupValue !== ''
6666
+ const headingText = (
6667
+ <GroupHeaderText
6668
+ label={groupColumnLabel}
6669
+ value={section.groupValue}
6670
+ title={section.title}
6671
+ description={section.description}
6672
+ />
6673
+ )
6674
+ const headingNode = drillable
6675
+ ? <GroupHeadingLink href={buildGroupKeyHref!(section.groupValue!)} navigate={navigate}>{headingText}</GroupHeadingLink>
6676
+ : headingText
6677
+ if (groupCollapsible) {
6678
+ return (
6679
+ <div className="flex w-full items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
6680
+ <button
6681
+ type="button"
6682
+ className="inline-flex items-center"
6683
+ onClick={() => toggleGroupCollapsed(section.groupValue!)}
6684
+ aria-expanded={!collapsed}
6685
+ aria-label={collapsed ? 'Expand group' : 'Collapse group'}
6686
+ >
6687
+ <ChevronDownIcon
6688
+ className={[
6689
+ 'size-4 transition-transform',
6690
+ collapsed ? '-rotate-90' : '',
6691
+ ].filter(Boolean).join(' ')}
6692
+ />
6693
+ </button>
6694
+ {headingNode}
6695
+ </div>
6696
+ )
6697
+ }
6698
+ return (
6285
6699
  <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
6286
- <GroupHeaderText
6287
- label={groupColumnLabel}
6288
- value={section.groupValue}
6289
- title={section.title}
6290
- description={section.description}
6291
- />
6700
+ {headingNode}
6292
6701
  </div>
6293
6702
  )
6294
- )}
6703
+ })()}
6295
6704
  {!collapsed && (
6296
6705
  <div className={gridClass}>
6297
6706
  {section.indices.map((ri) => {