@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.
Files changed (100) hide show
  1. package/esm/_internal/ButtonRoot.js.map +1 -1
  2. package/esm/_internal/Checkbox2.js +3 -1
  3. package/esm/_internal/Checkbox2.js.map +1 -1
  4. package/esm/_internal/ClickAwayProvider.js +90 -12
  5. package/esm/_internal/ClickAwayProvider.js.map +1 -1
  6. package/esm/_internal/DatePickerField.js +18 -11
  7. package/esm/_internal/DatePickerField.js.map +1 -1
  8. package/esm/_internal/Dialog2.js +2 -2
  9. package/esm/_internal/Dialog2.js.map +1 -1
  10. package/esm/_internal/GenericBlock.js +90 -0
  11. package/esm/_internal/GenericBlock.js.map +1 -0
  12. package/esm/_internal/Lightbox2.js +2 -2
  13. package/esm/_internal/Lightbox2.js.map +1 -1
  14. package/esm/_internal/LinkPreview.js +22 -12
  15. package/esm/_internal/LinkPreview.js.map +1 -1
  16. package/esm/_internal/Popover2.js +21 -8
  17. package/esm/_internal/Popover2.js.map +1 -1
  18. package/esm/_internal/SelectMultiple.js +16 -4
  19. package/esm/_internal/SelectMultiple.js.map +1 -1
  20. package/esm/_internal/Tooltip2.js +3 -7
  21. package/esm/_internal/Tooltip2.js.map +1 -1
  22. package/esm/_internal/UserBlock.js +9 -2
  23. package/esm/_internal/UserBlock.js.map +1 -1
  24. package/esm/_internal/alert-dialog.js +2 -2
  25. package/esm/_internal/autocomplete.js +2 -1
  26. package/esm/_internal/autocomplete.js.map +1 -1
  27. package/esm/_internal/button.js +2 -1
  28. package/esm/_internal/button.js.map +1 -1
  29. package/esm/_internal/comment-block.js +2 -1
  30. package/esm/_internal/comment-block.js.map +1 -1
  31. package/esm/_internal/date-picker.js +3 -2
  32. package/esm/_internal/date-picker.js.map +1 -1
  33. package/esm/_internal/dialog.js +2 -2
  34. package/esm/_internal/dropdown.js +2 -1
  35. package/esm/_internal/dropdown.js.map +1 -1
  36. package/esm/_internal/expansion-panel.js +1 -1
  37. package/esm/_internal/generic-block.js +12 -0
  38. package/esm/_internal/generic-block.js.map +1 -0
  39. package/esm/_internal/lightbox.js +3 -2
  40. package/esm/_internal/lightbox.js.map +1 -1
  41. package/esm/_internal/popover.js +2 -1
  42. package/esm/_internal/popover.js.map +1 -1
  43. package/esm/_internal/select.js +2 -1
  44. package/esm/_internal/select.js.map +1 -1
  45. package/esm/_internal/side-navigation.js +2 -1
  46. package/esm/_internal/side-navigation.js.map +1 -1
  47. package/esm/_internal/slideshow.js +2 -1
  48. package/esm/_internal/slideshow.js.map +1 -1
  49. package/esm/_internal/text-field.js +2 -1
  50. package/esm/_internal/text-field.js.map +1 -1
  51. package/esm/_internal/tooltip.js +2 -1
  52. package/esm/_internal/tooltip.js.map +1 -1
  53. package/esm/_internal/type.js.map +1 -1
  54. package/esm/_internal/useFocusTrap.js +65 -72
  55. package/esm/_internal/useFocusTrap.js.map +1 -1
  56. package/esm/_internal/user-block.js +1 -0
  57. package/esm/_internal/user-block.js.map +1 -1
  58. package/esm/index.js +4 -2
  59. package/esm/index.js.map +1 -1
  60. package/package.json +5 -5
  61. package/src/components/button/Button.stories.tsx +1 -0
  62. package/src/components/button/ButtonRoot.tsx +4 -4
  63. package/src/components/checkbox/Checkbox.tsx +2 -1
  64. package/src/components/checkbox/__snapshots__/Checkbox.test.tsx.snap +4 -0
  65. package/src/components/date-picker/DatePickerField.tsx +15 -16
  66. package/src/components/date-picker/types.ts +2 -2
  67. package/src/components/dialog/Dialog.stories.tsx +57 -14
  68. package/src/components/dialog/Dialog.tsx +1 -1
  69. package/src/components/dialog/__snapshots__/Dialog.test.tsx.snap +160 -91
  70. package/src/components/generic-block/GenericBlock.stories.tsx +149 -0
  71. package/src/components/generic-block/GenericBlock.test.tsx +28 -0
  72. package/src/components/generic-block/GenericBlock.tsx +120 -0
  73. package/src/components/generic-block/__snapshots__/GenericBlock.test.tsx.snap +92 -0
  74. package/src/components/generic-block/index.ts +1 -0
  75. package/src/components/lightbox/Lightbox.tsx +1 -1
  76. package/src/components/link-preview/LinkPreview.test.tsx +50 -55
  77. package/src/components/link-preview/LinkPreview.tsx +43 -16
  78. package/src/components/popover/Popover.tsx +20 -4
  79. package/src/components/select/Select.stories.tsx +2 -0
  80. package/src/components/select/Select.tsx +11 -1
  81. package/src/components/select/SelectMultiple.stories.tsx +2 -0
  82. package/src/components/select/SelectMultiple.tsx +11 -1
  83. package/src/components/select/constants.ts +2 -0
  84. package/src/components/table/__snapshots__/Table.test.tsx.snap +5 -0
  85. package/src/components/tooltip/Tooltip.tsx +2 -5
  86. package/src/components/user-block/UserBlock.stories.tsx +4 -4
  87. package/src/components/user-block/UserBlock.tsx +9 -3
  88. package/src/components/user-block/__snapshots__/UserBlock.test.tsx.snap +51 -8
  89. package/src/hooks/useBooleanState.tsx +4 -10
  90. package/src/hooks/useCallbackOnEscape.ts +21 -13
  91. package/src/hooks/useFocusTrap.ts +67 -76
  92. package/src/index.ts +1 -0
  93. package/src/stories/generated/Dialog/Demos.stories.tsx +1 -0
  94. package/src/stories/generated/GenericBlock/Demos.stories.tsx +6 -0
  95. package/src/utils/focus/getFirstAndLastFocusable.test.ts +128 -0
  96. package/src/utils/focus/getFirstAndLastFocusable.ts +27 -0
  97. package/src/utils/makeListenerTowerContext.ts +32 -0
  98. package/src/utils/type.ts +3 -0
  99. package/types.d.ts +50 -9
  100. 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. */
