maquina-components 0.4.1 → 0.4.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28216906695fc33245706bd71b7496d3d7508fb3317ac7c0ba6524a3fd899ec2
4
- data.tar.gz: a2f18961cdbe2a57604d14af2ef810655d89f9a3f0473af1880a4cbad65bcaf5
3
+ metadata.gz: b709941be68c552761043a38b7359796b7bc644af13827fbd47b2f40eab90fb9
4
+ data.tar.gz: 931d38949a68e47881e9dc417786a412e5fb16ed02a23539d44259f452249913
5
5
  SHA512:
6
- metadata.gz: 0deefadda8d4c8c049f20ade8f1112325fb2ca58fdadc41006efb1343812158e943d01399e2fe2fa23f418610865e7c08894c70344ce8cfc433e6d07bf05c83f
7
- data.tar.gz: fada4a588f3cfa1629eebc28a11f3e8400be69d1c00bbda71cec643b94ab7f4687b5131499051d2f6a33ccf41cd008664f15899456ceedc2b025871baf69f04c
6
+ metadata.gz: 3b063e95e3ea3c7d4fd649b75cd00696ac33ac88025c3ebab14bea256f7456005a81b8e0c7f3b70f8904f73cc987c4d5cc22a707cbef48f3d10dafa21a7d9ec1
7
+ data.tar.gz: 80b31cd01a92351baedec4a87509b494c6dbfe09cb42bb71c7a59444cca0cc48e4b020684d9c450c1b02568a352f0b2781654c5333a84a0c5af7ea38940f91a6
@@ -107,6 +107,29 @@
107
107
  }
108
108
 
109
109
  /* ===== Responsive Behavior ===== */
110
+ /* Force nowrap when responsive controller is active so scrollWidth detects overflow */
111
+ [data-controller="breadcrumb"] [data-breadcrumb-part="list"] {
112
+ @apply flex-nowrap overflow-hidden;
113
+ }
114
+
115
+ /* Items must not shrink or wrap text, otherwise flex compresses them and scrollWidth never exceeds clientWidth */
116
+ [data-controller="breadcrumb"] [data-breadcrumb-part="item"],
117
+ [data-controller="breadcrumb"] [data-breadcrumb-part="separator"] {
118
+ @apply shrink-0 whitespace-nowrap;
119
+ }
120
+
121
+ /* Allow the last item (current page) to truncate gracefully instead of hard-clipping */
122
+ [data-controller="breadcrumb"] [data-breadcrumb-part="item"]:last-child {
123
+ @apply shrink min-w-0 overflow-hidden;
124
+ text-overflow: ellipsis;
125
+ }
126
+
127
+ [data-controller="breadcrumb"] [data-breadcrumb-part="item"]:last-child [data-breadcrumb-part="page"],
128
+ [data-controller="breadcrumb"] [data-breadcrumb-part="item"]:last-child [data-breadcrumb-part="link"] {
129
+ @apply overflow-hidden;
130
+ text-overflow: ellipsis;
131
+ }
132
+
110
133
  /* Items hidden by Stimulus controller */
111
134
  [data-controller="breadcrumb"] [data-breadcrumb-target="item"].hidden,
