maquina-components 0.4.2 → 0.4.4

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: 55a2ec2879afb8976c7b668873da307a50d2a2798e003399dbc8c2d4d9779471
4
- data.tar.gz: 2db272ca8f735467c06d2f37b133fb7da4156d9662b85cb19ec93ac9d84078f7
3
+ metadata.gz: 5347ee9b9b49584bdb8549ecc263f2137ce2331b805c5539cab9db84babf9f14
4
+ data.tar.gz: 18b6b97ab09e7f5506c0d539db940808e48c481893ce6ad933f5226fbbd1dae9
5
5
  SHA512:
6
- metadata.gz: 719870f407619f1f4fe5714db8e77af06db45ae3272a58784f647c4280b8ef78d0c1e805e95b58ccdd2f439392652f1483f054c421b093621472bac8cfc34de9
7
- data.tar.gz: 4bc447930b1e71135ef1012455d82a78de7be2483ba17fae25185239a459185b0ed29278ac49bc40c086ade7a8ed2bbc0bd248f4cce8505a26897c273e96246f
6
+ metadata.gz: bcbde28da68d8b05b3d97d47f2f373cb7313f37265d26b78ed309827011c1cf7e1d1871d68ec6b9670240b73b1464979a0af37faa4d495e15c5b7f40ecc1a3ca
7
+ data.tar.gz: 47eb6037d5e98fc4b913750239d2e420c28c69b128ea4c5b9b17a450a5a2b5163f36e16cf7892b514e0c02622773c67e6620e90eea3e25d1f6350525b1a234d3
@@ -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 {
@@ -44,8 +44,8 @@ module MaquinaComponents
44
44
  # "Button"
45
45
  # )
46
46
  #
47
- def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "")
48
- render "components/breadcrumbs", css_classes: css_classes, responsive: true do
47
+ def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "", collapse_after: 0)
48
+ render "components/breadcrumbs", css_classes: css_classes, responsive: true, collapse_after: collapse_after do
49
49
  render "components/breadcrumbs/list" do
50
50
  build_breadcrumb_items(links, current_page, responsive: true)
51
51
  end
@@ -2,8 +2,16 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static targets = ["item", "ellipsis", "ellipsisSeparator"]
5
+ static values = { collapseAfter: { type: Number, default: 0 } }
5
6
 