@@ -3,6 +3,11 @@
3
3
  exports[`<Table> Snapshots and structure should render story 'Default' 1`] = `
4
4
  <table
5
5
  className="lumx-table lumx-table--has-dividers lumx-table--theme-light"
6
+ style={
7
+ Object {
8
+ "minWidth": 620,
9
+ }
10
+ }
6
11
  >
7
12
  <TableHeader>
8
13
  <TableRow>
@@ -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
- if (!label) {
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(), alt: 'Avatar' }}
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(), alt: 'Avatar' }}
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(), alt: 'Avatar' },
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
- {...avatarProps}
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="Avatar"
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="Avatar"
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="Avatar"
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="Avatar"
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="Avatar"
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="Avatar"
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="Avatar"
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="Avatar"
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
- * Triggers a callback when the escape key is pressed.
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
- if (closeOnEscape && callback && rootElement) {
19
- const onKeyDown = onEscapePressed(callback);
20
- rootElement.addEventListener('keydown', onKeyDown);
21
- return () => rootElement.removeEventListener('keydown', onKeyDown);
19
+ const rootElement = DOCUMENT?.body;
20
+ if (!closeOnEscape || !callback || !rootElement) {
21
+ return undefined;
22
22
  }
23
- return undefined;
24
- }, [callback, closeOnEscape, rootElement]);
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
- /** CSS selector listing all tabbable elements. */
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
- * Get first and last elements focusable in an element.
11
+ * Trap 'Tab' focus switch inside the `focusZoneElement`.
16
12
  *
17
- * @param parentElement The element in which to search focusable elements.
18
- * @return first and last focusable elements
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
- * @param rootElement The element on which the key down event will be placed.
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
- if (rootElement && focusZoneElement) {
48
- (document.activeElement as HTMLElement)?.blur();
49
- if (focusElement) {
50
- focusElement.focus();
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
- const onKeyDown = (evt: KeyboardEvent) => {
54
- const { key } = evt;
55
- if (key !== 'Tab') {
56
- return;
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
- // Prevent focus switch if no focusable available.
61
- if (!focusable.first) {
62
- evt.preventDefault();
63
- return;
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
- if (
67
- // No previous focus
68
- !document.activeElement ||
69
- // Previous focus is at the end of the focus zone.
70
- (!evt.shiftKey && document.activeElement === focusable.last) ||
71
- // Previous focus is outside the focus zone
72
- !focusZoneElement.contains(document.activeElement)
73
- ) {
74
- focusable.first.focus();
75
- evt.preventDefault();
76
- return;
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
- if (
80
- // Focus order reversed
81
- evt.shiftKey &&
82
- // Previous focus is at the start of the focus zone.
83
- document.activeElement === focusable.first
84
- ) {
85
- focusable.last.focus();
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
- return undefined;
93
- }, [focusElement, focusZoneElement, rootElement]);
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,6 @@
1
+ /**
2
+ * File generated when storybook is started. Do not edit directly!
3
+ */
4
+ export default { title: 'LumX components/generic-block/GenericBlock Demos' };
5
+
6
+ export { App as Default } from './default';
@@ -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
+ &lt;/div&gt;
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
+ });