@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
@@ -1,6 +1,15 @@
1
1
  import { Field, type FieldMeta } from './Field.js'
2
2
  import type { RenderContext } from '../schema/resolveSchema.js'
3
3
 
4
+ export type PanelLayout = 'list' | 'grid' | 'integrated'
5
+
6
+ export interface AspectRatioOption {
7
+ /** Numeric ratio, e.g. `16/9`, `4/3`, `1` for square. */
8
+ ratio: number
9
+ /** Human-readable label shown in the editor picker, e.g. `'Widescreen'`. */
10
+ label: string
11
+ }
12
+
4
13
  /**
5
14
  * File upload. The field stores the resolved upload's URL string —
6
15
  * `string` for single-file mode, `string[]` for multi.
@@ -17,6 +26,16 @@ export class FileUploadField extends Field {
17
26
  private _multiple = false
18
27
  private _preview = true
19
28
  private _directory?: string
29
+ private _downloadable = false
30
+ private _openable = false
31
+ private _reorderable = false
32
+ private _appendFiles = false
33
+ private _panelLayout: PanelLayout = 'list'
34
+ private _imageEditor = false
35
+ private _imageEditorAspectRatioOptions?: AspectRatioOption[]
36
+ private _circleCropper = false
37
+ private _automaticallyCropImagesToAspectRatio = false
38
+ private _automaticallyResize?: { width: number; height: number }
20
39
 
21
40
  private constructor(name: string) {
22
41
  super(name, 'fileUpload')
@@ -45,20 +64,89 @@ export class FileUploadField extends Field {
45
64
  */
46
65
  directory(d: string): this { this._directory = d; return this }
47
66
 
67
+ /** Add a download button to each uploaded file item. */
68
+ downloadable(value: boolean = true): this { this._downloadable = value; return this }
69
+
70
+ /** Make the file thumbnail/icon link to the file (open in new tab). */
71
+ openable(value: boolean = true): this { this._openable = value; return this }
72
+
73
+ /** Allow files to be reordered via drag-and-drop. Only useful with `multiple()`. */
74
+ reorderable(value: boolean = true): this { this._reorderable = value; return this }
75
+
76
+ /**
77
+ * Append new uploads to the existing file list instead of replacing it.
78
+ * By default new picks replace the current value. Combine with `multiple()`.
79
+ */
80
+ appendFiles(value: boolean = true): this { this._appendFiles = value; return this }
81
+
82
+ /** Display layout for the uploaded file list. `'grid'` shows square tiles; `'integrated'` embeds the upload button as a tile. */
83
+ panelLayout(layout: PanelLayout): this { this._panelLayout = layout; return this }
84
+
85
+ /** Open a crop/resize modal after the user selects an image, before it is uploaded. */
86
+ imageEditor(value: boolean = true): this { this._imageEditor = value; return this }
87
+
88
+ /** Aspect ratio presets shown in the image editor picker. */
89
+ imageEditorAspectRatioOptions(options: AspectRatioOption[]): this { this._imageEditorAspectRatioOptions = options; return this }
90
+
91
+ /** Render a circular crop overlay in the image editor. */
92
+ circleCropper(value: boolean = true): this { this._circleCropper = value; return this }
93
+
94
+ /**
95
+ * When aspect ratio options are configured, automatically apply the first
96
+ * ratio and skip showing the editor UI unless the user explicitly opens it.
97
+ */
98
+ automaticallyCropImagesToAspectRatio(value: boolean = true): this { this._automaticallyCropImagesToAspectRatio = value; return this }
99
+
100
+ /**
101
+ * Resize the image to the given dimensions on the server after upload.
102
+ * Requires `@rudderjs/image` to be installed and the upload adapter to
103
+ * forward the `resize_width` / `resize_height` FormData fields.
104
+ */
105
+ automaticallyResize(width: number, height: number): this { this._automaticallyResize = { width, height }; return this }
106
+
107
+ /**
108
+ * Shorthand for `.imageEditor().circleCropper().multiple(false)`.
109
+ * Ideal for single avatar / profile-picture fields.
110
+ */
111
+ avatar(value: boolean = true): this {
112
+ if (value) { this._imageEditor = true; this._circleCropper = true; this._multiple = false }
113
+ return this
114
+ }
115
+
48
116
  getAccept(): string[] | undefined { return this._accept }