6
7
  connect() {
8
+ this._dropdown = null
9
+ this._clickOutsideHandler = this._closeDropdown.bind(this)
10
+ this._escapeHandler = this._handleEscape.bind(this)
11
+ this._teardownHandler = this._teardown.bind(this)
12
+
13
+ document.addEventListener("turbo:before-cache", this._teardownHandler)
14
+
7
15
  this.windowResizeHandler = this.handleResize.bind(this)
8
16
  window.addEventListener('resize', this.windowResizeHandler)
9
17
  this.handleResize()
@@ -11,61 +19,205 @@ export default class extends Controller {
11
19
 
12
20
  disconnect() {
13
21
  window.removeEventListener('resize', this.windowResizeHandler)
22
+ this._teardown()
23
+ document.removeEventListener("turbo:before-cache", this._teardownHandler)
24
+ }
25
+
26
+ ellipsisTargetConnected(element) {
27
+ const trigger = element.querySelector('[data-breadcrumb-part="ellipsis"]')
28
+ if (trigger) {
29
+ this._ellipsisTrigger = trigger
30
+ this._toggleHandler = this._toggleDropdown.bind(this)
31
+ trigger.addEventListener('click', this._toggleHandler)
32
+ trigger.style.cursor = 'pointer'
33
+ }
34
+ }
35
+
36
+ ellipsisTargetDisconnected(_element) {
37
+ if (this._ellipsisTrigger && this._toggleHandler) {
38
+ this._ellipsisTrigger.removeEventListener('click', this._toggleHandler)
39
+ this._ellipsisTrigger = null
40
+ this._toggleHandler = null
41
+ }
14
42
  }
15
43
 
16
44
  handleResize() {
17
- // Get visible width of container
18
- const containerWidth = this.element.clientWidth
45
+ const list = this.element.querySelector('[data-breadcrumb-part="list"]')
46
+ if (!list) return
47
+
19
48
  const items = this.itemTargets
20
49
  const ellipsis = this.hasEllipsisTarget ? this.ellipsisTarget : null
21
50
  const ellipsisSeparator = this.hasEllipsisSeparatorTarget ? this.ellipsisSeparatorTarget : null
22
51
 
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
- }
52
+ if (items.length < 1 || !ellipsis) return
27
53
 
28
- // Reset visibility
29
- if (ellipsis) ellipsis.classList.add('hidden')
54
+ // Reset all items and their adjacent separators to visible
55
+ ellipsis.classList.add('hidden')
30
56
  if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
31
-
32
57
  items.forEach(item => {
33
58
  item.classList.remove('hidden')
59
+ const sep = this._adjacentSeparator(item)
60
+ if (sep) sep.classList.remove('hidden')
34
61
  })
35
62
 
36
- // Check if we need to collapse items
37
- let totalWidth = 0
38
- items.forEach(item => {
39
- totalWidth += item.offsetWidth
40
- })
63
+ // Count-based collapsing: force-collapse when total items exceed threshold
64
+ // items are middle targets only; total visible = middle targets + first + last (2)
65
+ const totalItems = items.length + 2
66
+ if (this.collapseAfterValue > 0 && totalItems > this.collapseAfterValue) {
67
+ ellipsis.classList.remove('hidden')
68
+ if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
69
+
70
+ // collapseAfterValue includes first + last, so middle budget = collapseAfterValue - 2
71
+ const maxMiddleVisible = Math.max(this.collapseAfterValue - 2, 0)
72
+ let visibleMiddle = items.length
41
73
 
42
- if (totalWidth > containerWidth) {
43
- // We need to collapse items - show ellipsis
44
- if (ellipsis) ellipsis.classList.remove('hidden')
74
+ for (let i = items.length - 1; i >= 0 && visibleMiddle > maxMiddleVisible; i--) {
75
+ items[i].classList.add('hidden')
76
+ const sep = this._adjacentSeparator(items[i])
77
+ if (sep) sep.classList.add('hidden')
78
+ visibleMiddle--
79
+ }
80
+ }
81
+
82
+ // Check overflow using scrollWidth vs clientWidth
83
+ if (list.scrollWidth > list.clientWidth) {
84
+ ellipsis.classList.remove('hidden')
45
85
  if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
46
86
 
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')
87
+ // Hide middle items one at a time until it fits
88
+ const visibleItems = items.filter(item => !item.classList.contains('hidden'))
89
+ for (let i = visibleItems.length - 1; i >= 0; i--) {
90
+ visibleItems[i].classList.add('hidden')
91
+ const sep = this._adjacentSeparator(visibleItems[i])
92
+ if (sep) sep.classList.add('hidden')
93
+ if (list.scrollWidth <= list.clientWidth) break
94
+ }
95
+ }
51
96
 
52
- // Recalculate total width
53
- totalWidth = 0
97
+ this._updateDropdown()
98
+ }
54
99
 
55
- if (ellipsis) totalWidth += ellipsis.offsetWidth
56
- if (ellipsisSeparator) totalWidth += ellipsisSeparator.offsetWidth
100
+ // Find the next sibling separator <li> (not the managed ellipsisSeparator)
101
+ _adjacentSeparator(item) {
102
+ const next = item.nextElementSibling
103
+ if (next && next.dataset.breadcrumbPart === "separator" && !next.dataset.breadcrumbTarget) {
104
+ return next
105
+ }
106
+ return null
107
+ }
57
108
 
58
- items.forEach(item => {
59
- if (!item.classList.contains('hidden')) {
60
- totalWidth += item.offsetWidth
61
- }
62
- })
109
+ // Collect hidden items and update dropdown content
110
+ _updateDropdown() {
111
+ const hiddenItems = this.itemTargets.filter(item => item.classList.contains('hidden'))
63
112
 
64
- if (totalWidth <= containerWidth) {
65
- break
66
- }
67
- }
113
+ if (hiddenItems.length === 0) {
114
+ this._removeDropdown()
115
+ return
116
+ }
117
+
118
+ // Build list of links from hidden items
119
+ this._hiddenLinks = hiddenItems.map(item => {
120
+ const link = item.querySelector('[data-breadcrumb-part="link"]')
121
+ if (link) {
122
+ return { href: link.getAttribute('href'), text: link.textContent.trim() }
68
123
  }
124
+ return null
125
+ }).filter(Boolean)
126
+
127
+ // If dropdown is currently open, rebuild its content
128
+ if (this._dropdown && this._dropdown.dataset.state === "open") {
129
+ this._buildDropdownContent()
130
+ }
131
+ }
132
+
133
+ _toggleDropdown(event) {
134
+ event.stopPropagation()
135
+
136
+ if (this._dropdown && this._dropdown.dataset.state === "open") {
137
+ this._closeDropdown()
138
+ } else {
139
+ this._openDropdown()
69
140
  }
70
141
  }
142
+
143
+ _openDropdown() {
144
+ if (!this._hiddenLinks || this._hiddenLinks.length === 0) return
145
+
146
+ if (!this._dropdown) {
147
+ this._dropdown = document.createElement('div')
148
+ this._dropdown.setAttribute('role', 'menu')
149
+ this._dropdown.dataset.dropdownMenuPart = 'content'
150
+ this._dropdown.style.position = 'fixed'
151
+ this._dropdown.style.zIndex = '50'
152
+ document.body.appendChild(this._dropdown)
153
+ }
154
+
155
+ this._buildDropdownContent()
156
+ this._positionDropdown()
157
+ this._dropdown.dataset.state = 'open'
158
+
159
+ if (this._ellipsisTrigger) {
160
+ this._ellipsisTrigger.dataset.state = 'open'
161
+ }
162
+
163
+ // Defer listeners so the current click doesn't immediately close
164
+ requestAnimationFrame(() => {
165
+ document.addEventListener('click', this._clickOutsideHandler)
166
+ document.addEventListener('keydown', this._escapeHandler)
167
+ })
168
+ }
169
+
170
+ _closeDropdown() {
171
+ if (this._dropdown) {
172
+ this._dropdown.dataset.state = 'closed'
173
+ }
174
+ if (this._ellipsisTrigger) {
175
+ delete this._ellipsisTrigger.dataset.state
176
+ }
177
+
178
+ document.removeEventListener('click', this._clickOutsideHandler)
179
+ document.removeEventListener('keydown', this._escapeHandler)
180
+ }
181
+
182
+ _removeDropdown() {
183
+ this._closeDropdown()
184
+ if (this._dropdown) {
185
+ this._dropdown.remove()
186
+ this._dropdown = null
187
+ }
188
+ }
189
+
190
+ _buildDropdownContent() {
191
+ if (!this._dropdown || !this._hiddenLinks) return
192
+
193
+ this._dropdown.innerHTML = ''
194
+ this._hiddenLinks.forEach(({ href, text }) => {
195
+ const link = document.createElement('a')
196
+ link.setAttribute('href', href)
197
+ link.setAttribute('role', 'menuitem')
198
+ link.dataset.dropdownMenuPart = 'item'
199
+ link.setAttribute('tabindex', '-1')
200
+ link.textContent = text
201
+ this._dropdown.appendChild(link)
202
+ })
203
+ }
204
+
205
+ _positionDropdown() {
206
+ if (!this._dropdown || !this._ellipsisTrigger) return
207
+
208
+ const rect = this._ellipsisTrigger.getBoundingClientRect()
209
+ this._dropdown.style.top = `${rect.bottom + 4}px`
210
+ this._dropdown.style.left = `${rect.left}px`
211
+ }
212
+
213
+ _handleEscape(event) {
214
+ if (event.key === 'Escape') {
215
+ this._closeDropdown()
216
+ }
217
+ }
218
+
219
+ _teardown() {
220
+ this._removeDropdown()
221
+ this._hiddenLinks = null
222
+ }
71
223
  }
@@ -1,10 +1,13 @@
1
- <%# locals: (css_classes: "", responsive: false, **html_options) %>
1
+ <%# locals: (css_classes: "", responsive: false, collapse_after: 0, **html_options) %>
2
2
  <% merged_data = (html_options.delete(:data) || {}).merge(
3
3
  component: :breadcrumbs
4
4
  )
5
-
5
+
6
6
  if responsive
7
7
  merged_data[:controller] = "breadcrumb"
8
+ if collapse_after > 0
9
+ merged_data[:breadcrumb_collapse_after_value] = collapse_after
10
+ end
8
11
  end %>
9
12
 
10
13
  <nav
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.4.2"
2
+ VERSION = "0.4.4"
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.2
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez