@coveo/quantic 3.39.0 → 3.39.1

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.
@@ -0,0 +1,214 @@
1
+ import {
2
+ AriaLiveRegion,
3
+ isFocusable,
4
+ getFirstFocusableElement,
5
+ getLastFocusableElement,
6
+ isCustomElement,
7
+ isParentOf,
8
+ } from 'c/quanticUtils';
9
+
10
+ describe('accessibilityUtils', () => {
11
+ describe('AriaLiveRegion', () => {
12
+ let elem;
13
+
14
+ beforeEach(() => {
15
+ elem = {dispatchEvent: jest.fn()};
16
+ });
17
+
18
+ it('should dispatch a register region event on creation', () => {
19
+ AriaLiveRegion('test-region', elem);
20
+ expect(elem.dispatchEvent).toHaveBeenCalledTimes(1);
21
+ const event = elem.dispatchEvent.mock.calls[0][0];
22
+ expect(event.type).toBe('quantic__registerregion');
23
+ expect(event.detail).toEqual({
24
+ regionName: 'test-region',
25
+ assertive: false,
26
+ });
27
+ });
28
+
29
+ it('should dispatch a register region event with assertive flag', () => {
30
+ AriaLiveRegion('test-region', elem, true);
31
+ const event = elem.dispatchEvent.mock.calls[0][0];
32
+ expect(event.detail.assertive).toBe(true);
33
+ });
34
+
35
+ it('should dispatch an aria live message event when dispatchMessage is called', () => {
36
+ const region = AriaLiveRegion('test-region', elem);
37
+ region.dispatchMessage('hello');
38
+ expect(elem.dispatchEvent).toHaveBeenCalledTimes(2);
39
+ const event = elem.dispatchEvent.mock.calls[1][0];
40
+ expect(event.type).toBe('quantic__arialivemessage');
41
+ expect(event.detail).toEqual({
42
+ regionName: 'test-region',
43
+ assertive: false,
44
+ message: 'hello',
45
+ });
46
+ });
47
+ });
48
+
49
+ describe('isFocusable', () => {
50
+ function createElement(tag, attrs = {}) {
51
+ const el = document.createElement(tag);
52
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
53
+ return el;
54
+ }
55
+
56
+ it('should return true for a button', () => {
57
+ expect(isFocusable(createElement('button'))).toBe(true);
58
+ });
59
+
60
+ it('should return false for a disabled button', () => {
61
+ expect(isFocusable(createElement('button', {disabled: ''}))).toBe(false);
62
+ });
63
+
64
+ it('should return true for an anchor with href', () => {
65
+ expect(isFocusable(createElement('a', {href: '#'}))).toBe(true);
66
+ });
67
+
68
+ it('should return false for an anchor without href', () => {
69
+ expect(isFocusable(createElement('a'))).toBe(false);
70
+ });
71
+
72
+ it('should return true for an input', () => {
73
+ expect(isFocusable(createElement('input'))).toBe(true);
74
+ });
75
+
76
+ it('should return false for a disabled input', () => {
77
+ expect(isFocusable(createElement('input', {disabled: ''}))).toBe(false);
78
+ });
79
+
80
+ it('should return true for an element with tabindex >= 0', () => {
81
+ expect(isFocusable(createElement('div', {tabindex: '0'}))).toBe(true);
82
+ });
83
+
84
+ it('should return false for an element with tabindex -1', () => {
85
+ expect(isFocusable(createElement('div', {tabindex: '-1'}))).toBe(false);
86
+ });
87
+
88
+ it('should return true for a contentEditable element', () => {
89
+ expect(isFocusable(createElement('div', {contentEditable: 'true'}))).toBe(
90
+ true
91
+ );
92
+ });
93
+
94
+ it('should return false for a plain div', () => {
95
+ expect(isFocusable(createElement('div'))).toBe(false);
96
+ });
97
+
98
+ it('should return true for an iframe', () => {
99
+ expect(isFocusable(createElement('iframe'))).toBe(true);
100
+ });
101
+
102
+ it('should return true for a select', () => {
103
+ expect(isFocusable(createElement('select'))).toBe(true);
104
+ });
105
+
106
+ it('should return true for a textarea', () => {
107
+ expect(isFocusable(createElement('textarea'))).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('isCustomElement', () => {
112
+ it('should return true for elements with a hyphen in the tag name', () => {
113
+ const el = document.createElement('my-component');
114
+ expect(isCustomElement(el)).toBe(true);
115
+ });
116
+
117
+ it('should return false for standard elements', () => {
118
+ const el = document.createElement('div');
119
+ expect(isCustomElement(el)).toBe(false);
120
+ });
121
+
122
+ it('should return false for null', () => {
123
+ expect(isCustomElement(null)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('getFirstFocusableElement', () => {
128
+ it('should return null for null input', () => {
129
+ expect(getFirstFocusableElement(null)).toBeNull();
130
+ });
131
+
132
+ it('should return the element itself if it is focusable and has no focusable children', () => {
133
+ const btn = document.createElement('button');
134
+ expect(getFirstFocusableElement(btn)).toBe(btn);
135
+ });
136
+
137
+ it('should return the first focusable child', () => {
138
+ const div = document.createElement('div');
139
+ const span = document.createElement('span');
140
+ const btn1 = document.createElement('button');
141
+ const btn2 = document.createElement('button');
142
+ div.appendChild(span);
143
+ div.appendChild(btn1);
144
+ div.appendChild(btn2);
145
+ expect(getFirstFocusableElement(div)).toBe(btn1);
146
+ });
147
+
148
+ it('should return null for a custom element without data-focusable', () => {
149
+ const el = document.createElement('my-component');
150
+ expect(getFirstFocusableElement(el)).toBeNull();
151
+ });
152
+
153
+ it('should return the custom element if data-focusable is true', () => {
154
+ const el = document.createElement('my-component');
155
+ el.dataset.focusable = 'true';
156
+ expect(getFirstFocusableElement(el)).toBe(el);
157
+ });
158
+
159
+ it('should return null for a text node', () => {
160
+ const text = document.createTextNode('hello');
161
+ expect(getFirstFocusableElement(text)).toBeNull();
162
+ });
163
+ });
164
+
165
+ describe('getLastFocusableElement', () => {
166
+ it('should return null for null input', () => {
167
+ expect(getLastFocusableElement(null)).toBeNull();
168
+ });
169
+
170
+ it('should return the last focusable child', () => {
171
+ const div = document.createElement('div');
172
+ const btn1 = document.createElement('button');
173
+ const btn2 = document.createElement('button');
174
+ div.appendChild(btn1);
175
+ div.appendChild(btn2);
176
+ expect(getLastFocusableElement(div)).toBe(btn2);
177
+ });
178
+
179
+ it('should return the element itself if it is focusable and has no focusable children', () => {
180
+ const input = document.createElement('input');
181
+ expect(getLastFocusableElement(input)).toBe(input);
182
+ });
183
+ });
184
+
185
+ describe('isParentOf', () => {
186
+ it('should return false for null', () => {
187
+ expect(isParentOf(null, 'MY-COMPONENT')).toBe(false);
188
+ });
189
+
190
+ it('should return true if the element itself matches the target tag', () => {
191
+ const el = document.createElement('my-component');
192
+ expect(isParentOf(el, 'MY-COMPONENT')).toBe(true);
193
+ });
194
+
195
+ it('should return true if a descendant matches the target tag', () => {
196
+ const wrapper = document.createElement('div');
197
+ const child = document.createElement('my-component');
198
+ wrapper.appendChild(child);
199
+ expect(isParentOf(wrapper, 'MY-COMPONENT')).toBe(true);
200
+ });
201
+
202
+ it('should return false if no descendant matches', () => {
203
+ const wrapper = document.createElement('div');
204
+ const child = document.createElement('span');
205
+ wrapper.appendChild(child);
206
+ expect(isParentOf(wrapper, 'MY-COMPONENT')).toBe(false);
207
+ });
208
+
209
+ it('should return false for a text node', () => {
210
+ const text = document.createTextNode('hello');
211
+ expect(isParentOf(text, 'MY-COMPONENT')).toBe(false);
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,86 @@
1
+ import {Store} from 'c/quanticUtils';
2
+
3
+ describe('storeUtils', () => {
4
+ describe('Store.facetTypes', () => {
5
+ it('should expose the expected facet type keys', () => {
6
+ expect(Store.facetTypes).toEqual({
7
+ FACETS: 'facets',
8
+ NUMERICFACETS: 'numericFacets',
9
+ DATEFACETS: 'dateFacets',
10
+ CATEGORYFACETS: 'categoryFacets',
11
+ });
12
+ });
13
+ });
14
+
15
+ describe('Store.initialize', () => {
16
+ it('should return a store with empty state', () => {
17
+ const store = Store.initialize();
18
+ expect(store).toEqual({
19
+ state: {
20
+ facets: {},
21
+ numericFacets: {},
22
+ dateFacets: {},
23
+ categoryFacets: {},
24
+ sort: {},
25
+ },
26
+ });
27
+ });
28
+ });
29
+
30
+ describe('Store.registerFacetToStore', () => {
31
+ it('should register facet data under the given facet type and id', () => {
32
+ const store = Store.initialize();
33
+ const data = {facetId: 'author', label: 'Author'};
34
+ Store.registerFacetToStore(store, 'facets', data);
35
+ expect(store.state.facets.author).toEqual(data);
36
+ });
37
+
38
+ it('should not overwrite an existing facet entry', () => {
39
+ const store = Store.initialize();
40
+ const data = {facetId: 'author', label: 'Author'};
41
+ Store.registerFacetToStore(store, 'facets', data);
42
+ Store.registerFacetToStore(store, 'facets', {
43
+ facetId: 'author',
44
+ label: 'Updated',
45
+ });
46
+ expect(store.state.facets.author.label).toBe('Author');
47
+ });
48
+ });
49
+
50
+ describe('Store.registerSortOptionDataToStore', () => {
51
+ it('should set sort data on the store', () => {
52
+ const store = Store.initialize();
53
+ const sortData = [{label: 'Relevance', value: 'relevance'}];
54
+ Store.registerSortOptionDataToStore(store, sortData);
55
+ expect(store.state.sort).toBe(sortData);
56
+ });
57
+ });
58
+
59
+ describe('Store.getFromStore', () => {
60
+ it('should return facet data for the given type', () => {
61
+ const store = Store.initialize();
62
+ const data = {facetId: 'source', label: 'Source'};
63
+ Store.registerFacetToStore(store, 'facets', data);
64
+ expect(Store.getFromStore(store, 'facets')).toEqual({source: data});
65
+ });
66
+
67
+ it('should return an empty object when no facets are registered', () => {
68
+ const store = Store.initialize();
69
+ expect(Store.getFromStore(store, 'facets')).toEqual({});
70
+ });
71
+ });
72
+
73
+ describe('Store.getSortOptionsFromStore', () => {
74
+ it('should return the sort options', () => {
75
+ const store = Store.initialize();
76
+ const sortData = [{label: 'Date', value: 'date'}];
77
+ Store.registerSortOptionDataToStore(store, sortData);
78
+ expect(Store.getSortOptionsFromStore(store)).toBe(sortData);
79
+ });
80
+
81
+ it('should return an empty object when no sort options are registered', () => {
82
+ const store = Store.initialize();
83
+ expect(Store.getSortOptionsFromStore(store)).toEqual({});
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * AriaLiveUtils
3
+ * @typedef {Object} AriaLiveUtils
4
+ * @property {Function} dispatchMessage
5
+ * @property {Function} registerRegion
6
+ */
7
+
8
+ /**
9
+ * AriaLiveRegion Create an AriaLiveRegion to be able to send events to dispatch messages for assistive technologies.
10
+ * @param {string} regionName
11
+ * @param {Object} elem
12
+ * @param {boolean} assertive
13
+ * @returns {AriaLiveUtils} Object with methods to dispatch messages and register the region.
14
+ */
15
+ export function AriaLiveRegion(regionName, elem, assertive = false) {
16
+ function dispatchMessage(message) {
17
+ const ariaLiveMessageEvent = new CustomEvent('quantic__arialivemessage', {
18
+ bubbles: true,
19
+ composed: true,
20
+ detail: {
21
+ regionName,
22
+ assertive,
23
+ message,
24
+ },
25
+ });
26
+ elem.dispatchEvent(ariaLiveMessageEvent);
27
+ }
28
+
29
+ function registerRegion() {
30
+ const registerRegionEvent = new CustomEvent('quantic__registerregion', {
31
+ bubbles: true,
32
+ composed: true,
33
+ detail: {
34
+ regionName,
35
+ assertive,
36
+ },
37
+ });
38
+ elem.dispatchEvent(registerRegionEvent);
39
+ }
40
+
41
+ registerRegion();
42
+
43
+ return {
44
+ dispatchMessage,
45
+ registerRegion,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Checks whether an element is focusable.
51
+ * @param {HTMLElement | Element} element
52
+ * @returns {boolean}
53
+ */
54
+ export function isFocusable(element) {
55
+ // Source: https://stackoverflow.com/a/30753870
56
+ if (element.getAttribute('tabindex') === '-1') {
57
+ return false;
58
+ }
59
+ if (
60
+ element.hasAttribute('tabindex') ||
61
+ element.getAttribute('contentEditable') === 'true'
62
+ ) {
63
+ return true;
64
+ }
65
+ switch (element.tagName) {
66
+ case 'A':
67
+ case 'AREA':
68
+ return element.hasAttribute('href');
69
+ case 'INPUT':
70
+ case 'SELECT':
71
+ case 'TEXTAREA':
72
+ case 'BUTTON':
73
+ return !element.hasAttribute('disabled');
74
+ case 'IFRAME':
75
+ return true;
76
+ default:
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Returns the last focusable element of for an HTML element.
83
+ * This function would NOT work with shadow root.
84
+ * @param {HTMLElement & {assignedElements?: () => Array<HTMLElement>} | null} element
85
+ * @returns {HTMLElement | null}
86
+ */
87
+ export function getLastFocusableElement(element) {
88
+ if (!element || element.nodeType === Node.TEXT_NODE) return null;
89
+
90
+ if (isCustomElement(element)) {
91
+ if (element.dataset?.focusable?.toString() === 'true') {
92
+ return element;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ if (element.tagName === 'SLOT' && element.assignedElements().length) {
98
+ return getLastFocusableElementFromSlot(element);
99
+ }
100
+
101
+ /** @type {Array} */
102
+ const childNodes = Array.from(element.childNodes);
103
+ const focusableElements = childNodes
104
+ .map((item) => getLastFocusableElement(item))
105
+ .filter((item) => !!item);
106
+
107
+ if (focusableElements.length) {
108
+ return focusableElements[focusableElements.length - 1];
109
+ } else if (isFocusable(element)) {
110
+ return element;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Returns the First focusable element of for an HTML element.
117
+ * This function would NOT work with shadow root.
118
+ * @param {HTMLElement & {assignedElements?: () => Array<HTMLElement>} | null} element
119
+ * @returns {HTMLElement | null}
120
+ */
121
+ export function getFirstFocusableElement(element) {
122
+ if (!element || element.nodeType === Node.TEXT_NODE) return null;
123
+
124
+ if (isCustomElement(element)) {
125
+ if (element.dataset?.focusable?.toString() === 'true') {
126
+ return element;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ if (element.tagName === 'SLOT' && element.assignedElements().length) {
132
+ return getFirstFocusableElementFromSlot(element);
133
+ }
134
+
135
+ /** @type {Array} */
136
+ const childNodes = Array.from(element.childNodes);
137
+ const focusableElements = childNodes
138
+ .map((item) => getFirstFocusableElement(item))
139
+ .filter((item) => !!item);
140
+
141
+ if (focusableElements.length) {
142
+ return focusableElements[0];
143
+ } else if (isFocusable(element)) {
144
+ return element;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Checks whether an element is a custom element.
151
+ * @param {HTMLElement | null} element
152
+ * @returns {boolean}
153
+ */
154
+ export function isCustomElement(element) {
155
+ if (element && element.tagName.includes('-')) {
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * Returns the last focusable element in an HTML slot.
163
+ * @param {HTMLElement & {assignedElements?: () => Array<HTMLElement> | null}} slotElement
164
+ * @returns {HTMLElement | null}
165
+ */
166
+ function getLastFocusableElementFromSlot(slotElement) {
167
+ if (!slotElement && slotElement.assignedElements) {
168
+ return null;
169
+ }
170
+ const assignedElements = Array.from(slotElement.assignedElements());
171
+ const focusableElements = assignedElements
172
+ .map((item) => getLastFocusableElement(item))
173
+ .filter((item) => !!item);
174
+
175
+ if (focusableElements.length) {
176
+ return focusableElements[focusableElements.length - 1];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Returns the first focusable element in an HTML slot.
183
+ * @param {HTMLElement & {assignedElements?: () => Array<HTMLElement> | null}} slotElement
184
+ * @return {HTMLElement | null}
185
+ */
186
+ function getFirstFocusableElementFromSlot(slotElement) {
187
+ if (!slotElement && slotElement.assignedElements) {
188
+ return null;
189
+ }
190
+ const assignedElements = Array.from(slotElement.assignedElements());
191
+ const focusableElements = assignedElements
192
+ .map((item) => getFirstFocusableElement(item))
193
+ .filter((item) => !!item);
194
+
195
+ if (focusableElements.length) {
196
+ return focusableElements[0];
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * Checks whether an element is indeed the targetElement or one of its parents.
203
+ * @param {HTMLElement} element
204
+ * @param {string} targetElement
205
+ * @returns {boolean}
206
+ */
207
+ export function isParentOf(element, targetElement) {
208
+ if (!element || element.nodeType === Node.TEXT_NODE) {
209
+ return false;
210
+ }
211
+
212
+ if (isCustomElement(element)) {
213
+ if (element.tagName === targetElement) {
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+ /** @type {Array} */
219
+ const childNodes = Array.from(element.childNodes);
220
+ if (childNodes.length === 0) return false;
221
+ return childNodes.reduce(
222
+ (acc, val) => acc || isParentOf(val, targetElement),
223
+ false
224
+ );
225
+ }
@@ -0,0 +1,65 @@
1
+ /** @typedef {import("coveo").SortCriterion} SortCriterion */
2
+
3
+ /**
4
+ * Utility class for managing a simple in-memory store.
5
+ * Supports registering and retrieving facet and sort option data.
6
+ */
7
+ export class Store {
8
+ static facetTypes = {
9
+ FACETS: 'facets',
10
+ NUMERICFACETS: 'numericFacets',
11
+ DATEFACETS: 'dateFacets',
12
+ CATEGORYFACETS: 'categoryFacets',
13
+ };
14
+ static initialize() {
15
+ return {
16
+ state: {
17
+ facets: {},
18
+ numericFacets: {},
19
+ dateFacets: {},
20
+ categoryFacets: {},
21
+ sort: {},
22
+ },
23
+ };
24
+ }
25
+ /**
26
+ * Registers a facet to the store if it does not already exist.
27
+ * @param {Record<String, unknown>} store
28
+ * @param {string} facetType
29
+ * @param {{ label?: string; facetId: any; format?: Function;}} data
30
+ */
31
+ static registerFacetToStore(store, facetType, data) {
32
+ if (store?.state[facetType][data.facetId]) {
33
+ return;
34
+ }
35
+ store.state[facetType][data.facetId] = data;
36
+ }
37
+
38
+ /**
39
+ * Registers sort option data to the store.
40
+ * @param {Record<String, any>} store
41
+ * @param {Array<{label: string; value: string; criterion: SortCriterion;}>} data
42
+ */
43
+ static registerSortOptionDataToStore(store, data) {
44
+ store.state.sort = data;
45
+ }
46
+
47
+ /**
48
+ * Gets facet data from the store.
49
+ * @param {Record<String, unknown>} store
50
+ * @param {string} facetType
51
+ * @return {Object} The facet data.
52
+ */
53
+ static getFromStore(store, facetType) {
54
+ return store.state[facetType];
55
+ }
56
+
57
+ /**
58
+ * Gets sort options from the store.
59
+ * @param {Record<String, Object>} store
60
+ * @return {Array} The sort options.
61
+ */
62
+ static getSortOptionsFromStore(store) {
63
+ return store.state.sort;
64
+ }
65
+ }