112
135
  [data-controller="breadcrumb"] [data-breadcrumb-target="ellipsisSeparator"].hidden {
@@ -3,11 +3,31 @@ module MaquinaComponents
3
3
  def icon_for(name, options = {})
4
4
  return nil unless name
5
5
 
6
- svg = icon_svg_for(name.to_sym) || main_icon_svg_for(name.to_sym)
6
+ svg = main_icon_svg_for(name.to_sym) || icon_svg_for(name.to_sym)
7
7
  return nil unless svg
8
8
 
9
+ apply_icon_options(svg, options)
10
+ end
11
+
12
+ # Internal icon helper for engine components. Always uses built-in SVGs
13
+ # so components work reliably regardless of the app's icon configuration.
14
+ def builtin_icon_for(name, options = {})
15
+ return nil unless name
16
+
17
+ svg = icon_svg_for(name.to_sym)
18
+ return nil unless svg
19
+
20
+ apply_icon_options(svg, options)
21
+ end
22
+
23
+ def main_icon_svg_for(name)
24
+ end
25
+
26
+ private
27
+
28
+ def apply_icon_options(svg, options)
9
29
  css_classes = options[:class]
10
- svg = svg.gsub('class="', "class=\"#{css_classes} ")
30
+ svg = svg.gsub('class="', "class=\"#{css_classes} ") if css_classes
11
31
 
12
32
  if options[:stroke_width]
13
33
  svg = svg.gsub('stroke-width="2"', "stroke-width=\"#{options[:stroke_width]}\"")
@@ -16,9 +36,6 @@ module MaquinaComponents
16
36
  svg.html_safe
17
37
  end
18
38
 
19
- def main_icon_svg_for(name)
20
- end
21
-
22
39
  def icon_svg_for(name)
23
40
  case name
24
41
  when :dollar
@@ -4,6 +4,13 @@ export default class extends Controller {
4
4
  static targets = ["item", "ellipsis", "ellipsisSeparator"]
5
5
 
6
6
  connect() {
7
+ this._dropdown = null
8
+ this._clickOutsideHandler = this._closeDropdown.bind(this)
9
+ this._escapeHandler = this._handleEscape.bind(this)
10
+ this._teardownHandler = this._teardown.bind(this)
11
+
12
+ document.addEventListener("turbo:before-cache", this._teardownHandler)
13
+
7
14
  this.windowResizeHandler = this.handleResize.bind(this)
8
15
  window.addEventListener('resize', this.windowResizeHandler)
9
16
  this.handleResize()
@@ -11,61 +18,185 @@ export default class extends Controller {
11
18
 
12
19
  disconnect() {
13
20
  window.removeEventListener('resize', this.windowResizeHandler)
21
+ this._teardown()
22
+ document.removeEventListener("turbo:before-cache", this._teardownHandler)
23
+ }
24
+
25
+ ellipsisTargetConnected(element) {
26
+ const trigger = element.querySelector('[data-breadcrumb-part="ellipsis"]')
27
+ if (trigger) {
28
+ this._ellipsisTrigger = trigger
29
+ this._toggleHandler = this._toggleDropdown.bind(this)
30
+ trigger.addEventListener('click', this._toggleHandler)
31
+ trigger.style.cursor = 'pointer'
32
+ }
33
+ }
34
+
35
+ ellipsisTargetDisconnected(_element) {
36
+ if (this._ellipsisTrigger && this._toggleHandler) {
37
+ this._ellipsisTrigger.removeEventListener('click', this._toggleHandler)
38
+ this._ellipsisTrigger = null
39
+ this._toggleHandler = null
40
+ }
14
41
  }
15
42
 
16
43
  handleResize() {
17
- // Get visible width of container
18
- const containerWidth = this.element.clientWidth
44
+ const list = this.element.querySelector('[data-breadcrumb-part="list"]')
45
+ if (!list) return
46
+
19
47
  const items = this.itemTargets
20
48
  const ellipsis = this.hasEllipsisTarget ? this.ellipsisTarget : null
21
49
  const ellipsisSeparator = this.hasEllipsisSeparatorTarget ? this.ellipsisSeparatorTarget : null
22
50
 
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
- }
51
+ if (items.length < 1 || !ellipsis) return
27
52
 
28
- // Reset visibility
29
- if (ellipsis) ellipsis.classList.add('hidden')
53
+ // Reset all items and their adjacent separators to visible
54
+ ellipsis.classList.add('hidden')
30
55
  if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
31
-
32
56
  items.forEach(item => {
33
57
  item.classList.remove('hidden')
58
+ const sep = this._adjacentSeparator(item)
59
+ if (sep) sep.classList.remove('hidden')
34
60
  })
35
61
 
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')
62
+ // Check overflow using scrollWidth vs clientWidth
63
+ if (list.scrollWidth > list.clientWidth) {
64
+ ellipsis.classList.remove('hidden')
45
65
  if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
46
66
 
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')
67
+ // Hide middle items one at a time until it fits
68
+ for (let i = items.length - 1; i >= 0; i--) {
69
+ items[i].classList.add('hidden')
70
+ const sep = this._adjacentSeparator(items[i])
71
+ if (sep) sep.classList.add('hidden')
72
+ if (list.scrollWidth <= list.clientWidth) break
73
+ }
74
+ }
51
75
 
52
- // Recalculate total width
53
- totalWidth = 0
76
+ this._updateDropdown()
77
+ }
54
78
 
55
- if (ellipsis) totalWidth += ellipsis.offsetWidth
56
- if (ellipsisSeparator) totalWidth += ellipsisSeparator.offsetWidth
79
+ // Find the next sibling separator <li> (not the managed ellipsisSeparator)
80
+ _adjacentSeparator(item) {
81
+ const next = item.nextElementSibling
82
+ if (next && next.dataset.breadcrumbPart === "separator" && !next.dataset.breadcrumbTarget) {
83
+ return next
84
+ }
85
+ return null
86
+ }
87
+
88
+ // Collect hidden items and update dropdown content
89
+ _updateDropdown() {
90
+ const hiddenItems = this.itemTargets.filter(item => item.classList.contains('hidden'))
57
91
 
58
- items.forEach(item => {
59
- if (!item.classList.contains('hidden')) {
60
- totalWidth += item.offsetWidth
61
- }
62
- })
92
+ if (hiddenItems.length === 0) {
93
+ this._removeDropdown()
94
+ return
95
+ }
63
96
 
64
- if (totalWidth <= containerWidth) {
65
- break
66
- }
67
- }
97
+ // Build list of links from hidden items
98
+ this._hiddenLinks = hiddenItems.map(item => {
99
+ const link = item.querySelector('[data-breadcrumb-part="link"]')
100
+ if (link) {
101
+ return { href: link.getAttribute('href'), text: link.textContent.trim() }
68
102
  }
103
+ return null
104
+ }).filter(Boolean)
105
+
106
+ // If dropdown is currently open, rebuild its content
107
+ if (this._dropdown && this._dropdown.dataset.state === "open") {
108
+ this._buildDropdownContent()
69
109
  }
70
110
  }
111
+
112
+ _toggleDropdown(event) {
113
+ event.stopPropagation()
114
+
115
+ if (this._dropdown && this._dropdown.dataset.state === "open") {
116
+ this._closeDropdown()
117
+ } else {
118
+ this._openDropdown()
119
+ }
120
+ }
121
+
122
+ _openDropdown() {
123
+ if (!this._hiddenLinks || this._hiddenLinks.length === 0) return
124
+
125
+ if (!this._dropdown) {
126
+ this._dropdown = document.createElement('div')
127
+ this._dropdown.setAttribute('role', 'menu')
128
+ this._dropdown.dataset.dropdownMenuPart = 'content'
129
+ this._dropdown.style.position = 'fixed'
130
+ this._dropdown.style.zIndex = '50'
131
+ document.body.appendChild(this._dropdown)
132
+ }
133
+
134
+ this._buildDropdownContent()
135
+ this._positionDropdown()
136
+ this._dropdown.dataset.state = 'open'
137
+
138
+ if (this._ellipsisTrigger) {
139
+ this._ellipsisTrigger.dataset.state = 'open'
140
+ }
141
+
142
+ // Defer listeners so the current click doesn't immediately close
143
+ requestAnimationFrame(() => {
144
+ document.addEventListener('click', this._clickOutsideHandler)
145
+ document.addEventListener('keydown', this._escapeHandler)
146
+ })
147
+ }
148
+
149
+ _closeDropdown() {
150
+ if (this._dropdown) {
151
+ this._dropdown.dataset.state = 'closed'
152
+ }
153
+ if (this._ellipsisTrigger) {
154
+ delete this._ellipsisTrigger.dataset.state
155
+ }
156
+
157
+ document.removeEventListener('click', this._clickOutsideHandler)
158
+ document.removeEventListener('keydown', this._escapeHandler)
159
+ }
160
+
161
+ _removeDropdown() {
162
+ this._closeDropdown()
163
+ if (this._dropdown) {
164
+ this._dropdown.remove()
165
+ this._dropdown = null
166
+ }
167
+ }
168
+
169
+ _buildDropdownContent() {
170
+ if (!this._dropdown || !this._hiddenLinks) return
171
+
172
+ this._dropdown.innerHTML = ''
173
+ this._hiddenLinks.forEach(({ href, text }) => {
174
+ const link = document.createElement('a')
175
+ link.setAttribute('href', href)
176
+ link.setAttribute('role', 'menuitem')
177
+ link.dataset.dropdownMenuPart = 'item'
178
+ link.setAttribute('tabindex', '-1')
179
+ link.textContent = text
180
+ this._dropdown.appendChild(link)
181
+ })
182
+ }
183
+
184
+ _positionDropdown() {
185
+ if (!this._dropdown || !this._ellipsisTrigger) return
186
+
187
+ const rect = this._ellipsisTrigger.getBoundingClientRect()
188
+ this._dropdown.style.top = `${rect.bottom + 4}px`
189
+ this._dropdown.style.left = `${rect.left}px`
190
+ }
191
+
192
+ _handleEscape(event) {
193
+ if (event.key === 'Escape') {
194
+ this._closeDropdown()
195
+ }
196
+ }
197
+
198
+ _teardown() {
199
+ this._removeDropdown()
200
+ this._hiddenLinks = null
201
+ }
71
202
  }
@@ -68,7 +68,7 @@
68
68
  <%= "disabled" if disabled %>
69
69
  aria-haspopup="dialog"
70
70
  aria-expanded="false">
71
- <%= icon_for :calendar, class: "size-4" %>
71
+ <%= builtin_icon_for :calendar, class: "size-4" %>
72
72
  <span data-date-picker-target="display">
73
73
  <%= display_value || placeholder || default_placeholder %>
74
74
  </span>
@@ -36,7 +36,7 @@
36
36
  <span class="truncate text-xs"><%= subtitle %></span>
37
37
  <% end %>
38
38
  </div>
39
- <%= icon_for(:chevron_up_down, class: "ml-auto") if submenu %>
39
+ <%= builtin_icon_for(:chevron_up_down, class: "ml-auto") if submenu %>
40
40
  </button>
41
41
 
42
42
  <%= yield %>
@@ -26,7 +26,7 @@
26
26
  **html_options do %>
27
27
  <% if toast_icon %>
28
28
  <div data-toast-part="icon">
29
- <%= icon_for toast_icon, class: "size-5" %>
29
+ <%= builtin_icon_for toast_icon, class: "size-5" %>
30
30
  </div>
31
31
  <% end %>
32
32
 
@@ -47,7 +47,7 @@
47
47
  data-toast-part="close"
48
48
  data-action="toast#dismiss"
49
49
  aria-label="Dismiss notification">
50
- <%= icon_for :x, class: "size-4" %>
50
+ <%= builtin_icon_for :x, class: "size-4" %>
51
51
  </button>
52
52
  <% end %>
53
53
  <% end %>
@@ -4,6 +4,6 @@
4
4
  ) %>
