Skip to content

Commit 1fabd68

Browse files
fix: properly handle special characters in user-provided IDs (closes #4927, #5561) (#5564)
* fix(b-form-group): make it work for ids with special characters like "/" Special characters are allowed in HTML5 (https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id) but need to be escaped when used in a selector for usage in e.g. "querySelector" Refs #5561 * Use own `cssEscape()` util + use/test everywhere needed Co-authored-by: Jacob Müller <jacob.mueller.elz@gmail.com>
1 parent ec51ef0 commit 1fabd68

File tree

6 files changed

+223
-2
lines changed

6 files changed

+223
-2
lines changed

src/components/form-group/form-group.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import cssEscape from '../../utils/css-escape'
12
import memoize from '../../utils/memoize'
23
import { arrayIncludes } from '../../utils/array'
34
import { getBreakpointsUpCached } from '../../utils/config'
@@ -379,7 +380,8 @@ export const BFormGroup = {
379380
// Optionally accepts a string of IDs to remove as the second parameter.
380381
// Preserves any aria-describedby value(s) user may have on input.
381382
if (this.labelFor && isBrowser) {
382-
const input = select(`#${this.labelFor}`, this.$refs.content)
383+
// We need to escape `labelFor` since it can be user-provided
384+
const input = select(`#${cssEscape(this.labelFor)}`, this.$refs.content)
383385
if (input) {
384386
const adb = 'aria-describedby'
385387
let ids = (getAttr(input, adb) || '').split(/\s+/)

src/components/form-group/form-group.spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,32 @@ describe('form-group', () => {
176176
wrapper.destroy()
177177
})
178178

179+
it('sets "aria-describedby" even when special characters are used in IDs', async () => {
180+
const wrapper = mount(BFormGroup, {
181+
propsData: {
182+
id: '/group-id',
183+
label: 'test',
184+
labelFor: '/input-id',
185+
description: 'foo' // Description is needed to set "aria-describedby"
186+
},
187+
slots: {
188+
default: '<input id="/input-id" type="text">'
189+
}
190+
})
191+
192+
expect(wrapper.vm).toBeDefined()
193+
194+
// Auto ID is created after mounted
195+
await waitNT(wrapper.vm)
196+
197+
const $input = wrapper.find('input')
198+
expect($input.exists()).toBe(true)
199+
expect($input.attributes('aria-describedby')).toBeDefined()
200+
expect($input.attributes('aria-describedby')).toEqual('/group-id__BV_description_')
201+
202+
wrapper.destroy()
203+
})
204+
179205
it('horizontal layout without prop label-for set has expected structure', async () => {
180206
const wrapper = mount(BFormGroup, {
181207
propsData: {

src/components/form-tags/form-tags.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Based loosely on https://adamwathan.me/renderless-components-in-vuejs/
33
import Vue from '../../utils/vue'
44
import KeyCodes from '../../utils/key-codes'
5+
import cssEscape from '../../utils/css-escape'
56
import identity from '../../utils/identity'
67
import looseEqual from '../../utils/loose-equal'
78
import { arrayIncludes, concat } from '../../utils/array'
@@ -523,7 +524,8 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
523524
},
524525
getInput() {
525526
// Returns the input element reference (or null if not found)
526-
return select(`#${this.computedInputId}`, this.$el)
527+
// We need to escape `computedInputId` since it can be user-provided
528+
return select(`#${cssEscape(this.computedInputId)}`, this.$el)
527529
},
528530
// Default User Interface render
529531
defaultRender({

src/components/form-tags/form-tags.spec.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,40 @@ describe('form-tags', () => {
157157
wrapper.destroy()
158158
})
159159

160+
it('applies "input-id" to the input', async () => {
161+
const wrapper = mount(BFormTags, {
162+
propsData: {
163+
inputId: '1-tag-input',
164+
value: ['apple', 'orange']
165+
}
166+
})
167+
168+
expect(wrapper.element.tagName).toBe('DIV')
169+
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
170+
expect(wrapper.vm.newTag).toEqual('')
171+
172+
const $input = wrapper.find('input')
173+
expect($input.exists()).toBe(true)
174+
expect($input.element.value).toBe('')
175+
expect($input.element.type).toBe('text')
176+
expect($input.element.id).toEqual('1-tag-input')
177+
178+
$input.element.value = 'pear'
179+
await $input.trigger('input')
180+
expect(wrapper.vm.newTag).toEqual('pear')
181+
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
182+
await $input.trigger('change')
183+
expect(wrapper.vm.newTag).toEqual('pear')
184+
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
185+
await wrapper.setProps({ addOnChange: true })
186+
await $input.trigger('change')
187+
expect(wrapper.vm.newTag).toEqual('')
188+
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
189+
await wrapper.setProps({ addOnChange: false })
190+
191+
wrapper.destroy()
192+
})
193+
160194
it('removes tags when user clicks remove on tag', async () => {
161195
const wrapper = mount(BFormTags, {
162196
propsData: {

src/utils/css-escape.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { toString } from './string'
2+
3+
const escapeChar = value => '\\' + value
4+
5+
// The `cssEscape()` util is based on this `CSS.escape()` polyfill:
6+
// https://github.com/mathiasbynens/CSS.escape
7+
const cssEscape = value => {
8+
value = toString(value)
9+
10+
const length = value.length
11+
const firstCharCode = value.charCodeAt(0)
12+
13+
return value.split('').reduce((result, char, index) => {
14+
const charCode = value.charCodeAt(index)
15+
16+
// If the character is NULL (U+0000), use (U+FFFD) as replacement
17+
if (charCode === 0x0000) {
18+
return result + '\uFFFD'
19+
}
20+
21+
// If the character ...
22+
if (
23+
// ... is U+007F OR
24+
charCode === 0x007f ||
25+
// ... is in the range [\1-\1F] (U+0001 to U+001F) OR ...
26+
(charCode >= 0x0001 && charCode <= 0x001f) ||
27+
// ... is the first character and is in the range [0-9] (U+0030 to U+0039) OR ...
28+
(index === 0 && charCode >= 0x0030 && charCode <= 0x0039) ||
29+
// ... is the second character and is in the range [0-9] (U+0030 to U+0039)
30+
// and the first character is a `-` (U+002D) ...
31+
(index === 1 && charCode >= 0x0030 && charCode <= 0x0039 && firstCharCode === 0x002d)
32+
) {
33+
// ... https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
34+
return result + escapeChar(`${charCode.toString(16)} `)
35+
}
36+
37+
// If the character ...
38+
if (
39+
// ... is the first character AND ...
40+
index === 0 &&
41+
// ... is a `-` (U+002D) AND ...
42+
charCode === 0x002d &&
43+
// ... there is no second character ...
44+
length === 1
45+
) {
46+
// ... use the escaped character
47+
return result + escapeChar(char)
48+
}
49+
50+
// If the character ...
51+
if (
52+
// ... is greater than or equal to U+0080 OR ...
53+
charCode >= 0x0080 ||
54+
// ... is `-` (U+002D) OR ...
55+
charCode === 0x002d ||
56+
// ... is `_` (U+005F) OR ...
57+
charCode === 0x005f ||
58+
// ... is in the range [0-9] (U+0030 to U+0039) OR ...
59+
(charCode >= 0x0030 && charCode <= 0x0039) ||
60+
// ... is in the range [A-Z] (U+0041 to U+005A) OR ...
61+
(charCode >= 0x0041 && charCode <= 0x005a) ||
62+
// ... is in the range [a-z] (U+0061 to U+007A) ...
63+
(charCode >= 0x0061 && charCode <= 0x007a)
64+
) {
65+
// ... use the character itself
66+
return result + char
67+
}
68+
69+
// Otherwise use the escaped character
70+
// See: https://drafts.csswg.org/cssom/#escape-a-character
71+
return result + escapeChar(char)
72+
}, '')
73+
}
74+
75+
export default cssEscape

src/utils/css-escape.spec.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import cssEscape from './css-escape'
2+
3+
describe('utils/cssEscape', () => {
4+
it('works', () => {
5+
expect(cssEscape('\0')).toBe('\uFFFD')
6+
expect(cssEscape('a\0')).toBe('a\uFFFD')
7+
expect(cssEscape('\0b')).toBe('\uFFFDb')
8+
expect(cssEscape('a\0b')).toBe('a\uFFFDb')
9+
10+
expect(cssEscape('\uFFFD')).toBe('\uFFFD')
11+
expect(cssEscape('a\uFFFD')).toBe('a\uFFFD')
12+
expect(cssEscape('\uFFFDb')).toBe('\uFFFDb')
13+
expect(cssEscape('a\uFFFDb')).toBe('a\uFFFDb')
14+
15+
expect(cssEscape(undefined)).toBe('')
16+
expect(cssEscape(null)).toBe('')
17+
expect(cssEscape(true)).toBe('true')
18+
expect(cssEscape(false)).toBe('false')
19+
expect(cssEscape('')).toBe('')
20+
21+
expect(cssEscape('\x01\x02\x1E\x1F')).toBe('\\1 \\2 \\1e \\1f ')
22+
23+
expect(cssEscape('0a')).toBe('\\30 a')
24+
expect(cssEscape('1a')).toBe('\\31 a')
25+
expect(cssEscape('2a')).toBe('\\32 a')
26+
expect(cssEscape('3a')).toBe('\\33 a')
27+
expect(cssEscape('4a')).toBe('\\34 a')
28+
expect(cssEscape('5a')).toBe('\\35 a')
29+
expect(cssEscape('6a')).toBe('\\36 a')
30+
expect(cssEscape('7a')).toBe('\\37 a')
31+
expect(cssEscape('8a')).toBe('\\38 a')
32+
expect(cssEscape('9a')).toBe('\\39 a')
33+
34+
expect(cssEscape('a0b')).toBe('a0b')
35+
expect(cssEscape('a1b')).toBe('a1b')
36+
expect(cssEscape('a2b')).toBe('a2b')
37+
expect(cssEscape('a3b')).toBe('a3b')
38+
expect(cssEscape('a4b')).toBe('a4b')
39+
expect(cssEscape('a5b')).toBe('a5b')
40+
expect(cssEscape('a6b')).toBe('a6b')
41+
expect(cssEscape('a7b')).toBe('a7b')
42+
expect(cssEscape('a8b')).toBe('a8b')
43+
expect(cssEscape('a9b')).toBe('a9b')
44+
45+
expect(cssEscape('-0a')).toBe('-\\30 a')
46+
expect(cssEscape('-1a')).toBe('-\\31 a')
47+
expect(cssEscape('-2a')).toBe('-\\32 a')
48+
expect(cssEscape('-3a')).toBe('-\\33 a')
49+
expect(cssEscape('-4a')).toBe('-\\34 a')
50+
expect(cssEscape('-5a')).toBe('-\\35 a')
51+
expect(cssEscape('-6a')).toBe('-\\36 a')
52+
expect(cssEscape('-7a')).toBe('-\\37 a')
53+
expect(cssEscape('-8a')).toBe('-\\38 a')
54+
expect(cssEscape('-9a')).toBe('-\\39 a')
55+
56+
expect(cssEscape('-')).toBe('\\-')
57+
expect(cssEscape('-a')).toBe('-a')
58+
expect(cssEscape('--')).toBe('--')
59+
expect(cssEscape('--a')).toBe('--a')
60+
61+
expect(cssEscape('\x80\x2D\x5F\xA9')).toBe('\x80\x2D\x5F\xA9')
62+
expect(
63+
cssEscape(
64+
'\x7F\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8A\x8B\x8C\x8D\x8E\x8F\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9A\x9B\x9C\x9D\x9E\x9F'
65+
)
66+
).toBe(
67+
'\\7f \x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8A\x8B\x8C\x8D\x8E\x8F\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9A\x9B\x9C\x9D\x9E\x9F'
68+
)
69+
expect(cssEscape('\xA0\xA1\xA2')).toBe('\xA0\xA1\xA2')
70+
expect(cssEscape('a0123456789b')).toBe('a0123456789b')
71+
expect(cssEscape('abcdefghijklmnopqrstuvwxyz')).toBe('abcdefghijklmnopqrstuvwxyz')
72+
expect(cssEscape('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
73+
74+
expect(cssEscape('\x20\x21\x78\x79')).toBe('\\ \\!xy')
75+
76+
// Astral symbol (U+1D306 TETRAGRAM FOR CENTRE)
77+
expect(cssEscape('\uD834\uDF06')).toBe('\uD834\uDF06')
78+
// Lone surrogates
79+
expect(cssEscape('\uDF06')).toBe('\uDF06')
80+
expect(cssEscape('\uD834')).toBe('\uD834')
81+
})
82+
})

0 commit comments

Comments
 (0)