Skip to content

Commit 485e22b

Browse files
committed
Massive update
1 parent ad4bd09 commit 485e22b

File tree

31 files changed

+1445
-604
lines changed

31 files changed

+1445
-604
lines changed

js/src/dropdown.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const Default = {
102102
floatingConfig: null,
103103
placement: DEFAULT_PLACEMENT,
104104
reference: 'toggle',
105+
strategy: 'absolute',
105106
// Submenu options
106107
submenuTrigger: 'both', // 'click', 'hover', or 'both'
107108
submenuDelay: SUBMENU_CLOSE_DELAY
@@ -115,6 +116,7 @@ const DefaultType = {
115116
floatingConfig: '(null|object|function)',
116117
placement: 'string',
117118
reference: '(string|element|object)',
119+
strategy: 'string',
118120
submenuTrigger: 'string',
119121
submenuDelay: 'number'
120122
}
@@ -326,7 +328,8 @@ class Dropdown extends BaseComponent {
326328
referenceElement,
327329
this._menu,
328330
floatingConfig.placement,
329-
floatingConfig.middleware
331+
floatingConfig.middleware,
332+
floatingConfig.strategy
330333
)
331334
}
332335

@@ -434,7 +437,8 @@ class Dropdown extends BaseComponent {
434437
_getFloatingConfig(placement, middleware) {
435438
const defaultConfig = {
436439
placement,
437-
middleware
440+
middleware,
441+
strategy: this._config.strategy
438442
}
439443

440444
return {
@@ -451,23 +455,23 @@ class Dropdown extends BaseComponent {
451455
}
452456

453457
// Shared helper for positioning any floating element
454-
async _applyFloatingPosition(reference, floating, placement, middleware) {
458+
async _applyFloatingPosition(reference, floating, placement, middleware, strategy = 'absolute') {
455459
if (!floating.isConnected) {
456460
return null
457461
}
458462

459463
const { x, y, placement: finalPlacement } = await computePosition(
460464
reference,
461465
floating,
462-
{ placement, middleware }
466+
{ placement, middleware, strategy }
463467
)
464468

465469
if (!floating.isConnected) {
466470
return null
467471
}
468472

469473
Object.assign(floating.style, {
470-
position: 'absolute',
474+
position: strategy,
471475
left: `${x}px`,
472476
top: `${y}px`,
473477
margin: '0'

js/src/nav-overflow.js

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/**
2+
* --------------------------------------------------------------------------
3+
* Bootstrap nav-overflow.js
4+
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5+
* --------------------------------------------------------------------------
6+
*/
7+
8+
import BaseComponent from './base-component.js'
9+
import EventHandler from './dom/event-handler.js'
10+
import SelectorEngine from './dom/selector-engine.js'
11+
import Dropdown from './dropdown.js'
12+
13+
/**
14+
* Constants
15+
*/
16+
17+
const NAME = 'navoverflow'
18+
const DATA_KEY = 'bs.navoverflow'
19+
const EVENT_KEY = `.${DATA_KEY}`
20+
21+
const EVENT_UPDATE = `update${EVENT_KEY}`
22+
const EVENT_OVERFLOW = `overflow${EVENT_KEY}`
23+
24+
const CLASS_NAME_OVERFLOW = 'nav-overflow'
25+
const CLASS_NAME_OVERFLOW_MENU = 'nav-overflow-menu'
26+
const CLASS_NAME_HIDDEN = 'd-none'
27+
28+
const SELECTOR_NAV_ITEM = '.nav-item'
29+
const SELECTOR_NAV_LINK = '.nav-link'
30+
const SELECTOR_OVERFLOW_TOGGLE = '.nav-overflow-toggle'
31+
const SELECTOR_OVERFLOW_MENU = '.nav-overflow-menu'
32+
const CLASS_NAME_KEEP = 'nav-overflow-keep'
33+
34+
const Default = {
35+
moreText: 'More',
36+
moreIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/></svg>',
37+
threshold: 0 // Minimum items to keep visible before showing overflow
38+
}
39+
40+
const DefaultType = {
41+
moreText: 'string',
42+
moreIcon: 'string',
43+
threshold: 'number'
44+
}
45+
46+
/**
47+
* Class definition
48+
*/
49+
50+
class NavOverflow extends BaseComponent {
51+
constructor(element, config) {
52+
super(element, config)
53+
54+
this._items = []
55+
this._overflowItems = []
56+
this._overflowMenu = null
57+
this._overflowToggle = null
58+
this._resizeObserver = null
59+
this._isInitialized = false
60+
61+
this._init()
62+
}
63+
64+
// Getters
65+
static get Default() {
66+
return Default
67+
}
68+
69+
static get DefaultType() {
70+
return DefaultType
71+
}
72+
73+
static get NAME() {
74+
return NAME
75+
}
76+
77+
// Public
78+
update() {
79+
this._calculateOverflow()
80+
EventHandler.trigger(this._element, EVENT_UPDATE)
81+
}
82+
83+
dispose() {
84+
if (this._resizeObserver) {
85+
this._resizeObserver.disconnect()
86+
}
87+
88+
// Move items back to original positions
89+
this._restoreItems()
90+
91+
// Remove overflow menu
92+
if (this._overflowToggle && this._overflowToggle.parentElement) {
93+
this._overflowToggle.parentElement.remove()
94+
}
95+
96+
super.dispose()
97+
}
98+
99+
// Private
100+
_init() {
101+
// Add overflow class to nav
102+
this._element.classList.add(CLASS_NAME_OVERFLOW)
103+
104+
// Get all nav items
105+
this._items = [...SelectorEngine.find(SELECTOR_NAV_ITEM, this._element)]
106+
107+
// Store original order data
108+
for (const [index, item] of this._items.entries()) {
109+
item.dataset.bsNavOrder = index
110+
}
111+
112+
// Create overflow dropdown if it doesn't exist
113+
this._createOverflowMenu()
114+
115+
// Setup resize observer
116+
this._setupResizeObserver()
117+
118+
// Initial calculation
119+
this._calculateOverflow()
120+
121+
this._isInitialized = true
122+
}
123+
124+
_createOverflowMenu() {
125+
// Check if overflow menu already exists
126+
this._overflowToggle = SelectorEngine.findOne(SELECTOR_OVERFLOW_TOGGLE, this._element)
127+
128+
if (this._overflowToggle) {
129+
this._overflowMenu = SelectorEngine.findOne(SELECTOR_OVERFLOW_MENU, this._element)
130+
return
131+
}
132+
133+
// Create the overflow dropdown item
134+
const overflowItem = document.createElement('li')
135+
overflowItem.className = 'nav-item nav-overflow-item dropdown'
136+
overflowItem.innerHTML = `
137+
<button class="nav-link nav-overflow-toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
138+
<span class="nav-overflow-icon">${this._config.moreIcon}</span>
139+
<span class="nav-overflow-text">${this._config.moreText}</span>
140+
</button>
141+
<ul class="${CLASS_NAME_OVERFLOW_MENU} dropdown-menu dropdown-menu-end"></ul>
142+
`
143+
144+
this._element.append(overflowItem)
145+
this._overflowToggle = overflowItem.querySelector(SELECTOR_OVERFLOW_TOGGLE)
146+
this._overflowMenu = overflowItem.querySelector(SELECTOR_OVERFLOW_MENU)
147+
148+
// Initialize dropdown with fixed strategy to escape overflow containers
149+
Dropdown.getOrCreateInstance(this._overflowToggle, {
150+
strategy: 'fixed'
151+
})
152+
}
153+
154+
_setupResizeObserver() {
155+
if (typeof ResizeObserver === 'undefined') {
156+
// Fallback for older browsers
157+
EventHandler.on(window, 'resize', () => this._calculateOverflow())
158+
return
159+
}
160+
161+
this._resizeObserver = new ResizeObserver(() => {
162+
this._calculateOverflow()
163+
})
164+
165+
this._resizeObserver.observe(this._element)
166+
}
167+
168+
_calculateOverflow() {
169+
// First, restore all items to measure properly
170+
this._restoreItems()
171+
172+
const navWidth = this._element.offsetWidth
173+
const overflowItem = this._overflowToggle?.closest('.nav-item')
174+
const overflowWidth = overflowItem?.offsetWidth || 0
175+
176+
let usedWidth = 0
177+
const itemsToOverflow = []
178+
const overflowThreshold = navWidth - overflowWidth - 10 // 10px buffer
179+
180+
// Calculate which items need to overflow (skip items with keep class)
181+
for (const item of this._items) {
182+
const itemWidth = item.offsetWidth
183+
usedWidth += itemWidth
184+
185+
// Never overflow items with the keep class
186+
if (item.classList.contains(CLASS_NAME_KEEP)) {
187+
continue
188+
}
189+
190+
if (usedWidth > overflowThreshold) {
191+
itemsToOverflow.push(item)
192+
}
193+
}
194+
195+
// Check if we need threshold minimum visible
196+
const visibleCount = this._items.length - itemsToOverflow.length
197+
if (visibleCount < this._config.threshold && this._items.length > this._config.threshold) {
198+
// Add more items to overflow until we reach threshold (but not keep items)
199+
const toMove = this._items.slice(this._config.threshold).filter(item => !item.classList.contains(CLASS_NAME_KEEP))
200+
itemsToOverflow.length = 0
201+
itemsToOverflow.push(...toMove)
202+
}
203+
204+
// Move items to overflow menu
205+
this._moveToOverflow(itemsToOverflow)
206+
207+
// Show/hide overflow toggle
208+
if (overflowItem) {
209+
if (itemsToOverflow.length > 0) {
210+
overflowItem.classList.remove(CLASS_NAME_HIDDEN)
211+
} else {
212+
overflowItem.classList.add(CLASS_NAME_HIDDEN)
213+
}
214+
}
215+
216+
// Trigger overflow event if items changed
217+
if (itemsToOverflow.length > 0) {
218+
EventHandler.trigger(this._element, EVENT_OVERFLOW, {
219+
overflowCount: itemsToOverflow.length,
220+
visibleCount: this._items.length - itemsToOverflow.length
221+
})
222+
}
223+
}
224+
225+
_moveToOverflow(items) {
226+
if (!this._overflowMenu) {
227+
return
228+
}
229+
230+
// Clear existing overflow items
231+
this._overflowMenu.innerHTML = ''
232+
this._overflowItems = []
233+
234+
for (const item of items) {
235+
// Clone the nav link as a dropdown item
236+
const link = SelectorEngine.findOne(SELECTOR_NAV_LINK, item)
237+
if (!link) {
238+
continue
239+
}
240+
241+
const dropdownItem = document.createElement('li')
242+
const clonedLink = link.cloneNode(true)
243+
clonedLink.className = 'dropdown-item'
244+
245+
// Preserve active state
246+
if (link.classList.contains('active')) {
247+
clonedLink.classList.add('active')
248+
}
249+
250+
// Preserve disabled state
251+
if (link.classList.contains('disabled') || link.hasAttribute('disabled')) {
252+
clonedLink.classList.add('disabled')
253+
}
254+
255+
dropdownItem.append(clonedLink)
256+
this._overflowMenu.append(dropdownItem)
257+
258+
// Hide original item
259+
item.classList.add(CLASS_NAME_HIDDEN)
260+
item.dataset.bsNavOverflow = 'true'
261+
262+
this._overflowItems.push(item)
263+
}
264+
}
265+
266+
_restoreItems() {
267+
for (const item of this._items) {
268+
item.classList.remove(CLASS_NAME_HIDDEN)
269+
delete item.dataset.bsNavOverflow
270+
}
271+
272+
if (this._overflowMenu) {
273+
this._overflowMenu.innerHTML = ''
274+
}
275+
276+
this._overflowItems = []
277+
}
278+
}
279+
280+
/**
281+
* Data API implementation
282+
*/
283+
284+
EventHandler.on(document, 'DOMContentLoaded', () => {
285+
for (const element of SelectorEngine.find('[data-bs-toggle="nav-overflow"]')) {
286+
NavOverflow.getOrCreateInstance(element)
287+
}
288+
})
289+
290+
export default NavOverflow

scss/_nav-overflow.scss

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Nav Overflow (Priority+ Pattern)
2+
//
3+
// A responsive navigation pattern that automatically moves items
4+
// to an overflow dropdown when space is limited.
5+
6+
@use "config" as *;
7+
@use "variables" as *;
8+
9+
@layer components {
10+
.nav-overflow {
11+
flex-wrap: nowrap;
12+
}
13+
14+
// Container item for overflow
15+
.nav-overflow-item {
16+
flex-shrink: 0;
17+
margin-inline-start: auto;
18+
}
19+
20+
// Hide items that have been moved to overflow
21+
.nav-overflow [data-bs-nav-overflow="true"] {
22+
display: none;
23+
}
24+
25+
// Preserve items that should never overflow
26+
.nav-overflow-keep {
27+
flex-shrink: 0;
28+
}
29+
}

0 commit comments

Comments
 (0)