@lumx/react 2.2.18 → 2.2.20
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/esm/_internal/ButtonRoot.js.map +1 -1
- package/esm/_internal/Checkbox2.js +3 -1
- package/esm/_internal/Checkbox2.js.map +1 -1
- package/esm/_internal/ClickAwayProvider.js +90 -12
- package/esm/_internal/ClickAwayProvider.js.map +1 -1
- package/esm/_internal/DatePickerField.js +18 -11
- package/esm/_internal/DatePickerField.js.map +1 -1
- package/esm/_internal/Dialog2.js +2 -2
- package/esm/_internal/Dialog2.js.map +1 -1
- package/esm/_internal/GenericBlock.js +90 -0
- package/esm/_internal/GenericBlock.js.map +1 -0
- package/esm/_internal/Lightbox2.js +2 -2
- package/esm/_internal/Lightbox2.js.map +1 -1
- package/esm/_internal/LinkPreview.js +22 -12
- package/esm/_internal/LinkPreview.js.map +1 -1
- package/esm/_internal/Popover2.js +21 -8
- package/esm/_internal/Popover2.js.map +1 -1
- package/esm/_internal/SelectMultiple.js +16 -4
- package/esm/_internal/SelectMultiple.js.map +1 -1
- package/esm/_internal/Tooltip2.js +3 -7
- package/esm/_internal/Tooltip2.js.map +1 -1
- package/esm/_internal/UserBlock.js +9 -2
- package/esm/_internal/UserBlock.js.map +1 -1
- package/esm/_internal/alert-dialog.js +2 -2
- package/esm/_internal/autocomplete.js +2 -1
- package/esm/_internal/autocomplete.js.map +1 -1
- package/esm/_internal/button.js +2 -1
- package/esm/_internal/button.js.map +1 -1
- package/esm/_internal/comment-block.js +2 -1
- package/esm/_internal/comment-block.js.map +1 -1
- package/esm/_internal/date-picker.js +3 -2
- package/esm/_internal/date-picker.js.map +1 -1
- package/esm/_internal/dialog.js +2 -2
- package/esm/_internal/dropdown.js +2 -1
- package/esm/_internal/dropdown.js.map +1 -1
- package/esm/_internal/expansion-panel.js +1 -1
- package/esm/_internal/generic-block.js +12 -0
- package/esm/_internal/generic-block.js.map +1 -0
- package/esm/_internal/lightbox.js +3 -2
- package/esm/_internal/lightbox.js.map +1 -1
- package/esm/_internal/popover.js +2 -1
- package/esm/_internal/popover.js.map +1 -1
- package/esm/_internal/select.js +2 -1
- package/esm/_internal/select.js.map +1 -1
- package/esm/_internal/side-navigation.js +2 -1
- package/esm/_internal/side-navigation.js.map +1 -1
- package/esm/_internal/slideshow.js +2 -1
- package/esm/_internal/slideshow.js.map +1 -1
- package/esm/_internal/text-field.js +2 -1
- package/esm/_internal/text-field.js.map +1 -1
- package/esm/_internal/tooltip.js +2 -1
- package/esm/_internal/tooltip.js.map +1 -1
- package/esm/_internal/type.js.map +1 -1
- package/esm/_internal/useFocusTrap.js +65 -72
- package/esm/_internal/useFocusTrap.js.map +1 -1
- package/esm/_internal/user-block.js +1 -0
- package/esm/_internal/user-block.js.map +1 -1
- package/esm/index.js +4 -2
- package/esm/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/button/Button.stories.tsx +1 -0
- package/src/components/button/ButtonRoot.tsx +4 -4
- package/src/components/checkbox/Checkbox.tsx +2 -1
- package/src/components/checkbox/__snapshots__/Checkbox.test.tsx.snap +4 -0
- package/src/components/date-picker/DatePickerField.tsx +15 -16
- package/src/components/date-picker/types.ts +2 -2
- package/src/components/dialog/Dialog.stories.tsx +57 -14
- package/src/components/dialog/Dialog.tsx +1 -1
- package/src/components/dialog/__snapshots__/Dialog.test.tsx.snap +160 -91
- package/src/components/generic-block/GenericBlock.stories.tsx +149 -0
- package/src/components/generic-block/GenericBlock.test.tsx +28 -0
- package/src/components/generic-block/GenericBlock.tsx +120 -0
- package/src/components/generic-block/__snapshots__/GenericBlock.test.tsx.snap +92 -0
- package/src/components/generic-block/index.ts +1 -0
- package/src/components/lightbox/Lightbox.tsx +1 -1
- package/src/components/link-preview/LinkPreview.test.tsx +50 -55
- package/src/components/link-preview/LinkPreview.tsx +43 -16
- package/src/components/popover/Popover.tsx +20 -4
- package/src/components/select/Select.stories.tsx +2 -0
- package/src/components/select/Select.tsx +11 -1
- package/src/components/select/SelectMultiple.stories.tsx +2 -0
- package/src/components/select/SelectMultiple.tsx +11 -1
- package/src/components/select/constants.ts +2 -0
- package/src/components/table/__snapshots__/Table.test.tsx.snap +5 -0
- package/src/components/tooltip/Tooltip.tsx +2 -5
- package/src/components/user-block/UserBlock.stories.tsx +4 -4
- package/src/components/user-block/UserBlock.tsx +9 -3
- package/src/components/user-block/__snapshots__/UserBlock.test.tsx.snap +51 -8
- package/src/hooks/useBooleanState.tsx +4 -10
- package/src/hooks/useCallbackOnEscape.ts +21 -13
- package/src/hooks/useFocusTrap.ts +67 -76
- package/src/index.ts +1 -0
- package/src/stories/generated/Dialog/Demos.stories.tsx +1 -0
- package/src/stories/generated/GenericBlock/Demos.stories.tsx +6 -0
- package/src/utils/focus/getFirstAndLastFocusable.test.ts +128 -0
- package/src/utils/focus/getFirstAndLastFocusable.ts +27 -0
- package/src/utils/makeListenerTowerContext.ts +32 -0
- package/src/utils/type.ts +3 -0
- package/types.d.ts +50 -9
- package/src/components/link-preview/__snapshots__/LinkPreview.test.tsx.snap +0 -51
|
@@ -21,6 +21,8 @@ export interface CoreSelectProps extends GenericProps {
|
|
|
21
21
|
helper?: string;
|
|
22
22
|
/** Whether the select should close on click. */
|
|
23
23
|
closeOnClick?: boolean;
|
|
24
|
+
/** Icon (SVG path). */
|
|
25
|
+
icon?: string;
|
|
24
26
|
/** Whether the component is disabled or not. */
|
|
25
27
|
isDisabled?: boolean;
|
|
26
28
|
/** Whether the component is required or not. */
|
|
@@ -65,12 +65,9 @@ const ARROW_SIZE = 8;
|
|
|
65
65
|
* @return React element.
|
|
66
66
|
*/
|
|
67
67
|
export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, ref) => {
|
|
68
|
-
if (!DOCUMENT) {
|
|
69
|
-
// Can't render in SSR.
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
68
|
const { label, children, className, delay, placement, forceOpen, ...forwardedProps } = props;
|
|
73
|
-
|
|
69
|
+
// Disable in SSR or without a label.
|
|
70
|
+
if (!DOCUMENT || !label) {
|
|
74
71
|
return <>{children}</>;
|
|
75
72
|
}
|
|
76
73
|
|
|
@@ -17,7 +17,7 @@ export const Default = ({ theme }: any) => (
|
|
|
17
17
|
theme={theme}
|
|
18
18
|
name="Emmitt O. Lum"
|
|
19
19
|
fields={['Creative developer', 'Denpasar']}
|
|
20
|
-
avatarProps={{ image: avatarImageKnob()
|
|
20
|
+
avatarProps={{ image: avatarImageKnob() }}
|
|
21
21
|
onMouseEnter={logAction('Mouse entered')}
|
|
22
22
|
onMouseLeave={logAction('Mouse left')}
|
|
23
23
|
/>
|
|
@@ -30,7 +30,7 @@ export const Sizes = ({ theme }: any) =>
|
|
|
30
30
|
theme={theme}
|
|
31
31
|
name="Emmitt O. Lum"
|
|
32
32
|
fields={['Creative developer', 'Denpasar']}
|
|
33
|
-
avatarProps={{ image: avatarImageKnob()
|
|
33
|
+
avatarProps={{ image: avatarImageKnob() }}
|
|
34
34
|
size={size}
|
|
35
35
|
onMouseEnter={logAction('Mouse entered')}
|
|
36
36
|
onMouseLeave={logAction('Mouse left')}
|
|
@@ -41,8 +41,9 @@ export const Clickable = ({ theme }: any) => {
|
|
|
41
41
|
const baseProps = {
|
|
42
42
|
theme,
|
|
43
43
|
name: 'Emmitt O. Lum',
|
|
44
|
+
nameProps: { 'aria-label': 'Emmitt O. Lum - open user profile' },
|
|
44
45
|
fields: ['Creative developer', 'Denpasar'],
|
|
45
|
-
avatarProps: { image: avatarImageKnob()
|
|
46
|
+
avatarProps: { image: avatarImageKnob() },
|
|
46
47
|
} as any;
|
|
47
48
|
return (
|
|
48
49
|
<>
|
|
@@ -65,7 +66,6 @@ export const WithBadge = ({ theme }: any) => (
|
|
|
65
66
|
fields={['Creative developer', 'Denpasar']}
|
|
66
67
|
avatarProps={{
|
|
67
68
|
image: avatarImageKnob(),
|
|
68
|
-
alt: 'Avatar',
|
|
69
69
|
badge: (
|
|
70
70
|
<Badge color={ColorPalette.blue}>
|
|
71
71
|
<Icon icon={mdiStar} />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { forwardRef, ReactNode } from 'react';
|
|
2
2
|
import isEmpty from 'lodash/isEmpty';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
|
+
import set from 'lodash/set';
|
|
4
5
|
|
|
5
6
|
import { Avatar, ColorPalette, Link, Orientation, Size, Theme } from '@lumx/react';
|
|
6
7
|
import { Comp, GenericProps, getRootClassName, handleBasicClasses } from '@lumx/react/utils';
|
|
@@ -17,7 +18,7 @@ export type UserBlockSize = Extract<Size, 's' | 'm' | 'l'>;
|
|
|
17
18
|
*/
|
|
18
19
|
export interface UserBlockProps extends GenericProps {
|
|
19
20
|
/** Props to pass to the avatar. */
|
|
20
|
-
avatarProps?: AvatarProps
|
|
21
|
+
avatarProps?: Omit<AvatarProps, 'alt'>;
|
|
21
22
|
/** Additional fields used to describe the user. */
|
|
22
23
|
fields?: string[];
|
|
23
24
|
/** Props to pass to the link wrapping the avatar thumbnail. */
|
|
@@ -121,8 +122,12 @@ export const UserBlock: Comp<UserBlockProps, HTMLDivElement> = forwardRef((props
|
|
|
121
122
|
color: ColorPalette.dark,
|
|
122
123
|
});
|
|
123
124
|
}
|
|
125
|
+
// Disable avatar focus since the name block is the same link / same button.
|
|
126
|
+
if (avatarProps) {
|
|
127
|
+
set(avatarProps, ['thumbnailProps', 'tabIndex'], -1);
|
|
128
|
+
}
|
|
124
129
|
return <NameComponent {...nProps}>{name}</NameComponent>;
|
|
125
|
-
}, [isClickable, linkAs, linkProps, name, nameProps, onClick]);
|
|
130
|
+
}, [avatarProps, isClickable, linkAs, linkProps, name, nameProps, onClick]);
|
|
126
131
|
|
|
127
132
|
const fieldsBlock: ReactNode = fields && componentSize !== Size.s && (
|
|
128
133
|
<div className={`${CLASSNAME}__fields`}>
|
|
@@ -149,7 +154,8 @@ export const UserBlock: Comp<UserBlockProps, HTMLDivElement> = forwardRef((props
|
|
|
149
154
|
<Avatar
|
|
150
155
|
linkAs={linkAs}
|
|
151
156
|
linkProps={linkProps}
|
|
152
|
-
|
|
157
|
+
alt=""
|
|
158
|
+
{...(avatarProps as any)}
|
|
153
159
|
className={classNames(`${CLASSNAME}__avatar`, avatarProps.className)}
|
|
154
160
|
size={componentSize}
|
|
155
161
|
onClick={onClick}
|
|
@@ -6,17 +6,23 @@ Array [
|
|
|
6
6
|
className="lumx-user-block lumx-user-block--orientation-horizontal lumx-user-block--size-m lumx-user-block--theme-light lumx-user-block--is-clickable"
|
|
7
7
|
>
|
|
8
8
|
<Avatar
|
|
9
|
-
alt="
|
|
9
|
+
alt=""
|
|
10
10
|
className="lumx-user-block__avatar"
|
|
11
11
|
image="/demo-assets/avatar1.jpg"
|
|
12
12
|
onClick={[Function]}
|
|
13
13
|
size="m"
|
|
14
14
|
theme="light"
|
|
15
|
+
thumbnailProps={
|
|
16
|
+
Object {
|
|
17
|
+
"tabIndex": -1,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
15
20
|
/>
|
|
16
21
|
<div
|
|
17
22
|
className="lumx-user-block__wrapper"
|
|
18
23
|
>
|
|
19
24
|
<Link
|
|
25
|
+
aria-label="Emmitt O. Lum - open user profile"
|
|
20
26
|
className="lumx-user-block__name"
|
|
21
27
|
color="dark"
|
|
22
28
|
onClick={[Function]}
|
|
@@ -45,7 +51,7 @@ Array [
|
|
|
45
51
|
className="lumx-user-block lumx-user-block--orientation-horizontal lumx-user-block--size-m lumx-user-block--theme-light lumx-user-block--is-clickable"
|
|
46
52
|
>
|
|
47
53
|
<Avatar
|
|
48
|
-
alt="
|
|
54
|
+
alt=""
|
|
49
55
|
className="lumx-user-block__avatar"
|
|
50
56
|
image="/demo-assets/avatar1.jpg"
|
|
51
57
|
linkProps={
|
|
@@ -55,11 +61,17 @@ Array [
|
|
|
55
61
|
}
|
|
56
62
|
size="m"
|
|
57
63
|
theme="light"
|
|
64
|
+
thumbnailProps={
|
|
65
|
+
Object {
|
|
66
|
+
"tabIndex": -1,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
58
69
|
/>
|
|
59
70
|
<div
|
|
60
71
|
className="lumx-user-block__wrapper"
|
|
61
72
|
>
|
|
62
73
|
<Link
|
|
74
|
+
aria-label="Emmitt O. Lum - open user profile"
|
|
63
75
|
className="lumx-user-block__name"
|
|
64
76
|
color="dark"
|
|
65
77
|
href="https://example.com"
|
|
@@ -88,17 +100,23 @@ Array [
|
|
|
88
100
|
className="lumx-user-block lumx-user-block--orientation-horizontal lumx-user-block--size-m lumx-user-block--theme-light lumx-user-block--is-clickable"
|
|
89
101
|
>
|
|
90
102
|
<Avatar
|
|
91
|
-
alt="
|
|
103
|
+
alt=""
|
|
92
104
|
className="lumx-user-block__avatar"
|
|
93
105
|
image="/demo-assets/avatar1.jpg"
|
|
94
106
|
linkAs={[Function]}
|
|
95
107
|
size="m"
|
|
96
108
|
theme="light"
|
|
109
|
+
thumbnailProps={
|
|
110
|
+
Object {
|
|
111
|
+
"tabIndex": -1,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
97
114
|
/>
|
|
98
115
|
<div
|
|
99
116
|
className="lumx-user-block__wrapper"
|
|
100
117
|
>
|
|
101
118
|
<Link
|
|
119
|
+
aria-label="Emmitt O. Lum - open user profile"
|
|
102
120
|
className="lumx-user-block__name"
|
|
103
121
|
color="dark"
|
|
104
122
|
linkAs={[Function]}
|
|
@@ -133,11 +151,16 @@ exports[`<UserBlock> Snapshots and structure should render story 'Default' 1`] =
|
|
|
133
151
|
onMouseLeave={[Function]}
|
|
134
152
|
>
|
|
135
153
|
<Avatar
|
|
136
|
-
alt="
|
|
154
|
+
alt=""
|
|
137
155
|
className="lumx-user-block__avatar"
|
|
138
156
|
image="/demo-assets/avatar1.jpg"
|
|
139
157
|
size="m"
|
|
140
158
|
theme="light"
|
|
159
|
+
thumbnailProps={
|
|
160
|
+
Object {
|
|
161
|
+
"tabIndex": -1,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
141
164
|
/>
|
|
142
165
|
<div
|
|
143
166
|
className="lumx-user-block__wrapper"
|
|
@@ -175,11 +198,16 @@ Array [
|
|
|
175
198
|
onMouseLeave={[Function]}
|
|
176
199
|
>
|
|
177
200
|
<Avatar
|
|
178
|
-
alt="
|
|
201
|
+
alt=""
|
|
179
202
|
className="lumx-user-block__avatar"
|
|
180
203
|
image="/demo-assets/avatar1.jpg"
|
|
181
204
|
size="s"
|
|
182
205
|
theme="light"
|
|
206
|
+
thumbnailProps={
|
|
207
|
+
Object {
|
|
208
|
+
"tabIndex": -1,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
183
211
|
/>
|
|
184
212
|
<div
|
|
185
213
|
className="lumx-user-block__wrapper"
|
|
@@ -197,11 +225,16 @@ Array [
|
|
|
197
225
|
onMouseLeave={[Function]}
|
|
198
226
|
>
|
|
199
227
|
<Avatar
|
|
200
|
-
alt="
|
|
228
|
+
alt=""
|
|
201
229
|
className="lumx-user-block__avatar"
|
|
202
230
|
image="/demo-assets/avatar1.jpg"
|
|
203
231
|
size="m"
|
|
204
232
|
theme="light"
|
|
233
|
+
thumbnailProps={
|
|
234
|
+
Object {
|
|
235
|
+
"tabIndex": -1,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
205
238
|
/>
|
|
206
239
|
<div
|
|
207
240
|
className="lumx-user-block__wrapper"
|
|
@@ -235,11 +268,16 @@ Array [
|
|
|
235
268
|
onMouseLeave={[Function]}
|
|
236
269
|
>
|
|
237
270
|
<Avatar
|
|
238
|
-
alt="
|
|
271
|
+
alt=""
|
|
239
272
|
className="lumx-user-block__avatar"
|
|
240
273
|
image="/demo-assets/avatar1.jpg"
|
|
241
274
|
size="l"
|
|
242
275
|
theme="light"
|
|
276
|
+
thumbnailProps={
|
|
277
|
+
Object {
|
|
278
|
+
"tabIndex": -1,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
243
281
|
/>
|
|
244
282
|
<div
|
|
245
283
|
className="lumx-user-block__wrapper"
|
|
@@ -275,7 +313,7 @@ exports[`<UserBlock> Snapshots and structure should render story 'WithBadge' 1`]
|
|
|
275
313
|
className="lumx-user-block lumx-user-block--orientation-horizontal lumx-user-block--size-m lumx-user-block--theme-light"
|
|
276
314
|
>
|
|
277
315
|
<Avatar
|
|
278
|
-
alt="
|
|
316
|
+
alt=""
|
|
279
317
|
badge={
|
|
280
318
|
<Badge
|
|
281
319
|
color="blue"
|
|
@@ -289,6 +327,11 @@ exports[`<UserBlock> Snapshots and structure should render story 'WithBadge' 1`]
|
|
|
289
327
|
image="/demo-assets/avatar1.jpg"
|
|
290
328
|
size="m"
|
|
291
329
|
theme="light"
|
|
330
|
+
thumbnailProps={
|
|
331
|
+
Object {
|
|
332
|
+
"tabIndex": -1,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
292
335
|
/>
|
|
293
336
|
<div
|
|
294
337
|
className="lumx-user-block__wrapper"
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
export const useBooleanState = (defaultValue: boolean): [boolean, () => void, () => void, () => void] => {
|
|
4
4
|
const [booleanValue, setBoolean] = useState<boolean>(defaultValue);
|
|
5
5
|
|
|
6
|
-
const setToFalse = () =>
|
|
7
|
-
setBoolean(false);
|
|
8
|
-
};
|
|
6
|
+
const setToFalse = useCallback(() => setBoolean(false), []);
|
|
9
7
|
|
|
10
|
-
const setToTrue = () =>
|
|
11
|
-
setBoolean(true);
|
|
12
|
-
};
|
|
8
|
+
const setToTrue = useCallback(() => setBoolean(true), []);
|
|
13
9
|
|
|
14
|
-
const toggleBoolean = () =>
|
|
15
|
-
setBoolean(!booleanValue);
|
|
16
|
-
};
|
|
10
|
+
const toggleBoolean = useCallback(() => setBoolean((previousValue) => !previousValue), []);
|
|
17
11
|
|
|
18
12
|
return [booleanValue, setToFalse, setToTrue, toggleBoolean];
|
|
19
13
|
};
|
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import { DOCUMENT } from '@lumx/react/constants';
|
|
2
2
|
import { Callback, onEscapePressed } from '@lumx/react/utils';
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
|
+
import { Listener, makeListenerTowerContext } from '@lumx/react/utils/makeListenerTowerContext';
|
|
5
|
+
|
|
6
|
+
const LISTENERS = makeListenerTowerContext();
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
*
|
|
9
|
+
* Register a global listener on 'Escape' key pressed.
|
|
10
|
+
*
|
|
11
|
+
* If multiple listener are registered, only the last one is maintained. When a listener is unregistered, the previous
|
|
12
|
+
* one gets activated again.
|
|
7
13
|
*
|
|
8
14
|
* @param callback Callback
|
|
9
15
|
* @param closeOnEscape Disables the hook when false
|
|
10
|
-
* @param rootElement Element on which to listen the escape key
|
|
11
16
|
*/
|
|
12
|
-
export function useCallbackOnEscape(
|
|
13
|
-
callback: Callback | undefined,
|
|
14
|
-
closeOnEscape = true,
|
|
15
|
-
rootElement = DOCUMENT?.body,
|
|
16
|
-
) {
|
|
17
|
+
export function useCallbackOnEscape(callback: Callback | undefined, closeOnEscape = true) {
|
|
17
18
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return () => rootElement.removeEventListener('keydown', onKeyDown);
|
|
19
|
+
const rootElement = DOCUMENT?.body;
|
|
20
|
+
if (!closeOnEscape || !callback || !rootElement) {
|
|
21
|
+
return undefined;
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const onKeyDown = onEscapePressed(callback);
|
|
24
|
+
|
|
25
|
+
const listener: Listener = {
|
|
26
|
+
enable: () => rootElement.addEventListener('keydown', onKeyDown),
|
|
27
|
+
disable: () => rootElement.removeEventListener('keydown', onKeyDown),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
LISTENERS.register(listener);
|
|
31
|
+
return () => LISTENERS.unregister(listener);
|
|
32
|
+
}, [callback, closeOnEscape]);
|
|
25
33
|
}
|
|
@@ -1,94 +1,85 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
|
|
3
3
|
import { DOCUMENT } from '@lumx/react/constants';
|
|
4
|
+
import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
|
|
5
|
+
import { Falsy } from '@lumx/react/utils';
|
|
6
|
+
import { Listener, makeListenerTowerContext } from '@lumx/react/utils/makeListenerTowerContext';
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
const TABBABLE_ELEMENTS_SELECTOR = `a[href]:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
7
|
-
button:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
8
|
-
textarea:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
9
|
-
input[type="text"]:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
10
|
-
input[type="radio"]:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
11
|
-
input[type="checkbox"]:not([tabindex="-1"], [disabled], [aria-disabled]),
|
|
12
|
-
[tabindex]:not([tabindex="-1"], [disabled], [aria-disabled])`;
|
|
8
|
+
const FOCUS_TRAPS = makeListenerTowerContext();
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
|
-
*
|
|
11
|
+
* Trap 'Tab' focus switch inside the `focusZoneElement`.
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*/
|
|
20
|
-
function getFocusable(parentElement: HTMLElement) {
|
|
21
|
-
const focusableElements = parentElement.querySelectorAll<HTMLElement>(TABBABLE_ELEMENTS_SELECTOR);
|
|
22
|
-
|
|
23
|
-
if (focusableElements.length <= 0) {
|
|
24
|
-
return {};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const first = focusableElements[0];
|
|
28
|
-
const last = focusableElements[focusableElements.length - 1];
|
|
29
|
-
return { first, last };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Add a key down event handler to the given root element (document.body by default) to trap the move of focus
|
|
34
|
-
* (TAB and SHIFT-TAB keys) inside the given focusZoneElement.
|
|
35
|
-
* Will focus the given focus element when activating the focus trap.
|
|
13
|
+
* If multiple focus trap are activated, only the last one is maintained and when a focus trap closes, the previous one
|
|
14
|
+
* gets activated again.
|
|
36
15
|
*
|
|
37
16
|
* @param focusZoneElement The element in which to trap the focus.
|
|
38
|
-
* @param focusElement The element to focus when the focus trap is activated
|
|
39
|
-
*
|
|
17
|
+
* @param focusElement The element to focus when the focus trap is activated (otherwise the first focusable element
|
|
18
|
+
* will be focused).
|
|
40
19
|
*/
|
|
41
|
-
export function useFocusTrap(
|
|
42
|
-
focusZoneElement: HTMLElement | null,
|
|
43
|
-
focusElement?: HTMLElement | null,
|
|
44
|
-
rootElement = DOCUMENT?.body,
|
|
45
|
-
): void {
|
|
20
|
+
export function useFocusTrap(focusZoneElement: HTMLElement | Falsy, focusElement?: HTMLElement | null): void {
|
|
46
21
|
useEffect(() => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
22
|
+
// Body element can be undefined in SSR context.
|
|
23
|
+
const rootElement = DOCUMENT?.body;
|
|
24
|
+
|
|
25
|
+
if (!rootElement || !focusZoneElement) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Trap 'Tab' key down focus switch into the focus zone.
|
|
30
|
+
const trapTabFocusInFocusZone = (evt: KeyboardEvent) => {
|
|
31
|
+
const { key } = evt;
|
|
32
|
+
if (key !== 'Tab') {
|
|
33
|
+
return;
|
|
51
34
|
}
|
|
35
|
+
const focusable = getFirstAndLastFocusable(focusZoneElement);
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const focusable = getFocusable(focusZoneElement);
|
|
37
|
+
// Prevent focus switch if no focusable available.
|
|
38
|
+
if (!focusable.first) {
|
|
39
|
+
evt.preventDefault();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
59
42
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
43
|
+
if (
|
|
44
|
+
// No previous focus
|
|
45
|
+
!document.activeElement ||
|
|
46
|
+
// Previous focus is at the end of the focus zone.
|
|
47
|
+
(!evt.shiftKey && document.activeElement === focusable.last) ||
|
|
48
|
+
// Previous focus is outside the focus zone
|
|
49
|
+
!focusZoneElement.contains(document.activeElement)
|
|
50
|
+
) {
|
|
51
|
+
focusable.first.focus();
|
|
52
|
+
evt.preventDefault();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
56
|
+
if (
|
|
57
|
+
// Focus order reversed
|
|
58
|
+
evt.shiftKey &&
|
|
59
|
+
// Previous focus is at the start of the focus zone.
|
|
60
|
+
document.activeElement === focusable.first
|
|
61
|
+
) {
|
|
62
|
+
focusable.last.focus();
|
|
63
|
+
evt.preventDefault();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const focusTrap: Listener = {
|
|
68
|
+
enable: () => rootElement.addEventListener('keydown', trapTabFocusInFocusZone),
|
|
69
|
+
disable: () => rootElement.removeEventListener('keydown', trapTabFocusInFocusZone),
|
|
70
|
+
};
|
|
78
71
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
evt.preventDefault();
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
rootElement.addEventListener('keydown', onKeyDown);
|
|
90
|
-
return () => rootElement.removeEventListener('keydown', onKeyDown);
|
|
72
|
+
// SETUP:
|
|
73
|
+
if (focusElement && focusZoneElement.contains(focusElement)) {
|
|
74
|
+
// Focus the given element.
|
|
75
|
+
focusElement.focus();
|
|
76
|
+
} else {
|
|
77
|
+
// Focus the first focusable element in the zone.
|
|
78
|
+
getFirstAndLastFocusable(focusZoneElement).first?.focus();
|
|
91
79
|
}
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
FOCUS_TRAPS.register(focusTrap);
|
|
81
|
+
|
|
82
|
+
// TEARDOWN:
|
|
83
|
+
return () => FOCUS_TRAPS.unregister(focusTrap);
|
|
84
|
+
}, [focusElement, focusZoneElement]);
|
|
94
85
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from './components/dropdown';
|
|
|
15
15
|
export * from './components/expansion-panel';
|
|
16
16
|
export * from './components/flag';
|
|
17
17
|
export * from './components/flex-box';
|
|
18
|
+
export * from './components/generic-block';
|
|
18
19
|
export * from './components/grid';
|
|
19
20
|
export * from './components/icon';
|
|
20
21
|
export * from './components/image-block';
|
|
@@ -6,4 +6,5 @@ export default { title: 'LumX components/dialog/Dialog Demos' };
|
|
|
6
6
|
export { App as Alert } from './alert';
|
|
7
7
|
export { App as Confirm } from './confirm';
|
|
8
8
|
export { App as Default } from './default';
|
|
9
|
+
export { App as Isloading } from './isloading';
|
|
9
10
|
export { App as Sizes } from './sizes';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
|
|
2
|
+
|
|
3
|
+
function htmlToElement(html: string): any {
|
|
4
|
+
const template = document.createElement('template');
|
|
5
|
+
template.innerHTML = html.trim();
|
|
6
|
+
return template.content.firstChild;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe(getFirstAndLastFocusable.name, () => {
|
|
10
|
+
it('should get empty', () => {
|
|
11
|
+
const element = htmlToElement(`<div></div>`);
|
|
12
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
13
|
+
expect(focusable).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should get single item', () => {
|
|
17
|
+
const element = htmlToElement(`<div><button /></div>`);
|
|
18
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
19
|
+
expect(focusable.last).toBe(focusable.first);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should get first and last', () => {
|
|
23
|
+
const element = htmlToElement(`
|
|
24
|
+
<div>
|
|
25
|
+
<div>Non focusable div</div>
|
|
26
|
+
<button>Simple button</button>
|
|
27
|
+
<div>Non focusable div</div>
|
|
28
|
+
<input />
|
|
29
|
+
<div>Non focusable div</div>
|
|
30
|
+
</div>
|
|
31
|
+
`);
|
|
32
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
33
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
34
|
+
<button>
|
|
35
|
+
Simple button
|
|
36
|
+
</button>
|
|
37
|
+
`);
|
|
38
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
39
|
+
<button>
|
|
40
|
+
Simple button
|
|
41
|
+
</button>
|
|
42
|
+
`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('match focusable elements', () => {
|
|
46
|
+
it('should match input element', () => {
|
|
47
|
+
const element = htmlToElement(`<div><input /></div>`);
|
|
48
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
49
|
+
expect(focusable.first).toMatchInlineSnapshot(`<input />`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should match link element', () => {
|
|
53
|
+
const element = htmlToElement(`<div><a href="#" /></div>`);
|
|
54
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
55
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
56
|
+
<a
|
|
57
|
+
href="#"
|
|
58
|
+
/>
|
|
59
|
+
`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should match textarea element', () => {
|
|
63
|
+
const element = htmlToElement(`<div><textarea /></div>`);
|
|
64
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
65
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
66
|
+
<textarea>
|
|
67
|
+
</div>
|
|
68
|
+
</textarea>
|
|
69
|
+
`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should match element with tabindex', () => {
|
|
73
|
+
const element = htmlToElement(`<div><span tabindex="0" /></div>`);
|
|
74
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
75
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
76
|
+
<span
|
|
77
|
+
tabindex="0"
|
|
78
|
+
/>
|
|
79
|
+
`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should keep disabled=false', () => {
|
|
83
|
+
const element = htmlToElement(`<div><button disabled="false" /><button /></div>`);
|
|
84
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
85
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
86
|
+
<button
|
|
87
|
+
disabled="false"
|
|
88
|
+
/>
|
|
89
|
+
`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should keep aria-disabled=false', () => {
|
|
93
|
+
const element = htmlToElement(`<div><button aria-disabled="false" /><button /></div>`);
|
|
94
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
95
|
+
expect(focusable.first).toMatchInlineSnapshot(`
|
|
96
|
+
<button
|
|
97
|
+
aria-disabled="false"
|
|
98
|
+
/>
|
|
99
|
+
`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('skip disabled elements', () => {
|
|
104
|
+
it('should skip disabled', () => {
|
|
105
|
+
const element = htmlToElement(`<div><button disabled /><button /></div>`);
|
|
106
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
107
|
+
expect(focusable.first).toMatchInlineSnapshot(`<button />`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should skip aria-disabled', () => {
|
|
111
|
+
const element = htmlToElement(`<div><button aria-disabled /><button /></div>`);
|
|
112
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
113
|
+
expect(focusable.first).toMatchInlineSnapshot(`<button />`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should skip tabindex=-1', () => {
|
|
117
|
+
const element = htmlToElement(`<div><button tabindex="-1" /><button /></div>`);
|
|
118
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
119
|
+
expect(focusable.first).toMatchInlineSnapshot(`<button />`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should skip input type hidden', () => {
|
|
123
|
+
const element = htmlToElement(`<div><input type="hidden" /><button /></div>`);
|
|
124
|
+
const focusable = getFirstAndLastFocusable(element);
|
|
125
|
+
expect(focusable.first).toMatchInlineSnapshot(`<button />`);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|