shadcn_phlexcomponents 0.1.11 → 0.1.14

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/app/javascript/controllers/accordion_controller.ts +65 -62
  3. data/app/javascript/controllers/alert_dialog_controller.ts +12 -0
  4. data/app/javascript/controllers/avatar_controller.ts +7 -2
  5. data/app/javascript/controllers/checkbox_controller.ts +11 -4
  6. data/app/javascript/controllers/collapsible_controller.ts +12 -5
  7. data/app/javascript/controllers/combobox_controller.ts +270 -39
  8. data/app/javascript/controllers/command_controller.ts +223 -51
  9. data/app/javascript/controllers/date_picker_controller.ts +185 -125
  10. data/app/javascript/controllers/date_range_picker_controller.ts +89 -79
  11. data/app/javascript/controllers/dialog_controller.ts +59 -57
  12. data/app/javascript/controllers/dropdown_menu_controller.ts +212 -36
  13. data/app/javascript/controllers/dropdown_menu_sub_controller.ts +31 -29
  14. data/app/javascript/controllers/form_field_controller.ts +6 -1
  15. data/app/javascript/controllers/hover_card_controller.ts +36 -26
  16. data/app/javascript/controllers/loading_button_controller.ts +6 -1
  17. data/app/javascript/controllers/popover_controller.ts +42 -65
  18. data/app/javascript/controllers/progress_controller.ts +9 -3
  19. data/app/javascript/controllers/radio_group_controller.ts +16 -9
  20. data/app/javascript/controllers/select_controller.ts +206 -65
  21. data/app/javascript/controllers/slider_controller.ts +23 -16
  22. data/app/javascript/controllers/switch_controller.ts +11 -4
  23. data/app/javascript/controllers/tabs_controller.ts +26 -18
  24. data/app/javascript/controllers/theme_switcher_controller.ts +6 -1
  25. data/app/javascript/controllers/toast_container_controller.ts +6 -1
  26. data/app/javascript/controllers/toast_controller.ts +7 -1
  27. data/app/javascript/controllers/toggle_controller.ts +28 -0
  28. data/app/javascript/controllers/toggle_group_controller.ts +28 -0
  29. data/app/javascript/controllers/tooltip_controller.ts +43 -31
  30. data/app/javascript/shadcn_phlexcomponents.ts +29 -25
  31. data/app/javascript/utils/command.ts +544 -0
  32. data/app/javascript/utils/floating_ui.ts +196 -0
  33. data/app/javascript/utils/index.ts +417 -0
  34. data/app/stylesheets/date_picker.css +118 -0
  35. data/lib/shadcn_phlexcomponents/alias.rb +3 -0
  36. data/lib/shadcn_phlexcomponents/components/accordion.rb +2 -1
  37. data/lib/shadcn_phlexcomponents/components/alert_dialog.rb +18 -15
  38. data/lib/shadcn_phlexcomponents/components/base.rb +14 -0
  39. data/lib/shadcn_phlexcomponents/components/collapsible.rb +1 -2
  40. data/lib/shadcn_phlexcomponents/components/combobox.rb +87 -57
  41. data/lib/shadcn_phlexcomponents/components/command.rb +77 -47
  42. data/lib/shadcn_phlexcomponents/components/date_picker.rb +25 -81
  43. data/lib/shadcn_phlexcomponents/components/date_range_picker.rb +21 -4
  44. data/lib/shadcn_phlexcomponents/components/dialog.rb +14 -12
  45. data/lib/shadcn_phlexcomponents/components/dropdown_menu.rb +5 -4
  46. data/lib/shadcn_phlexcomponents/components/dropdown_menu_sub.rb +2 -1
  47. data/lib/shadcn_phlexcomponents/components/form/form_combobox.rb +64 -0
  48. data/lib/shadcn_phlexcomponents/components/form.rb +14 -0
  49. data/lib/shadcn_phlexcomponents/components/hover_card.rb +3 -2
  50. data/lib/shadcn_phlexcomponents/components/popover.rb +3 -3
  51. data/lib/shadcn_phlexcomponents/components/select.rb +10 -25
  52. data/lib/shadcn_phlexcomponents/components/sheet.rb +15 -11
  53. data/lib/shadcn_phlexcomponents/components/table.rb +1 -1
  54. data/lib/shadcn_phlexcomponents/components/tabs.rb +1 -1
  55. data/lib/shadcn_phlexcomponents/components/toast_container.rb +1 -1
  56. data/lib/shadcn_phlexcomponents/components/toggle.rb +54 -0
  57. data/lib/shadcn_phlexcomponents/components/tooltip.rb +3 -2
  58. data/lib/shadcn_phlexcomponents/engine.rb +1 -5
  59. data/lib/shadcn_phlexcomponents/version.rb +1 -1
  60. metadata +9 -4
  61. data/app/javascript/controllers/command_root_controller.ts +0 -355
  62. data/app/javascript/controllers/dropdown_menu_root_controller.ts +0 -234
  63. data/app/javascript/utils.ts +0 -437
