@pilotiq/pilotiq 0.2.0 → 0.4.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/CLAUDE.md +2 -2
  4. package/dist/actions/Action.d.ts +25 -0
  5. package/dist/actions/Action.d.ts.map +1 -1
  6. package/dist/actions/Action.js +25 -0
  7. package/dist/actions/Action.js.map +1 -1
  8. package/dist/elements/dispatchForm.d.ts +0 -14
  9. package/dist/elements/dispatchForm.d.ts.map +1 -1
  10. package/dist/elements/dispatchForm.js +28 -0
  11. package/dist/elements/dispatchForm.js.map +1 -1
  12. package/dist/fields/BuilderField.d.ts +27 -1
  13. package/dist/fields/BuilderField.d.ts.map +1 -1
  14. package/dist/fields/BuilderField.js +36 -1
  15. package/dist/fields/BuilderField.js.map +1 -1
  16. package/dist/fields/FileUploadField.d.ts +65 -0
  17. package/dist/fields/FileUploadField.d.ts.map +1 -1
  18. package/dist/fields/FileUploadField.js +72 -0
  19. package/dist/fields/FileUploadField.js.map +1 -1
  20. package/dist/fields/RepeaterField.d.ts +34 -1
  21. package/dist/fields/RepeaterField.d.ts.map +1 -1
  22. package/dist/fields/RepeaterField.js +43 -1
  23. package/dist/fields/RepeaterField.js.map +1 -1
  24. package/dist/fields/RowButton.d.ts +9 -2
  25. package/dist/fields/RowButton.d.ts.map +1 -1
  26. package/dist/fields/TextField.d.ts +106 -0
  27. package/dist/fields/TextField.d.ts.map +1 -1
  28. package/dist/fields/TextField.js +115 -0
  29. package/dist/fields/TextField.js.map +1 -1
  30. package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
  31. package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
  32. package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
  33. package/dist/filters/queryBuilder/TextConstraint.js +2 -3
  34. package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
  35. package/dist/orm/modelDefaults.d.ts +1 -1
  36. package/dist/orm/modelDefaults.d.ts.map +1 -1
  37. package/dist/pageData.d.ts +11 -0
  38. package/dist/pageData.d.ts.map +1 -1
  39. package/dist/pageData.js +31 -0
  40. package/dist/pageData.js.map +1 -1
  41. package/dist/react/FieldLabelSlotRegistry.d.ts +26 -0
  42. package/dist/react/FieldLabelSlotRegistry.d.ts.map +1 -0
  43. package/dist/react/FieldLabelSlotRegistry.js +16 -0
  44. package/dist/react/FieldLabelSlotRegistry.js.map +1 -0
  45. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  46. package/dist/react/SchemaRenderer.js +120 -9
  47. package/dist/react/SchemaRenderer.js.map +1 -1
  48. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  49. package/dist/react/fields/BuilderInput.js +32 -3
  50. package/dist/react/fields/BuilderInput.js.map +1 -1
  51. package/dist/react/fields/FieldShell.d.ts +12 -1
  52. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  53. package/dist/react/fields/FieldShell.js +5 -4
  54. package/dist/react/fields/FieldShell.js.map +1 -1
  55. package/dist/react/fields/FileUploadInput.d.ts +17 -4
  56. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  57. package/dist/react/fields/FileUploadInput.js +204 -25
  58. package/dist/react/fields/FileUploadInput.js.map +1 -1
  59. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  60. package/dist/react/fields/RepeaterInput.js +33 -2
  61. package/dist/react/fields/RepeaterInput.js.map +1 -1
  62. package/dist/react/fields/TextLikeInput.d.ts +5 -1
  63. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  64. package/dist/react/fields/TextLikeInput.js +17 -2
  65. package/dist/react/fields/TextLikeInput.js.map +1 -1
  66. package/dist/react/fields/rowChromeButton.d.ts +24 -5
  67. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  68. package/dist/react/fields/rowChromeButton.js +51 -8
  69. package/dist/react/fields/rowChromeButton.js.map +1 -1
  70. package/dist/react/fields/textInputControls.d.ts +47 -0
  71. package/dist/react/fields/textInputControls.d.ts.map +1 -0
  72. package/dist/react/fields/textInputControls.js +134 -0
  73. package/dist/react/fields/textInputControls.js.map +1 -0
  74. package/dist/react/index.d.ts +1 -0
  75. package/dist/react/index.d.ts.map +1 -1
  76. package/dist/react/index.js +1 -0
  77. package/dist/react/index.js.map +1 -1
  78. package/dist/routes.d.ts.map +1 -1
  79. package/dist/routes.js +21 -1
  80. package/dist/routes.js.map +1 -1
  81. package/dist/schema/Alert.d.ts +58 -0
  82. package/dist/schema/Alert.d.ts.map +1 -1
  83. package/dist/schema/Alert.js +68 -1
  84. package/dist/schema/Alert.js.map +1 -1
  85. package/dist/schema/resolveSchema.d.ts.map +1 -1
  86. package/dist/schema/resolveSchema.js +32 -0
  87. package/dist/schema/resolveSchema.js.map +1 -1
  88. package/package.json +2 -1
  89. package/src/actions/Action.test.ts +47 -0
  90. package/src/actions/Action.ts +35 -0
  91. package/src/elements/dispatchForm.ts +28 -0
  92. package/src/fields/BuilderField.ts +38 -1
  93. package/src/fields/FileUploadField.test.ts +46 -0
  94. package/src/fields/FileUploadField.ts +90 -2
  95. package/src/fields/RepeaterField.ts +45 -1
  96. package/src/fields/RowButton.test.ts +70 -0
  97. package/src/fields/RowButton.ts +11 -1
  98. package/src/fields/TextField.test.ts +168 -0
  99. package/src/fields/TextField.ts +141 -1
  100. package/src/filters/QueryBuilderFilter.test.ts +18 -0
  101. package/src/filters/queryBuilder/Constraint.ts +1 -1
  102. package/src/filters/queryBuilder/TextConstraint.ts +5 -6
  103. package/src/orm/modelDefaults.ts +1 -1
  104. package/src/pageData.ts +33 -0
  105. package/src/react/FieldLabelSlotRegistry.ts +30 -0
  106. package/src/react/SchemaRenderer.tsx +238 -16
  107. package/src/react/fields/BuilderInput.tsx +37 -0
  108. package/src/react/fields/FieldShell.tsx +17 -2
  109. package/src/react/fields/FileUploadInput.tsx +516 -85
  110. package/src/react/fields/RepeaterInput.tsx +39 -0
  111. package/src/react/fields/TextLikeInput.tsx +22 -2
  112. package/src/react/fields/rowChromeButton.tsx +102 -6
  113. package/src/react/fields/textInputControls.tsx +238 -0
  114. package/src/react/index.ts +1 -0
  115. package/src/routes.ts +21 -1
  116. package/src/schema/Alert.test.ts +46 -0
  117. package/src/schema/Alert.ts +90 -8
  118. package/src/schema/resolveSchema.ts +32 -0
