shadcn-phlex 0.1.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 (113) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +195 -0
  3. data/app.css +20 -0
  4. data/css/shadcn-source.css +3 -0
  5. data/css/shadcn-tailwind.css +160 -0
  6. data/css/themes/mauve.css +62 -0
  7. data/css/themes/mist.css +62 -0
  8. data/css/themes/neutral.css +74 -0
  9. data/css/themes/olive.css +62 -0
  10. data/css/themes/stone.css +62 -0
  11. data/css/themes/taupe.css +62 -0
  12. data/css/themes/zinc.css +62 -0
  13. data/js/controllers/accordion_controller.js +135 -0
  14. data/js/controllers/checkbox_controller.js +52 -0
  15. data/js/controllers/collapsible_controller.js +85 -0
  16. data/js/controllers/combobox_controller.js +168 -0
  17. data/js/controllers/command_controller.js +171 -0
  18. data/js/controllers/context_menu_controller.js +132 -0
  19. data/js/controllers/dark_mode_controller.js +106 -0
  20. data/js/controllers/dialog_controller.js +205 -0
  21. data/js/controllers/drawer_controller.js +161 -0
  22. data/js/controllers/dropdown_menu_controller.js +189 -0
  23. data/js/controllers/hover_card_controller.js +85 -0
  24. data/js/controllers/index.js +89 -0
  25. data/js/controllers/menubar_controller.js +171 -0
  26. data/js/controllers/navigation_menu_controller.js +160 -0
  27. data/js/controllers/popover_controller.js +151 -0
  28. data/js/controllers/radio_group_controller.js +78 -0
  29. data/js/controllers/scroll_area_controller.js +117 -0
  30. data/js/controllers/select_controller.js +198 -0
  31. data/js/controllers/sheet_controller.js +130 -0
  32. data/js/controllers/slider_controller.js +142 -0
  33. data/js/controllers/switch_controller.js +40 -0
  34. data/js/controllers/tabs_controller.js +96 -0
  35. data/js/controllers/toast_controller.js +206 -0
  36. data/js/controllers/toggle_controller.js +30 -0
  37. data/js/controllers/toggle_group_controller.js +73 -0
  38. data/js/controllers/tooltip_controller.js +146 -0
  39. data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
  40. data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
  41. data/lib/shadcn/base.rb +27 -0
  42. data/lib/shadcn/engine.rb +24 -0
  43. data/lib/shadcn/kit.rb +1158 -0
  44. data/lib/shadcn/themes/accent_colors.rb +106 -0
  45. data/lib/shadcn/themes/base_colors.rb +313 -0
  46. data/lib/shadcn/ui/accordion.rb +135 -0
  47. data/lib/shadcn/ui/alert.rb +79 -0
  48. data/lib/shadcn/ui/alert_dialog.rb +220 -0
  49. data/lib/shadcn/ui/aspect_ratio.rb +35 -0
  50. data/lib/shadcn/ui/avatar.rb +134 -0
  51. data/lib/shadcn/ui/badge.rb +48 -0
  52. data/lib/shadcn/ui/breadcrumb.rb +180 -0
  53. data/lib/shadcn/ui/button.rb +63 -0
  54. data/lib/shadcn/ui/button_group.rb +58 -0
  55. data/lib/shadcn/ui/card.rb +133 -0
  56. data/lib/shadcn/ui/checkbox.rb +72 -0
  57. data/lib/shadcn/ui/collapsible.rb +76 -0
  58. data/lib/shadcn/ui/combobox.rb +229 -0
  59. data/lib/shadcn/ui/command.rb +256 -0
  60. data/lib/shadcn/ui/context_menu.rb +319 -0
  61. data/lib/shadcn/ui/dialog.rb +226 -0
  62. data/lib/shadcn/ui/direction.rb +23 -0
  63. data/lib/shadcn/ui/drawer.rb +217 -0
  64. data/lib/shadcn/ui/dropdown_menu.rb +384 -0
  65. data/lib/shadcn/ui/empty.rb +97 -0
  66. data/lib/shadcn/ui/field.rb +126 -0
  67. data/lib/shadcn/ui/hover_card.rb +75 -0
  68. data/lib/shadcn/ui/input.rb +36 -0
  69. data/lib/shadcn/ui/input_group.rb +32 -0
  70. data/lib/shadcn/ui/input_otp.rb +112 -0
  71. data/lib/shadcn/ui/item.rb +115 -0
  72. data/lib/shadcn/ui/kbd.rb +45 -0
  73. data/lib/shadcn/ui/label.rb +28 -0
  74. data/lib/shadcn/ui/menubar.rb +345 -0
  75. data/lib/shadcn/ui/native_select.rb +31 -0
  76. data/lib/shadcn/ui/navigation_menu.rb +238 -0
  77. data/lib/shadcn/ui/pagination.rb +224 -0
  78. data/lib/shadcn/ui/popover.rb +147 -0
  79. data/lib/shadcn/ui/progress.rb +40 -0
  80. data/lib/shadcn/ui/radio_group.rb +92 -0
  81. data/lib/shadcn/ui/resizable.rb +108 -0
  82. data/lib/shadcn/ui/scroll_area.rb +75 -0
  83. data/lib/shadcn/ui/select.rb +235 -0
  84. data/lib/shadcn/ui/separator.rb +36 -0
  85. data/lib/shadcn/ui/sheet.rb +231 -0
  86. data/lib/shadcn/ui/sidebar.rb +420 -0
  87. data/lib/shadcn/ui/skeleton.rb +23 -0
  88. data/lib/shadcn/ui/slider.rb +72 -0
  89. data/lib/shadcn/ui/sonner.rb +177 -0
  90. data/lib/shadcn/ui/spinner.rb +58 -0
  91. data/lib/shadcn/ui/switch.rb +75 -0
  92. data/lib/shadcn/ui/table.rb +154 -0
  93. data/lib/shadcn/ui/tabs.rb +154 -0
  94. data/lib/shadcn/ui/text_field.rb +146 -0
  95. data/lib/shadcn/ui/textarea.rb +32 -0
  96. data/lib/shadcn/ui/theme_toggle.rb +74 -0
  97. data/lib/shadcn/ui/toggle.rb +66 -0
  98. data/lib/shadcn/ui/toggle_group.rb +75 -0
  99. data/lib/shadcn/ui/tooltip.rb +78 -0
  100. data/lib/shadcn/ui/typography.rb +217 -0
  101. data/lib/shadcn/version.rb +5 -0
  102. data/lib/shadcn-phlex.rb +6 -0
  103. data/lib/shadcn.rb +80 -0
  104. data/package.json +14 -0
  105. data/skills/shadcn-phlex/SKILL.md +190 -0
  106. data/skills/shadcn-phlex/evals/evals.json +90 -0
  107. data/skills/shadcn-phlex/references/component-catalog.md +355 -0
  108. data/skills/shadcn-phlex/rules/composition.md +235 -0
  109. data/skills/shadcn-phlex/rules/forms.md +151 -0
  110. data/skills/shadcn-phlex/rules/helpers.md +54 -0
  111. data/skills/shadcn-phlex/rules/stimulus.md +61 -0
  112. data/skills/shadcn-phlex/rules/styling.md +177 -0
  113. metadata +209 -0