@@ -0,0 +1,544 @@
1
+ import { Command } from '../controllers/command_controller'
2
+ import {
3
+ Combobox,
4
+ ComboboxController,
5
+ } from '../controllers/combobox_controller'
6
+ import { getNextEnabledIndex, getPreviousEnabledIndex } from '.'
7
+
8
+ const scrollToItem = (controller: Command | Combobox, index: number) => {
9
+ const item = controller.filteredItems[index]
10
+ const itemRect = item.getBoundingClientRect()
11
+ const listContainerRect =
12
+ controller.listContainerTarget.getBoundingClientRect()
13
+ let newScrollTop = null as number | null
14
+
15
+ const maxScrollTop =
16
+ controller.listContainerTarget.scrollHeight -
17
+ controller.listContainerTarget.clientHeight
18
+
19
+ // scroll to bottom
20
+ if (itemRect.bottom - listContainerRect.bottom > 0) {
21
+ if (index === controller.filteredItems.length - 1) {
22
+ newScrollTop = maxScrollTop
23
+ } else {
24
+ newScrollTop =
25
+ controller.listContainerTarget.scrollTop +
26
+ (itemRect.bottom - listContainerRect.bottom)
27
+ }
28
+ } else if (listContainerRect.top - itemRect.top > 0) {
29
+ // scroll to top
30
+ if (index === 0) {
31
+ newScrollTop = 0
32
+ } else {
33
+ newScrollTop =
34
+ controller.listContainerTarget.scrollTop -
35
+ (listContainerRect.top - itemRect.top)
36
+ }
37
+ }
38
+
39
+ if (newScrollTop !== null) {
40
+ controller.scrollingViaKeyboard = true
41
+
42
+ if (newScrollTop >= 0 && newScrollTop <= maxScrollTop) {
43
+ controller.listContainerTarget.scrollTop = newScrollTop
44
+ }
45
+
46
+ // Clear the flag after scroll settles
47
+ clearTimeout(controller.keyboardScrollTimeout)
48
+ controller.keyboardScrollTimeout = window.setTimeout(() => {
49
+ controller.scrollingViaKeyboard = false
50
+ }, 200)
51
+ }
52
+ }
53
+
54
+ const highlightItem = (
55
+ controller: Command | Combobox,
56
+ event: MouseEvent | KeyboardEvent | null = null,
57
+ index: number | null = null,
58
+ ) => {
59
+ if (event !== null) {
60
+ if (event instanceof KeyboardEvent) {
61
+ const key = event.key
62
+ const item = controller.filteredItems.find(
63
+ (i) => i.dataset.highlighted === 'true',
64
+ )
65
+
66
+ if (item) {
67
+ const index = controller.filteredItems.indexOf(item)
68
+
69
+ let newIndex = 0
70
+ if (key === 'ArrowUp') {
71
+ newIndex = getPreviousEnabledIndex({
72
+ items: controller.filteredItems,
73
+ currentIndex: index,
74
+ filterFn: (item: HTMLElement) =>
75
+ item.dataset.disabled === undefined,
76
+ wrapAround: false,
77
+ })
78
+ } else {
79
+ newIndex = getNextEnabledIndex({
80
+ items: controller.filteredItems,
81
+ currentIndex: index,
82
+ filterFn: (item: HTMLElement) =>
83
+ item.dataset.disabled === undefined,
84
+ wrapAround: false,
85
+ })
86
+ }
87
+
88
+ controller.highlightItemByIndex(newIndex)
89
+ controller.scrollToItem(newIndex)
90
+ } else {
91
+ if (key === 'ArrowUp') {
92
+ controller.highlightItemByIndex(controller.filteredItems.length - 1)
93
+ } else {
94
+ controller.highlightItemByIndex(0)
95
+ }
96
+ }
97
+ } else {
98
+ // mouse event
99
+ if (controller.scrollingViaKeyboard) {
100
+ event.stopImmediatePropagation()
101
+ return
102
+ } else {
103
+ const item = event.currentTarget as HTMLElement
104
+ const index = controller.filteredItems.indexOf(item)
105
+ controller.highlightItemByIndex(index)
106
+ }
107
+ }
108
+ } else if (index !== null) {
109
+ controller.highlightItemByIndex(index)
110
+ }
111
+ }
112
+
113
+ const highlightItemByIndex = (
114
+ controller: Command | Combobox,
115
+ index: number,
116
+ ) => {
117
+ controller.filteredItems.forEach((item, i) => {
118
+ if (i === index) {
119
+ item.dataset.highlighted = 'true'
120
+ } else {
121
+ item.dataset.highlighted = 'false'
122
+ }
123
+ })
124
+ }
125
+
126
+ const filteredItemsChanged = (
127
+ controller: Command | Combobox,
128
+ filteredItemIndexes: number[],
129
+ ) => {
130
+ if (controller.orderedItems) {
131
+ const filteredItems = filteredItemIndexes.map(
132
+ (i) => controller.orderedItems[i],
133
+ )
134
+
135
+ // 1. Toggle visibility of items
136
+ controller.orderedItems.forEach((item) => {
137
+ if (filteredItems.includes(item)) {
138
+ item.ariaHidden = 'false'
139
+ item.classList.remove('hidden')
140
+ } else {
141
+ item.ariaHidden = 'true'
142
+ item.classList.add('hidden')
143
+ }
144
+ })
145
+
146
+ // 2. Get groups based on order of filtered items
147
+ const groupIds = filteredItems.map((item) => item.dataset.groupId)
148
+ const uniqueGroupIds = [...new Set(groupIds)].filter((groupId) => !!groupId)
149
+ const orderedGroups = uniqueGroupIds.map((groupId) => {
150
+ return controller.listTarget.querySelector(
151
+ `[aria-labelledby=${groupId}]`,
152
+ ) as HTMLElement
153
+ })
154
+
155
+ // 3. Append items and groups based on filtered items
156
+ const appendedGroupIds = [] as string[]
157
+
158
+ filteredItems.forEach((item) => {
159
+ const groupId = item.dataset.groupId
160
+
161
+ if (groupId) {
162
+ const group = orderedGroups.find(
163
+ (g) => g.getAttribute('aria-labelledby') === groupId,
164
+ )
165
+
166
+ if (group) {
167
+ group.appendChild(item)
168
+
169
+ if (!appendedGroupIds.includes(groupId)) {
170
+ controller.listTarget.appendChild(group)
171
+ appendedGroupIds.push(groupId)
172
+ }
173
+ }
174
+ } else {
175
+ controller.listTarget.appendChild(item)
176
+ }
177
+ })
178
+
179
+ // 4. Toggle visibility of groups
180
+ controller.groupTargets.forEach((group) => {
181
+ const itemsCount = group.querySelectorAll(
182
+ `[data-${controller.identifier}-target=item][aria-hidden=false]`,
183
+ ).length
184
+ if (itemsCount > 0) {
185
+ group.classList.remove('hidden')
186
+ } else {
187
+ group.classList.add('hidden')
188
+ }
189
+ })
190
+
191
+ // 5. Move remote items to the end
192
+ const remoteItems = Array.from(
193
+ controller.element.querySelectorAll(
194
+ `[data-shadcn-phlexcomponents="${controller.identifier}-item"][data-remote='true']`,
195
+ ),
196
+ )
197
+ remoteItems.forEach((i) => {
198
+ const isInsideGroup =
199
+ i.parentElement?.dataset?.shadcnPhlexcomponents ===
200
+ `${controller.identifier}-group`
201
+
202
+ if (isInsideGroup) {
203
+ const isRemoteGroup = i.parentElement.dataset.remote === 'true'
204
+
205
+ // Move group to last
206
+ if (isRemoteGroup) {
207
+ controller.listTarget.appendChild(i.parentElement)
208
+ }
209
+ } else {
210
+ // Move item to last
211
+ controller.listTarget.appendChild(i)
212
+ }
213
+ })
214
+
215
+ // 6. Assign filteredItems based on the order it is displayed in the DOM
216
+ controller.filteredItems = Array.from(
217
+ controller.listTarget.querySelectorAll(
218
+ `[data-${controller.identifier}-target=item][aria-hidden=false]`,
219
+ ),
220
+ )
221
+
222
+ // 7. Highlight first item
223
+ controller.highlightItemByIndex(0)
224
+
225
+ // 8. Toggle visibility of empty
226
+ if (controller.isDirty && !controller.isLoading) {
227
+ if (controller.filteredItems.length > 0) {
228
+ hideEmpty(controller)
229
+ } else {
230
+ showEmpty(controller)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ const setItemsGroupId = (controller: Command | Combobox) => {
237
+ controller.itemTargets.forEach((item) => {
238
+ const parent = item.parentElement
239
+
240
+ if (parent?.dataset[`${controller.identifier}Target`] === 'group') {
241
+ item.dataset.groupId = parent.getAttribute('aria-labelledby') as string
242
+ }
243
+ })
244
+ }
245
+
246
+ const search = (controller: Command | Combobox, event: InputEvent) => {
247
+ const input = event.target as HTMLInputElement
248
+ const value = input.value.trim()
249
+
250
+ if (value.length > 0) {
251
+ const results = controller.fuse.search(value)
252
+
253
+ // Don't show disabled items when filtering
254
+ let filteredItemIndexes = results.map((result) => result.refIndex)
255
+ filteredItemIndexes = filteredItemIndexes.filter((index) => {
256
+ const item = controller.orderedItems[index]
257
+ return item.dataset.disabled === undefined
258
+ })
259
+
260
+ if (controller.searchPath) {
261
+ hideSelectedRemoteItems(controller)
262
+ showLoading(controller)
263
+ hideList(controller)
264
+ hideEmpty(controller)
265
+ controller.filteredItemIndexesValue = filteredItemIndexes
266
+ performRemoteSearch(controller, value)
267
+ } else {
268
+ controller.filteredItemIndexesValue = filteredItemIndexes
269
+ }
270
+ } else {
271
+ if (controller.searchPath) {
272
+ showSelectedRemoteItems(controller)
273
+ }
274
+ controller.filteredItemIndexesValue = Array.from(
275
+ { length: controller.orderedItems.length },
276
+ (_, i) => i,
277
+ )
278
+ }
279
+ }
280
+
281
+ const performRemoteSearch = async (
282
+ controller: Command | Combobox,
283
+ query: string,
284
+ ) => {
285
+ // Cancel previous request
286
+ if (controller.abortController) {
287
+ controller.abortController.abort()
288
+ }
289
+
290
+ // Create new abort controller
291
+ controller.abortController = new AbortController()
292
+
293
+ try {
294
+ const response = await fetch(`${controller.searchPath}?q=${query}`, {
295
+ signal: controller.abortController.signal,
296
+ headers: {
297
+ Accept: 'application/json',
298
+ 'Content-Type': 'application/json',
299
+ },
300
+ })
301
+
302
+ if (!response.ok) {
303
+ throw new Error(`HTTP error! status: ${response.status}`)
304
+ }
305
+
306
+ const data = await response.json()
307
+ renderRemoteResults(controller, data)
308
+ showList(controller)
309
+ } catch (error) {
310
+ if (error instanceof Error && error.name !== 'AbortError') {
311
+ console.error('Remote search error:', error)
312
+ showError(controller)
313
+ }
314
+ } finally {
315
+ hideLoading(controller)
316
+ }
317
+ }
318
+
319
+ const renderRemoteResults = (
320
+ controller: Command | Combobox,
321
+ data: { html: string; group?: string }[],
322
+ ) => {
323
+ console.log('data', data)
324
+ data.forEach((item) => {
325
+ const tempDiv = document.createElement('div')
326
+ tempDiv.innerHTML = item.html
327
+ const itemEl = tempDiv.firstElementChild as HTMLElement
328
+ itemEl.dataset.remote = 'true'
329
+ itemEl.ariaHidden = 'false'
330
+
331
+ if (controller instanceof ComboboxController) {
332
+ // Don't append same item
333
+ if (controller.selectedValue === itemEl.dataset.value) {
334
+ const item = controller.itemTargets.find(
335
+ (i) => i.dataset.value === controller.selectedValue,
336
+ )
337
+ if (item) {
338
+ item.classList.remove('hidden')
339
+ item.ariaHidden = 'false'
340
+ }
341
+
342
+ return
343
+ }
344
+ }
345
+
346
+ const group = item.group
347
+
348
+ if (group) {
349
+ const groupEl = controller.groupTargets.find((g) => {
350
+ const label = g.querySelector(
351
+ `[data-shadcn-phlexcomponents="${controller.identifier}-label"]`,
352
+ ) as HTMLElement
353
+ if (!label) return false
354
+ return label.textContent === group
355
+ })
356
+
357
+ if (groupEl) {
358
+ groupEl.classList.remove('hidden')
359
+ groupEl.append(itemEl)
360
+ } else {
361
+ const template = controller.element.querySelector(
362
+ 'template',
363
+ ) as HTMLTemplateElement
364
+
365
+ const clone = template.content.cloneNode(true) as HTMLElement
366
+ const groupEl = clone.querySelector(
367
+ `[data-shadcn-phlexcomponents="${controller.identifier}-group"]`,
368
+ ) as HTMLElement
369
+ const groupId = crypto.randomUUID()
370
+ const label = clone.querySelector(
371
+ `[data-shadcn-phlexcomponents="${controller.identifier}-label"]`,
372
+ ) as HTMLElement
373
+ label.textContent = group
374
+ label.id = groupId
375
+ groupEl.setAttribute('aria-labelledby', groupId)
376
+ groupEl.dataset.remote = 'true'
377
+ groupEl.append(itemEl)
378
+ controller.listTarget.append(clone)
379
+ }
380
+ } else {
381
+ controller.listTarget.append(itemEl)
382
+ }
383
+ })
384
+
385
+ // Update filtered items for keyboard navigation
386
+ controller.filteredItems = Array.from(
387
+ controller.listTarget.querySelectorAll(
388
+ `[data-${controller.identifier}-target="item"][aria-hidden=false]`,
389
+ ),
390
+ )
391
+
392
+ controller.highlightItemByIndex(0)
393
+
394
+ console.log('controller.filteredItems', controller.filteredItems)
395
+ if (controller.filteredItems.length > 0) {
396
+ hideEmpty(controller)
397
+ } else {
398
+ showEmpty(controller)
399
+ }
400
+ }
401
+
402
+ const clearRemoteResults = (controller: Command | Combobox) => {
403
+ const remoteGroups = Array.from(
404
+ controller.element.querySelectorAll(
405
+ `[data-shadcn-phlexcomponents="${controller.identifier}-group"][data-remote='true']`,
406
+ ),
407
+ )
408
+
409
+ remoteGroups.forEach((g) => {
410
+ const containsSelected = g.querySelector('[aria-selected="true"]')
411
+
412
+ if (!containsSelected) {
413
+ g.remove()
414
+ }
415
+ })
416
+
417
+ const remoteItems = Array.from(
418
+ controller.element.querySelectorAll(
419
+ `[data-shadcn-phlexcomponents="${controller.identifier}-item"][data-remote='true']:not([aria-selected="true"])`,
420
+ ),
421
+ )
422
+
423
+ remoteItems.forEach((i) => i.remove())
424
+ }
425
+
426
+ const resetState = (controller: Command | Combobox) => {
427
+ controller.searchInputTarget.value = ''
428
+
429
+ if (controller.searchPath) {
430
+ clearRemoteResults(controller)
431
+ showSelectedRemoteItems(controller)
432
+ }
433
+
434
+ controller.filteredItemIndexesValue = Array.from(
435
+ { length: controller.orderedItems.length },
436
+ (_, i) => i,
437
+ )
438
+ }
439
+
440
+ const showLoading = (controller: Command | Combobox) => {
441
+ controller.isLoading = true
442
+ controller.loadingTarget.classList.remove('hidden')
443
+ }
444
+
445
+ const hideLoading = (controller: Command | Combobox) => {
446
+ controller.isLoading = false
447
+ controller.loadingTarget.classList.add('hidden')
448
+ }
449
+
450
+ const showList = (controller: Command | Combobox) => {
451
+ controller.listTarget.classList.remove('hidden')
452
+ }
453
+
454
+ const hideList = (controller: Command | Combobox) => {
455
+ controller.listTarget.classList.add('hidden')
456
+ }
457
+
458
+ const showError = (controller: Command | Combobox) => {
459
+ controller.errorTarget.classList.remove('hidden')
460
+ }
461
+
462
+ const hideError = (controller: Command | Combobox) => {
463
+ controller.errorTarget.classList.add('hidden')
464
+ }
465
+
466
+ const showEmpty = (controller: Command | Combobox) => {
467
+ controller.emptyTarget.classList.remove('hidden')
468
+ }
469
+
470
+ const hideEmpty = (controller: Command | Combobox) => {
471
+ controller.emptyTarget.classList.add('hidden')
472
+ }
473
+
474
+ const showSelectedRemoteItems = (controller: Command | Combobox) => {
475
+ const remoteItems = Array.from(
476
+ controller.element.querySelectorAll(
477
+ `[data-shadcn-phlexcomponents="${controller.identifier}-item"][data-remote='true']`,
478
+ ),
479
+ )
480
+
481
+ remoteItems.forEach((i) => {
482
+ const isInsideGroup =
483
+ i.parentElement?.dataset?.shadcnPhlexcomponents ===
484
+ `${controller.identifier}-group`
485
+
486
+ if (isInsideGroup) {
487
+ const isRemoteGroup = i.parentElement.dataset.remote === 'true'
488
+
489
+ if (isRemoteGroup) {
490
+ i.parentElement.classList.remove('hidden')
491
+ }
492
+ }
493
+
494
+ i.ariaHidden = 'false'
495
+ i.classList.remove('hidden')
496
+ })
497
+ }
498
+
499
+ const hideSelectedRemoteItems = (controller: Command | Combobox) => {
500
+ const remoteItems = Array.from(
501
+ controller.element.querySelectorAll(
502
+ `[data-shadcn-phlexcomponents="${controller.identifier}-item"][data-remote='true']`,
503
+ ),
504
+ )
505
+
506
+ remoteItems.forEach((i) => {
507
+ const isInsideGroup =
508
+ i.parentElement?.dataset?.shadcnPhlexcomponents ===
509
+ `${controller.identifier}-group`
510
+
511
+ if (isInsideGroup) {
512
+ const isRemoteGroup = i.parentElement.dataset.remote === 'true'
513
+
514
+ if (isRemoteGroup) {
515
+ i.parentElement.classList.add('hidden')
516
+ }
517
+ }
518
+
519
+ i.ariaHidden = 'true'
520
+ i.classList.add('hidden')
521
+ })
522
+ }
523
+
524
+ export {
525
+ scrollToItem,
526
+ highlightItem,
527
+ highlightItemByIndex,
528
+ filteredItemsChanged,
529
+ setItemsGroupId,
530
+ search,
531
+ performRemoteSearch,
532
+ clearRemoteResults,
533
+ resetState,
534
+ showLoading,
535
+ hideLoading,
536
+ showList,
537
+ hideList,
538
+ showError,
539
+ hideError,
540
+ showEmpty,
541
+ hideEmpty,
542
+ showSelectedRemoteItems,
543
+ hideSelectedRemoteItems,
544
+ }