Skip to content

Commit ef4e2da

Browse files
Properly escape IDs in getSelector() to handle weird IDs (#35565) (#35566)
1 parent e81e7cd commit ef4e2da

File tree

5 files changed

+59
-6
lines changed

5 files changed

+59
-6
lines changed

.bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
},
3939
{
4040
"path": "./dist/js/bootstrap.bundle.min.js",
41-
"maxSize": "22.75 kB"
41+
"maxSize": "23.0 kB"
4242
},
4343
{
4444
"path": "./dist/js/bootstrap.esm.js",

js/src/dom/selector-engine.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* --------------------------------------------------------------------------
66
*/
77

8-
import { isDisabled, isVisible } from '../util/index.js'
8+
import { isDisabled, isVisible, parseSelector } from '../util/index.js'
99

1010
/**
1111
* Constants
@@ -99,6 +99,7 @@ const SelectorEngine = {
9999
}
100100

101101
selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null
102+
selector = parseSelector(selector)
102103
}
103104

104105
return selector

js/src/util/index.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ const MAX_UID = 1_000_000
99
const MILLISECONDS_MULTIPLIER = 1000
1010
const TRANSITION_END = 'transitionend'
1111

12+
/**
13+
* Properly escape IDs selectors to handle weird IDs
14+
* @param {string} selector
15+
* @returns {string}
16+
*/
17+
const parseSelector = selector => {
18+
if (selector && window.CSS && window.CSS.escape) {
19+
// document.querySelector needs escaping to handle IDs (html5+) containing for instance /
20+
selector = selector.replaceAll(/#([^\s"#']+)/g, (match, id) => '#' + CSS.escape(id))
21+
}
22+
23+
return selector
24+
}
25+
1226
// Shout-out Angus Croll (https://goo.gl/pxwQGp)
1327
const toType = object => {
1428
if (object === null || object === undefined) {
@@ -76,7 +90,7 @@ const getElement = object => {
7690
}
7791

7892
if (typeof object === 'string' && object.length > 0) {
79-
return document.querySelector(object)
93+
return document.querySelector(parseSelector(object))
8094
}
8195

8296
return null
@@ -285,6 +299,7 @@ export {
285299
isVisible,
286300
noop,
287301
onDOMContentLoaded,
302+
parseSelector,
288303
reflow,
289304
triggerTransitionEnd,
290305
toType

js/tests/unit/collapse.spec.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -887,17 +887,17 @@ describe('Collapse', () => {
887887
return new Promise(resolve => {
888888
fixtureEl.innerHTML = [
889889
'<a id="trigger1" role="button" data-bs-toggle="collapse" href="#test1"></a>',
890-
'<a id="trigger2" role="button" data-bs-toggle="collapse" href="#test2"></a>',
890+
'<a id="trigger2" role="button" data-bs-toggle="collapse" href="#0/my/id"></a>',
891891
'<a id="trigger3" role="button" data-bs-toggle="collapse" href=".multi"></a>',
892892
'<div id="test1" class="multi"></div>',
893-
'<div id="test2" class="multi"></div>'
893+
'<div id="0/my/id" class="multi"></div>'
894894
].join('')
895895

896896
const trigger1 = fixtureEl.querySelector('#trigger1')
897897
const trigger2 = fixtureEl.querySelector('#trigger2')
898898
const trigger3 = fixtureEl.querySelector('#trigger3')
899899
const target1 = fixtureEl.querySelector('#test1')
900-
const target2 = fixtureEl.querySelector('#test2')
900+
const target2 = fixtureEl.querySelector('#' + CSS.escape('0/my/id'))
901901

902902
const target2Shown = () => {
903903
expect(trigger1).not.toHaveClass('collapsed')

js/tests/unit/tab.spec.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,43 @@ describe('Tab', () => {
177177
})
178178
})
179179

180+
it('should work with tab id being an int', done => {
181+
fixtureEl.innerHTML = [
182+
'<div class="card-header d-block d-inline-block">',
183+
' <ul class="nav nav-tabs card-header-tabs" id="page_tabs">',
184+
' <li class="nav-item">',
185+
' <a class="nav-link" draggable="false" data-toggle="tab" href="#tab1">',
186+
' Working Tab 1 (#tab1)',
187+
' </a>',
188+
' </li>',
189+
' <li class="nav-item">',
190+
' <a id="trigger2" class="nav-link" draggable="false" data-toggle="tab" href="#2">',
191+
' Tab with numeric ID should work (#2)',
192+
' </a>',
193+
' </li>',
194+
' </ul>',
195+
'</div>',
196+
'<div class="card-body">',
197+
' <div class="tab-content" id="page_content">',
198+
' <div class="tab-pane fade" id="tab1">',
199+
' Working Tab 1 (#tab1) Content Here',
200+
' </div>',
201+
' <div class="tab-pane fade" id="2">',
202+
' Working Tab 2 (#2) with numeric ID',
203+
' </div>',
204+
'</div>'
205+
].join('')
206+
const profileTriggerEl = fixtureEl.querySelector('#trigger2')
207+
const tab = new Tab(profileTriggerEl)
208+
209+
profileTriggerEl.addEventListener('shown.bs.tab', () => {
210+
expect(fixtureEl.querySelector('#' + CSS.escape('2'))).toHaveClass('active')
211+
done()
212+
})
213+
214+
tab.show()
215+
})
216+
180217
it('should not fire shown when show is prevented', () => {
181218
return new Promise((resolve, reject) => {
182219
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'

0 commit comments

Comments
 (0)