@@ -12,6 +12,7 @@ import {
12
12
  RowChromeIconButton,
13
13
  ReorderGrip,
14
14
  CollapseChevron,
15
+ BulkCollapseHeader,
15
16
  resolveRowChrome,
16
17
  DEFAULT_MOVE_UP,
17
18
  DEFAULT_MOVE_DOWN,
@@ -428,6 +429,37 @@ export function RepeaterInput({
428
429
  })
429
430
  }
430
431
 
432
+ // ── Bulk expand / collapse ──────────────────────────────
433
+ // Accordion's "only one open" invariant survives both bulk actions:
434
+ // expandAll opens the first visible row (matches the implicit
435
+ // "always show something" mental model); collapseAll sets null.
436
+ // Per-row mode iterates every row id and writes the storage slot
437
+ // alongside the in-memory map so reload restores the bulk action.
438
+ const expandAll = (): void => {
439
+ if (accordion) {
440
+ const firstVisible = rows.find(r => !r.hidden)
441
+ const next = firstVisible?.id ?? null
442
+ setAccordionOpenId(next)
443
+ writeAccordionToStorage(formId, name, next)
444
+ return
445
+ }
446
+ setCollapsed({})
447
+ for (const r of rows) writeCollapsedToStorage(formId, name, r.id, false)
448
+ }
449
+ const collapseAll = (): void => {
450
+ if (accordion) {
451
+ setAccordionOpenId(null)
452
+ writeAccordionToStorage(formId, name, null)
453
+ return
454
+ }
455
+ const next: Record<string, boolean> = {}
456
+ for (const r of rows) {
457
+ next[r.id] = true
458
+ writeCollapsedToStorage(formId, name, r.id, true)
459
+ }
460
+ setCollapsed(next)
461
+ }
462
+
431
463
  // Visibility computed each render — hidden rows still occupy slots in
