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,196 @@
1
+ import {
2
+ computePosition,
3
+ flip,
4
+ shift,
5
+ offset,
6
+ autoUpdate,
7
+ size,
8
+ Placement,
9
+ Middleware,
10
+ arrow,
11
+ } from '@floating-ui/dom'
12
+
13
+ const OPPOSITE_SIDE = {
14
+ top: 'bottom',
15
+ right: 'left',
16
+ bottom: 'top',
17
+ left: 'right',
18
+ }
19
+
20
+ const ARROW_TRANSFORM_ORIGIN = {
21
+ top: '',
22
+ right: '0 0',
23
+ bottom: 'center 0',
24
+ left: '100% 0',
25
+ }
26
+
27
+ const ARROW_TRANSFORM = {
28
+ top: 'translateY(100%)',
29
+ right: 'translateY(50%) rotate(90deg) translateX(-50%)',
30
+ bottom: `rotate(180deg)`,
31
+ left: 'translateY(50%) rotate(-90deg) translateX(50%)',
32
+ }
33
+
34
+ const initFloatingUi = ({
35
+ referenceElement,
36
+ floatingElement,
37
+ side = 'bottom',
38
+ align = 'center',
39
+ sideOffset = 0,
40
+ alignOffset = 0,
41
+ arrowElement,
42
+ }: {
43
+ referenceElement: HTMLElement
44
+ floatingElement: HTMLElement
45
+ side?: string
46
+ align?: string
47
+ sideOffset?: number
48
+ alignOffset?: number
49
+ offsetPx?: number
50
+ arrowElement?: HTMLElement
51
+ }) => {
52
+ let placement = `${side}-${align}`
53
+ placement = placement.replace(/-center/g, '')
54
+
55
+ let arrowHeight = 0,
56
+ arrowWidth = 0
57
+
58
+ if (arrowElement) {
59
+ const rect = arrowElement.getBoundingClientRect()
60
+ arrowWidth = rect.width
61
+ arrowHeight = rect.height
62
+ }
63
+
64
+ const middleware = [
65
+ transformOrigin({ arrowHeight, arrowWidth }),
66
+ offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
67
+ size({
68
+ apply: ({ elements, rects, availableWidth, availableHeight }) => {
69
+ const { width: anchorWidth, height: anchorHeight } = rects.reference
70
+ const contentStyle = elements.floating.style
71
+ contentStyle.setProperty(
72
+ '--radix-popper-available-width',
73
+ `${availableWidth}px`,
74
+ )
75
+ contentStyle.setProperty(
76
+ '--radix-popper-available-height',
77
+ `${availableHeight}px`,
78
+ )
79
+ contentStyle.setProperty(
80
+ '--radix-popper-anchor-width',
81
+ `${anchorWidth}px`,
82
+ )
83
+ contentStyle.setProperty(
84
+ '--radix-popper-anchor-height',
85
+ `${anchorHeight}px`,
86
+ )
87
+ },
88
+ }),
89
+ ]
90
+
91
+ const flipMiddleware = flip({
92
+ // Ensure we flip to the perpendicular axis if it doesn't fit
93
+ // on narrow viewports.
94
+ crossAxis: 'alignment',
95
+ fallbackAxisSideDirection: 'end', // or 'start'
96
+ })
97
+ const shiftMiddleware = shift()
98
+
99
+ // Prioritize flip over shift for edge-aligned placements only.
100
+ if (placement.includes('-')) {
101
+ middleware.push(flipMiddleware, shiftMiddleware)
102
+ } else {
103
+ middleware.push(shiftMiddleware, flipMiddleware)
104
+ }
105
+
106
+ if (arrowElement) {
107
+ middleware.push(arrow({ element: arrowElement, padding: 0 }))
108
+ }
109
+
110
+ return autoUpdate(referenceElement, floatingElement, () => {
111
+ computePosition(referenceElement, floatingElement, {
112
+ placement: placement as Placement,
113
+ strategy: 'fixed',
114
+ middleware,
115
+ }).then(({ middlewareData, x, y }) => {
116
+ const arrowX = middlewareData.arrow?.x
117
+ const arrowY = middlewareData.arrow?.y
118
+ const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0
119
+
120
+ floatingElement.style.setProperty(
121
+ '--radix-popper-transform-origin',
122
+ `${middlewareData.transformOrigin?.x} ${middlewareData.transformOrigin?.y}`,
123
+ )
124
+ if (arrowElement) {
125
+ const baseSide = OPPOSITE_SIDE[side as keyof typeof OPPOSITE_SIDE]
126
+
127
+ const arrowStyle = {
128
+ position: 'absolute',
129
+ left: arrowX ? `${arrowX}px` : undefined,
130
+ top: arrowY ? `${arrowY}px` : undefined,
131
+ [baseSide]: 0,
132
+ transformOrigin:
133
+ ARROW_TRANSFORM_ORIGIN[side as keyof typeof ARROW_TRANSFORM_ORIGIN],
134
+ transform: ARROW_TRANSFORM[side as keyof typeof ARROW_TRANSFORM],
135
+ visibility: cannotCenterArrow ? 'hidden' : undefined,
136
+ }
137
+
138
+ Object.assign(arrowElement.style, arrowStyle)
139
+ }
140
+ Object.assign(floatingElement.style, {
141
+ left: `${x}px`,
142
+ top: `${y}px`,
143
+ })
144
+ })
145
+ })
146
+ }
147
+
148
+ const transformOrigin = (options: {
149
+ arrowWidth: number
150
+ arrowHeight: number
151
+ }): Middleware => {
152
+ return {
153
+ name: 'transformOrigin',
154
+ options,
155
+ fn(data) {
156
+ const { placement, rects, middlewareData } = data
157
+ const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0
158
+ const isArrowHidden = cannotCenterArrow
159
+ const arrowWidth = isArrowHidden ? 0 : options.arrowWidth
160
+ const arrowHeight = isArrowHidden ? 0 : options.arrowHeight
161
+
162
+ const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement)
163
+ const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[
164
+ placedAlign
165
+ ] as string
166
+
167
+ const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2
168
+ const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2
169
+
170
+ let x = ''
171
+ let y = ''
172
+
173
+ if (placedSide === 'bottom') {
174
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
175
+ y = `${-arrowHeight}px`
176
+ } else if (placedSide === 'top') {
177
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
178
+ y = `${rects.floating.height + arrowHeight}px`
179
+ } else if (placedSide === 'right') {
180
+ x = `${-arrowHeight}px`
181
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
182
+ } else if (placedSide === 'left') {
183
+ x = `${rects.floating.width + arrowHeight}px`
184
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
185
+ }
186
+ return { data: { x, y } }
187
+ },
188
+ }
189
+ }
190
+
191
+ function getSideAndAlignFromPlacement(placement: Placement) {
192
+ const [side, align = 'center'] = placement.split('-')
193
+ return [side, align] as const
194
+ }
195
+
196
+ export { initFloatingUi }
@@ -0,0 +1,417 @@
1
+ import type { DropdownMenu } from '../controllers/dropdown_menu_controller'
2
+ import type { Select } from '../controllers/select_controller'
3
+ import type { Popover } from '../controllers/popover_controller'
4
+ import type { Command } from '../controllers/command_controller'
5
+ import type { Combobox } from '../controllers/combobox_controller'
6
+ import type { Dialog } from '../controllers/dialog_controller'
7
+ import type { AlertDialog } from '../controllers/alert_dialog_controller'
8
+ import type { HoverCard } from '../controllers/hover_card_controller'
9
+ import type { Tooltip } from '../controllers/tooltip_controller'
10
+ import type { DatePicker } from '../controllers/date_picker_controller'
11
+ import type { DateRangePicker } from '../controllers/date_range_picker_controller'
12
+
13
+ const ANIMATION_OUT_DELAY = 100
14
+ const ON_OPEN_FOCUS_DELAY = 100
15
+ const ON_CLOSE_FOCUS_DELAY = 50
16
+
17
+ const getScrollbarWidth = () => {
18
+ // Create a temporary div container and append it into the body
19
+ const outer = document.createElement('div')
20
+ outer.style.visibility = 'hidden'
21
+ outer.style.overflow = 'scroll' // force scrollbars
22
+ outer.style.width = '100px'
23
+ outer.style.position = 'absolute'
24
+ outer.style.top = '-9999px'
25
+ document.body.appendChild(outer)
26
+
27
+ // Create an inner div and place it inside the outer div
28
+ const inner = document.createElement('div')
29
+ inner.style.width = '100%'
30
+ outer.appendChild(inner)
31
+
32
+ // Calculate the scrollbar width
33
+ const scrollbarWidth = outer.offsetWidth - inner.offsetWidth
34
+
35
+ // Clean up
36
+ outer.remove()
37
+
38
+ return scrollbarWidth
39
+ }
40
+
41
+ const lockScroll = (contentId: string) => {
42
+ if (window.innerHeight < document.documentElement.scrollHeight) {
43
+ document.body.dataset.scrollLocked = '1'
44
+ document.body.classList.add(
45
+ 'data-[scroll-locked]:pointer-events-none',
46
+ 'data-[scroll-locked]:!overflow-hidden',
47
+ 'data-[scroll-locked]:!relative',
48
+ 'data-[scroll-locked]:px-0',
49
+ 'data-[scroll-locked]:pt-0',
50
+ 'data-[scroll-locked]:ml-0',
51
+ 'data-[scroll-locked]:mt-0',
52
+ )
53
+ document.body.style.marginRight = `${getScrollbarWidth()}px`
54
+
55
+ const contentIdsString =
56
+ document.body.dataset.scrollLockedContentIds || '[]'
57
+ const contentIds = JSON.parse(contentIdsString)
58
+
59
+ contentIds.push(contentId)
60
+ document.body.dataset.scrollLockedContentIds = JSON.stringify(contentIds)
61
+ }
62
+ }
63
+
64
+ const unlockScroll = (contentId: string) => {
65
+ const contentIdsString = document.body.dataset.scrollLockedContentIds || '[]'
66
+ const contentIds = JSON.parse(contentIdsString)
67
+ const newContentIds = contentIds.filter((id: string) => id !== contentId)
68
+ document.body.dataset.scrollLockedContentIds = JSON.stringify(newContentIds)
69
+
70
+ if (newContentIds.length === 0) {
71
+ delete document.body.dataset.scrollLocked
72
+ document.body.classList.remove(
73
+ 'data-[scroll-locked]:pointer-events-none',
74
+ 'data-[scroll-locked]:!overflow-hidden',
75
+ 'data-[scroll-locked]:!relative',
76
+ 'data-[scroll-locked]:px-0',
77
+ 'data-[scroll-locked]:pt-0',
78
+ 'data-[scroll-locked]:ml-0',
79
+ 'data-[scroll-locked]:mt-0',
80
+ )
81
+
82
+ document.body.style.marginRight = ''
83
+ }
84
+ }
85
+
86
+ const focusTrigger = (triggerTarget: HTMLElement) => {
87
+ setTimeout(() => {
88
+ if (triggerTarget.dataset.asChild === 'false') {
89
+ const childElement = triggerTarget.firstElementChild as HTMLElement
90
+
91
+ if (childElement) {
92
+ childElement.focus()
93
+ }
94
+ } else {
95
+ triggerTarget.focus()
96
+ }
97
+ }, ON_CLOSE_FOCUS_DELAY)
98
+ }
99
+
100
+ const focusElement = (element?: HTMLElement | null) => {
101
+ setTimeout(() => {
102
+ if (element) {
103
+ element.focus()
104
+ }
105
+ }, ON_OPEN_FOCUS_DELAY)
106
+ }
107
+
108
+ const getFocusableElements = (container: HTMLElement) => {
109
+ return Array.from(
110
+ container.querySelectorAll(
111
+ 'button, [href], input:not([type="hidden"]), select:not([tabindex="-1"]), textarea, [tabindex]:not([tabindex="-1"])',
112
+ ),
113
+ ) as HTMLElement[]
114
+ }
115
+
116
+ const getSameLevelItems = ({
117
+ content,
118
+ items,
119
+ closestContentSelector,
120
+ }: {
121
+ content: HTMLElement
122
+ items: HTMLElement[]
123
+ closestContentSelector: string
124
+ }) => {
125
+ const sameLevelItems = [] as HTMLElement[]
126
+
127
+ items.forEach((i) => {
128
+ if (
129
+ i.closest(closestContentSelector) === content &&
130
+ i.dataset.disabled === undefined
131
+ ) {
132
+ sameLevelItems.push(i)
133
+ }
134
+ })
135
+
136
+ return sameLevelItems
137
+ }
138
+
139
+ const showContent = ({
140
+ trigger,
141
+ content,
142
+ contentContainer,
143
+ setEqualWidth,
144
+ overlay,
145
+ }: {
146
+ trigger?: HTMLElement
147
+ content: HTMLElement
148
+ contentContainer: HTMLElement
149
+ overlay?: HTMLElement
150
+ setEqualWidth?: boolean
151
+ appendToBody?: boolean
152
+ }) => {
153
+ contentContainer.style.display = ''
154
+
155
+ if (trigger) {
156
+ if (setEqualWidth) {
157
+ const triggerWidth = trigger.offsetWidth
158
+ const contentContainerWidth = contentContainer.offsetWidth
159
+
160
+ if (contentContainerWidth < triggerWidth) {
161
+ contentContainer.style.width = `${triggerWidth}px`
162
+ }
163
+ }
164
+
165
+ trigger.ariaExpanded = 'true'
166
+ trigger.dataset.state = 'open'
167
+ }
168
+
169
+ content.dataset.state = 'open'
170
+
171
+ if (overlay) {
172
+ overlay.style.display = ''
173
+ overlay.dataset.state = 'open'
174
+ lockScroll(content.id)
175
+ }
176
+ }
177
+
178
+ const hideContent = ({
179
+ trigger,
180
+ content,
181
+ contentContainer,
182
+ overlay,
183
+ }: {
184
+ trigger?: HTMLElement
185
+ content: HTMLElement
186
+ contentContainer: HTMLElement
187
+ overlay?: HTMLElement
188
+ }) => {
189
+ if (trigger) {
190
+ trigger.ariaExpanded = 'false'
191
+ trigger.dataset.state = 'closed'
192
+ }
193
+
194
+ content.dataset.state = 'closed'
195
+
196
+ setTimeout(() => {
197
+ contentContainer.style.display = 'none'
198
+
199
+ if (overlay) {
200
+ overlay.style.display = 'none'
201
+ overlay.dataset.state = 'closed'
202
+ unlockScroll(content.id)
203
+ }
204
+ }, ANIMATION_OUT_DELAY)
205
+ }
206
+
207
+ const getStimulusInstance = <T>(
208
+ controller: string,
209
+ element: HTMLElement | null,
210
+ ) => {
211
+ if (!element) return
212
+
213
+ return window.Stimulus.getControllerForElementAndIdentifier(
214
+ element,
215
+ controller,
216
+ ) as T
217
+ }
218
+
219
+ type NestedComponent =
220
+ | DropdownMenu
221
+ | Select
222
+ | Popover
223
+ | Command
224
+ | Combobox
225
+ | Dialog
226
+ | AlertDialog
227
+ | HoverCard
228
+ | Tooltip
229
+ | DatePicker
230
+ | DateRangePicker
231
+ const anyNestedComponentsOpen = (element: HTMLElement) => {
232
+ const components = [] as NestedComponent[]
233
+
234
+ const componentNames = [
235
+ 'dialog',
236
+ 'alert-dialog',
237
+ 'dropdown-menu',
238
+ 'popover',
239
+ 'select',
240
+ 'combobox',
241
+ 'command',
242
+ 'hover-card',
243
+ 'tooltip',
244
+ 'date-picker',
245
+ 'date-range-picker',
246
+ ]
247
+
248
+ componentNames.forEach((name) => {
249
+ const triggers = Array.from(
250
+ element.querySelectorAll(
251
+ `[data-shadcn-phlexcomponents="${name}-trigger"]`,
252
+ ),
253
+ )
254
+
255
+ const controllerElements = Array.from(
256
+ element.querySelectorAll(`[data-controller="${name}"]`),
257
+ ) as HTMLElement[]
258
+
259
+ controllerElements.forEach((controller) => {
260
+ const stimulusInstance = getStimulusInstance<NestedComponent>(
261
+ name,
262
+ controller,
263
+ )
264
+
265
+ if (stimulusInstance) {
266
+ components.push(stimulusInstance)
267
+ }
268
+ })
269
+
270
+ triggers.forEach((trigger) => {
271
+ const stimulusInstance = getStimulusInstance<NestedComponent>(
272
+ name,
273
+ document.querySelector(`#${trigger.getAttribute('aria-controls')}`),
274
+ )
275
+
276
+ if (stimulusInstance) {
277
+ components.push(stimulusInstance)
278
+ }
279
+ })
280
+ })
281
+
282
+ return components.some((c) => c.isOpenValue)
283
+ }
284
+
285
+ const onClickOutside = (
286
+ controller: DropdownMenu | Select | Popover | Combobox,
287
+ event: MouseEvent,
288
+ ) => {
289
+ const target = event.target as HTMLElement
290
+ // Let trigger handle state
291
+ if (target === controller.triggerTarget) return
292
+ if (controller.triggerTarget.contains(target)) return
293
+
294
+ controller.close()
295
+ }
296
+
297
+ const setGroupLabelsId = (controller: Select | Command | Combobox) => {
298
+ controller.groupTargets.forEach((g) => {
299
+ const label = g.querySelector(
300
+ `[data-shadcn-phlexcomponents="${controller.identifier}-label"]`,
301
+ ) as HTMLElement
302
+
303
+ if (label) {
304
+ label.id = g.getAttribute('aria-labelledby') as string
305
+ }
306
+ })
307
+ }
308
+
309
+ const getNextEnabledIndex = ({
310
+ items,
311
+ currentIndex,
312
+ wrapAround,
313
+ filterFn,
314
+ }: {
315
+ items: HTMLElement[]
316
+ currentIndex: number
317
+ wrapAround: boolean
318
+ filterFn?: (item: HTMLElement) => boolean
319
+ }) => {
320
+ let newIndex = null as number | null
321
+
322
+ if (filterFn) {
323
+ newIndex = items.findIndex(
324
+ (item, index) => index > currentIndex && filterFn(item),
325
+ )
326
+
327
+ if (newIndex === -1) {
328
+ newIndex = currentIndex
329
+ }
330
+ } else {
331
+ newIndex = currentIndex + 1
332
+ }
333
+
334
+ if (newIndex > items.length - 1) {
335
+ if (wrapAround) {
336
+ newIndex = 0
337
+ } else {
338
+ newIndex = items.length - 1
339
+ }
340
+ }
341
+
342
+ return newIndex
343
+ }
344
+
345
+ const getPreviousEnabledIndex = ({
346
+ items,
347
+ currentIndex,
348
+ wrapAround,
349
+ filterFn,
350
+ }: {
351
+ items: HTMLElement[]
352
+ currentIndex: number
353
+ wrapAround: boolean
354
+ filterFn?: (item: HTMLElement) => boolean
355
+ }) => {
356
+ let newIndex = null as number | null
357
+
358
+ if (filterFn) {
359
+ newIndex = items.findLastIndex(
360
+ (item, index) => index < currentIndex && filterFn(item),
361
+ )
362
+
363
+ if (newIndex === -1) {
364
+ newIndex = currentIndex
365
+ }
366
+ } else {
367
+ newIndex = currentIndex - 1
368
+ }
369
+
370
+ if (newIndex < 0) {
371
+ if (wrapAround) {
372
+ newIndex = items.length - 1
373
+ } else {
374
+ newIndex = 0
375
+ }
376
+ }
377
+
378
+ return newIndex
379
+ }
380
+
381
+ const handleTabNavigation = (element: HTMLElement, event: KeyboardEvent) => {
382
+ const focusableElements = getFocusableElements(element)
383
+ const firstElement = focusableElements[0]
384
+ const lastElement = focusableElements[focusableElements.length - 1]
385
+
386
+ // If Shift + Tab pressed on first element, go to last element
387
+ if (event.shiftKey && document.activeElement === firstElement) {
388
+ event.preventDefault()
389
+ lastElement.focus()
390
+ }
391
+ // If Tab pressed on last element, go to first element
392
+ else if (!event.shiftKey && document.activeElement === lastElement) {
393
+ event.preventDefault()
394
+ firstElement.focus()
395
+ }
396
+ }
397
+
398
+ export {
399
+ ANIMATION_OUT_DELAY,
400
+ ON_CLOSE_FOCUS_DELAY,
401
+ ON_OPEN_FOCUS_DELAY,
402
+ lockScroll,
403
+ unlockScroll,
404
+ focusTrigger,
405
+ focusElement,
406
+ getFocusableElements,
407
+ getSameLevelItems,
408
+ showContent,
409
+ hideContent,
410
+ getStimulusInstance,
411
+ anyNestedComponentsOpen,
412
+ onClickOutside,
413
+ setGroupLabelsId,
414
+ getNextEnabledIndex,
415
+ getPreviousEnabledIndex,
416
+ handleTabNavigation,
417
+ }