shadcn_phlexcomponents 0.1.11 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/javascript/controllers/accordion_controller.js +107 -0
  3. data/app/javascript/controllers/alert_dialog_controller.js +7 -0
  4. data/app/javascript/controllers/avatar_controller.js +14 -0
  5. data/app/javascript/controllers/checkbox_controller.js +29 -0
  6. data/app/javascript/controllers/collapsible_controller.js +39 -0
  7. data/app/javascript/controllers/combobox_controller.js +278 -0
  8. data/app/javascript/controllers/command_controller.js +207 -0
  9. data/app/javascript/controllers/date_picker_controller.js +258 -0
  10. data/app/javascript/controllers/date_range_picker_controller.js +200 -0
  11. data/app/javascript/controllers/dialog_controller.js +83 -0
  12. data/app/javascript/controllers/dropdown_menu_controller.js +238 -0
  13. data/app/javascript/controllers/dropdown_menu_sub_controller.js +118 -0
  14. data/app/javascript/controllers/form_field_controller.js +20 -0
  15. data/app/javascript/controllers/hover_card_controller.js +73 -0
  16. data/app/javascript/controllers/loading_button_controller.js +14 -0
  17. data/app/javascript/controllers/popover_controller.js +90 -0
  18. data/app/javascript/controllers/progress_controller.js +14 -0
  19. data/app/javascript/controllers/radio_group_controller.js +80 -0
  20. data/app/javascript/controllers/select_controller.js +265 -0
  21. data/app/javascript/controllers/sidebar_controller.js +29 -0
  22. data/app/javascript/controllers/sidebar_trigger_controller.js +15 -0
  23. data/app/javascript/controllers/slider_controller.js +82 -0
  24. data/app/javascript/controllers/switch_controller.js +26 -0
  25. data/app/javascript/controllers/tabs_controller.js +66 -0
  26. data/app/javascript/controllers/theme_switcher_controller.js +32 -0
  27. data/app/javascript/controllers/toast_container_controller.js +48 -0
  28. data/app/javascript/controllers/toast_controller.js +22 -0
  29. data/app/javascript/controllers/toggle_controller.js +20 -0
  30. data/app/javascript/controllers/toggle_group_controller.js +20 -0
  31. data/app/javascript/controllers/tooltip_controller.js +79 -0
  32. data/app/javascript/shadcn_phlexcomponents.js +60 -0
  33. data/app/javascript/utils/command.js +448 -0
  34. data/app/javascript/utils/floating_ui.js +160 -0
  35. data/app/javascript/utils/index.js +288 -0
  36. data/app/stylesheets/date_picker.css +118 -0
  37. data/app/typescript/controllers/accordion_controller.ts +136 -0
  38. data/app/typescript/controllers/alert_dialog_controller.ts +12 -0
  39. data/app/{javascript → typescript}/controllers/avatar_controller.ts +7 -2
  40. data/app/{javascript → typescript}/controllers/checkbox_controller.ts +11 -4
  41. data/app/{javascript → typescript}/controllers/collapsible_controller.ts +12 -5
  42. data/app/typescript/controllers/combobox_controller.ts +376 -0
  43. data/app/typescript/controllers/command_controller.ts +301 -0
  44. data/app/{javascript → typescript}/controllers/date_picker_controller.ts +185 -125
  45. data/app/{javascript → typescript}/controllers/date_range_picker_controller.ts +89 -79
  46. data/app/{javascript → typescript}/controllers/dialog_controller.ts +59 -57
  47. data/app/typescript/controllers/dropdown_menu_controller.ts +309 -0
  48. data/app/{javascript → typescript}/controllers/dropdown_menu_sub_controller.ts +31 -29
  49. data/app/{javascript → typescript}/controllers/form_field_controller.ts +6 -1
  50. data/app/{javascript → typescript}/controllers/hover_card_controller.ts +36 -26
  51. data/app/{javascript → typescript}/controllers/loading_button_controller.ts +6 -1
  52. data/app/{javascript → typescript}/controllers/popover_controller.ts +42 -65
  53. data/app/{javascript → typescript}/controllers/progress_controller.ts +9 -3
  54. data/app/{javascript → typescript}/controllers/radio_group_controller.ts +16 -9
  55. data/app/typescript/controllers/select_controller.ts +341 -0
  56. data/app/{javascript → typescript}/controllers/slider_controller.ts +23 -16
  57. data/app/{javascript → typescript}/controllers/switch_controller.ts +11 -4
  58. data/app/{javascript → typescript}/controllers/tabs_controller.ts +26 -18
  59. data/app/{javascript → typescript}/controllers/theme_switcher_controller.ts +6 -1
  60. data/app/{javascript → typescript}/controllers/toast_container_controller.ts +6 -1
  61. data/app/{javascript → typescript}/controllers/toast_controller.ts +7 -1
  62. data/app/typescript/controllers/toggle_controller.ts +28 -0
  63. data/app/typescript/controllers/toggle_group_controller.ts +28 -0
  64. data/app/{javascript → typescript}/controllers/tooltip_controller.ts +43 -31
  65. data/app/typescript/shadcn_phlexcomponents.ts +61 -0
  66. data/app/typescript/utils/command.ts +544 -0
  67. data/app/typescript/utils/floating_ui.ts +196 -0
  68. data/app/typescript/utils/index.ts +424 -0
  69. data/lib/install/install_shadcn_phlexcomponents.rb +10 -3
  70. data/lib/shadcn_phlexcomponents/alias.rb +3 -0
  71. data/lib/shadcn_phlexcomponents/components/accordion.rb +2 -1
  72. data/lib/shadcn_phlexcomponents/components/alert_dialog.rb +18 -15
  73. data/lib/shadcn_phlexcomponents/components/base.rb +14 -0
  74. data/lib/shadcn_phlexcomponents/components/collapsible.rb +1 -2
  75. data/lib/shadcn_phlexcomponents/components/combobox.rb +87 -57
  76. data/lib/shadcn_phlexcomponents/components/command.rb +77 -47
  77. data/lib/shadcn_phlexcomponents/components/date_picker.rb +25 -81
  78. data/lib/shadcn_phlexcomponents/components/date_range_picker.rb +21 -4
  79. data/lib/shadcn_phlexcomponents/components/dialog.rb +14 -12
  80. data/lib/shadcn_phlexcomponents/components/dropdown_menu.rb +5 -4
  81. data/lib/shadcn_phlexcomponents/components/dropdown_menu_sub.rb +2 -1
  82. data/lib/shadcn_phlexcomponents/components/form/form_combobox.rb +64 -0
  83. data/lib/shadcn_phlexcomponents/components/form.rb +14 -0
  84. data/lib/shadcn_phlexcomponents/components/hover_card.rb +3 -2
  85. data/lib/shadcn_phlexcomponents/components/popover.rb +3 -3
  86. data/lib/shadcn_phlexcomponents/components/select.rb +10 -25
  87. data/lib/shadcn_phlexcomponents/components/sheet.rb +15 -11
  88. data/lib/shadcn_phlexcomponents/components/table.rb +1 -1
  89. data/lib/shadcn_phlexcomponents/components/tabs.rb +1 -1
  90. data/lib/shadcn_phlexcomponents/components/toast_container.rb +1 -1
  91. data/lib/shadcn_phlexcomponents/components/toggle.rb +54 -0
  92. data/lib/shadcn_phlexcomponents/components/tooltip.rb +3 -2
  93. data/lib/shadcn_phlexcomponents/engine.rb +1 -5
  94. data/lib/shadcn_phlexcomponents/version.rb +1 -1
  95. metadata +71 -32
  96. data/app/javascript/controllers/accordion_controller.ts +0 -133
  97. data/app/javascript/controllers/combobox_controller.ts +0 -145
  98. data/app/javascript/controllers/command_controller.ts +0 -129
  99. data/app/javascript/controllers/command_root_controller.ts +0 -355
  100. data/app/javascript/controllers/dropdown_menu_controller.ts +0 -133
  101. data/app/javascript/controllers/dropdown_menu_root_controller.ts +0 -234
  102. data/app/javascript/controllers/select_controller.ts +0 -200
  103. data/app/javascript/shadcn_phlexcomponents.ts +0 -57
  104. data/app/javascript/utils.ts +0 -437
  105. /data/app/{javascript → typescript}/controllers/sidebar_controller.ts +0 -0
  106. /data/app/{javascript → typescript}/controllers/sidebar_trigger_controller.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