432
464
  // `rows` (so values round-trip + reorder-around math stays simple), but
433
465
  // they don't count for the user-facing empty state, drop indicator, or
@@ -494,6 +526,13 @@ export function RepeaterInput({
494
526
  onChange={onContainerChange}
495
527
  onBlur={onContainerBlur}
496
528
  >
529
+ <BulkCollapseHeader
530
+ buttons={buttons}
531
+ disabled={disabled || !hasVisibleRow}
532
+ onExpandAll={expandAll}
533
+ onCollapseAll={collapseAll}
534
+ />
535
+
497
536
  {!hasVisibleRow && (
498
537
  <div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
499
538
  No items yet. Click {addLabel} to start.
@@ -12,7 +12,7 @@ import { Textarea } from '../ui/textarea.js'
12
12
  * config. Outside a controlled form, falls back to plain `defaultValue`.
13
13
  */
14
14
  export function TextLikeInput({
15
- el, name, common, type, extraProps, multiline,
15
+ el, name, common, type, extraProps, multiline, applyMask,
16
16
  }: {
17
17
  el: ElementMeta
18
18
  name: string
@@ -20,6 +20,10 @@ export function TextLikeInput({
20
20
  type: string
21
21
  extraProps: Record<string, unknown>
22
22
  multiline: boolean
23
+ /** Optional keystroke formatter — `TextField.mask(pattern)`. When
24
+ * set, every change runs the value through this fn before it lands
25
+ * in state / the DOM. Defaults to identity. */
26
+ applyMask?: (value: string) => string
23
27
  }): React.ReactElement {
24
28
  const fs = useFieldState(name)
25
29
  const liveCfg = el['live']
@@ -27,11 +31,13 @@ export function TextLikeInput({
27
31
  ? liveCfg as { onBlur?: boolean; debounce?: number }
28
32
  : {})
29
33
  const onBlurMode = liveOpts.onBlur === true
34
+ const mask = applyMask ?? identity
30
35
 
31
36
  if (fs.controlled) {
32
37
  const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
33
38
  const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
34
- fs.setValue(e.target.value)
39
+ const formatted = mask(e.target.value)
40
+ fs.setValue(formatted)
35
41
  if (!onBlurMode) fs.triggerLive()
36
42
  }
37
43
  const onBlur = (): void => {
@@ -49,6 +55,20 @@ export function TextLikeInput({
49
55
  return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
50
56
  }
51
57
 
58
+ // Uncontrolled path with mask: wire onInput so the user sees the
59
+ // formatted value as they type. Without `applyMask`, fall through to
60
+ // the legacy bare-defaultValue render so the DOM stays unchanged.
61
+ if (applyMask) {
62
+ const onInput = (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
63
+ const target = e.currentTarget
64
+ target.value = mask(target.value)
65
+ }
66
+ if (multiline) return <Textarea {...(common as React.ComponentProps<typeof Textarea>)} {...extraProps} onInput={onInput} />
67
+ return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} onInput={onInput} />
68
+ }
69
+
52
70
  if (multiline) return <Textarea {...(common as React.ComponentProps<typeof Textarea>)} {...extraProps} />
53
71
  return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} />
54
72
  }
73
+
74
+ function identity(v: string): string { return v }
@@ -4,6 +4,8 @@ import {
4
4
  ArrowUpIcon,
5
5
  ChevronDownIcon,
6
6
  ChevronRightIcon,
7
+ ChevronsDownIcon,
8
+ ChevronsUpIcon,
7
9
  CopyIcon,
8
10
  GripVerticalIcon,
9
11
  Trash2Icon,
@@ -154,11 +156,14 @@ export function ReorderGrip({
154
156
  }
155
157
 
156
158
  /**
157
- * Collapse chevron — picks the open/closed glyph from state, then lets a
158
- * `collapseAction` customizer override icon / label / tooltip / color.
159
- * When a custom icon is set, both states use it (matches Filament; flat
160
- * customizer surface separate open/closed icon overrides aren't worth
161
- * the extra setter).
159
+ * Collapse chevron — picks the open/closed glyph from state, then lets
160
+ * customizer overrides take over. When the row is currently collapsed,
161
+ * `expand` (from `expandAction(...)`) wins, falling back to `collapse`
162
+ * (from `collapseAction(...)`) for back-compat with single-override
163
+ * setups. When the row is open, `collapse` is used directly. Authors
164
+ * who want different chrome per state set both `collapseAction` and
165
+ * `expandAction`; authors who want a single uniform override keep
166
+ * setting just `collapseAction`.
162
167
  */
163
168
  export function CollapseChevron({
164
169
  isCollapsed,
@@ -177,10 +182,13 @@ export function CollapseChevron({
177
182
  tooltip: isCollapsed ? 'Expand' : 'Collapse',
178
183
  colorClass: 'text-muted-foreground hover:text-foreground',
179
184
  }
185
+ const override = isCollapsed
186
+ ? (buttons?.expand ?? buttons?.collapse)
187
+ : buttons?.collapse
180
188
  return (
181
189
  <RowChromeIconButton
182
190
  defaults={defaults}
183
- override={buttons?.collapse}
191
+ override={override}
184
192
  disabled={disabled}
185
193
  onClick={onToggle}
186
194
  ariaExpanded={!isCollapsed}
@@ -223,3 +231,91 @@ export const DEFAULT_REORDER: ButtonDefaults = {
223
231
  tooltip: 'Drag to reorder',
224
232
  colorClass: 'text-muted-foreground',
225
233
  }
234
+ export const DEFAULT_EXPAND_ALL: ButtonDefaults = {
235
+ Icon: ChevronsDownIcon,
236
+ label: 'Expand all',
237
+ tooltip: 'Expand all',
238
+ colorClass: 'text-muted-foreground hover:text-foreground',
239
+ }
240
+ export const DEFAULT_COLLAPSE_ALL: ButtonDefaults = {
241
+ Icon: ChevronsUpIcon,
242
+ label: 'Collapse all',
243
+ tooltip: 'Collapse all',
244
+ colorClass: 'text-muted-foreground hover:text-foreground',
245
+ }
246
+
247
+ /**
248
+ * Field-header strip with bulk Expand-all / Collapse-all chips. Renders
249
+ * only the buttons whose customizer slot is set on `buttons` — the
250
+ * presence of `buttons.expandAll` / `buttons.collapseAll` is what flips
251
+ * the matching button into existence (different from per-row chrome,
252
+ * which always renders when collapsible). Returns `null` when neither
253
+ * slot is configured so callers can mount the strip unconditionally.
254
+ */
255
+ export function BulkCollapseHeader({
256
+ buttons,
257
+ disabled,
258
+ onExpandAll,
259
+ onCollapseAll,
260
+ }: {
261
+ buttons: RowButtonsMeta | undefined
262
+ disabled: boolean
263
+ onExpandAll: () => void
264
+ onCollapseAll: () => void
265
+ }): React.ReactElement | null {
266
+ const expandAllOverride = buttons?.expandAll
267
+ const collapseAllOverride = buttons?.collapseAll
268
+ if (!expandAllOverride && !collapseAllOverride) return null
269
+ return (
270
+ <div className="flex items-center justify-end gap-1">
271
+ {expandAllOverride && (
272
+ <BulkChromeButton
273
+ defaults={DEFAULT_EXPAND_ALL}
274
+ override={expandAllOverride}
275
+ disabled={disabled}
276
+ onClick={onExpandAll}
277
+ />
278
+ )}
279
+ {collapseAllOverride && (
280
+ <BulkChromeButton
281
+ defaults={DEFAULT_COLLAPSE_ALL}
282
+ override={collapseAllOverride}
283
+ disabled={disabled}
284
+ onClick={onCollapseAll}
285
+ />
286
+ )}
287
+ </div>
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Compact button used by `BulkCollapseHeader` — icon + label, sized to
293
+ * read as a header chip (smaller than a full Action button, larger than
294
+ * the icon-only per-row chrome). Picks up the same `RowButton`
295
+ * override surface as every other slot.
296
+ */
297
+ function BulkChromeButton({
298
+ defaults,
299
+ override,
300
+ disabled,
301
+ onClick,
302
+ }: {
303
+ defaults: ButtonDefaults
304
+ override: RowButtonMeta | undefined
305
+ disabled: boolean
306
+ onClick: () => void
307
+ }): React.ReactElement {
308
+ const { Icon, label, tooltip, colorClass } = resolveRowChrome(defaults, override)
309
+ return (
310
+ <button
311
+ type="button"
312
+ onClick={onClick}
313
+ disabled={disabled}
314
+ title={tooltip}
315
+ className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${colorClass} disabled:opacity-30`}
316
+ >
317
+ <Icon className="size-3.5" />
318
+ {label}
319
+ </button>
320
+ )
321
+ }
@@ -0,0 +1,238 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import type { ElementMeta } from '../../schema/Element.js'
3
+ import { useToast } from '../Toaster.js'
4
+
5
+ /**
6
+ * TextField rich affordances live in this file (audit gap #3):
7
+ * - `revealable()` — eye-icon toggle that flips `type="password"` ↔ `text`
8
+ * - `copyable(message?)` — copy-to-clipboard button + toast
9
+ * - `prefixAction(Action) / suffixAction(Action)` — embedded Action buttons
10
+ * - `mask(pattern)` — keystroke-by-keystroke pattern formatter
11
+ *
12
+ * The components export a `useTextInputControls(el, name)` hook that
13
+ * returns `{ before, after, type, applyMask }` so the calling renderer
14
+ * can mount the controls inside `FieldShell`'s `before / after` slots
15
+ * and feed the masked value back through the controlled-input bridge.
16
+ *
17
+ * Mask alphabet:
18
+ * `9` — digit (0-9)
19
+ * `a` — alpha (A-Za-z)
20
+ * `*` — alphanumeric or any character
21
+ * anything else — literal (rendered verbatim)
22
+ *
23
+ * Submitted values are stripped of mask literals via `stripCharacters`
24
+ * (server-side coerce); the client also strips on input so what the user
25
+ * sees in the DOM matches what gets posted.
26
+ */
27
+
28
+ export interface TextInputControlsResult {
29
+ before: React.ReactNode
30
+ after: React.ReactNode
31
+ /** Effective `type` attribute — `'password'` when password+!revealed,
32
+ * `'text'` otherwise. Pass through to the rendered `<input>`. */
33
+ type: string
34
+ /** When the field has `mask(pattern)` set, format `value` against the
35
+ * pattern. Returns the formatted string. Skips when no mask. */
36
+ applyMask: (value: string) => string
37
+ }
38
+
39
+ /** `renderAction(meta)` is supplied by the caller (SchemaRenderer's
40
+ * `renderElement`) to avoid an import cycle between the controls
41
+ * module and the main schema renderer. */
42
+ export function useTextInputControls(
43
+ el: ElementMeta,
44
+ name: string,
45
+ renderAction: (meta: ElementMeta) => React.ReactNode,
46
+ ): TextInputControlsResult {
47
+ const isPassword = el['password'] === true
48
+ const isRevealable= el['revealable'] === true
49
+ const isCopyable = el['copyable'] === true
50
+ const copyMessage = (el['copyMessage'] as string | undefined) ?? 'Copied!'
51
+ const mask = el['mask'] as string | undefined
52
+ const prefixAct = el['prefixAction'] as ElementMeta | undefined
53
+ const suffixAct = el['suffixAction'] as ElementMeta | undefined
54
+
55
+ const [revealed, setRevealed] = useState(false)
56
+ const inputId = name
57
+
58
+ const onCopy = useCopyHandler(inputId, copyMessage)
59
+
60
+ const type = isPassword && !revealed ? 'password' : 'text'
61
+
62
+ const beforeNodes: React.ReactNode[] = []
63
+ if (prefixAct) beforeNodes.push(
64
+ <div key="pre-act" className="shrink-0">{renderAction(prefixAct)}</div>
65
+ )
66
+
67
+ const afterNodes: React.ReactNode[] = []
68
+ if (isCopyable) afterNodes.push(<CopyButton key="copy" onCopy={onCopy} />)
69
+ if (isRevealable && isPassword) {
70
+ afterNodes.push(
71
+ <RevealToggle
72
+ key="reveal"
73
+ revealed={revealed}
74
+ onToggle={() => setRevealed(v => !v)}
75
+ />
76
+ )
77
+ }
78
+ if (suffixAct) afterNodes.push(
79
+ <div key="suf-act" className="shrink-0">{renderAction(suffixAct)}</div>
80
+ )
81
+
82
+ const applyMask = useCallback((value: string): string => {
83
+ if (!mask) return value
84
+ return formatWithMask(value, mask)
85
+ }, [mask])
86
+
87
+ return {
88
+ before: beforeNodes.length > 0 ? <>{beforeNodes}</> : null,
89
+ after: afterNodes.length > 0 ? <>{afterNodes}</> : null,
90
+ type,
91
+ applyMask,
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Format `raw` against `mask` using the documented alphabet. Literals
97
+ * in the mask render verbatim and consume no source character; pattern
98
+ * tokens consume the next matching source character (skipping
99
+ * mismatches so the user can paste an already-formatted value and have
100
+ * it re-format cleanly).
101
+ */
102
+ export function formatWithMask(raw: string, mask: string): string {
103
+ let out = ''
104
+ let r = 0
105
+ for (let m = 0; m < mask.length; m++) {
106
+ const token = mask[m]!
107
+ const isPattern = token === '9' || token === 'a' || token === '*'
108
+ if (isPattern) {
109
+ if (r >= raw.length) break
110
+ // Advance through `raw` until we find a matching character.
111
+ while (r < raw.length) {
112
+ const ch = raw[r]!
113
+ const ok = token === '9'
114
+ ? /[0-9]/.test(ch)
115
+ : token === 'a'
116
+ ? /[A-Za-z]/.test(ch)
117
+ : true
118
+ r++
119
+ if (ok) { out += ch; break }
120
+ }
121
+ } else {
122
+ // Literal character — emit even when raw is exhausted, so a
123
+ // partially-typed value still shows the upcoming chrome
124
+ // (e.g. `(415) ` after the user types three digits). Consume the
125
+ // matching raw char if present so a paste of the already-formatted
126
+ // value doesn't double up.
127
+ out += token
128
+ if (raw[r] === token) r++
129
+ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ function useCopyHandler(
135
+ inputId: string,
136
+ message: string,
137
+ ): () => void {
138
+ const { notify } = useToast()
139
+ return () => {
140
+ if (typeof document === 'undefined') return
141
+ const el = document.getElementById(inputId) as HTMLInputElement | HTMLTextAreaElement | null
142
+ if (!el) return
143
+ const value = el.value
144
+ const writeText = navigator?.clipboard?.writeText?.bind(navigator.clipboard)
145
+ if (writeText) {
146
+ writeText(value).then(
147
+ () => notify({ type: 'success', title: message, duration: 2000 }),
148
+ () => notify({ type: 'error', title: 'Copy failed' }),
149
+ )
150
+ } else {
151
+ // Fallback: select + execCommand. Modern browsers all expose
152
+ // navigator.clipboard, so this is rare-path.
153
+ el.select()
154
+ try {
155
+ document.execCommand('copy')
156
+ notify({ type: 'success', title: message, duration: 2000 })
157
+ } catch {
158
+ notify({ type: 'error', title: 'Copy failed' })
159
+ }
160
+ el.setSelectionRange?.(0, 0)
161
+ }
162
+ }
163
+ }
164
+
165
+ function ChromeButton({ onClick, ariaLabel, children, autoFocus }: {
166
+ onClick: (e: React.MouseEvent) => void
167
+ ariaLabel: string
168
+ children: React.ReactNode
169
+ autoFocus?: boolean
170
+ }): React.ReactElement {
171
+ return (
172
+ <button
173
+ type="button"
174
+ tabIndex={-1}
175
+ onClick={(e) => { e.preventDefault(); onClick(e) }}
176
+ aria-label={ariaLabel}
177
+ className="inline-flex items-center justify-center rounded-md size-8 text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0"
178
+ autoFocus={autoFocus}
179
+ >
180
+ {children}
181
+ </button>
182
+ )
183
+ }
184
+
185
+ function CopyButton({ onCopy }: { onCopy: () => void }): React.ReactElement {
186
+ return (
187
+ <ChromeButton onClick={onCopy} ariaLabel="Copy">
188
+ <CopyIcon className="size-4" />
189
+ </ChromeButton>
190
+ )
191
+ }
192
+
193
+ function RevealToggle({ revealed, onToggle }: {
194
+ revealed: boolean
195
+ onToggle: () => void
196
+ }): React.ReactElement {
197
+ return (
198
+ <ChromeButton
199
+ onClick={onToggle}
200
+ ariaLabel={revealed ? 'Hide value' : 'Show value'}
201
+ >
202
+ {revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
203
+ </ChromeButton>
204
+ )
205
+ }
206
+
207
+ // ─── Inline icon SVGs (avoids pulling lucide-react import into this file
208
+ // without checking it's already a dep — keeps the bundle stable). ────
209
+
210
+ function CopyIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
211
+ return (
212
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
213
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
214
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
215
+ </svg>
216
+ )
217
+ }
218
+
219
+ function EyeIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
220
+ return (
221
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
222
+ <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
223
+ <circle cx="12" cy="12" r="3" />
224
+ </svg>
225
+ )
226
+ }
227
+
228
+ function EyeOffIcon(props: React.SVGProps<SVGSVGElement>): React.ReactElement {
229
+ return (
230
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
231
+ <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
232
+ <path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
233
+ <path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
234
+ <line x1="2" x2="22" y1="2" y2="22" />
235
+ </svg>
236
+ )
237
+ }
238
+
@@ -13,6 +13,7 @@ export {
13
13
  type FormFieldsProps,
14
14
  } from './SchemaRenderer.js'
15
15
  export { registerFieldRenderer, getFieldRenderer, type FieldRendererProps } from './registry.js'
16
+ export { registerFieldLabelSlot, getFieldLabelSlot, type FieldLabelSlotProps } from './FieldLabelSlotRegistry.js'
16
17
  export {
17
18
  registerWidgetRenderer,
18
19
  getWidgetRenderer,
package/src/routes.ts CHANGED
@@ -547,9 +547,29 @@ async function handleUploadRequest(
547
547
  }
548
548
  }
549
549
 
550
+ // Server-side resize via @rudderjs/image (optional peer dep)
551
+ const resizeWidthStr = typeof body['resize_width'] === 'string' ? body['resize_width'] : ''
552
+ const resizeHeightStr = typeof body['resize_height'] === 'string' ? body['resize_height'] : ''
553
+ let uploadFile: File = file
554
+ if (resizeWidthStr && resizeHeightStr) {
555
+ const w = Number(resizeWidthStr)
556
+ const h = Number(resizeHeightStr)
557
+ if (Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0) {
558
+ try {
559
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
560
+ const pkg = await import('@rudderjs/image' as string) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
561
+ const buf = await pkg.image(file).resize(w, h).format('webp').toBuffer()
562
+ const baseName = file.name.replace(/\.[^.]+$/, '')
563
+ uploadFile = new File([buf.buffer as ArrayBuffer], `${baseName}.webp`, { type: 'image/webp' })
564
+ } catch {
565
+ // @rudderjs/image not installed or resize failed — fall through with original file
566
+ }
567
+ }
568
+ }
569
+
550
570
  try {
551
571
  const result = await cfg.uploads.adapter.put({
552
- file,
572
+ file: uploadFile,
553
573
  ...(directory ? { directory } : {}),
554
574
  fieldName,
555
575
  })
@@ -60,4 +60,50 @@ describe('Alert schema primitive', () => {
60
60
  const out = await resolveSchema(tree)
61
61
  assert.equal('children' in out[0]!, false)
62
62
  })
63
+
64
+ // ─── Filament v5 chrome polish (2026-05-08, audit gap #8) ──
65
+
66
+ it('controls(...) is an alias for actions(...)', async () => {
67
+ const a = Action.make('upgrade').label('Upgrade').url('/billing')
68
+ const tree = [Alert.make('hi').controls([a])]
69
+ const out = await resolveSchema(tree)
70
+ const children = (out[0]!.children ?? []) as Array<{ type: string; name?: string }>
71
+ assert.equal(children.length, 1)
72
+ assert.equal(children[0]!.name, 'upgrade')
73
+ })
74
+
75
+ it('controlActions(...) is the variadic spread alias', async () => {
76
+ const a = Action.make('upgrade').label('Upgrade').url('/billing')
77
+ const b = Action.make('learnMore').label('Read more').url('/docs')
78
+ const tree = [Alert.make('hi').controlActions(a, b)]
79
+ const out = await resolveSchema(tree)
80
+ const children = (out[0]!.children ?? []) as Array<{ name?: string }>
81
+ assert.equal(children.length, 2)
82
+ assert.equal(children[0]!.name, 'upgrade')
83
+ assert.equal(children[1]!.name, 'learnMore')
84
+ })
85
+
86
+ it('dismissible() emits dismissible: true on meta; absent by default', () => {
87
+ assert.equal('dismissible' in Alert.make('a').toMeta(), false)
88
+ assert.equal(Alert.make('a').dismissible().toMeta().dismissible, true)
89
+ assert.equal('dismissible' in Alert.make('a').dismissible(false).toMeta(), false)
90
+ })
91
+
92
+ it('persistDismissal(key) auto-arms dismissible() and stamps the key', () => {
93
+ const meta = Alert.make('a').persistDismissal('billing-2026-q2').toMeta()
94
+ assert.equal(meta.dismissible, true)
95
+ assert.equal(meta.persistDismissal, 'billing-2026-q2')
96
+ })
97
+
98
+ it('iconColor() emits the override only when set', () => {
99
+ assert.equal('iconColor' in Alert.make('a').toMeta(), false)
100
+ assert.equal(Alert.make('a').iconColor('destructive').toMeta().iconColor, 'destructive')
101
+ })
102
+
103
+ it('footerActionsAlignment() emits only when non-default', () => {
104
+ assert.equal('actionsAlignment' in Alert.make('a').toMeta(), false)
105
+ assert.equal('actionsAlignment' in Alert.make('a').footerActionsAlignment('start').toMeta(), false)
106
+ assert.equal(Alert.make('a').footerActionsAlignment('center').toMeta().actionsAlignment, 'center')
107
+ assert.equal(Alert.make('a').footerActionsAlignment('end').toMeta().actionsAlignment, 'end')
108
+ })
63
109
  })