5
5
 
6
6
  <%= content_tag :span, role: "presentation", class: css_classes, data: merged_data, **html_options do %>
7
- <%= icon_for(:ellipsis) %>
7
+ <%= builtin_icon_for(:ellipsis) %>
8
8
  <span class="sr-only">More</span>
9
9
  <% end %>
@@ -6,7 +6,7 @@
6
6
  data-action="click->calendar#previousMonth"
7
7
  data-calendar-target="prevButton"
8
8
  aria-label="Previous month">
9
- <%= icon_for :chevron_left, class: "size-4" %>
9
+ <%= builtin_icon_for :chevron_left, class: "size-4" %>
10
10
  </button>
11
11
 
12
12
  <div data-calendar-part="caption">
@@ -17,6 +17,6 @@
17
17
  data-action="click->calendar#nextMonth"
18
18
  data-calendar-target="nextButton"
19
19
  aria-label="Next month">
20
- <%= icon_for :chevron_right, class: "size-4" %>
20
+ <%= builtin_icon_for :chevron_right, class: "size-4" %>
21
21
  </button>
22
22
  <% end %>
@@ -6,7 +6,7 @@
6
6
  ) %>
7
7
 
8
8
  <div data-combobox-part="input-wrapper">
9
- <%= icon_for :search, class: "size-4 shrink-0 opacity-50" %>
9
+ <%= builtin_icon_for :search, class: "size-4 shrink-0 opacity-50" %>
10
10
  <%= tag.input type: "text",