- export default class extends Controller<HTMLButtonElement> {
3
+ const LoadingButtonController = class extends Controller<HTMLButtonElement> {
4
4
  connect() {
5
5
  const el = this.element
6
6
  const form = el.closest('form')
@@ -13,3 +13,8 @@ export default class extends Controller<HTMLButtonElement> {
13
13
  }
14
14
  }
15
15
  }
16
+
17
+ type LoadingButton = InstanceType<typeof LoadingButtonController>
18
+
19
+ export { LoadingButtonController }
20
+ export type { LoadingButton }
@@ -1,22 +1,28 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import { useClickOutside } from 'stimulus-use'
3
+ import { initFloatingUi } from '../utils/floating_ui'
3
4
  import {
4
- initFloatingUi,
5
5
  focusTrigger,
6
- ON_OPEN_FOCUS_DELAY,
7
6
  getFocusableElements,
8
7
  showContent,
9
8
  hideContent,
9
+ onClickOutside,
10
+ handleTabNavigation,
11
+ focusElement,
10
12
  } from '../utils'
11
13
 
12
- export default class extends Controller<HTMLElement> {
14
+ const PopoverController = class extends Controller<HTMLElement> {
15
+ // targets
13
16
  static targets = ['trigger', 'contentContainer', 'content']
14
- static values = { isOpen: Boolean }
15
-
16
- declare isOpenValue: boolean
17
17
  declare readonly triggerTarget: HTMLElement
18
18
  declare readonly contentContainerTarget: HTMLElement
19
19
  declare readonly contentTarget: HTMLElement
20
+
21
+ // values
22
+ static values = { isOpen: Boolean }
23
+ declare isOpenValue: boolean
24
+
25
+ // custom properties
20
26
  declare DOMKeydownListener: (event: KeyboardEvent) => void
21
27
  declare cleanup: () => void
22
28
 
@@ -35,67 +41,18 @@ export default class extends Controller<HTMLElement> {
35
41
 
36
42
  open() {
37
43
  this.isOpenValue = true
38
- this.onOpen()
39
-
40
- setTimeout(() => {
41
- this.onOpenFocusedElement().focus()
42
- }, ON_OPEN_FOCUS_DELAY)
43
44
  }
44
45
 
45
46
  close() {
46
47
  this.isOpenValue = false
47
- this.onClose()
48
- }
49
-
50
- onDOMKeydown(event: KeyboardEvent) {
51
- if (!this.isOpenValue) return
52
-
53
- const key = event.key
54
-
55
- if (key === 'Escape') {
56
- this.close()
57
- } else if (key === 'Tab') {
58
- const focusableElements = getFocusableElements(this.contentTarget)
59
-
60
- const firstElement = focusableElements[0]
61
- const lastElement = focusableElements[focusableElements.length - 1]
62
-
63
- // If Shift + Tab pressed on first element, go to last element
64
- if (event.shiftKey && document.activeElement === firstElement) {
65
- event.preventDefault()
66
- lastElement.focus()
67
- }
68
- // If Tab pressed on last element, go to first element
69
- else if (!event.shiftKey && document.activeElement === lastElement) {
70
- event.preventDefault()
71
- firstElement.focus()
72
- }
73
- }
74
48
  }
75
49
 
76
50
  clickOutside(event: MouseEvent) {
77
- const target = event.target
78
- // Let #toggle to handle state when clicked on trigger
79
- if (target === this.triggerTarget) return
80
-
81
- this.close()
82
- }
83
-
84
- onOpen() {}
85
-
86
- onOpenFocusedElement() {
87
- const focusableElements = getFocusableElements(this.contentTarget)
88
- return focusableElements[0]
89
- }
90
-
91
- onClose() {}
92
-
93
- referenceElement() {
94
- return this.triggerTarget
51
+ onClickOutside(this, event)
95
52
  }
96
53
 
97
54
  isOpenValueChanged(isOpen: boolean, previousIsOpen: boolean) {
98
- if (isOpen === true) {
55
+ if (isOpen) {
99
56
  showContent({
100
57
  trigger: this.triggerTarget,
101
58
  content: this.contentTarget,
@@ -103,12 +60,16 @@ export default class extends Controller<HTMLElement> {
103
60
  })
104
61
 
105
62
  this.cleanup = initFloatingUi({
106
- referenceElement: this.referenceElement(),
63
+ referenceElement: this.triggerTarget,
107
64
  floatingElement: this.contentContainerTarget,
108
65
  side: this.contentTarget.dataset.side,
109
66
  align: this.contentTarget.dataset.align,
110
67
  sideOffset: 4,
111
68
  })
69
+
70
+ const focusableElements = getFocusableElements(this.contentTarget)
71
+ focusElement(focusableElements[0])
72
+
112
73
  this.setupEventListeners()
113
74
  } else {
114
75
  hideContent({
@@ -117,25 +78,41 @@ export default class extends Controller<HTMLElement> {
117
78
  contentContainer: this.contentContainerTarget,
118
79
  })
119
80
 
120
- this.cleanupEventListeners()
121
-
122
- // Only focus trigger when is previously opened
123
81
  if (previousIsOpen) {
124
82
  focusTrigger(this.triggerTarget)
125
83
  }
84
+
85
+ this.cleanupEventListeners()
126
86
  }
127
87
  }
128
88
 
129
- setupEventListeners() {
89
+ disconnect() {
90
+ this.cleanupEventListeners()
91
+ }
92
+
93
+ protected setupEventListeners() {
130
94
  document.addEventListener('keydown', this.DOMKeydownListener)
131
95
  }
132
96
 
133
- cleanupEventListeners() {
97
+ protected cleanupEventListeners() {
134
98
  if (this.cleanup) this.cleanup()
135
99
  document.removeEventListener('keydown', this.DOMKeydownListener)
136
100
  }
137
101
 
138
- disconnect() {
139
- this.cleanupEventListeners()
102
+ protected onDOMKeydown(event: KeyboardEvent) {
103
+ if (!this.isOpenValue) return
104
+
105
+ const key = event.key
106
+
107
+ if (key === 'Escape') {
108
+ this.close()
109
+ } else if (key === 'Tab') {
110
+ handleTabNavigation(this.contentTarget, event)
111
+ }
140
112
  }
141
113
  }
114
+
115
+ type Popover = InstanceType<typeof PopoverController>
116
+
117
+ export { PopoverController }
118
+ export type { Popover }
@@ -1,13 +1,14 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
- export default class extends Controller {
3
+ const ProgressController = class extends Controller {
4
+ // targets
4
5
  static targets = ['indicator']
6
+ declare readonly indicatorTarget: HTMLElement
5
7
 
8
+ // values
6
9
  static values = {
7
10
  percent: Number,
8
11
  }
9
-
10
- declare readonly indicatorTarget: HTMLElement
11
12
  declare percentValue: number
12
13
 
13
14
  percentValueChanged(value: number) {
@@ -15,3 +16,8 @@ export default class extends Controller {
15
16
  this.indicatorTarget.style.transform = `translateX(-${100 - value}%)`
16
17
  }
17
18
  }
19
+
20
+ type Progress = InstanceType<typeof ProgressController>
21
+
22
+ export { ProgressController }
23
+ export type { Progress }
@@ -1,14 +1,16 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
- export default class extends Controller<HTMLElement> {
3
+ const RadioGroupController = class extends Controller<HTMLElement> {
4
+ // targets
4
5
  static targets = ['item', 'input', 'indicator']
5
- static values = {
6
- selected: String,
7
- }
8
-
9
6
  declare readonly itemTargets: HTMLInputElement[]
10
7
  declare readonly inputTargets: HTMLInputElement[]
11
8
  declare readonly indicatorTargets: HTMLInputElement[]
9
+
10
+ // values
11
+ static values = {
12
+ selected: String,
13
+ }
12
14
  declare selectedValue: string
13
15
 
14
16
  connect() {
@@ -22,10 +24,6 @@ export default class extends Controller<HTMLElement> {
22
24
  this.selectedValue = item.dataset.value as string
23
25
  }
24
26
 
25
- preventDefault(event: KeyboardEvent) {
26
- event.preventDefault()
27
- }
28
-
29
27
  selectItem(event: KeyboardEvent) {
30
28
  const focusableItems = this.itemTargets.filter(
31
29
  (t) => !t.disabled,
@@ -53,6 +51,10 @@ export default class extends Controller<HTMLElement> {
53
51
  this.selectedValue = focusableItems[newIndex].dataset.value as string
54
52
  }
55
53
 
54
+ preventDefault(event: KeyboardEvent) {
55
+ event.preventDefault()
56
+ }
57
+
56
58
  focusItem() {
57
59
  const item = this.itemTargets.find(
58
60
  (i) => i.dataset.value === this.selectedValue,
@@ -104,3 +106,8 @@ export default class extends Controller<HTMLElement> {
104
106
  this.focusItem()
105
107
  }
106
108
  }
109
+
110
+ type RadioGroup = InstanceType<typeof RadioGroupController>
111
+
112
+ export { RadioGroupController }
113
+ export type { RadioGroup }
@@ -0,0 +1,341 @@
1
+ import { useClickOutside } from 'stimulus-use'
2
+ import { onKeydown, focusItemByIndex } from './dropdown_menu_controller'
3
+ import { initFloatingUi } from '../utils/floating_ui'
4
+ import {
5
+ getSameLevelItems,
6
+ focusTrigger,
7
+ hideContent,
8
+ showContent,
9
+ lockScroll,
10
+ unlockScroll,
11
+ onClickOutside,
12
+ setGroupLabelsId,
13
+ getNextEnabledIndex,
14
+ getPreviousEnabledIndex,
15
+ focusElement,
16
+ } from '../utils'
17
+ import { Controller } from '@hotwired/stimulus'
18
+
19
+ const SelectController = class extends Controller<HTMLElement> {
20
+ // targets
21
+ static targets = [
22
+ 'trigger',
23
+ 'contentContainer',
24
+ 'content',
25
+ 'item',
26
+ 'triggerText',
27
+ 'group',
28
+ 'select',
29
+ ]
30
+ declare readonly triggerTarget: HTMLElement
31
+ declare readonly contentContainerTarget: HTMLElement
32
+ declare readonly contentTarget: HTMLElement
33
+ declare readonly itemTargets: HTMLElement[]
34
+ declare triggerTextTarget: HTMLElement
35
+ declare groupTargets: HTMLElement[]
36
+ declare selectTarget: HTMLSelectElement
37
+
38
+ // values
39
+ static values = {
40
+ isOpen: Boolean,
41
+ selected: String,
42
+ }
43
+ declare isOpenValue: boolean
44
+ declare selectedValue: string
45
+
46
+ // custom properties
47
+ declare searchString: string
48
+ declare searchTimeout: number
49
+ declare itemsInnerText: string[]
50
+ declare items: HTMLElement[]
51
+ declare DOMKeydownListener: (event: KeyboardEvent) => void
52
+ declare cleanup: () => void
53
+
54
+ connect() {
55
+ this.items = getSameLevelItems({
56
+ content: this.contentTarget,
57
+ items: this.itemTargets,
58
+ closestContentSelector: '[data-select-target="content"]',
59
+ })
60
+ this.itemsInnerText = this.items.map((i) => i.innerText.trim())
61
+ this.searchString = ''
62
+ useClickOutside(this, { element: this.contentTarget, dispatchEvent: false })
63
+ this.DOMKeydownListener = this.onDOMKeydown.bind(this)
64
+ setGroupLabelsId(this)
65
+ }
66
+
67
+ toggle(event: MouseEvent) {
68
+ if (this.isOpenValue) {
69
+ this.close()
70
+ } else {
71
+ this.open(event)
72
+ }
73
+ }
74
+
75
+ open(event: MouseEvent | KeyboardEvent) {
76
+ this.isOpenValue = true
77
+
78
+ let elementToFocus = null as HTMLElement | null
79
+
80
+ if (this.selectedValue) {
81
+ const item = this.itemTargets.find(
82
+ (i) => i.dataset.value === this.selectedValue,
83
+ )
84
+
85
+ if (item && !item.dataset.disabled) {
86
+ elementToFocus = item
87
+ }
88
+ }
89
+
90
+ if (!elementToFocus) {
91
+ if (event instanceof KeyboardEvent) {
92
+ const key = event.key
93
+
94
+ if (['ArrowDown', 'Enter', ' '].includes(key)) {
95
+ elementToFocus = this.items[0]
96
+ }
97
+ } else {
98
+ elementToFocus = this.contentTarget
99
+ }
100
+ }
101
+
102
+ focusElement(elementToFocus)
103
+ }
104
+
105
+ close() {
106
+ this.isOpenValue = false
107
+ }
108
+
109
+ onItemFocus(event: FocusEvent) {
110
+ const item = event.currentTarget as HTMLElement
111
+ item.tabIndex = 0
112
+ }
113
+
114
+ onItemBlur(event: FocusEvent) {
115
+ const item = event.currentTarget as HTMLElement
116
+ item.tabIndex = -1
117
+ }
118
+
119
+ focusItemByIndex(
120
+ event: KeyboardEvent | null = null,
121
+ index: number | null = null,
122
+ ) {
123
+ focusItemByIndex(this, event, index)
124
+ }
125
+
126
+ focusItem(event: MouseEvent | KeyboardEvent) {
127
+ const item = event.currentTarget as HTMLElement
128
+ const index = this.items.indexOf(item)
129
+
130
+ if (event instanceof KeyboardEvent) {
131
+ const key = event.key
132
+ let newIndex = 0
133
+
134
+ if (key === 'ArrowUp') {
135
+ newIndex = getPreviousEnabledIndex({
136
+ items: this.items,
137
+ currentIndex: index,
138
+ wrapAround: false,
139
+ })
140
+ } else {
141
+ newIndex = getNextEnabledIndex({
142
+ items: this.items,
143
+ currentIndex: index,
144
+ wrapAround: false,
145
+ })
146
+ }
147
+
148
+ this.items[newIndex].focus()
149
+ } else {
150
+ // item mouseover event
151
+ this.items[index].focus()
152
+ }
153
+ }
154
+
155
+ focusContent() {
156
+ this.contentTarget.focus()
157
+ }
158
+
159
+ select(event: MouseEvent | KeyboardEvent) {
160
+ const item = event.currentTarget as HTMLElement
161
+ const value = item.dataset.value as string
162
+ this.selectedValue = value
163
+ this.close()
164
+ }
165
+
166
+ clickOutside(event: MouseEvent) {
167
+ onClickOutside(this, event)
168
+ }
169
+
170
+ isOpenValueChanged(isOpen: boolean, previousIsOpen: boolean) {
171
+ if (isOpen) {
172
+ lockScroll(this.contentTarget.id)
173
+
174
+ showContent({
175
+ trigger: this.triggerTarget,
176
+ content: this.contentTarget,
177
+ contentContainer: this.contentContainerTarget,
178
+ setEqualWidth: true,
179
+ })
180
+
181
+ this.cleanup = initFloatingUi({
182
+ referenceElement: this.triggerTarget,
183
+ floatingElement: this.contentContainerTarget,
184
+ side: this.contentTarget.dataset.side,
185
+ align: this.contentTarget.dataset.align,
186
+ sideOffset: 4,
187
+ })
188
+
189
+ this.setupEventListeners()
190
+ } else {
191
+ unlockScroll(this.contentTarget.id)
192
+
193
+ hideContent({
194
+ trigger: this.triggerTarget,
195
+ content: this.contentTarget,
196
+ contentContainer: this.contentContainerTarget,
197
+ })
198
+
199
+ if (previousIsOpen) {
200
+ focusTrigger(this.triggerTarget)
201
+ }
202
+
203
+ this.cleanupEventListeners()
204
+ }
205
+ }
206
+
207
+ selectedValueChanged(value: string) {
208
+ const item = this.itemTargets.find((i) => i.dataset.value === value)
209
+
210
+ if (item) {
211
+ this.triggerTextTarget.textContent = item.textContent
212
+
213
+ this.itemTargets.forEach((i) => {
214
+ if (i.dataset.value === value) {
215
+ i.setAttribute('aria-selected', 'true')
216
+ } else {
217
+ i.setAttribute('aria-selected', 'false')
218
+ }
219
+ })
220
+
221
+ this.selectTarget.value = value
222
+ }
223
+
224
+ this.triggerTarget.dataset.hasValue = `${!!value && value.length > 0}`
225
+
226
+ const placeholder = this.triggerTarget.dataset.placeholder
227
+
228
+ if (placeholder && this.triggerTarget.dataset.hasValue === 'false') {
229
+ this.triggerTextTarget.textContent = placeholder
230
+ }
231
+ }
232
+
233
+ disconnect() {
234
+ this.cleanupEventListeners()
235
+ }
236
+
237
+ protected onDOMKeydown(event: KeyboardEvent) {
238
+ if (!this.isOpenValue) return
239
+
240
+ onKeydown(this, event)
241
+
242
+ const { key, altKey, ctrlKey, metaKey } = event
243
+
244
+ if (
245
+ key === 'Backspace' ||
246
+ key === 'Clear' ||
247
+ (key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)
248
+ ) {
249
+ this.handleSearch(key)
250
+ }
251
+ }
252
+
253
+ protected setupEventListeners() {
254
+ document.addEventListener('keydown', this.DOMKeydownListener)
255
+ }
256
+
257
+ protected cleanupEventListeners() {
258
+ if (this.cleanup) this.cleanup()
259
+ document.removeEventListener('keydown', this.DOMKeydownListener)
260
+ }
261
+
262
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
263
+ protected handleSearch(char: string) {
264
+ const searchString = this.getSearchString(char)
265
+ const focusedItem = this.items.find(
266
+ (item) => document.activeElement === item,
267
+ )
268
+ const focusedIndex = focusedItem ? this.items.indexOf(focusedItem) : 0
269
+ const searchIndex = this.getIndexByLetter(searchString, focusedIndex + 1)
270
+
271
+ // if a match was found, go to it
272
+ if (searchIndex >= 0) {
273
+ this.focusItemByIndex(null, searchIndex)
274
+ }
275
+ // if no matches, clear the timeout and search string
276
+ else {
277
+ window.clearTimeout(this.searchTimeout)
278
+ this.searchString = ''
279
+ }
280
+ }
281
+
282
+ protected filterItemsInnerText(items: string[], filter: string) {
283
+ return items.filter((item) => {
284
+ const matches = item.toLowerCase().indexOf(filter.toLowerCase()) === 0
285
+ return matches
286
+ })
287
+ }
288
+
289
+ protected getSearchString(char: string) {
290
+ // reset typing timeout and start new timeout
291
+ // this allows us to make multiple-letter matches, like a native select
292
+ if (typeof this.searchTimeout === 'number') {
293
+ window.clearTimeout(this.searchTimeout)
294
+ }
295
+
296
+ this.searchTimeout = window.setTimeout(() => {
297
+ this.searchString = ''
298
+ }, 500)
299
+
300
+ // add most recent letter to saved search string
301
+ this.searchString += char
302
+ return this.searchString
303
+ }
304
+
305
+ // return the index of an option from an array of options, based on a search string
306
+ // if the filter is multiple iterations of the same letter (e.g "aaa"), then cycle through first-letter matches
307
+ protected getIndexByLetter(filter: string, startIndex: number) {
308
+ const orderedItems = [
309
+ ...this.itemsInnerText.slice(startIndex),
310
+ ...this.itemsInnerText.slice(0, startIndex),
311
+ ]
312
+
313
+ const firstMatch = this.filterItemsInnerText(orderedItems, filter)[0]
314
+
315
+ const allSameLetter = (array: string[]) =>
316
+ array.every((letter) => letter === array[0])
317
+
318
+ // first check if there is an exact match for the typed string
319
+ if (firstMatch) {
320
+ const index = this.itemsInnerText.indexOf(firstMatch)
321
+ return index
322
+ }
323
+
324
+ // if the same letter is being repeated, cycle through first-letter matches
325
+ else if (allSameLetter(filter.split(''))) {
326
+ const matches = this.filterItemsInnerText(orderedItems, filter[0])
327
+ const index = this.itemsInnerText.indexOf(matches[0])
328
+ return index
329
+ }
330
+
331
+ // if no matches, return -1
332
+ else {
333
+ return -1
334
+ }
335
+ }
336
+ }
337
+
338
+ type Select = InstanceType<typeof SelectController>
339
+
340
+ export { SelectController }
341
+ export type { Select }
@@ -1,17 +1,19 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import noUiSlider, { API, Options } from 'nouislider'
3
3
 
4
- export default class extends Controller<HTMLElement> {
4
+ const SliderController = class extends Controller<HTMLElement> {
5
+ // targets
5
6
  static targets = ['slider', 'hiddenInput', 'endHiddenInput']
6
-
7
7
  declare readonly sliderTarget: HTMLInputElement
8
8
  declare readonly hiddenInputTarget: HTMLInputElement
9
9
  declare readonly endHiddenInputTarget: HTMLInputElement
10
10
  declare readonly hasEndHiddenInputTarget: boolean
11
- declare onUpdateValuesListener: (values: (string | number)[]) => void
12
- declare DOMClickListener: (event: MouseEvent) => void
11
+
12
+ // custom properties
13
13
  declare range: boolean
14
14
  declare slider: API
15
+ declare onUpdateValuesListener: (values: (string | number)[]) => void
16
+ declare DOMClickListener: (event: MouseEvent) => void
15
17
 
16
18
  connect() {
17
19
  this.range = this.element.dataset.range === 'true'
@@ -35,15 +37,11 @@ export default class extends Controller<HTMLElement> {
35
37
  document.addEventListener('click', this.DOMClickListener)
36
38
  }
37
39
 
38
- onUpdateValues(values: (string | number)[]) {
39
- this.hiddenInputTarget.value = `${values[0]}`
40
-
41
- if (this.range && this.hasEndHiddenInputTarget) {
42
- this.endHiddenInputTarget.value = `${values[1]}`
43
- }
40
+ disconnect() {
41
+ document.removeEventListener('click', this.DOMClickListener)
44
42
  }
45
43
 
46
- getOptions() {
44
+ protected getOptions() {
47
45
  const defaultOptions = {
48
46
  connect: this.range ? true : 'lower',
49
47
  tooltips: true,
@@ -84,7 +82,15 @@ export default class extends Controller<HTMLElement> {
84
82
  }
85
83
  }
86
84
 
87
- onDOMClick(event: MouseEvent) {
85
+ protected onUpdateValues(values: (string | number)[]) {
86
+ this.hiddenInputTarget.value = `${values[0]}`
87
+
88
+ if (this.range && this.hasEndHiddenInputTarget) {
89
+ this.endHiddenInputTarget.value = `${values[1]}`
90
+ }
91
+ }
92
+
93
+ protected onDOMClick(event: MouseEvent) {
88
94
  const target = event.target
89
95
 
90
96
  // Focus handle of slider when label is clicked.
@@ -100,8 +106,9 @@ export default class extends Controller<HTMLElement> {
100
106
  }
101
107
  }
102
108
  }
103
-
104
- disconnect() {
105
- document.removeEventListener('click', this.DOMClickListener)
106
- }
107
109
  }
110
+
111
+ type Slider = InstanceType<typeof SliderController>
112
+
113
+ export { SliderController }
114
+ export type { Slider }
@@ -1,13 +1,15 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
- export default class extends Controller<HTMLElement> {
3
+ const SwitchController = class extends Controller<HTMLElement> {
4
+ // targets
4
5
  static targets = ['input', 'thumb']
6
+ declare readonly inputTarget: HTMLInputElement
7
+ declare readonly thumbTarget: HTMLElement
8
+
9
+ // values
5
10
  static values = {
6
11
  isChecked: Boolean,
7
12
  }
8
-
9
- declare readonly inputTarget: HTMLInputElement
10
- declare readonly thumbTarget: HTMLElement
11
13
  declare isCheckedValue: boolean
12
14
 
13
15
  toggle() {
@@ -28,3 +30,8 @@ export default class extends Controller<HTMLElement> {
28
30
  }
29
31
  }
30
32
  }
33
+
34
+ type Switch = InstanceType<typeof SwitchController>
35
+
36
+ export { SwitchController }
37
+ export type { Switch }