maquina-components 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +410 -13
- data/app/assets/images/maquina.svg +1 -0
- data/app/assets/stylesheets/alert.css +143 -0
- data/app/assets/stylesheets/badge.css +145 -0
- data/app/assets/stylesheets/breadcrumbs.css +163 -0
- data/app/assets/stylesheets/card.css +128 -0
- data/app/assets/stylesheets/dropdown_menu.css +248 -0
- data/app/assets/stylesheets/empty.css +133 -0
- data/app/assets/stylesheets/form.css +617 -0
- data/app/assets/stylesheets/header.css +61 -0
- data/app/assets/stylesheets/maquina_components.css +178 -0
- data/app/assets/stylesheets/pagination.css +154 -0
- data/app/assets/stylesheets/sidebar.css +477 -0
- data/app/assets/stylesheets/table.css +205 -0
- data/app/assets/stylesheets/toggle_group.css +151 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +16 -0
- data/app/helpers/maquina_components/breadcrumbs_helper.rb +118 -0
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +249 -0
- data/app/helpers/maquina_components/empty_helper.rb +102 -0
- data/app/helpers/maquina_components/icons_helper.rb +161 -0
- data/app/helpers/maquina_components/pagination_helper.rb +153 -0
- data/app/helpers/maquina_components/sidebar_helper.rb +63 -0
- data/app/helpers/maquina_components/table_helper.rb +144 -0
- data/app/helpers/maquina_components/toggle_group_helper.rb +172 -0
- data/app/javascript/controllers/breadcrumb_controller.js +71 -0
- data/app/javascript/controllers/dropdown_menu_controller.js +203 -0
- data/app/javascript/controllers/menu_button_controller.js +59 -0
- data/app/javascript/controllers/sidebar_controller.js +316 -0
- data/app/javascript/controllers/sidebar_trigger_controller.js +32 -0
- data/app/javascript/controllers/toggle_group_controller.js +178 -0
- data/app/views/components/_alert.html.erb +12 -0
- data/app/views/components/_badge.html.erb +10 -0
- data/app/views/components/_breadcrumbs.html.erb +16 -0
- data/app/views/components/_card.html.erb +6 -0
- data/app/views/components/_dropdown.html.erb +25 -0
- data/app/views/components/_dropdown_menu.html.erb +9 -0
- data/app/views/components/_empty.html.erb +10 -0
- data/app/views/components/_header.html.erb +8 -0
- data/app/views/components/_menu_button.html.erb +44 -0
- data/app/views/components/_pagination.html.erb +13 -0
- data/app/views/components/_separator.html.erb +11 -0
- data/app/views/components/_sidebar.html.erb +40 -0
- data/app/views/components/_simple_table.html.erb +49 -0
- data/app/views/components/_table.html.erb +21 -0
- data/app/views/components/_toggle_group.html.erb +24 -0
- data/app/views/components/alert/_description.html.erb +6 -0
- data/app/views/components/alert/_title.html.erb +6 -0
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +9 -0
- data/app/views/components/breadcrumbs/_item.html.erb +8 -0
- data/app/views/components/breadcrumbs/_link.html.erb +8 -0
- data/app/views/components/breadcrumbs/_list.html.erb +8 -0
- data/app/views/components/breadcrumbs/_page.html.erb +8 -0
- data/app/views/components/breadcrumbs/_separator.html.erb +17 -0
- data/app/views/components/card/_action.html.erb +6 -0
- data/app/views/components/card/_content.html.erb +9 -0
- data/app/views/components/card/_description.html.erb +6 -0
- data/app/views/components/card/_footer.html.erb +17 -0
- data/app/views/components/card/_header.html.erb +9 -0
- data/app/views/components/card/_title.html.erb +9 -0
- data/app/views/components/dropdown_menu/_content.html.erb +20 -0
- data/app/views/components/dropdown_menu/_group.html.erb +12 -0
- data/app/views/components/dropdown_menu/_item.html.erb +29 -0
- data/app/views/components/dropdown_menu/_label.html.erb +13 -0
- data/app/views/components/dropdown_menu/_separator.html.erb +11 -0
- data/app/views/components/dropdown_menu/_shortcut.html.erb +12 -0
- data/app/views/components/dropdown_menu/_trigger.html.erb +24 -0
- data/app/views/components/empty/_content.html.erb +8 -0
- data/app/views/components/empty/_description.html.erb +12 -0
- data/app/views/components/empty/_header.html.erb +8 -0
- data/app/views/components/empty/_media.html.erb +13 -0
- data/app/views/components/empty/_title.html.erb +12 -0
- data/app/views/components/pagination/_content.html.erb +8 -0
- data/app/views/components/pagination/_ellipsis.html.erb +28 -0
- data/app/views/components/pagination/_item.html.erb +8 -0
- data/app/views/components/pagination/_link.html.erb +23 -0
- data/app/views/components/pagination/_next.html.erb +57 -0
- data/app/views/components/pagination/_previous.html.erb +57 -0
- data/app/views/components/sidebar/_content.html.erb +8 -0
- data/app/views/components/sidebar/_footer.html.erb +8 -0
- data/app/views/components/sidebar/_group.html.erb +12 -0
- data/app/views/components/sidebar/_header.html.erb +8 -0
- data/app/views/components/sidebar/_inset.html.erb +8 -0
- data/app/views/components/sidebar/_menu.html.erb +8 -0
- data/app/views/components/sidebar/_menu_button.html.erb +14 -0
- data/app/views/components/sidebar/_menu_item.html.erb +7 -0
- data/app/views/components/sidebar/_menu_link.html.erb +32 -0
- data/app/views/components/sidebar/_provider.html.erb +16 -0
- data/app/views/components/sidebar/_trigger.html.erb +12 -0
- data/app/views/components/stats/_stats_card.html.erb +100 -0
- data/app/views/components/stats/_stats_grid.html.erb +38 -0
- data/app/views/components/table/_body.html.erb +5 -0
- data/app/views/components/table/_caption.html.erb +5 -0
- data/app/views/components/table/_cell.html.erb +5 -0
- data/app/views/components/table/_footer.html.erb +5 -0
- data/app/views/components/table/_head.html.erb +8 -0
- data/app/views/components/table/_header.html.erb +8 -0
- data/app/views/components/table/_row.html.erb +8 -0
- data/app/views/components/toggle_group/_item.html.erb +19 -0
- data/config/importmap.rb +1 -0
- data/lib/generators/maquina_components/install/USAGE +39 -0
- data/lib/generators/maquina_components/install/install_generator.rb +123 -0
- data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +68 -0
- data/lib/generators/maquina_components/install/templates/theme.css.tt +179 -0
- data/lib/maquina_components/engine.rb +10 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +121 -5
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaquinaComponents
|
|
4
|
+
# Toggle Group Helper
|
|
5
|
+
#
|
|
6
|
+
# Provides convenient methods for creating toggle group components.
|
|
7
|
+
#
|
|
8
|
+
# @example Using partials directly
|
|
9
|
+
# <%%= render "components/toggle_group", type: :single, variant: :outline do %>
|
|
10
|
+
# <%%= render "components/toggle_group/item", value: "bold", aria_label: "Toggle bold" do %>
|
|
11
|
+
# <%%= icon_for :bold %>
|
|
12
|
+
# <%% end %>
|
|
13
|
+
# <%% end %>
|
|
14
|
+
#
|
|
15
|
+
# @example Using the helper with builder
|
|
16
|
+
# <%%= toggle_group type: :multiple, variant: :outline do |group| %>
|
|
17
|
+
# <%% group.item value: "bold", icon: :bold, aria_label: "Toggle bold" %>
|
|
18
|
+
# <%% group.item value: "italic", icon: :italic, aria_label: "Toggle italic" %>
|
|
19
|
+
# <%% end %>
|
|
20
|
+
#
|
|
21
|
+
# @example Simple data-driven helper
|
|
22
|
+
# <%%= toggle_group_simple type: :single, items: [
|
|
23
|
+
# { value: "left", icon: :align_left, aria_label: "Align left" },
|
|
24
|
+
# { value: "center", icon: :align_center, aria_label: "Align center" },
|
|
25
|
+
# { value: "right", icon: :align_right, aria_label: "Align right" }
|
|
26
|
+
# ] %>
|
|
27
|
+
#
|
|
28
|
+
module ToggleGroupHelper
|
|
29
|
+
# Renders a toggle group with builder pattern
|
|
30
|
+
#
|
|
31
|
+
# @param type [Symbol] Selection mode (:single, :multiple)
|
|
32
|
+
# @param variant [Symbol] Visual style (:default, :outline)
|
|
33
|
+
# @param size [Symbol] Size variant (:default, :sm, :lg)
|
|
34
|
+
# @param value [String, Array, nil] Initially selected value(s)
|
|
35
|
+
# @param disabled [Boolean] Disable all items
|
|
36
|
+
# @param css_classes [String] Additional CSS classes
|
|
37
|
+
# @param html_options [Hash] Additional HTML attributes
|
|
38
|
+
# @yield [ToggleGroupBuilder] Builder for adding items
|
|
39
|
+
# @return [String] Rendered HTML
|
|
40
|
+
def toggle_group(type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options, &block)
|
|
41
|
+
builder = ToggleGroupBuilder.new(self, type: type, variant: variant, size: size, value: value, disabled: disabled)
|
|
42
|
+
|
|
43
|
+
if block && block.arity == 1
|
|
44
|
+
capture { yield(builder) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
render "components/toggle_group",
|
|
48
|
+
type: type,
|
|
49
|
+
variant: variant,
|
|
50
|
+
size: size,
|
|
51
|
+
value: value,
|
|
52
|
+
disabled: disabled,
|
|
53
|
+
css_classes: css_classes,
|
|
54
|
+
**html_options do
|
|
55
|
+
if block && block.arity == 1
|
|
56
|
+
builder.to_html
|
|
57
|
+
elsif block
|
|
58
|
+
capture(&block)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Renders a simple data-driven toggle group
|
|
64
|
+
#
|
|
65
|
+
# @param type [Symbol] Selection mode (:single, :multiple)
|
|
66
|
+
# @param items [Array<Hash>] Array of item configurations
|
|
67
|
+
# @param variant [Symbol] Visual style
|
|
68
|
+
# @param size [Symbol] Size variant
|
|
69
|
+
# @param value [String, Array, nil] Initially selected value(s)
|
|
70
|
+
# @return [String] Rendered HTML
|
|
71
|
+
def toggle_group_simple(items:, type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options)
|
|
72
|
+
selected_values = normalize_value(value)
|
|
73
|
+
|
|
74
|
+
render "components/toggle_group",
|
|
75
|
+
type: type,
|
|
76
|
+
variant: variant,
|
|
77
|
+
size: size,
|
|
78
|
+
value: value,
|
|
79
|
+
disabled: disabled,
|
|
80
|
+
css_classes: css_classes,
|
|
81
|
+
**html_options do
|
|
82
|
+
safe_join(items.map do |item|
|
|
83
|
+
item_value = item[:value].to_s
|
|
84
|
+
is_pressed = selected_values.include?(item_value)
|
|
85
|
+
|
|
86
|
+
render "components/toggle_group/item",
|
|
87
|
+
value: item_value,
|
|
88
|
+
pressed: is_pressed,
|
|
89
|
+
disabled: item[:disabled] || disabled,
|
|
90
|
+
aria_label: item[:aria_label] do
|
|
91
|
+
parts = []
|
|
92
|
+
parts << icon_for(item[:icon]) if item[:icon] && respond_to?(:icon_for)
|
|
93
|
+
parts << item[:label] if item[:label]
|
|
94
|
+
safe_join(parts)
|
|
95
|
+
end
|
|
96
|
+
end)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builder class for toggle group
|
|
101
|
+
class ToggleGroupBuilder
|
|
102
|
+
def initialize(view_context, type:, variant:, size:, value:, disabled:)
|
|
103
|
+
@view = view_context
|
|
104
|
+
@type = type
|
|
105
|
+
@variant = variant
|
|
106
|
+
@size = size
|
|
107
|
+
@value = value
|
|
108
|
+
@disabled = disabled
|
|
109
|
+
@items = []
|
|
110
|
+
@selected_values = normalize_value(value)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Add an item to the toggle group
|
|
114
|
+
def item(value:, label: nil, icon: nil, disabled: false, aria_label: nil, **options, &block)
|
|
115
|
+
is_pressed = @selected_values.include?(value.to_s)
|
|
116
|
+
|
|
117
|
+
@items << {
|
|
118
|
+
value: value,
|
|
119
|
+
label: label,
|
|
120
|
+
icon: icon,
|
|
121
|
+
disabled: disabled || @disabled,
|
|
122
|
+
aria_label: aria_label,
|
|
123
|
+
pressed: is_pressed,
|
|
124
|
+
options: options,
|
|
125
|
+
block: block
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_html
|
|
130
|
+
@view.safe_join(@items.map { |item| render_item(item) })
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def render_item(item)
|
|
136
|
+
@view.render "components/toggle_group/item",
|
|
137
|
+
value: item[:value],
|
|
138
|
+
pressed: item[:pressed],
|
|
139
|
+
disabled: item[:disabled],
|
|
140
|
+
aria_label: item[:aria_label],
|
|
141
|
+
**item[:options] do
|
|
142
|
+
if item[:block]
|
|
143
|
+
@view.capture(&item[:block])
|
|
144
|
+
else
|
|
145
|
+
parts = []
|
|
146
|
+
parts << @view.icon_for(item[:icon]) if item[:icon] && @view.respond_to?(:icon_for)
|
|
147
|
+
parts << item[:label] if item[:label]
|
|
148
|
+
@view.safe_join(parts)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_value(value)
|
|
154
|
+
case value
|
|
155
|
+
when Array then value.map(&:to_s)
|
|
156
|
+
when nil then []
|
|
157
|
+
else [value.to_s]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def normalize_value(value)
|
|
165
|
+
case value
|
|
166
|
+
when Array then value.map(&:to_s)
|
|
167
|
+
when nil then []
|
|
168
|
+
else [value.to_s]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["item", "ellipsis", "ellipsisSeparator"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.windowResizeHandler = this.handleResize.bind(this)
|
|
8
|
+
window.addEventListener('resize', this.windowResizeHandler)
|
|
9
|
+
this.handleResize()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
disconnect() {
|
|
13
|
+
window.removeEventListener('resize', this.windowResizeHandler)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
handleResize() {
|
|
17
|
+
// Get visible width of container
|
|
18
|
+
const containerWidth = this.element.clientWidth
|
|
19
|
+
const items = this.itemTargets
|
|
20
|
+
const ellipsis = this.hasEllipsisTarget ? this.ellipsisTarget : null
|
|
21
|
+
const ellipsisSeparator = this.hasEllipsisSeparatorTarget ? this.ellipsisSeparatorTarget : null
|
|
22
|
+
|
|
23
|
+
// Always show first and last items
|
|
24
|
+
if (items.length < 3 || !ellipsis) {
|
|
25
|
+
return; // Not enough items to collapse or no ellipsis element
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reset visibility
|
|
29
|
+
if (ellipsis) ellipsis.classList.add('hidden')
|
|
30
|
+
if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
|
|
31
|
+
|
|
32
|
+
items.forEach(item => {
|
|
33
|
+
item.classList.remove('hidden')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Check if we need to collapse items
|
|
37
|
+
let totalWidth = 0
|
|
38
|
+
items.forEach(item => {
|
|
39
|
+
totalWidth += item.offsetWidth
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (totalWidth > containerWidth) {
|
|
43
|
+
// We need to collapse items - show ellipsis
|
|
44
|
+
if (ellipsis) ellipsis.classList.remove('hidden')
|
|
45
|
+
if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
|
|
46
|
+
|
|
47
|
+
// Start hiding middle items until we fit
|
|
48
|
+
for (let i = items.length - 2; i > 0; i--) {
|
|
49
|
+
if (i !== 0 && i !== items.length - 1) {
|
|
50
|
+
items[i].classList.add('hidden')
|
|
51
|
+
|
|
52
|
+
// Recalculate total width
|
|
53
|
+
totalWidth = 0
|
|
54
|
+
|
|
55
|
+
if (ellipsis) totalWidth += ellipsis.offsetWidth
|
|
56
|
+
if (ellipsisSeparator) totalWidth += ellipsisSeparator.offsetWidth
|
|
57
|
+
|
|
58
|
+
items.forEach(item => {
|
|
59
|
+
if (!item.classList.contains('hidden')) {
|
|
60
|
+
totalWidth += item.offsetWidth
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (totalWidth <= containerWidth) {
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DropdownMenu Controller
|
|
5
|
+
*
|
|
6
|
+
* Handles opening/closing dropdown menus with:
|
|
7
|
+
* - Click to toggle
|
|
8
|
+
* - Click outside to close
|
|
9
|
+
* - Escape key to close
|
|
10
|
+
* - Keyboard navigation within menu
|
|
11
|
+
* - Focus management
|
|
12
|
+
* - Animation states
|
|
13
|
+
*/
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
static targets = ["trigger", "content", "chevron"]
|
|
16
|
+
|
|
17
|
+
static values = {
|
|
18
|
+
open: { type: Boolean, default: false }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
connect() {
|
|
22
|
+
this.handleClickOutside = this.handleClickOutside.bind(this)
|
|
23
|
+
this.handleKeydown = this.handleKeydown.bind(this)
|
|
24
|
+
|
|
25
|
+
// Set initial state on root element
|
|
26
|
+
this.element.dataset.state = "closed"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.removeEventListeners()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
toggle(event) {
|
|
34
|
+
event?.preventDefault()
|
|
35
|
+
|
|
36
|
+
if (this.openValue) {
|
|
37
|
+
this.close()
|
|
38
|
+
} else {
|
|
39
|
+
this.open()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
open() {
|
|
44
|
+
if (this.openValue || !this.hasContentTarget) return
|
|
45
|
+
|
|
46
|
+
this.openValue = true
|
|
47
|
+
this.element.dataset.state = "open"
|
|
48
|
+
this.contentTarget.dataset.state = "open"
|
|
49
|
+
this.contentTarget.hidden = false
|
|
50
|
+
|
|
51
|
+
// Update trigger aria
|
|
52
|
+
if (this.hasTriggerTarget) {
|
|
53
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Add event listeners
|
|
57
|
+
this.addEventListeners()
|
|
58
|
+
|
|
59
|
+
// Focus first item after animation
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
this.focusFirstItem()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close() {
|
|
66
|
+
if (!this.openValue || !this.hasContentTarget) return
|
|
67
|
+
|
|
68
|
+
// Start closing animation
|
|
69
|
+
this.contentTarget.dataset.state = "closing"
|
|
70
|
+
|
|
71
|
+
// Wait for animation to complete
|
|
72
|
+
const animationDuration = 100 // matches CSS animation duration
|
|
73
|
+
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
this.openValue = false
|
|
76
|
+
this.element.dataset.state = "closed"
|
|
77
|
+
this.contentTarget.dataset.state = "closed"
|
|
78
|
+
this.contentTarget.hidden = true
|
|
79
|
+
|
|
80
|
+
// Update trigger aria
|
|
81
|
+
if (this.hasTriggerTarget) {
|
|
82
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Remove event listeners
|
|
86
|
+
this.removeEventListeners()
|
|
87
|
+
|
|
88
|
+
// Return focus to trigger
|
|
89
|
+
if (this.hasTriggerTarget) {
|
|
90
|
+
this.triggerTarget.focus()
|
|
91
|
+
}
|
|
92
|
+
}, animationDuration)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Event Handlers
|
|
96
|
+
|
|
97
|
+
handleClickOutside(event) {
|
|
98
|
+
if (!this.openValue) return
|
|
99
|
+
if (this.element.contains(event.target)) return
|
|
100
|
+
|
|
101
|
+
this.close()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
handleKeydown(event) {
|
|
105
|
+
if (!this.openValue) return
|
|
106
|
+
|
|
107
|
+
switch (event.key) {
|
|
108
|
+
case "Escape":
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
this.close()
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
case "ArrowDown":
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
this.focusNextItem()
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
case "ArrowUp":
|
|
119
|
+
event.preventDefault()
|
|
120
|
+
this.focusPreviousItem()
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
case "Home":
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
this.focusFirstItem()
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
case "End":
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
this.focusLastItem()
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
case "Tab":
|
|
134
|
+
// Close menu and let focus move naturally
|
|
135
|
+
this.close()
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Focus Management
|
|
141
|
+
|
|
142
|
+
get menuItems() {
|
|
143
|
+
if (!this.hasContentTarget) return []
|
|
144
|
+
|
|
145
|
+
return Array.from(
|
|
146
|
+
this.contentTarget.querySelectorAll('[data-dropdown-menu-part="item"]:not([disabled]):not([aria-disabled="true"])')
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get focusedItemIndex() {
|
|
151
|
+
const items = this.menuItems
|
|
152
|
+
const focused = document.activeElement
|
|
153
|
+
return items.indexOf(focused)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
focusFirstItem() {
|
|
157
|
+
const items = this.menuItems
|
|
158
|
+
if (items.length > 0) {
|
|
159
|
+
items[0].focus()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
focusLastItem() {
|
|
164
|
+
const items = this.menuItems
|
|
165
|
+
if (items.length > 0) {
|
|
166
|
+
items[items.length - 1].focus()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
focusNextItem() {
|
|
171
|
+
const items = this.menuItems
|
|
172
|
+
if (items.length === 0) return
|
|
173
|
+
|
|
174
|
+
const currentIndex = this.focusedItemIndex
|
|
175
|
+
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
|
|
176
|
+
items[nextIndex].focus()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
focusPreviousItem() {
|
|
180
|
+
const items = this.menuItems
|
|
181
|
+
if (items.length === 0) return
|
|
182
|
+
|
|
183
|
+
const currentIndex = this.focusedItemIndex
|
|
184
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
|
|
185
|
+
items[prevIndex].focus()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Event Listener Management
|
|
189
|
+
|
|
190
|
+
addEventListeners() {
|
|
191
|
+
// Delay adding click outside listener to prevent immediate close
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
document.addEventListener("click", this.handleClickOutside)
|
|
194
|
+
}, 0)
|
|
195
|
+
|
|
196
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
removeEventListeners() {
|
|
200
|
+
document.removeEventListener("click", this.handleClickOutside)
|
|
201
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["button", "content"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
if (!this.hasContentTarget) {
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
this.clickOutside = this.clickOutside.bind(this)
|
|
12
|
+
this.isOpen = this.buttonTarget.dataset.state === "open"
|
|
13
|
+
|
|
14
|
+
if (this.isOpen) {
|
|
15
|
+
this.addClickOutsideListener()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
this.removeClickOutsideListener()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toggle() {
|
|
24
|
+
if (!this.hasContentTarget) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.contentTarget.classList.remove("hidden")
|
|
29
|
+
|
|
30
|
+
this.isOpen = !this.isOpen
|
|
31
|
+
this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
|
|
32
|
+
|
|
33
|
+
if (this.isOpen) {
|
|
34
|
+
// Add a small delay before adding the click outside listener
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
this.addClickOutsideListener()
|
|
37
|
+
}, 100)
|
|
38
|
+
} else {
|
|
39
|
+
this.removeClickOutsideListener()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clickOutside(event) {
|
|
44
|
+
if (!this.isOpen) return
|
|
45
|
+
if (event.target === this.element) return
|
|
46
|
+
|
|
47
|
+
if (!this.contentTarget.contains(event.target)) {
|
|
48
|
+
this.toggle()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addClickOutsideListener() {
|
|
53
|
+
document.addEventListener('click', this.clickOutside)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
removeClickOutsideListener() {
|
|
57
|
+
document.removeEventListener('click', this.clickOutside)
|
|
58
|
+
}
|
|
59
|
+
}
|