@@ -0,0 +1,171 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Command palette (cmdk-style)
4
+ // Cmd+K to open, search/filter, keyboard navigation, grouping
5
+ export default class extends Controller {
6
+ static targets = ["dialog", "overlay", "input", "list", "group", "item", "empty"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ shortcut: { type: String, default: "k" },
10
+ }
11
+
12
+ connect() {
13
+ this._onGlobalKeydown = this._handleGlobalKeydown.bind(this)
14
+ this._onKeydown = this._handleKeydown.bind(this)
15
+
16
+ this._allItems = this.itemTargets.map((el) => ({
17
+ element: el,
18
+ label: el.textContent.trim().toLowerCase(),
19
+ value: el.dataset.value || "",
20
+ group: el.closest('[data-slot="command-group"]'),
21
+ }))
22
+
23
+ this._hideTimeouts = []
24
+ document.addEventListener("keydown", this._onGlobalKeydown)
25
+ this._syncState()
26
+ }
27
+
28
+ disconnect() {
29
+ this._hideTimeouts.forEach(id => clearTimeout(id))
30
+ this._hideTimeouts = []
31
+ document.removeEventListener("keydown", this._onGlobalKeydown)
32
+ document.removeEventListener("keydown", this._onKeydown)
33
+ }
34
+
35
+ toggle() { this.openValue = !this.openValue }
36
+ show() { this.openValue = true }
37
+ hide() { this.openValue = false }
38
+
39
+ filter(event) {
40
+ const query = event.currentTarget.value.toLowerCase()
41
+ let totalVisible = 0
42
+
43
+ // Track visible items per group
44
+ const groupCounts = new Map()
45
+
46
+ this._allItems.forEach(({ element, label, group }) => {
47
+ const match = !query || label.includes(query)
48
+ element.hidden = !match
49
+ if (match) {
50
+ totalVisible++
51
+ if (group) {
52
+ groupCounts.set(group, (groupCounts.get(group) || 0) + 1)
53
+ }
54
+ }
55
+ })
56
+
57
+ // Hide groups with no visible items
58
+ this.groupTargets.forEach((group) => {
59
+ group.hidden = (groupCounts.get(group) || 0) === 0
60
+ })
61
+
62
+ // Show/hide empty state
63
+ this.emptyTargets.forEach((el) => {
64
+ el.hidden = totalVisible > 0
65
+ })
66
+ }
67
+
68
+ selectItem(event) {
69
+ const item = event.currentTarget
70
+ if (item.dataset.disabled) return
71
+
72
+ this.dispatch("select", { detail: { value: item.dataset.value } })
73
+
74
+ // Execute callback if specified
75
+ const callback = item.dataset.commandCallback
76
+ if (callback && window[callback]) {
77
+ window[callback]()
78
+ }
79
+
80
+ this.hide()
81
+ }
82
+
83
+ clickOverlay(event) {
84
+ if (event.target === event.currentTarget) this.hide()
85
+ }
86
+
87
+ openValueChanged() { this._syncState() }
88
+
89
+ _syncState() {
90
+ if (!this._hideTimeouts) return
91
+ if (this.openValue) {
92
+ this._open()
93
+ } else {
94
+ this._close()
95
+ }
96
+ }
97
+
98
+ _open() {
99
+ this.dialogTargets.forEach((el) => { el.dataset.state = "open"; el.hidden = false })
100
+ this.overlayTargets.forEach((el) => { el.dataset.state = "open"; el.hidden = false })
101
+
102
+ document.body.style.overflow = "hidden"
103
+ document.addEventListener("keydown", this._onKeydown)
104
+
105
+ requestAnimationFrame(() => {
106
+ if (this.hasInputTarget) {
107
+ this.inputTarget.value = ""
108
+ this.inputTarget.focus()
109
+ }
110
+ // Reset filter
111
+ this._allItems.forEach(({ element }) => { element.hidden = false })
112
+ this.groupTargets.forEach((g) => { g.hidden = false })
113
+ this.emptyTargets.forEach((el) => { el.hidden = true })
114
+ })
115
+ }
116
+
117
+ _close() {
118
+ this.dialogTargets.forEach((el) => {
119
+ el.dataset.state = "closed"
120
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
121
+ })
122
+ this.overlayTargets.forEach((el) => {
123
+ el.dataset.state = "closed"
124
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
125
+ })
126
+
127
+ document.body.style.overflow = ""
128
+ document.removeEventListener("keydown", this._onKeydown)
129
+ }
130
+
131
+ _handleGlobalKeydown(event) {
132
+ if ((event.metaKey || event.ctrlKey) && event.key === this.shortcutValue) {
133
+ event.preventDefault()
134
+ this.toggle()
135
+ }
136
+ }
137
+
138
+ _handleKeydown(event) {
139
+ if (event.key === "Escape") {
140
+ event.preventDefault()
141
+ this.hide()
142
+ return
143
+ }
144
+
145
+ const items = this._getVisibleItems()
146
+ const current = items.indexOf(document.activeElement)
147
+
148
+ switch (event.key) {
149
+ case "ArrowDown":
150
+ event.preventDefault()
151
+ if (current < items.length - 1) items[current + 1].focus()
152
+ else if (items.length > 0) items[0].focus()
153
+ break
154
+ case "ArrowUp":
155
+ event.preventDefault()
156
+ if (current > 0) items[current - 1].focus()
157
+ else if (this.hasInputTarget) this.inputTarget.focus()
158
+ break
159
+ case "Enter":
160
+ if (document.activeElement?.dataset.slot === "command-item") {
161
+ event.preventDefault()
162
+ document.activeElement.click()
163
+ }
164
+ break
165
+ }
166
+ }
167
+
168
+ _getVisibleItems() {
169
+ return this.itemTargets.filter((el) => !el.hidden && !el.dataset.disabled)
170
+ }
171
+ }
@@ -0,0 +1,132 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix ContextMenu behavior
4
+ // Right-click to open, same keyboard navigation as DropdownMenu
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "content", "item"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ }
10
+
11
+ connect() {
12
+ this._onClickOutside = this._handleClickOutside.bind(this)
13
+ this._onKeydown = this._handleKeydown.bind(this)
14
+ this._onContextMenu = this._handleContextMenu.bind(this)
15
+ this._hideTimeouts = []
16
+
17
+ this.triggerTargets.forEach((el) => {
18
+ el.addEventListener("contextmenu", this._onContextMenu)
19
+ })
20
+
21
+ this._syncState()
22
+ }
23
+
24
+ disconnect() {
25
+ this._hideTimeouts.forEach(id => clearTimeout(id))
26
+ this._hideTimeouts = []
27
+ this._removeListeners()
28
+ this.triggerTargets.forEach((el) => {
29
+ el.removeEventListener("contextmenu", this._onContextMenu)
30
+ })
31
+ }
32
+
33
+ hide() { this.openValue = false }
34
+
35
+ openValueChanged() { this._syncState() }
36
+
37
+ selectItem(event) {
38
+ const item = event.currentTarget
39
+ if (item.dataset.disabled) return
40
+ this.dispatch("select", { detail: { value: item.dataset.value } })
41
+ this.hide()
42
+ }
43
+
44
+ _handleContextMenu(event) {
45
+ event.preventDefault()
46
+ this._mouseX = event.clientX
47
+ this._mouseY = event.clientY
48
+ this.openValue = true
49
+ }
50
+
51
+ _syncState() {
52
+ if (!this._hideTimeouts) return
53
+ const state = this.openValue ? "open" : "closed"
54
+
55
+ this.contentTargets.forEach((el) => {
56
+ el.dataset.state = state
57
+ if (this.openValue) {
58
+ el.hidden = false
59
+ this._positionAt(el, this._mouseX || 0, this._mouseY || 0)
60
+ requestAnimationFrame(() => {
61
+ const firstItem = el.querySelector('[data-slot*="menu-item"]:not([data-disabled])')
62
+ firstItem?.focus()
63
+ })
64
+ } else {
65
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
66
+ }
67
+ })
68
+
69
+ if (this.openValue) {
70
+ document.addEventListener("click", this._onClickOutside, true)
71
+ document.addEventListener("keydown", this._onKeydown)
72
+ } else {
73
+ this._removeListeners()
74
+ }
75
+ }
76
+
77
+ _positionAt(content, x, y) {
78
+ content.style.position = "fixed"
79
+ content.style.zIndex = "50"
80
+ content.style.top = `${y}px`
81
+ content.style.left = `${x}px`
82
+
83
+ // Adjust if overflowing
84
+ requestAnimationFrame(() => {
85
+ const rect = content.getBoundingClientRect()
86
+ if (rect.right > window.innerWidth) {
87
+ content.style.left = `${x - rect.width}px`
88
+ }
89
+ if (rect.bottom > window.innerHeight) {
90
+ content.style.top = `${y - rect.height}px`
91
+ }
92
+ })
93
+ }
94
+
95
+ _handleClickOutside(event) {
96
+ if (!this.contentTargets.some((el) => el.contains(event.target))) {
97
+ this.hide()
98
+ }
99
+ }
100
+
101
+ _handleKeydown(event) {
102
+ if (event.key === "Escape") { event.preventDefault(); this.hide(); return }
103
+
104
+ const items = this._getItems()
105
+ const current = items.indexOf(document.activeElement)
106
+
107
+ if (event.key === "ArrowDown") {
108
+ event.preventDefault()
109
+ items[(current + 1) % items.length]?.focus()
110
+ } else if (event.key === "ArrowUp") {
111
+ event.preventDefault()
112
+ items[(current - 1 + items.length) % items.length]?.focus()
113
+ } else if (event.key === "Enter" || event.key === " ") {
114
+ if (document.activeElement?.matches('[data-slot*="menu-item"]')) {
115
+ event.preventDefault()
116
+ document.activeElement.click()
117
+ }
118
+ }
119
+ }
120
+
121
+ _getItems() {
122
+ if (!this.hasContentTarget) return []
123
+ return Array.from(this.contentTarget.querySelectorAll(
124
+ '[data-slot*="menu-item"]:not([data-disabled])'
125
+ )).filter((el) => !el.closest("[hidden]"))
126
+ }
127
+
128
+ _removeListeners() {
129
+ document.removeEventListener("click", this._onClickOutside, true)
130
+ document.removeEventListener("keydown", this._onKeydown)
131
+ }
132
+ }
@@ -0,0 +1,106 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Dark mode controller for Shadcn Phlex
4
+ //
5
+ // Usage:
6
+ // <div data-controller="shadcn--dark-mode"
7
+ // data-shadcn--dark-mode-mode-value="system">
8
+ // <button data-action="click->shadcn--dark-mode#toggle">Toggle</button>
9
+ // </div>
10
+ //
11
+ // Values:
12
+ // mode: "light" | "dark" | "system" (default: "system")
13
+ //
14
+ // Actions:
15
+ // toggle() - Cycles: light → dark → system
16
+ // setLight() - Force light mode
17
+ // setDark() - Force dark mode
18
+ // setSystem() - Follow OS preference
19
+
20
+ export default class extends Controller {
21
+ static values = {
22
+ mode: { type: String, default: "system" }
23
+ }
24
+
25
+ connect() {
26
+ this._connected = false
27
+
28
+ // Listen for OS preference changes
29
+ this._mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
30
+ this._onMediaChange = this._handleMediaChange.bind(this)
31
+ this._mediaQuery.addEventListener("change", this._onMediaChange)
32
+
33
+ // Read stored preference — set AFTER listeners are ready
34
+ const stored = localStorage.getItem("theme")
35
+ if (stored && ["light", "dark", "system"].includes(stored)) {
36
+ this.modeValue = stored
37
+ }
38
+
39
+ this._connected = true
40
+ this._apply()
41
+ }
42
+
43
+ disconnect() {
44
+ if (this._mediaQuery) {
45
+ this._mediaQuery.removeEventListener("change", this._onMediaChange)
46
+ }
47
+ }
48
+
49
+ modeValueChanged() {
50
+ // Only apply after connect has finished to avoid overwriting localStorage
51
+ // from the default value race
52
+ if (this._connected) {
53
+ this._apply()
54
+ }
55
+ }
56
+
57
+ // ── Actions ──────────────────────────────────────────────
58
+
59
+ toggle() {
60
+ const cycle = { light: "dark", dark: "system", system: "light" }
61
+ this.modeValue = cycle[this.modeValue] || "system"
62
+ }
63
+
64
+ setLight() {
65
+ this.modeValue = "light"
66
+ }
67
+
68
+ setDark() {
69
+ this.modeValue = "dark"
70
+ }
71
+
72
+ setSystem() {
73
+ this.modeValue = "system"
74
+ }
75
+
76
+ // ── Private ──────────────────────────────────────────────
77
+
78
+ _apply() {
79
+ const isDark = this._shouldBeDark()
80
+
81
+ document.documentElement.classList.toggle("dark", isDark)
82
+
83
+ // Persist preference
84
+ localStorage.setItem("theme", this.modeValue)
85
+
86
+ // Dispatch custom event
87
+ document.dispatchEvent(
88
+ new CustomEvent("theme:change", {
89
+ detail: { mode: this.modeValue, dark: isDark }
90
+ })
91
+ )
92
+ }
93
+
94
+ _shouldBeDark() {
95
+ if (this.modeValue === "dark") return true
96
+ if (this.modeValue === "light") return false
97
+ // system
98
+ return this._mediaQuery?.matches ?? false
99
+ }
100
+
101
+ _handleMediaChange() {
102
+ if (this.modeValue === "system") {
103
+ this._apply()
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,205 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Dialog behavior
4
+ // Focus trap, scroll lock, escape to close, overlay click to close
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "overlay", "content", "close", "title", "description"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ modal: { type: Boolean, default: true },
10
+ closeOnOverlay: { type: Boolean, default: true },
11
+ closeOnEscape: { type: Boolean, default: true },
12
+ }
13
+
14
+ connect() {
15
+ this._onKeydown = this._handleKeydown.bind(this)
16
+ this._previouslyFocused = null
17
+ this._hideTimeouts = []
18
+ this._syncState()
19
+ }
20
+
21
+ disconnect() {
22
+ this._hideTimeouts.forEach(id => clearTimeout(id))
23
+ this._hideTimeouts = []
24
+ this._removeListeners()
25
+ this._unlockScroll()
26
+ }
27
+
28
+ toggle() {
29
+ this.openValue = !this.openValue
30
+ }
31
+
32
+ show() {
33
+ this.openValue = true
34
+ }
35
+
36
+ hide() {
37
+ this.openValue = false
38
+ }
39
+
40
+ openValueChanged() {
41
+ this._syncState()
42
+ }
43
+
44
+ clickOverlay(event) {
45
+ if (this.closeOnOverlayValue && event.target === event.currentTarget) {
46
+ this.hide()
47
+ }
48
+ }
49
+
50
+ _syncState() {
51
+ if (!this._hideTimeouts) return
52
+ if (this.openValue) {
53
+ this._open()
54
+ } else {
55
+ this._close()
56
+ }
57
+ }
58
+
59
+ _open() {
60
+ this._previouslyFocused = document.activeElement
61
+
62
+ this.element.dataset.state = "open"
63
+
64
+ this.overlayTargets.forEach((el) => {
65
+ el.dataset.state = "open"
66
+ el.hidden = false
67
+ })
68
+
69
+ this.contentTargets.forEach((el) => {
70
+ el.dataset.state = "open"
71
+ el.hidden = false
72
+ el.setAttribute("role", "dialog")
73
+ el.setAttribute("aria-modal", String(this.modalValue))
74
+
75
+ // Link title/description for ARIA
76
+ if (this.hasTitleTarget) {
77
+ const titleId = this._ensureId(this.titleTarget, "dialog-title")
78
+ el.setAttribute("aria-labelledby", titleId)
79
+ }
80
+ if (this.hasDescriptionTarget) {
81
+ const descId = this._ensureId(this.descriptionTarget, "dialog-desc")
82
+ el.setAttribute("aria-describedby", descId)
83
+ }
84
+ })
85
+
86
+ if (this.modalValue) {
87
+ this._lockScroll()
88
+ }
89
+
90
+ document.addEventListener("keydown", this._onKeydown)
91
+
92
+ // Focus first focusable element in content
93
+ requestAnimationFrame(() => {
94
+ if (this.hasContentTarget) {
95
+ const focusable = this._getFocusableElements(this.contentTarget)
96
+ if (focusable.length > 0) {
97
+ focusable[0].focus()
98
+ }
99
+ }
100
+ })
101
+ }
102
+
103
+ _close() {
104
+ this.element.dataset.state = "closed"
105
+
106
+ this.overlayTargets.forEach((el) => {
107
+ el.dataset.state = "closed"
108
+ // Delay hiding for exit animation
109
+ this._hideAfterAnimation(el)
110
+ })
111
+
112
+ this.contentTargets.forEach((el) => {
113
+ el.dataset.state = "closed"
114
+ this._hideAfterAnimation(el)
115
+ })
116
+
117
+ this._removeListeners()
118
+ this._unlockScroll()
119
+
120
+ // Restore focus
121
+ if (this._previouslyFocused && this._previouslyFocused.focus) {
122
+ this._previouslyFocused.focus()
123
+ this._previouslyFocused = null
124
+ }
125
+ }
126
+
127
+ _handleKeydown(event) {
128
+ if (event.key === "Escape" && this.closeOnEscapeValue) {
129
+ event.preventDefault()
130
+ event.stopPropagation()
131
+ this.hide()
132
+ return
133
+ }
134
+
135
+ // Focus trap
136
+ if (event.key === "Tab" && this.modalValue && this.hasContentTarget) {
137
+ const focusable = this._getFocusableElements(this.contentTarget)
138
+ if (focusable.length === 0) return
139
+
140
+ const first = focusable[0]
141
+ const last = focusable[focusable.length - 1]
142
+
143
+ if (event.shiftKey) {
144
+ if (document.activeElement === first) {
145
+ event.preventDefault()
146
+ last.focus()
147
+ }
148
+ } else {
149
+ if (document.activeElement === last) {
150
+ event.preventDefault()
151
+ first.focus()
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ _getFocusableElements(container) {
158
+ const selector = [
159
+ 'a[href]', 'button:not([disabled])', 'input:not([disabled])',
160
+ 'select:not([disabled])', 'textarea:not([disabled])',
161
+ '[tabindex]:not([tabindex="-1"])', '[contenteditable]'
162
+ ].join(", ")
163
+ return Array.from(container.querySelectorAll(selector)).filter(
164
+ (el) => !el.closest("[hidden]") && el.offsetParent !== null
165
+ )
166
+ }
167
+
168
+ _lockScroll() {
169
+ this._scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
170
+ document.body.style.overflow = "hidden"
171
+ document.body.style.paddingRight = `${this._scrollbarWidth}px`
172
+ }
173
+
174
+ _unlockScroll() {
175
+ document.body.style.overflow = ""
176
+ document.body.style.paddingRight = ""
177
+ }
178
+
179
+ _removeListeners() {
180
+ document.removeEventListener("keydown", this._onKeydown)
181
+ }
182
+
183
+ _hideAfterAnimation(el) {
184
+ const onAnimEnd = () => {
185
+ if (el.dataset.state === "closed") {
186
+ el.hidden = true
187
+ }
188
+ el.removeEventListener("animationend", onAnimEnd)
189
+ }
190
+ el.addEventListener("animationend", onAnimEnd)
191
+ // Fallback if no animation
192
+ this._hideTimeouts.push(setTimeout(() => {
193
+ if (el.dataset.state === "closed") {
194
+ el.hidden = true
195
+ }
196
+ }, 300))
197
+ }
198
+
199
+ _ensureId(el, prefix) {
200
+ if (!el.id) {
201
+ el.id = `${prefix}-${Math.random().toString(36).slice(2, 9)}`
202
+ }
203
+ return el.id
204
+ }
205
+ }