11
11
  placeholder: placeholder,
12
12
  autocomplete: "off",
@@ -18,7 +18,7 @@
18
18
  data: merged_data,
19
19
  **html_options do %>
20
20
  <span data-combobox-part="check" class="<%= 'invisible' unless selected %>">
21
- <%= icon_for :check, class: "size-4" %>
21
+ <%= builtin_icon_for :check, class: "size-4" %>
22
22
  </span>
23
23
  <%= yield %>
24
24
  <% end %>
@@ -18,5 +18,5 @@
18
18
  data: merged_data,
19
19
  **html_options do %>
20
20
  <span data-combobox-target="label"><%= placeholder %></span>
21
- <%= icon_for :chevrons_up_down, class: "size-4 shrink-0 opacity-50" %>
21
+ <%= builtin_icon_for :chevrons_up_down, class: "size-4 shrink-0 opacity-50" %>
22
22
  <% end %>
@@ -19,6 +19,6 @@
19
19
  aria: { haspopup: "menu", expanded: "false" },
20
20
  **html_options do %>
21
21
  <%= yield %>
22
- <%= icon_for :chevron_down, data: { "dropdown-menu-target": "chevron" } %>
22
+ <%= builtin_icon_for :chevron_down, data: { "dropdown-menu-target": "chevron" } %>
23
23
  <% end %>
24
24
  <% end %>
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.4.1"
2
+ VERSION = "0.4.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez