@keenmate/web-grid 1.0.0-rc15 → 1.0.1

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.
@@ -0,0 +1,504 @@
1
+ DROPDOWN EDITORS IN @keenmate/web-grid
2
+ =======================================
3
+
4
+ Three editor types use dropdown menus: select, combobox, and autocomplete.
5
+ All three share a common set of options (EditorOptions) plus type-specific behavior.
6
+
7
+
8
+ EDITOR TYPES
9
+ ------------
10
+
11
+ select
12
+ Static dropdown list. User clicks to open, picks from list.
13
+ Cannot type to filter directly in the trigger, but typing while open
14
+ filters the list. Renders a styled div trigger (not native <select>).
15
+ CSS class: wg__editor--select, trigger: wg__select-trigger
16
+
17
+ combobox
18
+ Filterable dropdown. User types in a text input to narrow options.
19
+ Options are filtered client-side by matching against display text.
20
+ CSS class: wg__editor--combobox, input: wg__combobox-input
21
+
22
+ autocomplete
23
+ Async search dropdown. User types, then after debounce, searchCallback
24
+ is called to fetch results from a server. Supports AbortSignal for
25
+ cancellation, loading indicator, initialOptions, and multiple selection.
26
+ CSS class: wg__editor--autocomplete, input: wg__autocomplete-input
27
+
28
+ Column definition example:
29
+
30
+ {
31
+ field: 'status',
32
+ title: 'Status',
33
+ editor: 'select',
34
+ isEditable: true,
35
+ editorOptions: {
36
+ options: [
37
+ { value: 'active', label: 'Active' },
38
+ { value: 'inactive', label: 'Inactive' }
39
+ ]
40
+ }
41
+ }
42
+
43
+
44
+ PROVIDING OPTIONS
45
+ -----------------
46
+
47
+ There are two ways to supply options to any dropdown editor:
48
+
49
+ 1. Static array (options)
50
+
51
+ editorOptions: {
52
+ options: [
53
+ { value: 1, label: 'One' },
54
+ { value: 2, label: 'Two' }
55
+ ]
56
+ }
57
+
58
+ The EditorOption type requires value (string | number | boolean) and
59
+ label (string), plus allows arbitrary extra properties via [key: string]: unknown.
60
+
61
+ 2. Async loader (loadOptions)
62
+
63
+ editorOptions: {
64
+ loadOptions: async (row, field) => {
65
+ const response = await fetch('/api/options')
66
+ return response.json()
67
+ },
68
+ optionsLoadTrigger: 'oneditstart'
69
+ }
70
+
71
+ loadOptions receives the current row and field name, returns Promise<EditorOption[]>.
72
+
73
+ optionsLoadTrigger controls when loadOptions is called:
74
+
75
+ 'immediate' Load options as soon as the grid renders.
76
+ 'oneditstart' Load options when the cell enters edit mode (default).
77
+ 'ondropdownopen' Load options when the dropdown is actually opened.
78
+
79
+
80
+ DISPLAY MAPPING
81
+ ---------------
82
+
83
+ By default, options use { value, label } properties. When your data uses
84
+ different property names, use member strings or callback functions.
85
+
86
+ Each mapping has two alternatives: a member (string property name) and a
87
+ callback (function that receives the option object and returns the value).
88
+ Callbacks take priority over members when both are set.
89
+
90
+ valueMember / getValueCallback
91
+ Which property holds the option's committed value.
92
+ Default property: "value"
93
+
94
+ valueMember: 'id'
95
+ -- or --
96
+ getValueCallback: (opt) => opt.id
97
+
98
+ displayMember / getDisplayCallback
99
+ Which property holds the text shown in the dropdown and in the cell.
100
+ Default property: "label"
101
+
102
+ displayMember: 'name'
103
+ -- or --
104
+ getDisplayCallback: (opt) => opt.firstName + ' ' + opt.lastName
105
+
106
+ searchMember / getSearchCallback
107
+ Which property to search/filter against. Falls back to displayMember.
108
+ Useful when display text differs from searchable text.
109
+
110
+ searchMember: 'searchText'
111
+ -- or --
112
+ getSearchCallback: (opt) => opt.code + ' ' + opt.name
113
+
114
+ iconMember / getIconCallback
115
+ Property containing an icon string (emoji or text) shown before the label.
116
+
117
+ iconMember: 'emoji'
118
+ -- or --
119
+ getIconCallback: (opt) => opt.priority === 'high' ? '!' : ''
120
+
121
+ subtitleMember / getSubtitleCallback
122
+ Property containing a subtitle/description shown below the label.
123
+
124
+ subtitleMember: 'description'
125
+ -- or --
126
+ getSubtitleCallback: (opt) => opt.department + ' - ' + opt.role
127
+
128
+ disabledMember / getDisabledCallback
129
+ Property indicating the option cannot be selected.
130
+
131
+ disabledMember: 'isArchived'
132
+ -- or --
133
+ getDisabledCallback: (opt) => opt.stock === 0
134
+
135
+ groupMember / getGroupCallback
136
+ Property for grouping options under headers.
137
+
138
+ groupMember: 'category'
139
+ -- or --
140
+ getGroupCallback: (opt) => opt.type === 'fruit' ? 'Fruits' : 'Vegetables'
141
+
142
+ Fallback chain for resolving display value in non-editing cells:
143
+ 1. Find option by valueMember (or getValueCallback)
144
+ 2. Use getDisplayCallback if provided
145
+ 3. Use displayMember property
146
+ 4. Use 'label' property
147
+ 5. Fall back to raw value as string
148
+
149
+
150
+ RENDERING AND BEHAVIOR
151
+ ----------------------
152
+
153
+ renderOptionCallback(option, context)
154
+ Returns an HTML string for a single dropdown option. When provided,
155
+ completely replaces the default option rendering. The context object has:
156
+
157
+ index number Zero-based position in filtered list.
158
+ isHighlighted boolean True if this option has keyboard/mouse highlight.
159
+ isSelected boolean True if this option matches the cell's current value.
160
+ isDisabled boolean True if the option is disabled.
161
+
162
+ Example:
163
+
164
+ renderOptionCallback: (opt, ctx) => {
165
+ const classes = ['wg__dropdown-option']
166
+ if (ctx.isHighlighted) classes.push('wg__dropdown-option--highlighted')
167
+ if (ctx.isSelected) classes.push('wg__dropdown-option--selected')
168
+ if (ctx.isDisabled) classes.push('wg__dropdown-option--disabled')
169
+ return '<div class="' + classes.join(' ') + '" data-index="' + ctx.index + '">' +
170
+ '<strong>' + opt.label + '</strong>' +
171
+ '<small style="margin-left:8px;opacity:0.6">' + opt.code + '</small>' +
172
+ '</div>'
173
+ }
174
+
175
+ IMPORTANT: The returned HTML must include data-index="N" on the clickable
176
+ element for mouse selection to work. Use data-disabled="true" on disabled
177
+ options to prevent selection.
178
+
179
+ onselect(option, row)
180
+ Event fired after an option is selected and committed. The row parameter
181
+ is the current row data. Return value is ignored (fire-and-forget event).
182
+
183
+ editorOptions: {
184
+ onselect: (option, row) => {
185
+ console.log('Selected', option.label, 'for row', row.id)
186
+ }
187
+ }
188
+
189
+ allowEmpty (boolean)
190
+ When true, adds an empty/null option at the top of the dropdown.
191
+ Selecting it commits null/empty to the cell.
192
+
193
+ emptyLabel (string)
194
+ Label for the empty option. Default: "-- Select --"
195
+
196
+ noOptionsText (string)
197
+ Message shown when filter returns no matches.
198
+ Falls back to grid.labels.dropdownNoOptions (default: "No options").
199
+
200
+ searchingText (string)
201
+ Message shown during async search.
202
+ Falls back to grid.labels.dropdownSearching (default: "Searching...").
203
+
204
+ dropdownMinWidth (string)
205
+ CSS minimum width for the dropdown popup. Useful when the cell is narrow
206
+ but options need more space. Example: '300px'
207
+ By default, dropdown matches the cell width.
208
+
209
+ placeholder (string)
210
+ Placeholder text for combobox and autocomplete input fields.
211
+
212
+ Grid-level dropdown settings (set on the grid element, overridable per-column):
213
+
214
+ dropdownToggleVisibility 'always' or 'on-focus'
215
+ Controls when the dropdown arrow is visible in display mode.
216
+ 'always' shows it permanently; 'on-focus' shows it on hover/focus.
217
+ Per-column override: column.dropdownToggleVisibility
218
+
219
+ shouldShowDropdownOnFocus boolean (default: false)
220
+ When true, the dropdown opens automatically when a cell is focused.
221
+
222
+ shouldOpenDropdownOnEnter boolean (default: false)
223
+ When true, pressing Enter opens the dropdown instead of moving down.
224
+ Per-column override: column.shouldOpenDropdownOnEnter
225
+
226
+
227
+ AUTOCOMPLETE-SPECIFIC OPTIONS
228
+ ------------------------------
229
+
230
+ searchCallback(query, row, signal?)
231
+ Async function called after debounce when user types. Returns
232
+ Promise<EditorOption[]>. Receives the search query, current row data,
233
+ and an optional AbortSignal for cancellation of in-flight requests.
234
+
235
+ editorOptions: {
236
+ searchCallback: async (query, row, signal) => {
237
+ const res = await fetch('/api/search?q=' + query, { signal })
238
+ return res.json()
239
+ }
240
+ }
241
+
242
+ If searchCallback is not provided, autocomplete falls back to local
243
+ filtering of initialOptions (same behavior as combobox).
244
+
245
+ initialOptions (EditorOption[])
246
+ Options shown before the user starts typing. Also used as the base
247
+ list for fallback local filtering when no searchCallback is set.
248
+
249
+ minSearchLength (number, default: 1)
250
+ Minimum characters the user must type before searchCallback fires.
251
+ Below this threshold, initialOptions are shown.
252
+
253
+ debounceMs (number, default: 300)
254
+ Delay in milliseconds before searchCallback is invoked after
255
+ the user stops typing. Previous in-flight requests are aborted.
256
+
257
+ multiple (boolean, default: false)
258
+ Allow selecting more than one option.
259
+
260
+ maxSelections (number)
261
+ Maximum number of selected items when multiple is true.
262
+
263
+
264
+ CUSTOM OPTION RENDERING
265
+ ------------------------
266
+
267
+ When using renderOptionCallback, you must include certain CSS classes and
268
+ data attributes for the dropdown interaction to work correctly.
269
+
270
+ Required CSS classes:
271
+
272
+ wg__dropdown-option Base class for every option div.
273
+ wg__dropdown-option--highlighted Applied to the keyboard/mouse-highlighted option.
274
+ wg__dropdown-option--selected Applied to the option matching the current value.
275
+ wg__dropdown-option--disabled Applied to disabled options. Also add data-disabled="true".
276
+
277
+ Available inner-element classes (used by default rendering):
278
+
279
+ wg__dropdown-option-icon Container for icon/emoji (flex-shrink: 0, 1.5em wide).
280
+ wg__dropdown-option-content Flex container for label + subtitle.
281
+ wg__dropdown-option-label Primary text (ellipsis overflow).
282
+ wg__dropdown-option-subtitle Secondary text (smaller, muted color, ellipsis overflow).
283
+
284
+ Alignment classes (inherited from column.horizontalAlign):
285
+
286
+ wg__dropdown-option--align-left
287
+ wg__dropdown-option--align-center
288
+ wg__dropdown-option--align-right
289
+ wg__dropdown-option--align-justify
290
+
291
+ Empty state class:
292
+
293
+ wg__dropdown-empty Shown when no options match (italic, centered, muted).
294
+
295
+ Full custom rendering example:
296
+
297
+ editorOptions: {
298
+ options: [
299
+ { id: 'us', name: 'United States', flag: 'US', population: '331M' },
300
+ { id: 'de', name: 'Germany', flag: 'DE', population: '83M' },
301
+ { id: 'jp', name: 'Japan', flag: 'JP', population: '125M' }
302
+ ],
303
+ valueMember: 'id',
304
+ displayMember: 'name',
305
+ renderOptionCallback: (opt, ctx) => {
306
+ const cls = ['wg__dropdown-option']
307
+ if (ctx.isHighlighted) cls.push('wg__dropdown-option--highlighted')
308
+ if (ctx.isSelected) cls.push('wg__dropdown-option--selected')
309
+ if (ctx.isDisabled) cls.push('wg__dropdown-option--disabled')
310
+ return '<div class="' + cls.join(' ') + '" data-index="' + ctx.index + '"' +
311
+ (ctx.isDisabled ? ' data-disabled="true"' : '') + '>' +
312
+ '<span class="wg__dropdown-option-icon">' + opt.flag + '</span>' +
313
+ '<div class="wg__dropdown-option-content">' +
314
+ '<span class="wg__dropdown-option-label">' + opt.name + '</span>' +
315
+ '<span class="wg__dropdown-option-subtitle">Pop: ' + opt.population + '</span>' +
316
+ '</div>' +
317
+ '</div>'
318
+ }
319
+ }
320
+
321
+
322
+ EXAMPLES
323
+ --------
324
+
325
+ Example 1: Select with custom value/display members
326
+
327
+ {
328
+ field: 'countryId',
329
+ title: 'Country',
330
+ editor: 'select',
331
+ isEditable: true,
332
+ editorOptions: {
333
+ options: [
334
+ { code: 'US', name: 'United States', region: 'Americas' },
335
+ { code: 'DE', name: 'Germany', region: 'Europe' },
336
+ { code: 'JP', name: 'Japan', region: 'Asia' }
337
+ ],
338
+ valueMember: 'code',
339
+ displayMember: 'name',
340
+ groupMember: 'region',
341
+ allowEmpty: true,
342
+ emptyLabel: '(none)'
343
+ }
344
+ }
345
+
346
+ Example 2: Combobox with icons and subtitles
347
+
348
+ {
349
+ field: 'priority',
350
+ title: 'Priority',
351
+ editor: 'combobox',
352
+ isEditable: true,
353
+ editorOptions: {
354
+ options: [
355
+ { value: 'critical', label: 'Critical', icon: '!', desc: 'Immediate action' },
356
+ { value: 'high', label: 'High', icon: 'H', desc: 'Resolve within 24h' },
357
+ { value: 'medium', label: 'Medium', icon: 'M', desc: 'Resolve within 1 week' },
358
+ { value: 'low', label: 'Low', icon: 'L', desc: 'When time permits' }
359
+ ],
360
+ iconMember: 'icon',
361
+ subtitleMember: 'desc',
362
+ placeholder: 'Search priorities...',
363
+ dropdownMinWidth: '280px',
364
+ onselect: (option, row) => {
365
+ console.log('Priority changed to', option.label)
366
+ }
367
+ }
368
+ }
369
+
370
+ Example 3: Autocomplete with async search
371
+
372
+ {
373
+ field: 'assigneeId',
374
+ title: 'Assignee',
375
+ editor: 'autocomplete',
376
+ isEditable: true,
377
+ editorOptions: {
378
+ initialOptions: [
379
+ { value: 1, label: 'Alice Smith' },
380
+ { value: 2, label: 'Bob Johnson' }
381
+ ],
382
+ searchCallback: async (query, row, signal) => {
383
+ const response = await fetch(
384
+ '/api/users?search=' + encodeURIComponent(query),
385
+ { signal }
386
+ )
387
+ const users = await response.json()
388
+ return users.map(u => ({
389
+ value: u.id,
390
+ label: u.name,
391
+ subtitle: u.email
392
+ }))
393
+ },
394
+ subtitleMember: 'subtitle',
395
+ minSearchLength: 2,
396
+ debounceMs: 250,
397
+ placeholder: 'Search users...',
398
+ dropdownMinWidth: '350px',
399
+ allowEmpty: true
400
+ }
401
+ }
402
+
403
+ Example 4: Combobox with callback-based display mapping
404
+
405
+ {
406
+ field: 'productId',
407
+ title: 'Product',
408
+ editor: 'combobox',
409
+ isEditable: true,
410
+ editorOptions: {
411
+ options: products,
412
+ getValueCallback: (opt) => opt.sku,
413
+ getDisplayCallback: (opt) => opt.sku + ' - ' + opt.productName,
414
+ getSearchCallback: (opt) => opt.sku + ' ' + opt.productName + ' ' + opt.category,
415
+ getIconCallback: (opt) => opt.inStock ? '' : '!',
416
+ getSubtitleCallback: (opt) => '$' + opt.price.toFixed(2) + ' | ' + opt.category,
417
+ getDisabledCallback: (opt) => opt.discontinued,
418
+ dropdownMinWidth: '400px'
419
+ }
420
+ }
421
+
422
+ Example 5: Select with async options loaded on edit start
423
+
424
+ {
425
+ field: 'departmentId',
426
+ title: 'Department',
427
+ editor: 'select',
428
+ isEditable: true,
429
+ editorOptions: {
430
+ loadOptions: async (row, field) => {
431
+ const response = await fetch('/api/departments?company=' + row.companyId)
432
+ return response.json()
433
+ },
434
+ optionsLoadTrigger: 'oneditstart',
435
+ valueMember: 'id',
436
+ displayMember: 'name',
437
+ noOptionsText: 'No departments found'
438
+ }
439
+ }
440
+
441
+ Example 6: Autocomplete with multiple selection
442
+
443
+ {
444
+ field: 'tags',
445
+ title: 'Tags',
446
+ editor: 'autocomplete',
447
+ isEditable: true,
448
+ editorOptions: {
449
+ searchCallback: async (query, row, signal) => {
450
+ const res = await fetch('/api/tags?q=' + query, { signal })
451
+ return res.json()
452
+ },
453
+ multiple: true,
454
+ maxSelections: 5,
455
+ minSearchLength: 1,
456
+ debounceMs: 200,
457
+ placeholder: 'Add tags...'
458
+ }
459
+ }
460
+
461
+
462
+ DROPDOWN POSITIONING
463
+ --------------------
464
+
465
+ Dropdowns are positioned using Floating UI (@floating-ui/dom).
466
+ They appear below the cell by default, flipping above if not enough space.
467
+ The dropdown is appended to the shadow DOM root (not inside the cell)
468
+ and uses position: fixed with z-index: 1000.
469
+
470
+ The dropdown width defaults to the cell width (via minWidth), but can be
471
+ made wider with dropdownMinWidth in editorOptions.
472
+
473
+
474
+ GRID MODE DEFAULTS FOR DROPDOWNS
475
+ ---------------------------------
476
+
477
+ Grid modes set different defaults for dropdown behavior:
478
+
479
+ read-only dropdownToggleVisibility: 'on-focus', shouldShowDropdownOnFocus: false
480
+ excel dropdownToggleVisibility: 'always', shouldShowDropdownOnFocus: false
481
+ input-matrix dropdownToggleVisibility: 'always', shouldShowDropdownOnFocus: true
482
+
483
+ In input-matrix mode, dropdown cells auto-open when focused, making it
484
+ behave like a form. In excel mode, toggles are always visible but require
485
+ explicit interaction to open.
486
+
487
+
488
+ TYPE DEFINITIONS
489
+ ----------------
490
+
491
+ EditorOption = {
492
+ value: string | number | boolean
493
+ label: string
494
+ [key: string]: unknown
495
+ }
496
+
497
+ OptionRenderContext = {
498
+ index: number
499
+ isHighlighted: boolean
500
+ isSelected: boolean
501
+ isDisabled: boolean
502
+ }
503
+
504
+ OptionsLoadTrigger = 'immediate' | 'oneditstart' | 'ondropdownopen'