@kaizen/components 1.64.13 → 1.64.14
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.
- package/dist/cjs/Filter/FilterSelect/FilterSelect.cjs +2 -0
- package/dist/cjs/__future__/Select/subcomponents/ListBox/ListBox.cjs +46 -2
- package/dist/cjs/__future__/Select/subcomponents/Overlay/Overlay.cjs +1 -1
- package/dist/cjs/__utilities__/useIsClientReady/useIsClientReady.cjs +20 -0
- package/dist/esm/Filter/FilterSelect/FilterSelect.mjs +2 -0
- package/dist/esm/__future__/Select/subcomponents/ListBox/ListBox.mjs +47 -3
- package/dist/esm/__future__/Select/subcomponents/Overlay/Overlay.mjs +1 -1
- package/dist/esm/__utilities__/useIsClientReady/useIsClientReady.mjs +18 -0
- package/dist/styles.css +56 -56
- package/dist/types/__future__/Select/subcomponents/ListBox/ListBox.d.ts +2 -2
- package/dist/types/__utilities__/useIsClientReady/index.d.ts +1 -0
- package/dist/types/__utilities__/useIsClientReady/useIsClientReady.d.ts +5 -0
- package/package.json +1 -1
- package/src/Filter/FilterSelect/FilterSelect.spec.tsx +97 -2
- package/src/Filter/FilterSelect/FilterSelect.tsx +3 -0
- package/src/__future__/Select/Select.spec.tsx +5 -3
- package/src/__future__/Select/_docs/Select.mdx +1 -3
- package/src/__future__/Select/_docs/Select.stories.tsx +33 -17
- package/src/__future__/Select/subcomponents/ListBox/ListBox.tsx +56 -4
- package/src/__future__/Select/subcomponents/Overlay/Overlay.tsx +1 -1
- package/src/__utilities__/useIsClientReady/index.ts +1 -0
- package/src/__utilities__/useIsClientReady/useIsClientReady.tsx +17 -0
|
@@ -57,6 +57,8 @@ var FilterSelect = function (_a) {
|
|
|
57
57
|
triggerProps = _c.triggerProps,
|
|
58
58
|
menuProps = _c.menuProps;
|
|
59
59
|
var buttonProps = button.useButton(triggerProps, triggerRef).buttonProps;
|
|
60
|
+
// `aria-labelledby` and `aria-controls` are being remapped because the `buttonProps` ids generated by React Aria point to nothing.
|
|
61
|
+
// This should ideally be refactored but for now the `aria-controls` is set to the Filter's Listbox (menuProps.id) and the `aria-labelledby` to undefined so the accessible name is derived from the buttons content.
|
|
60
62
|
var renderTriggerButtonProps = tslib.__assign(tslib.__assign({}, buttonProps), {
|
|
61
63
|
"aria-labelledby": undefined,
|
|
62
64
|
"aria-controls": menuProps.id
|
|
@@ -4,6 +4,7 @@ var tslib = require('tslib');
|
|
|
4
4
|
var React = require('react');
|
|
5
5
|
var listbox = require('@react-aria/listbox');
|
|
6
6
|
var classnames = require('classnames');
|
|
7
|
+
var useIsClientReady = require('../../../../__utilities__/useIsClientReady/useIsClientReady.cjs');
|
|
7
8
|
var SelectContext = require('../../context/SelectContext.cjs');
|
|
8
9
|
var ListBox_module = require('./ListBox.module.scss.cjs');
|
|
9
10
|
function _interopDefault(e) {
|
|
@@ -13,16 +14,59 @@ function _interopDefault(e) {
|
|
|
13
14
|
}
|
|
14
15
|
var React__default = /*#__PURE__*/_interopDefault(React);
|
|
15
16
|
var classnames__default = /*#__PURE__*/_interopDefault(classnames);
|
|
17
|
+
|
|
18
|
+
/** A util to retrieve the key of the correct focusable items based of the focus strategy
|
|
19
|
+
* This is used to determine which element from the collection to focus to on open base on the keyboard event
|
|
20
|
+
* ie: UpArrow will set the focusStrategy to "last"
|
|
21
|
+
*/
|
|
22
|
+
var getOptionKeyFromCollection = function (state) {
|
|
23
|
+
if (state.selectedItem) {
|
|
24
|
+
return state.selectedItem.key;
|
|
25
|
+
} else if (state.focusStrategy === "last") {
|
|
26
|
+
return state.collection.getLastKey();
|
|
27
|
+
}
|
|
28
|
+
return state.collection.getFirstKey();
|
|
29
|
+
};
|
|
30
|
+
/** This makes the use of query selector less brittle in instances where a failed selector is passed in
|
|
31
|
+
*/
|
|
32
|
+
var safeQuerySelector = function (selector) {
|
|
33
|
+
try {
|
|
34
|
+
return document.querySelector(selector);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.error("Kaizen querySelector failed:", error);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
16
41
|
var ListBox = function (_a) {
|
|
17
42
|
var children = _a.children,
|
|
18
43
|
menuProps = _a.menuProps,
|
|
19
44
|
classNameOverride = _a.classNameOverride,
|
|
20
45
|
restProps = tslib.__rest(_a, ["children", "menuProps", "classNameOverride"]);
|
|
46
|
+
var isClientReady = useIsClientReady.useIsClientReady();
|
|
21
47
|
var state = SelectContext.useSelectContext().state;
|
|
22
|
-
var ref =
|
|
48
|
+
var ref = React.useRef(null);
|
|
23
49
|
var listBoxProps = listbox.useListBox(tslib.__assign(tslib.__assign({}, menuProps), {
|
|
24
|
-
disallowEmptySelection: true
|
|
50
|
+
disallowEmptySelection: true,
|
|
51
|
+
// This is to ensure that the listbox doesn't use React Aria's auto focus feature for Listbox, which creates a visual bug
|
|
52
|
+
autoFocus: false
|
|
25
53
|
}), state, ref).listBoxProps;
|
|
54
|
+
/**
|
|
55
|
+
* This uses the new useIsClientReady to ensure document exists before trying to querySelector and give the time to focus to the correct element
|
|
56
|
+
*/
|
|
57
|
+
React.useEffect(function () {
|
|
58
|
+
var _a;
|
|
59
|
+
if (isClientReady) {
|
|
60
|
+
var optionKey = getOptionKeyFromCollection(state);
|
|
61
|
+
var focusToElement = safeQuerySelector("[data-key='".concat(optionKey, "']"));
|
|
62
|
+
if (focusToElement) {
|
|
63
|
+
focusToElement.focus();
|
|
64
|
+
} else {
|
|
65
|
+
// If an element is not found, focus on the listbox. This ensures the list can still be navigated to via keyboard if the keys do not align to the data attributes of the list items.
|
|
66
|
+
(_a = ref.current) === null || _a === void 0 ? void 0 : _a.focus();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, [isClientReady]);
|
|
26
70
|
return React__default.default.createElement("ul", tslib.__assign({
|
|
27
71
|
ref: ref,
|
|
28
72
|
className: classnames__default.default(ListBox_module.listBox, classNameOverride)
|
|
@@ -32,7 +32,7 @@ var Overlay = function (_a) {
|
|
|
32
32
|
ref: overlayRef,
|
|
33
33
|
className: classNameOverride
|
|
34
34
|
}, overlayProps, restProps), React__default.default.createElement(focus.FocusScope, {
|
|
35
|
-
autoFocus:
|
|
35
|
+
autoFocus: false,
|
|
36
36
|
restoreFocus: true
|
|
37
37
|
}, React__default.default.createElement(overlays.DismissButton, {
|
|
38
38
|
onDismiss: state.close
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var React = require('react');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A hook that returns a truthy value indicating if the code can be run on client side.
|
|
7
|
+
* This is a useful hook for determining if the `document` or `window` objects are available.
|
|
8
|
+
*/
|
|
9
|
+
var useIsClientReady = function () {
|
|
10
|
+
var _a = React.useState(false),
|
|
11
|
+
isClientReady = _a[0],
|
|
12
|
+
setIsClientReady = _a[1];
|
|
13
|
+
React.useEffect(function () {
|
|
14
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
15
|
+
setIsClientReady(true);
|
|
16
|
+
}
|
|
17
|
+
}, []);
|
|
18
|
+
return isClientReady;
|
|
19
|
+
};
|
|
20
|
+
exports.useIsClientReady = useIsClientReady;
|
|
@@ -50,6 +50,8 @@ const FilterSelect = /*#__PURE__*/function () {
|
|
|
50
50
|
triggerProps = _c.triggerProps,
|
|
51
51
|
menuProps = _c.menuProps;
|
|
52
52
|
var buttonProps = useButton(triggerProps, triggerRef).buttonProps;
|
|
53
|
+
// `aria-labelledby` and `aria-controls` are being remapped because the `buttonProps` ids generated by React Aria point to nothing.
|
|
54
|
+
// This should ideally be refactored but for now the `aria-controls` is set to the Filter's Listbox (menuProps.id) and the `aria-labelledby` to undefined so the accessible name is derived from the buttons content.
|
|
53
55
|
var renderTriggerButtonProps = __assign(__assign({}, buttonProps), {
|
|
54
56
|
"aria-labelledby": undefined,
|
|
55
57
|
"aria-controls": menuProps.id
|
|
@@ -1,20 +1,64 @@
|
|
|
1
1
|
import { __rest, __assign } from 'tslib';
|
|
2
|
-
import React from 'react';
|
|
2
|
+
import React, { useRef, useEffect } from 'react';
|
|
3
3
|
import { useListBox } from '@react-aria/listbox';
|
|
4
4
|
import classnames from 'classnames';
|
|
5
|
+
import { useIsClientReady } from '../../../../__utilities__/useIsClientReady/useIsClientReady.mjs';
|
|
5
6
|
import { useSelectContext } from '../../context/SelectContext.mjs';
|
|
6
7
|
import styles from './ListBox.module.scss.mjs';
|
|
8
|
+
|
|
9
|
+
/** A util to retrieve the key of the correct focusable items based of the focus strategy
|
|
10
|
+
* This is used to determine which element from the collection to focus to on open base on the keyboard event
|
|
11
|
+
* ie: UpArrow will set the focusStrategy to "last"
|
|
12
|
+
*/
|
|
13
|
+
var getOptionKeyFromCollection = function (state) {
|
|
14
|
+
if (state.selectedItem) {
|
|
15
|
+
return state.selectedItem.key;
|
|
16
|
+
} else if (state.focusStrategy === "last") {
|
|
17
|
+
return state.collection.getLastKey();
|
|
18
|
+
}
|
|
19
|
+
return state.collection.getFirstKey();
|
|
20
|
+
};
|
|
21
|
+
/** This makes the use of query selector less brittle in instances where a failed selector is passed in
|
|
22
|
+
*/
|
|
23
|
+
var safeQuerySelector = function (selector) {
|
|
24
|
+
try {
|
|
25
|
+
return document.querySelector(selector);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.error("Kaizen querySelector failed:", error);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
7
32
|
const ListBox = /*#__PURE__*/function () {
|
|
8
33
|
const ListBox = function (_a) {
|
|
9
34
|
var children = _a.children,
|
|
10
35
|
menuProps = _a.menuProps,
|
|
11
36
|
classNameOverride = _a.classNameOverride,
|
|
12
37
|
restProps = __rest(_a, ["children", "menuProps", "classNameOverride"]);
|
|
38
|
+
var isClientReady = useIsClientReady();
|
|
13
39
|
var state = useSelectContext().state;
|
|
14
|
-
var ref =
|
|
40
|
+
var ref = useRef(null);
|
|
15
41
|
var listBoxProps = useListBox(__assign(__assign({}, menuProps), {
|
|
16
|
-
disallowEmptySelection: true
|
|
42
|
+
disallowEmptySelection: true,
|
|
43
|
+
// This is to ensure that the listbox doesn't use React Aria's auto focus feature for Listbox, which creates a visual bug
|
|
44
|
+
autoFocus: false
|
|
17
45
|
}), state, ref).listBoxProps;
|
|
46
|
+
/**
|
|
47
|
+
* This uses the new useIsClientReady to ensure document exists before trying to querySelector and give the time to focus to the correct element
|
|
48
|
+
*/
|
|
49
|
+
useEffect(function () {
|
|
50
|
+
var _a;
|
|
51
|
+
if (isClientReady) {
|
|
52
|
+
var optionKey = getOptionKeyFromCollection(state);
|
|
53
|
+
var focusToElement = safeQuerySelector("[data-key='".concat(optionKey, "']"));
|
|
54
|
+
if (focusToElement) {
|
|
55
|
+
focusToElement.focus();
|
|
56
|
+
} else {
|
|
57
|
+
// If an element is not found, focus on the listbox. This ensures the list can still be navigated to via keyboard if the keys do not align to the data attributes of the list items.
|
|
58
|
+
(_a = ref.current) === null || _a === void 0 ? void 0 : _a.focus();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}, [isClientReady]);
|
|
18
62
|
return /*#__PURE__*/React.createElement("ul", __assign({
|
|
19
63
|
ref: ref,
|
|
20
64
|
className: classnames(styles.listBox, classNameOverride)
|
|
@@ -25,7 +25,7 @@ const Overlay = /*#__PURE__*/function () {
|
|
|
25
25
|
ref: overlayRef,
|
|
26
26
|
className: classNameOverride
|
|
27
27
|
}, overlayProps, restProps), /*#__PURE__*/React.createElement(FocusScope, {
|
|
28
|
-
autoFocus:
|
|
28
|
+
autoFocus: false,
|
|
29
29
|
restoreFocus: true
|
|
30
30
|
}, /*#__PURE__*/React.createElement(DismissButton, {
|
|
31
31
|
onDismiss: state.close
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hook that returns a truthy value indicating if the code can be run on client side.
|
|
5
|
+
* This is a useful hook for determining if the `document` or `window` objects are available.
|
|
6
|
+
*/
|
|
7
|
+
var useIsClientReady = function () {
|
|
8
|
+
var _a = useState(false),
|
|
9
|
+
isClientReady = _a[0],
|
|
10
|
+
setIsClientReady = _a[1];
|
|
11
|
+
useEffect(function () {
|
|
12
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
13
|
+
setIsClientReady(true);
|
|
14
|
+
}
|
|
15
|
+
}, []);
|
|
16
|
+
return isClientReady;
|
|
17
|
+
};
|
|
18
|
+
export { useIsClientReady };
|
package/dist/styles.css
CHANGED
|
@@ -27,62 +27,6 @@
|
|
|
27
27
|
.OverlayArrow-module_overlayArrow__hoDyK.OverlayArrow-module_reversed__-WGcR path {
|
|
28
28
|
fill: var(--color-white, #ffffff);
|
|
29
29
|
}
|
|
30
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
31
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
32
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
33
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
34
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
35
|
-
/** THIS IS AN AUTOGENERATED FILE **/
|
|
36
|
-
.Tooltip-module_tooltip__efL1m {
|
|
37
|
-
max-width: 200px;
|
|
38
|
-
padding: var(--spacing-8, 0.5rem) var(--spacing-12, 0.75rem);
|
|
39
|
-
color: var(--color-white, #ffffff);
|
|
40
|
-
text-align: center;
|
|
41
|
-
font-family: var(--typography-paragraph-extra-small-font-family, "Inter", "Noto Sans", Helvetica, Arial, sans-serif);
|
|
42
|
-
font-size: var(--typography-paragraph-extra-small-font-size, 0.75rem);
|
|
43
|
-
font-weight: var(--typography-paragraph-extra-small-font-weight, 400);
|
|
44
|
-
letter-spacing: var(--typography-paragraph-extra-small-letter-spacing, normal);
|
|
45
|
-
line-height: var(--typography-paragraph-extra-small-line-height, 1.125rem);
|
|
46
|
-
border-radius: var(--border-solid-border-radius, 7px);
|
|
47
|
-
box-shadow: var(--shadow-small-box-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 3px 16px 0 rgba(0, 0, 0, 0.06));
|
|
48
|
-
background-color: var(--color-purple-800, #2f2438);
|
|
49
|
-
text-wrap: pretty;
|
|
50
|
-
/* fixes FF gap */
|
|
51
|
-
transform: translate3d(0, 0, 0);
|
|
52
|
-
}
|
|
53
|
-
.Tooltip-module_tooltip__efL1m.Tooltip-module_reversed__NnCbZ {
|
|
54
|
-
background-color: var(--color-white, #ffffff);
|
|
55
|
-
color: var(--color-purple-800, #2f2438);
|
|
56
|
-
}
|
|
57
|
-
.Tooltip-module_tooltip__efL1m[data-placement=top] {
|
|
58
|
-
--origin: translateY(4px);
|
|
59
|
-
}
|
|
60
|
-
.Tooltip-module_tooltip__efL1m[data-placement=bottom] {
|
|
61
|
-
--origin: translateY(-4px);
|
|
62
|
-
}
|
|
63
|
-
.Tooltip-module_tooltip__efL1m[data-placement=right] {
|
|
64
|
-
--origin: translateX(-4px);
|
|
65
|
-
}
|
|
66
|
-
.Tooltip-module_tooltip__efL1m[data-placement=left] {
|
|
67
|
-
--origin: translateX(4px);
|
|
68
|
-
}
|
|
69
|
-
.Tooltip-module_tooltip__efL1m[data-entering] {
|
|
70
|
-
animation: Tooltip-module_slide__lFdGA var(--animation-duration-fast, 300ms);
|
|
71
|
-
}
|
|
72
|
-
.Tooltip-module_tooltip__efL1m[data-exiting] {
|
|
73
|
-
animation: Tooltip-module_slide__lFdGA var(--animation-duration-fast, 300ms) reverse var(--animation-easing-function-ease-in, cubic-bezier(0.55, 0.085, 0.68, 0.53));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
@keyframes Tooltip-module_slide__lFdGA {
|
|
77
|
-
from {
|
|
78
|
-
transform: var(--origin);
|
|
79
|
-
opacity: 0;
|
|
80
|
-
}
|
|
81
|
-
to {
|
|
82
|
-
transform: translateY(0);
|
|
83
|
-
opacity: 1;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
30
|
.Menu-module_menu__iHYqh {
|
|
87
31
|
background-color: var(--color-white);
|
|
88
32
|
color: var(--color-purple-800);
|
|
@@ -238,6 +182,62 @@
|
|
|
238
182
|
.Focusable-module_focusableWrapper__NfuIi {
|
|
239
183
|
display: inline-flex;
|
|
240
184
|
}
|
|
185
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
186
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
187
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
188
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
189
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
190
|
+
/** THIS IS AN AUTOGENERATED FILE **/
|
|
191
|
+
.Tooltip-module_tooltip__efL1m {
|
|
192
|
+
max-width: 200px;
|
|
193
|
+
padding: var(--spacing-8, 0.5rem) var(--spacing-12, 0.75rem);
|
|
194
|
+
color: var(--color-white, #ffffff);
|
|
195
|
+
text-align: center;
|
|
196
|
+
font-family: var(--typography-paragraph-extra-small-font-family, "Inter", "Noto Sans", Helvetica, Arial, sans-serif);
|
|
197
|
+
font-size: var(--typography-paragraph-extra-small-font-size, 0.75rem);
|
|
198
|
+
font-weight: var(--typography-paragraph-extra-small-font-weight, 400);
|
|
199
|
+
letter-spacing: var(--typography-paragraph-extra-small-letter-spacing, normal);
|
|
200
|
+
line-height: var(--typography-paragraph-extra-small-line-height, 1.125rem);
|
|
201
|
+
border-radius: var(--border-solid-border-radius, 7px);
|
|
202
|
+
box-shadow: var(--shadow-small-box-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 3px 16px 0 rgba(0, 0, 0, 0.06));
|
|
203
|
+
background-color: var(--color-purple-800, #2f2438);
|
|
204
|
+
text-wrap: pretty;
|
|
205
|
+
/* fixes FF gap */
|
|
206
|
+
transform: translate3d(0, 0, 0);
|
|
207
|
+
}
|
|
208
|
+
.Tooltip-module_tooltip__efL1m.Tooltip-module_reversed__NnCbZ {
|
|
209
|
+
background-color: var(--color-white, #ffffff);
|
|
210
|
+
color: var(--color-purple-800, #2f2438);
|
|
211
|
+
}
|
|
212
|
+
.Tooltip-module_tooltip__efL1m[data-placement=top] {
|
|
213
|
+
--origin: translateY(4px);
|
|
214
|
+
}
|
|
215
|
+
.Tooltip-module_tooltip__efL1m[data-placement=bottom] {
|
|
216
|
+
--origin: translateY(-4px);
|
|
217
|
+
}
|
|
218
|
+
.Tooltip-module_tooltip__efL1m[data-placement=right] {
|
|
219
|
+
--origin: translateX(-4px);
|
|
220
|
+
}
|
|
221
|
+
.Tooltip-module_tooltip__efL1m[data-placement=left] {
|
|
222
|
+
--origin: translateX(4px);
|
|
223
|
+
}
|
|
224
|
+
.Tooltip-module_tooltip__efL1m[data-entering] {
|
|
225
|
+
animation: Tooltip-module_slide__lFdGA var(--animation-duration-fast, 300ms);
|
|
226
|
+
}
|
|
227
|
+
.Tooltip-module_tooltip__efL1m[data-exiting] {
|
|
228
|
+
animation: Tooltip-module_slide__lFdGA var(--animation-duration-fast, 300ms) reverse var(--animation-easing-function-ease-in, cubic-bezier(0.55, 0.085, 0.68, 0.53));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@keyframes Tooltip-module_slide__lFdGA {
|
|
232
|
+
from {
|
|
233
|
+
transform: var(--origin);
|
|
234
|
+
opacity: 0;
|
|
235
|
+
}
|
|
236
|
+
to {
|
|
237
|
+
transform: translateY(0);
|
|
238
|
+
opacity: 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
241
|
.SVG-module_icon__8J5Ev {
|
|
242
242
|
width: 20px;
|
|
243
243
|
height: 20px;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { HTMLAttributes, ReactNode } from "react";
|
|
2
2
|
import { AriaListBoxOptions } from "@react-aria/listbox";
|
|
3
3
|
import { OverrideClassName } from "../../../../types/OverrideClassName";
|
|
4
4
|
import { SelectOption, SelectItem } from "../../types";
|
|
5
5
|
export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<HTMLAttributes<HTMLUListElement>> & {
|
|
6
|
-
children:
|
|
6
|
+
children: ReactNode;
|
|
7
7
|
/** Props for the popup. */
|
|
8
8
|
menuProps: AriaListBoxOptions<SelectItem<Option>>;
|
|
9
9
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useIsClientReady";
|
package/package.json
CHANGED
|
@@ -48,7 +48,7 @@ describe("<FilterSelect>", () => {
|
|
|
48
48
|
it("shows the options initially when isOpen is true", async () => {
|
|
49
49
|
render(<FilterSelectWrapper isOpen />)
|
|
50
50
|
await waitFor(() => {
|
|
51
|
-
expect(screen.
|
|
51
|
+
expect(screen.getByRole("listbox")).toBeVisible()
|
|
52
52
|
})
|
|
53
53
|
})
|
|
54
54
|
|
|
@@ -107,10 +107,82 @@ describe("<FilterSelect>", () => {
|
|
|
107
107
|
render(<FilterSelectWrapper isOpen />)
|
|
108
108
|
expect(screen.queryByRole("listbox")).toBeVisible()
|
|
109
109
|
await waitFor(() => {
|
|
110
|
-
expect(screen.
|
|
110
|
+
expect(screen.getAllByRole("option")[0]).toHaveFocus()
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
it("moves focus to the first item on ArrowDown if nothing has been selected", async () => {
|
|
115
|
+
render(<FilterSelectWrapper selectedKey={undefined} />)
|
|
116
|
+
const trigger = screen.getByRole("button", { name: "Coffee" })
|
|
117
|
+
await user.tab()
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(trigger).toHaveFocus()
|
|
120
|
+
})
|
|
121
|
+
await user.keyboard("{ArrowDown}")
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(screen.getAllByRole("option")[0]).toHaveFocus()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
it("moves focus to the last item on ArrowUp if nothing has been selected", async () => {
|
|
128
|
+
render(<FilterSelectWrapper selectedKey={undefined} />)
|
|
129
|
+
const trigger = screen.getByRole("button", { name: "Coffee" })
|
|
130
|
+
await user.tab()
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(trigger).toHaveFocus()
|
|
133
|
+
})
|
|
134
|
+
await user.keyboard("{ArrowUp}")
|
|
135
|
+
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
const options = screen.getAllByRole("option")
|
|
138
|
+
expect(options[options.length - 1]).toHaveFocus()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
it("moves focus to the current selected item on Enter", async () => {
|
|
142
|
+
render(<FilterSelectWrapper selectedKey="hazelnut" />)
|
|
143
|
+
const trigger = screen.getByRole("button", {
|
|
144
|
+
name: "Coffee : Hazelnut",
|
|
145
|
+
})
|
|
146
|
+
await user.tab()
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(trigger).toHaveFocus()
|
|
149
|
+
})
|
|
150
|
+
await user.keyboard("{Enter}")
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
it("moves focus to the current selected item on ArrowUp", async () => {
|
|
157
|
+
render(<FilterSelectWrapper selectedKey="hazelnut" />)
|
|
158
|
+
const trigger = screen.getByRole("button", {
|
|
159
|
+
name: "Coffee : Hazelnut",
|
|
160
|
+
})
|
|
161
|
+
await user.tab()
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(trigger).toHaveFocus()
|
|
164
|
+
})
|
|
165
|
+
await user.keyboard("{ArrowUp}")
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
it("moves focus to the current selected item on ArrowDown", async () => {
|
|
172
|
+
render(<FilterSelectWrapper selectedKey="hazelnut" />)
|
|
173
|
+
const trigger = screen.getByRole("button", {
|
|
174
|
+
name: "Coffee : Hazelnut",
|
|
175
|
+
})
|
|
176
|
+
await user.tab()
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(trigger).toHaveFocus()
|
|
179
|
+
})
|
|
180
|
+
await user.keyboard("{ArrowDown}")
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
|
|
184
|
+
})
|
|
185
|
+
})
|
|
114
186
|
it("closes when user hits the escape key", async () => {
|
|
115
187
|
render(<FilterSelectWrapper isOpen />)
|
|
116
188
|
expect(screen.queryByRole("listbox")).toBeVisible()
|
|
@@ -168,6 +240,29 @@ describe("<FilterSelect>", () => {
|
|
|
168
240
|
})
|
|
169
241
|
})
|
|
170
242
|
|
|
243
|
+
describe("Stringified object values", () => {
|
|
244
|
+
it("finds selected option when value is a stringified object", () => {
|
|
245
|
+
const { getByRole } = render(
|
|
246
|
+
<FilterSelectWrapper
|
|
247
|
+
items={[
|
|
248
|
+
{
|
|
249
|
+
value: '{"sortBy":"creator_name","sortOrder":"asc"}',
|
|
250
|
+
label: "Created by A-Z",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
value: '{"sortBy":"creator_name","sortOrder":"dsc"}',
|
|
254
|
+
label: "Created by Z-A",
|
|
255
|
+
},
|
|
256
|
+
]}
|
|
257
|
+
selectedKey='{"sortBy":"creator_name","sortOrder":"asc"}'
|
|
258
|
+
/>
|
|
259
|
+
)
|
|
260
|
+
expect(
|
|
261
|
+
getByRole("button", { name: "Coffee : Created by A-Z" })
|
|
262
|
+
).toBeInTheDocument()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
171
266
|
const defaultProps: FilterSelectProps = {
|
|
172
267
|
label: "Coffee",
|
|
173
268
|
isOpen: false,
|
|
@@ -78,6 +78,9 @@ export const FilterSelect = <Option extends SelectOption = SelectOption>({
|
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
const { buttonProps } = useButton(triggerProps, triggerRef)
|
|
81
|
+
|
|
82
|
+
// `aria-labelledby` and `aria-controls` are being remapped because the `buttonProps` ids generated by React Aria point to nothing.
|
|
83
|
+
// This should ideally be refactored but for now the `aria-controls` is set to the Filter's Listbox (menuProps.id) and the `aria-labelledby` to undefined so the accessible name is derived from the buttons content.
|
|
81
84
|
const renderTriggerButtonProps = {
|
|
82
85
|
...buttonProps,
|
|
83
86
|
"aria-labelledby": undefined,
|
|
@@ -194,11 +194,13 @@ describe("<Select />", () => {
|
|
|
194
194
|
})
|
|
195
195
|
|
|
196
196
|
describe("Given the menu is opened", () => {
|
|
197
|
-
it("focuses the
|
|
198
|
-
const { getByRole } = render(
|
|
197
|
+
it("focuses on the first item", async () => {
|
|
198
|
+
const { getByRole, getAllByRole } = render(
|
|
199
|
+
<SelectWrapper defaultOpen />
|
|
200
|
+
)
|
|
199
201
|
expect(getByRole("listbox")).toBeVisible()
|
|
200
202
|
await waitFor(() => {
|
|
201
|
-
expect(
|
|
203
|
+
expect(getAllByRole("option")[0]).toHaveFocus()
|
|
202
204
|
})
|
|
203
205
|
})
|
|
204
206
|
it("is closed when hits the escape key", async () => {
|
|
@@ -98,8 +98,6 @@ Set `isFullWidth` to `true` to have the Select span the full width of its contai
|
|
|
98
98
|
|
|
99
99
|
By default, the Select's popover will attach itself to the `body` of the document using React's `createPortal`.
|
|
100
100
|
|
|
101
|
-
You can change the default
|
|
101
|
+
You can change the default behavior by providing a `portalContainerId` to attach this to different element in the DOM. This can help to resolve issues that may arise with `z-index` or having a Select in a modal.
|
|
102
102
|
|
|
103
103
|
<Canvas of={SelectStories.PortalContainer} />
|
|
104
|
-
|
|
105
|
-
There is currently a known issue whereby a selected option will cause the page to scroll to the top of the window on open (click on [default to see example](https://cultureamp.design/?path=/docs/components-select-future--docs#portals)). This can be solved by setting a `portalContainerId` to the closest parent of the Select.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { ContextModal } from "~components/Modal"
|
|
3
4
|
import { RadioField, RadioGroup } from "~components/Radio"
|
|
4
5
|
import { Select } from "../Select"
|
|
5
6
|
import { SelectOption } from "../types"
|
|
@@ -170,25 +171,40 @@ export const FullWidth: Story = {
|
|
|
170
171
|
export const PortalContainer: Story = {
|
|
171
172
|
render: args => {
|
|
172
173
|
const portalContainerId = "id--portal-container"
|
|
174
|
+
|
|
175
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
176
|
+
|
|
177
|
+
const handleOpen = (): void => setIsOpen(true)
|
|
178
|
+
const handleClose = (): void => setIsOpen(false)
|
|
173
179
|
return (
|
|
174
180
|
<>
|
|
175
|
-
<div
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
181
|
+
<div className=" h-[500px] mb-24 block bg-gray-100 flex flex-col gap-16 justify-center items-center">
|
|
182
|
+
Page content
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="border border-gray-500"
|
|
186
|
+
onClick={handleOpen}
|
|
187
|
+
>
|
|
188
|
+
Open Modal
|
|
189
|
+
</button>
|
|
190
|
+
<ContextModal
|
|
191
|
+
isOpen={isOpen}
|
|
192
|
+
onConfirm={handleClose}
|
|
193
|
+
onDismiss={handleClose}
|
|
194
|
+
title="Select test"
|
|
195
|
+
>
|
|
196
|
+
<div
|
|
197
|
+
className="flex gap-24 bg-gray-200 p-12"
|
|
198
|
+
id={portalContainerId}
|
|
199
|
+
>
|
|
200
|
+
<Select
|
|
201
|
+
{...args}
|
|
202
|
+
label="Select within a modal"
|
|
203
|
+
id="id--select-inner"
|
|
204
|
+
portalContainerId={portalContainerId}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</ContextModal>
|
|
192
208
|
</div>
|
|
193
209
|
</>
|
|
194
210
|
)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import React, { HTMLAttributes } from "react"
|
|
1
|
+
import React, { HTMLAttributes, Key, useEffect, useRef, ReactNode } from "react"
|
|
2
2
|
import { AriaListBoxOptions, useListBox } from "@react-aria/listbox"
|
|
3
|
+
import { SelectState } from "@react-stately/select"
|
|
3
4
|
import classnames from "classnames"
|
|
5
|
+
import { useIsClientReady } from "~components/__utilities__/useIsClientReady"
|
|
4
6
|
import { OverrideClassName } from "~components/types/OverrideClassName"
|
|
5
7
|
import { useSelectContext } from "../../context"
|
|
6
8
|
import { SelectOption, SelectItem } from "../../types"
|
|
@@ -9,25 +11,75 @@ import styles from "./ListBox.module.scss"
|
|
|
9
11
|
export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
|
|
10
12
|
HTMLAttributes<HTMLUListElement>
|
|
11
13
|
> & {
|
|
12
|
-
children:
|
|
14
|
+
children: ReactNode
|
|
13
15
|
/** Props for the popup. */
|
|
14
16
|
menuProps: AriaListBoxOptions<SelectItem<Option>>
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/** A util to retrieve the key of the correct focusable items based of the focus strategy
|
|
20
|
+
* This is used to determine which element from the collection to focus to on open base on the keyboard event
|
|
21
|
+
* ie: UpArrow will set the focusStrategy to "last"
|
|
22
|
+
*/
|
|
23
|
+
const getOptionKeyFromCollection = (
|
|
24
|
+
state: SelectState<SelectItem<any>>
|
|
25
|
+
): Key | null => {
|
|
26
|
+
if (state.selectedItem) {
|
|
27
|
+
return state.selectedItem.key
|
|
28
|
+
} else if (state.focusStrategy === "last") {
|
|
29
|
+
return state.collection.getLastKey()
|
|
30
|
+
}
|
|
31
|
+
return state.collection.getFirstKey()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** This makes the use of query selector less brittle in instances where a failed selector is passed in
|
|
35
|
+
*/
|
|
36
|
+
const safeQuerySelector = (selector: string): HTMLElement | null => {
|
|
37
|
+
try {
|
|
38
|
+
return document.querySelector(selector)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.error("Kaizen querySelector failed:", error)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export const ListBox = <Option extends SelectOption>({
|
|
18
47
|
children,
|
|
19
48
|
menuProps,
|
|
20
49
|
classNameOverride,
|
|
21
50
|
...restProps
|
|
22
51
|
}: SingleListBoxProps<Option>): JSX.Element => {
|
|
52
|
+
const isClientReady = useIsClientReady()
|
|
23
53
|
const { state } = useSelectContext<Option>()
|
|
24
|
-
const ref =
|
|
54
|
+
const ref = useRef<HTMLUListElement>(null)
|
|
25
55
|
const { listBoxProps } = useListBox(
|
|
26
|
-
{
|
|
56
|
+
{
|
|
57
|
+
...menuProps,
|
|
58
|
+
disallowEmptySelection: true,
|
|
59
|
+
// This is to ensure that the listbox doesn't use React Aria's auto focus feature for Listbox, which creates a visual bug
|
|
60
|
+
autoFocus: false,
|
|
61
|
+
},
|
|
27
62
|
state,
|
|
28
63
|
ref
|
|
29
64
|
)
|
|
30
65
|
|
|
66
|
+
/**
|
|
67
|
+
* This uses the new useIsClientReady to ensure document exists before trying to querySelector and give the time to focus to the correct element
|
|
68
|
+
*/
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isClientReady) {
|
|
71
|
+
const optionKey = getOptionKeyFromCollection(state)
|
|
72
|
+
const focusToElement = safeQuerySelector(`[data-key='${optionKey}']`)
|
|
73
|
+
|
|
74
|
+
if (focusToElement) {
|
|
75
|
+
focusToElement.focus()
|
|
76
|
+
} else {
|
|
77
|
+
// If an element is not found, focus on the listbox. This ensures the list can still be navigated to via keyboard if the keys do not align to the data attributes of the list items.
|
|
78
|
+
ref.current?.focus()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, [isClientReady])
|
|
82
|
+
|
|
31
83
|
return (
|
|
32
84
|
<ul
|
|
33
85
|
ref={ref}
|
|
@@ -36,7 +36,7 @@ export const Overlay = <Option extends SelectOption>({
|
|
|
36
36
|
{...restProps}
|
|
37
37
|
>
|
|
38
38
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
|
39
|
-
<FocusScope autoFocus restoreFocus>
|
|
39
|
+
<FocusScope autoFocus={false} restoreFocus>
|
|
40
40
|
<DismissButton onDismiss={state.close} />
|
|
41
41
|
{children}
|
|
42
42
|
<DismissButton onDismiss={state.close} />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useIsClientReady"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hook that returns a truthy value indicating if the code can be run on client side.
|
|
5
|
+
* This is a useful hook for determining if the `document` or `window` objects are available.
|
|
6
|
+
*/
|
|
7
|
+
export const useIsClientReady = (): boolean => {
|
|
8
|
+
const [isClientReady, setIsClientReady] = useState(false)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
12
|
+
setIsClientReady(true)
|
|
13
|
+
}
|
|
14
|
+
}, [])
|
|
15
|
+
|
|
16
|
+
return isClientReady
|
|
17
|
+
}
|