49
117
  getMaxSize(): number | undefined { return this._maxSize }
50
118
  isMultiple(): boolean { return this._multiple }
51
119
  hasPreview(): boolean { return this._preview }
52
120
  getDirectory(): string | undefined { return this._directory }
121
+ isDownloadable(): boolean { return this._downloadable }
122
+ isOpenable(): boolean { return this._openable }
123
+ isReorderable(): boolean { return this._reorderable }
124
+ doesAppendFiles(): boolean { return this._appendFiles }
125
+ getPanelLayout(): PanelLayout { return this._panelLayout }
126
+ hasImageEditor(): boolean { return this._imageEditor }
127
+ getImageEditorAspectRatioOptions(): AspectRatioOption[] | undefined { return this._imageEditorAspectRatioOptions }
128
+ hasCircleCropper(): boolean { return this._circleCropper }
129
+ doesAutomaticallyCrop(): boolean { return this._automaticallyCropImagesToAspectRatio }
130
+ getAutomaticallyResize(): { width: number; height: number } | undefined { return this._automaticallyResize }
53
131
 
54
132
  override toMeta(ctx?: RenderContext): FieldMeta {
55
133
  return {
56
134
  ...this.buildMeta(ctx),
57
135
  multiple: this._multiple,
58
136
  preview: this._preview,
59
- ...(this._accept ? { accept: this._accept } : {}),
137
+ ...(this._accept ? { accept: this._accept } : {}),
60
138
  ...(this._maxSize !== undefined ? { maxSize: this._maxSize } : {}),
61
- ...(this._directory ? { directory: this._directory } : {}),
139
+ ...(this._directory ? { directory: this._directory } : {}),
140
+ ...(this._downloadable ? { downloadable: true } : {}),
141
+ ...(this._openable ? { openable: true } : {}),
142
+ ...(this._reorderable ? { reorderable: true } : {}),
143
+ ...(this._appendFiles ? { appendFiles: true } : {}),
144
+ ...(this._panelLayout !== 'list' ? { panelLayout: this._panelLayout } : {}),
145
+ ...(this._imageEditor ? { imageEditor: true } : {}),
146
+ ...(this._imageEditorAspectRatioOptions?.length ? { imageEditorAspectRatioOptions: this._imageEditorAspectRatioOptions } : {}),
147
+ ...(this._circleCropper ? { circleCropper: true } : {}),
148
+ ...(this._automaticallyCropImagesToAspectRatio ? { automaticallyCropImagesToAspectRatio: true } : {}),
149
+ ...(this._automaticallyResize ? { automaticallyResize: this._automaticallyResize } : {}),
62
150
  // `uploadUrl` is stamped via RenderContext by the page-data
63
151
  // builders. Without it the renderer falls back to a clear error
64
152
  // ("no upload URL configured"); the route handler (and therefore
@@ -588,9 +588,53 @@ export class RepeaterField extends Field {
588
588
  */
589
589
  reorderAction(b: RowButton): this { this._buttons.reorder = b; return this }
590
590
 
591
- /** Customize the per-row collapse chevron. */
591
+ /**
592
+ * Customize the per-row collapse chevron. Applies to BOTH states by
593
+ * default — the open chevron and the collapsed chevron share the
594
+ * override unless `expandAction(...)` is also set, in which case
595
+ * `collapseAction` covers only the open state and `expandAction`
596
+ * covers the collapsed state.
597
+ */
592
598
  collapseAction(b: RowButton): this { this._buttons.collapse = b; return this }
593
599
 
600
+ /**
601
+ * Customize the per-row chevron when the row is currently *collapsed*
602
+ * (i.e. the "click me to expand" state). Sibling of `collapseAction`
603
+ * for the closed-state glyph; without this, both states fall through
604
+ * to `collapseAction` (back-compat) and ultimately to the default
605
+ * chevron pair (right when collapsed, down when open).
606
+ */
607
+ expandAction(b: RowButton): this { this._buttons.expand = b; return this }
608
+
609
+ /**
610
+ * Mount an "Expand all" button in the field header — clicking it opens
611
+ * every collapsed row. Opt-in: calling without args shows the button
612
+ * with the default icon (chevron-down) + label ("Expand all"); pass a
613
+ * `RowButton` to override icon / label / tooltip / color.
614
+ *
615
+ * Auto-arms `collapsible()` since the affordance is meaningless without
616
+ * collapsible rows. In `accordion()` mode the button opens the first
617
+ * visible row (accordion's "only one open" invariant survives).
618
+ */
619
+ expandAllAction(button?: RowButton): this {
620
+ this._buttons.expandAll = button ?? RowButton.make()
621
+ this._collapsible = true
622
+ return this
623
+ }
624
+
625
+ /**
626
+ * Mount a "Collapse all" button in the field header — clicking it
627
+ * collapses every open row. Opt-in (calling enables; pass a
628
+ * `RowButton` to customize). Auto-arms `collapsible()`. In `accordion()`
629
+ * mode the button closes the currently-open row, leaving everything
630
+ * collapsed.
631
+ */
632
+ collapseAllAction(button?: RowButton): this {
633
+ this._buttons.collapseAll = button ?? RowButton.make()
634
+ this._collapsible = true
635
+ return this
636
+ }
637
+
594
638
  /**
595
639
  * Per-row action buttons rendered in each row's header alongside the
596
640
  * built-in clone/delete strip. Useful for "Mark featured", "Send test",
@@ -98,6 +98,63 @@ describe('RepeaterField row-action customizers', () => {
98
98
  assert.equal(r.getButton('delete')!.getColor(), 'destructive')
99
99
  assert.equal(r.getButton('clone'), undefined)
100
100
  })
101
+
102
+ test('expandAction lands on meta.buttons.expand (separate from collapseAction)', () => {
103
+ const meta = Repeater.make('items')
104
+ .schema([TextField.make('text')])
105
+ .collapsible()
106
+ .collapseAction(RowButton.make().icon('chevron-down'))
107
+ .expandAction(RowButton.make().icon('chevron-right').tooltip('Open'))
108
+ .toMeta() as { buttons?: { collapse?: { icon?: string }; expand?: { icon?: string; tooltip?: string } } }
109
+ assert.deepEqual(meta.buttons?.collapse, { icon: 'chevron-down' })
110
+ assert.deepEqual(meta.buttons?.expand, { icon: 'chevron-right', tooltip: 'Open' })
111
+ })
112
+
113
+ test('expandAllAction() with no arg flips on the slot with empty defaults', () => {
114
+ const meta = Repeater.make('items')
115
+ .schema([TextField.make('text')])
116
+ .expandAllAction()
117
+ .toMeta()
118
+ assert.deepEqual(meta.buttons?.expandAll, {})
119
+ assert.equal(meta.collapsible, true, 'expandAllAction() auto-arms collapsible()')
120
+ })
121
+
122
+ test('expandAllAction(button) keeps the override and still auto-arms collapsible', () => {
123
+ const meta = Repeater.make('items')
124
+ .schema([TextField.make('text')])
125
+ .expandAllAction(RowButton.make().label('Open everything').icon('chevrons-down'))
126
+ .toMeta() as { buttons?: { expandAll?: { label?: string; icon?: string } }; collapsible?: boolean }
127
+ assert.deepEqual(meta.buttons?.expandAll, { label: 'Open everything', icon: 'chevrons-down' })
128
+ assert.equal(meta.collapsible, true)
129
+ })
130
+
131
+ test('collapseAllAction() with no arg flips on the slot and auto-arms collapsible', () => {
132
+ const meta = Repeater.make('items')
133
+ .schema([TextField.make('text')])
134
+ .collapseAllAction()
135
+ .toMeta()
136
+ assert.deepEqual(meta.buttons?.collapseAll, {})
137
+ assert.equal(meta.collapsible, true)
138
+ })
139
+
140
+ test('collapseAllAction(button) routes through the override', () => {
141
+ const meta = Repeater.make('items')
142
+ .schema([TextField.make('text')])
143
+ .collapseAllAction(RowButton.make().label('Hide all').color('muted'))
144
+ .toMeta() as { buttons?: { collapseAll?: { label?: string; color?: string } } }
145
+ assert.deepEqual(meta.buttons?.collapseAll, { label: 'Hide all', color: 'muted' })
146
+ })
147
+
148
+ test('non-customized field omits the new slots entirely', () => {
149
+ // Bare field never serializes any of the four new slots — back-compat
150
+ // for renderers that read `meta.buttons` and assume only the original
151
+ // seven keys are reachable.
152
+ const meta = Repeater.make('items')
153
+ .schema([TextField.make('text')])
154
+ .collapsible()
155
+ .toMeta() as { buttons?: unknown }
156
+ assert.equal(meta.buttons, undefined)
157
+ })
101
158
  })
102
159
 
103
160
  describe('BuilderField row-action customizers', () => {
@@ -146,4 +203,17 @@ describe('BuilderField row-action customizers', () => {
146
203
  .toMeta() as { buttons?: unknown }
147
204
  assert.deepEqual(r.buttons, b.buttons)
148
205
  })
206
+
207
+ test('expandAction / expandAllAction / collapseAllAction land on Builder meta with the same kind keys', () => {
208
+ const meta = Builder.make('blocks')
209
+ .blocks([Block.make('hero').schema([TextField.make('title')])])
210
+ .expandAction(RowButton.make().icon('chevron-right'))
211
+ .expandAllAction(RowButton.make().label('Open everything'))
212
+ .collapseAllAction()
213
+ .toMeta()
214
+ assert.deepEqual(meta.buttons?.expand, { icon: 'chevron-right' })
215
+ assert.deepEqual(meta.buttons?.expandAll, { label: 'Open everything' })
216
+ assert.deepEqual(meta.buttons?.collapseAll, {})
217
+ assert.equal(meta.collapsible, true, 'Builder bulk setters auto-arm collapsible()')
218
+ })
149
219
  })
@@ -107,9 +107,16 @@ export class RowButton {
107
107
  }
108
108
 
109
109
  /**
110
- * Slot id for one of the seven built-in row chrome buttons. The renderer
110
+ * Slot id for one of the built-in row chrome buttons. The renderer
111
111
  * looks these up in `meta.buttons[kind]` to merge customizer overrides
112
112
  * onto its hardcoded defaults.
113
+ *
114
+ * `expand` is the per-row sibling of `collapse` — used when the row is
115
+ * currently collapsed so authors can override icon/label/tooltip
116
+ * separately for each state. `expandAll` / `collapseAll` are the bulk
117
+ * field-header buttons; presence of either slot is what flips the
118
+ * matching button into existence (different from the always-rendered
119
+ * per-row chrome — see `RepeaterField.expandAllAction` for posture).
113
120
  */
114
121
  export type RowButtonKind =
115
122
  | 'add'
@@ -119,6 +126,9 @@ export type RowButtonKind =
119
126
  | 'moveDown'
120
127
  | 'reorder'
121
128
  | 'collapse'
129
+ | 'expand'
130
+ | 'expandAll'
131
+ | 'collapseAll'
122
132
 
123
133
  export type RowButtonsMeta = {
124
134
  [K in RowButtonKind]?: RowButtonMeta
@@ -0,0 +1,168 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { TextField } from './TextField.js'
5
+ import { Action } from '../actions/Action.js'
6
+ import { resolveSchema } from '../schema/resolveSchema.js'
7
+ import { coerceFormValues } from '../elements/dispatchForm.js'
8
+ import { formatWithMask } from '../react/fields/textInputControls.js'
9
+
10
+ describe('TextField rich affordances (audit gap #3)', () => {
11
+ describe('password / revealable', () => {
12
+ it('emits password + revealable flags only when set', () => {
13
+ const a = TextField.make('p').password().revealable().toMeta()
14
+ assert.equal(a['password'], true)
15
+ assert.equal(a['revealable'], true)
16
+
17
+ const b = TextField.make('p').toMeta()
18
+ assert.equal(b['password'], undefined)
19
+ assert.equal(b['revealable'], undefined)
20
+ })
21
+
22
+ it('disarms with explicit false', () => {
23
+ const a = TextField.make('p').password(false).toMeta()
24
+ assert.equal(a['password'], undefined)
25
+ })
26
+ })
27
+
28
+ describe('copyable', () => {
29
+ it('flag emits and message defaults are sparse', () => {
30
+ const a = TextField.make('x').copyable().toMeta()
31
+ assert.equal(a['copyable'], true)
32
+ assert.equal(a['copyMessage'], undefined)
33
+
34
+ const b = TextField.make('x').copyable('Got it').toMeta()
35
+ assert.equal(b['copyable'], true)
36
+ assert.equal(b['copyMessage'], 'Got it')
37
+ })
38
+ })
39
+
40
+ describe('mask', () => {
41
+ it('emits the pattern verbatim', () => {
42
+ const a = TextField.make('phone').mask('(999) 999-9999').toMeta()
43
+ assert.equal(a['mask'], '(999) 999-9999')
44
+ })
45
+ })
46
+
47
+ describe('datalist', () => {
48
+ it('emits a defensive copy of the values array', () => {
49
+ const values = ['gmail.com', 'outlook.com']
50
+ const a = TextField.make('email').datalist(values).toMeta()
51
+ const out = a['datalist'] as string[]
52
+ assert.deepEqual(out, values)
53
+ // Mutating the original after the fact must not leak in.
54
+ values.push('yahoo.com')
55
+ assert.deepEqual(a['datalist'], ['gmail.com', 'outlook.com'])
56
+ })
57
+ })
58
+
59
+ describe('stripCharacters', () => {
60
+ it('accepts a string of single chars', () => {
61
+ const a = TextField.make('phone').stripCharacters('()- ').toMeta()
62
+ assert.deepEqual(a['stripCharacters'], ['(', ')', '-', ' '])
63
+ })
64
+
65
+ it('accepts an explicit array', () => {
66
+ const a = TextField.make('phone').stripCharacters(['(', ')']).toMeta()
67
+ assert.deepEqual(a['stripCharacters'], ['(', ')'])
68
+ })
69
+
70
+ it('omits when empty', () => {
71
+ const a = TextField.make('x').stripCharacters('').toMeta()
72
+ assert.equal(a['stripCharacters'], undefined)
73
+ })
74
+
75
+ it('strips configured chars during coerce', () => {
76
+ const f = TextField.make('phone').stripCharacters('()- ')
77
+ const out = coerceFormValues([f], { phone: '(415) 555-1212' })
78
+ assert.equal(out['phone'], '4155551212')
79
+ })
80
+
81
+ it('coerce no-ops when not configured', () => {
82
+ const f = TextField.make('plain')
83
+ const out = coerceFormValues([f], { plain: 'a-b-c' })
84
+ assert.equal(out['plain'], 'a-b-c')
85
+ })
86
+
87
+ it('coerce skips non-string values', () => {
88
+ const f = TextField.make('plain').stripCharacters('-')
89
+ const out = coerceFormValues([f], { plain: 42 as unknown as string })
90
+ assert.equal(out['plain'], 42)
91
+ })
92
+ })
93
+
94
+ describe('inputMode + autocapitalize', () => {
95
+ it('emits each attribute when set', () => {
96
+ const a = TextField.make('q').inputMode('search').autocapitalize('off').toMeta()
97
+ assert.equal(a['inputMode'], 'search')
98
+ assert.equal(a['autocapitalize'], 'off')
99
+ })
100
+
101
+ it('omits each attribute when unset', () => {
102
+ const a = TextField.make('q').toMeta()
103
+ assert.equal(a['inputMode'], undefined)
104
+ assert.equal(a['autocapitalize'], undefined)
105
+ })
106
+ })
107
+
108
+ describe('prefixAction / suffixAction', () => {
109
+ it('resolves bound Actions through resolveSchema as ActionMetas', async () => {
110
+ const result = await resolveSchema([
111
+ TextField.make('apiKey')
112
+ .prefixAction(Action.make('generate').icon('plus'))
113
+ .suffixAction(Action.make('rotate').icon('refresh')),
114
+ ])
115
+ const meta = result[0]!
116
+ const pre = meta['prefixAction'] as Record<string, unknown> | undefined
117
+ const suf = meta['suffixAction'] as Record<string, unknown> | undefined
118
+ assert.equal(pre?.['type'], 'action')
119
+ assert.equal(pre?.['name'], 'generate')
120
+ assert.equal(suf?.['type'], 'action')
121
+ assert.equal(suf?.['name'], 'rotate')
122
+ })
123
+
124
+ it('drops a hidden Action from the slot', async () => {
125
+ const result = await resolveSchema([
126
+ TextField.make('q').prefixAction(Action.make('hide').visible(false)),
127
+ ])
128
+ assert.equal(result[0]!['prefixAction'], undefined)
129
+ })
130
+
131
+ it('omits the slots when not configured', async () => {
132
+ const result = await resolveSchema([TextField.make('q')])
133
+ assert.equal(result[0]!['prefixAction'], undefined)
134
+ assert.equal(result[0]!['suffixAction'], undefined)
135
+ })
136
+ })
137
+ })
138
+
139
+ describe('formatWithMask (client mask helper)', () => {
140
+ it('formats a US phone via the documented alphabet', () => {
141
+ assert.equal(formatWithMask('4155551212', '(999) 999-9999'), '(415) 555-1212')
142
+ })
143
+
144
+ it('emits literals even with no remaining input', () => {
145
+ assert.equal(formatWithMask('415', '(999) 999-9999'), '(415) ')
146
+ })
147
+
148
+ it('skips characters that do not match the token kind', () => {
149
+ assert.equal(formatWithMask('a4b1c5', '999'), '415')
150
+ })
151
+
152
+ it('handles alpha tokens', () => {
153
+ assert.equal(formatWithMask('xy12', 'aa-99'), 'xy-12')
154
+ })
155
+
156
+ it('any-token (*) accepts any character', () => {
157
+ assert.equal(formatWithMask('a1b2', '****'), 'a1b2')
158
+ })
159
+
160
+ it('does NOT double-emit literals already typed by the user', () => {
161
+ assert.equal(formatWithMask('(415)5551212', '(999) 999-9999'), '(415) 555-1212')
162
+ })
163
+
164
+ it('returns input unchanged when mask is empty', () => {
165
+ assert.equal(formatWithMask('hello', ''), '')
166
+ })
167
+ })
168
+
@@ -1,8 +1,47 @@
1
1
  import { Field, type FieldMeta } from './Field.js'
2
2
  import type { RenderContext } from '../schema/resolveSchema.js'
3
+ import type { Action } from '../actions/Action.js'
4
+
5
+ /**
6
+ * HTML5 `inputmode` values — drive the on-screen keyboard mobile browsers
7
+ * pop up. `'text'` is the default; the others map 1:1 to spec values.
8
+ */
9
+ export type TextInputMode =
10
+ | 'none' | 'text' | 'numeric' | 'tel' | 'email' | 'decimal' | 'search' | 'url'
11
+
12
+ /**
13
+ * HTML5 `autocapitalize` values — control which characters mobile virtual
14
+ * keyboards capitalize automatically. `'off'` and `'none'` are aliases per
15
+ * spec; we surface only `'off'` for clarity.
16
+ */
17
+ export type TextAutocapitalize = 'off' | 'sentences' | 'words' | 'characters'
18
+
19
+ /**
20
+ * Mask alphabet documented for `mask(pattern)`:
21
+ * `9` — digit (0-9)
22
+ * `a` — alpha (A-Za-z)
23
+ * `*` — alphanumeric or any character
24
+ * anything else — literal (rendered verbatim, skipped on input)
25
+ *
26
+ * Examples: `'(999) 999-9999'` (US phone), `'9999-9999-9999-9999'` (card),
27
+ * `'aaa-9999'` (custom). The renderer formats values keystroke-by-keystroke
28
+ * and strips literals from the submitted value so the persisted column
29
+ * stores the raw digits/letters.
30
+ */
3
31
 
4
32
  export class TextField extends Field {
5
33
  private _maxLength?: number
34
+ private _password = false
35
+ private _revealable = false
36
+ private _copyable = false
37
+ private _copyMessage?: string
38
+ private _mask?: string
39
+ private _datalist?: string[]
40
+ private _stripCharacters?: string[]
41
+ private _inputMode?: TextInputMode
42
+ private _autocapitalize?: TextAutocapitalize
43
+ private _prefixAction?: Action
44
+ private _suffixAction?: Action
6
45
 
7
46
  private constructor(name: string) {
8
47
  super(name, 'text')
@@ -15,10 +54,111 @@ export class TextField extends Field {
15
54
  maxLength(n: number): this { this._maxLength = n; return this }
16
55
  getMaxLength(): number | undefined { return this._maxLength }
17
56
 
57
+ /**
58
+ * Render the input as `type="password"`. Pair with `revealable()` to
59
+ * surface an eye-icon toggle. Pure presentation — the column type +
60
+ * value handling stays string-shaped.
61
+ */
62
+ password(v: boolean = true): this { this._password = v; return this }
63
+
64
+ /**
65
+ * Mount an eye-icon toggle in the suffix slot that flips the input
66
+ * type between `password` and `text`. No-op when the field is not in
67
+ * `password()` mode (the toggle hides itself client-side so a stray
68
+ * `revealable()` on a non-password input doesn't render a useless
69
+ * button).
70
+ */
71
+ revealable(v: boolean = true): this { this._revealable = v; return this }
72
+
73
+ /**
74
+ * Mount a copy button in the suffix slot that writes the current input
75
+ * value to the clipboard. The optional message overrides the default
76
+ * `'Copied!'` toast text — same convention as
77
+ * `Column.copyMessage()`.
78
+ */
79
+ copyable(message?: string): this {
80
+ this._copyable = true
81
+ if (message !== undefined) this._copyMessage = message
82
+ return this
83
+ }
84
+
85
+ /**
86
+ * Format the input keystroke-by-keystroke against the supplied mask
87
+ * pattern. See `TextField` doc-block for the alphabet (`9` = digit,
88
+ * `a` = alpha, `*` = any, literals passthrough). Submitted values are
89
+ * stripped of literal characters before the form body lands on the
90
+ * server — the persisted column stores the raw chars only.
91
+ */
92
+ mask(pattern: string): this { this._mask = pattern; return this }
93
+
94
+ /**
95
+ * Suggest values via a native HTML5 `<datalist>` attached to the
96
+ * input. Browsers render an autocomplete dropdown; users can still
97
+ * type a value not on the list. Useful for canonical-but-not-exclusive
98
+ * sets (countries, departments, common email domains).
99
+ */
100
+ datalist(values: string[]): this { this._datalist = values.slice(); return this }
101
+
102
+ /**
103
+ * Strip the listed characters from the submitted value before
104
+ * validation runs. Pass a single string (each char becomes a strip
105
+ * token) or an array of strings. Useful for input-mask-style fields
106
+ * where the persisted column should not carry the mask literals
107
+ * (`'(' / ')' / '-' / ' '` for phone numbers, `' '` for credit cards).
108
+ * The strip applies on both create and edit — server-side authority,
109
+ * so a tampered client still gets cleaned values.
110
+ */
111
+ stripCharacters(chars: string | string[]): this {
112
+ const list = typeof chars === 'string' ? Array.from(chars) : [...chars]
113
+ if (list.length === 0) delete this._stripCharacters
114
+ else this._stripCharacters = list
115
+ return this
116
+ }
117
+
118
+ getStripCharacters(): string[] | undefined { return this._stripCharacters }
119
+
120
+ /**
121
+ * Set the HTML `inputmode` attribute — drives the virtual-keyboard
122
+ * layout on mobile. Distinct from `type=` (a `text` field with
123
+ * `inputMode('numeric')` still accepts non-digit pastes; for strict
124
+ * numeric-only, use `NumberField` instead).
125
+ */
126
+ inputMode(mode: TextInputMode): this { this._inputMode = mode; return this }
127
+
128
+ /** Set the HTML `autocapitalize` attribute. */
129
+ autocapitalize(mode: TextAutocapitalize): this { this._autocapitalize = mode; return this }
130
+
131
+ /**
132
+ * Mount a clickable Action button in the prefix slot of the input
133
+ * shell. Distinct from the passive `prefix()` decoration (which is a
134
+ * string or icon descriptor only). Use this for an in-input affordance
135
+ * — e.g. an OAuth-style "Generate" button next to an API key field.
136
+ * The Action retains its full chrome (`.icon() / .color() / .visible()
137
+ * / .modal*()`); visibility rules evaluate through the standard schema
138
+ * walker the same way they do anywhere else.
139
+ */
140
+ prefixAction(action: Action): this { this._prefixAction = action; return this }
141
+
142
+ /** Suffix-slot variant of `prefixAction`. Same semantics. */
143
+ suffixAction(action: Action): this { this._suffixAction = action; return this }
144
+
145
+ /** Read-only access for the resolver. */
146
+ getPrefixAction(): Action | undefined { return this._prefixAction }
147
+ getSuffixAction(): Action | undefined { return this._suffixAction }
148
+
18
149
  override toMeta(ctx?: RenderContext): FieldMeta {
19
150
  return {
20
151
  ...this.buildMeta(ctx),
21
- ...(this._maxLength !== undefined ? { maxLength: this._maxLength } : {}),
152
+ ...(this._maxLength !== undefined ? { maxLength: this._maxLength } : {}),
153
+ ...(this._password ? { password: true } : {}),
154
+ ...(this._revealable ? { revealable: true } : {}),
155
+ ...(this._copyable ? { copyable: true } : {}),
156
+ ...(this._copyMessage !== undefined ? { copyMessage: this._copyMessage } : {}),
157
+ ...(this._mask !== undefined ? { mask: this._mask } : {}),
158
+ ...(this._datalist !== undefined ? { datalist: this._datalist } : {}),
159
+ ...(this._stripCharacters!== undefined ? { stripCharacters:this._stripCharacters} : {}),
160
+ ...(this._inputMode !== undefined ? { inputMode: this._inputMode } : {}),
161
+ ...(this._autocapitalize !== undefined ? { autocapitalize: this._autocapitalize } : {}),
22
162
  }
23
163
  }
24
164
  }
@@ -140,6 +140,24 @@ describe('TextConstraint', () => {
140
140
  assert.deepEqual(q.ops[0]!.args, ['title', 'LIKE', '%hello%'])
141
141
  })
142
142
 
143
+ it('notContains → NOT LIKE %v%', () => {
144
+ const q = new FakeQuery()
145
+ TextConstraint.make('title').apply(q, 'notContains', 'spam')
146
+ assert.deepEqual(q.ops[0]!.args, ['title', 'NOT LIKE', '%spam%'])
147
+ })
148
+
149
+ it('notContains escapes LIKE wildcards', () => {
150
+ const q = new FakeQuery()
151
+ TextConstraint.make('title').apply(q, 'notContains', '50% off')
152
+ assert.deepEqual(q.ops[0]!.args, ['title', 'NOT LIKE', '%50\\% off%'])
153
+ })
154
+
155
+ it('notContains drops empty string', () => {
156
+ const q = new FakeQuery()
157
+ TextConstraint.make('title').apply(q, 'notContains', '')
158
+ assert.equal(q.ops.length, 0)
159
+ })
160
+
143
161
  it('startsWith / endsWith / equals / notEquals', () => {
144
162
  const checks: Array<[string, string, string]> = [
145
163
  ['startsWith', 'LIKE', 'foo%'],
@@ -26,7 +26,7 @@ export type ConstraintValueKind =
26
26
  */
27
27
  export type ConstraintOperatorName =
28
28
  | 'equals' | 'notEquals'
29
- | 'contains' | 'startsWith' | 'endsWith'
29
+ | 'contains' | 'notContains' | 'startsWith' | 'endsWith'
30
30
  | 'lt' | 'lte' | 'gt' | 'gte' | 'between'
31
31
  | 'before' | 'after' | 'dateBetween'
32
32
  | 'in' | 'notIn'