@lumx/react 3.6.0 → 3.6.1-alpha.0

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/package.json CHANGED
@@ -7,8 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@juggle/resize-observer": "^3.2.0",
10
- "@lumx/core": "^3.6.0",
11
- "@lumx/icons": "^3.6.0",
10
+ "@lumx/core": "^3.6.1-alpha.0",
11
+ "@lumx/icons": "^3.6.1-alpha.0",
12
12
  "@popperjs/core": "^2.5.4",
13
13
  "body-scroll-lock": "^3.1.5",
14
14
  "classnames": "^2.3.2",
@@ -30,13 +30,12 @@
30
30
  "@babel/preset-typescript": "^7.18.6",
31
31
  "@rollup/plugin-commonjs": "^15.0.0",
32
32
  "@rollup/plugin-node-resolve": "9.0.0",
33
- "@storybook/addon-a11y": "^7.0.20",
34
- "@storybook/addon-essentials": "^7.0.20",
35
- "@storybook/addon-interactions": "^7.0.20",
36
- "@storybook/blocks": "^7.0.20",
37
- "@storybook/react": "^7.0.20",
38
- "@storybook/react-vite": "^7.0.20",
39
- "@storybook/testing-library": "^0.0.14-next.2",
33
+ "@storybook/addon-a11y": "^7.6.3",
34
+ "@storybook/addon-essentials": "^7.6.3",
35
+ "@storybook/addon-interactions": "^7.6.3",
36
+ "@storybook/blocks": "^7.6.3",
37
+ "@storybook/react": "^7.6.3",
38
+ "@storybook/react-vite": "^7.6.3",
40
39
  "@testing-library/jest-dom": "^5.16.4",
41
40
  "@testing-library/react": "^12.1.2",
42
41
  "@testing-library/user-event": "^14.4.3",
@@ -44,9 +43,9 @@
44
43
  "@types/classnames": "^2.2.9",
45
44
  "@types/jest": "^29.2.1",
46
45
  "@types/lodash": "^4.14.149",
47
- "@types/react": "^16.9.11",
48
- "@types/react-dom": "^16.9.4",
49
- "@types/react-is": "^16.7.1",
46
+ "@types/react": "^17.0.2",
47
+ "@types/react-dom": "^17.0.2",
48
+ "@types/react-is": "^17.0.2",
50
49
  "autoprefixer": "^9.7.4",
51
50
  "babel-jest": "29.1.2",
52
51
  "babel-loader": "^8.0.6",
@@ -69,8 +68,7 @@
69
68
  "rollup-plugin-peer-deps-external": "^2.2.4",
70
69
  "rollup-plugin-ts-paths-resolve": "^1.3.0",
71
70
  "rollup-plugin-typescript-paths": "^1.2.2",
72
- "storybook": "^7.0.20",
73
- "tsconfig-paths-webpack-plugin": "^3.3.0",
71
+ "storybook": "^7.6.3",
74
72
  "typescript": "^4.1.2",
75
73
  "vite": "^4.2.1",
76
74
  "vite-tsconfig-paths": "^4.0.7",
@@ -115,5 +113,5 @@
115
113
  "build:storybook": "storybook build"
116
114
  },
117
115
  "sideEffects": false,
118
- "version": "3.6.0"
116
+ "version": "3.6.1-alpha.0"
119
117
  }
@@ -1,8 +1,8 @@
1
1
  /* eslint-disable react-hooks/rules-of-hooks,react/display-name */
2
2
  import React from 'react';
3
- import { ImageBlock, Alignment, Lightbox, Button } from '@lumx/react';
3
+ import { ImageBlock, Alignment, Lightbox, Button, Slideshow, SlideshowItem } from '@lumx/react';
4
4
  import { useBooleanState } from '@lumx/react/hooks/useBooleanState';
5
- import { LANDSCAPE_IMAGES } from '@lumx/react/stories/controls/image';
5
+ import { LANDSCAPE_IMAGES, LANDSCAPE_IMAGES_ALT } from '@lumx/react/stories/controls/image';
6
6
 
7
7
  export default {
8
8
  title: 'LumX components/lightbox/Lightbox',
@@ -28,9 +28,17 @@ export default {
28
28
  /**
29
29
  * Base LightBox with image block
30
30
  */
31
- export const ImageBlock_ = {
31
+ export const Image = {
32
32
  args: {
33
- children: <ImageBlock align={Alignment.center} alt="" fillHeight image={LANDSCAPE_IMAGES.landscape1} />,
33
+ 'aria-label': 'Fullscreen image',
34
+ children: (
35
+ <ImageBlock
36
+ align={Alignment.center}
37
+ fillHeight
38
+ image={LANDSCAPE_IMAGES.landscape1}
39
+ alt={LANDSCAPE_IMAGES_ALT.landscape1}
40
+ />
41
+ ),
34
42
  },
35
43
  };
36
44
 
@@ -39,7 +47,55 @@ export const ImageBlock_ = {
39
47
  */
40
48
  export const WithCloseButton = {
41
49
  args: {
42
- ...ImageBlock_.args,
50
+ ...Image.args,
43
51
  closeButtonProps: { label: 'Close' },
44
52
  },
45
53
  };
54
+
55
+ /**
56
+ * Demo a LightBox containing an image slideshow
57
+ */
58
+ export const ImageSlideshow = {
59
+ args: {
60
+ 'aria-label': 'Fullscreen image slideshow',
61
+ closeButtonProps: { label: 'Close' },
62
+ children: (
63
+ <Slideshow
64
+ aria-label="Image slideshow"
65
+ theme="dark"
66
+ slideshowControlsProps={{
67
+ nextButtonProps: { label: 'Next image' },
68
+ previousButtonProps: { label: 'Previous image' },
69
+ }}
70
+ slideGroupLabel={(currentGroup, totalGroup) => `${currentGroup} of ${totalGroup}`}
71
+ >
72
+ <SlideshowItem>
73
+ <ImageBlock
74
+ align="center"
75
+ fillHeight
76
+ image={LANDSCAPE_IMAGES.landscape1}
77
+ alt={LANDSCAPE_IMAGES_ALT.landscape1}
78
+ />
79
+ </SlideshowItem>
80
+
81
+ <SlideshowItem>
82
+ <ImageBlock
83
+ align="center"
84
+ fillHeight
85
+ image={LANDSCAPE_IMAGES.landscape2}
86
+ alt={LANDSCAPE_IMAGES_ALT.landscape2}
87
+ />
88
+ </SlideshowItem>
89
+
90
+ <SlideshowItem>
91
+ <ImageBlock
92
+ align="center"
93
+ fillHeight
94
+ image={LANDSCAPE_IMAGES.landscape3}
95
+ alt={LANDSCAPE_IMAGES_ALT.landscape3}
96
+ />
97
+ </SlideshowItem>
98
+ </Slideshow>
99
+ ),
100
+ },
101
+ };
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, RefObject, useRef, useEffect } from 'react';
1
+ import React, { forwardRef, RefObject, useRef, useEffect, AriaAttributes } from 'react';
2
2
 
3
3
  import classNames from 'classnames';
4
4
  import { createPortal } from 'react-dom';
@@ -19,7 +19,7 @@ import { useTransitionVisibility } from '@lumx/react/hooks/useTransitionVisibili
19
19
  /**
20
20
  * Defines the props of the component.
21
21
  */
22
- export interface LightboxProps extends GenericProps, HasTheme {
22
+ export interface LightboxProps extends GenericProps, HasTheme, Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> {
23
23
  /** Props to pass to the close button (minus those already set by the Lightbox props). */
24
24
  closeButtonProps?: Pick<IconButtonProps, 'label'> &
25
25
  Omit<IconButtonProps, 'label' | 'onClick' | 'icon' | 'emphasis' | 'color'>;
@@ -27,6 +27,8 @@ export interface LightboxProps extends GenericProps, HasTheme {
27
27
  isOpen?: boolean;
28
28
  /** Reference to the element that triggered modal opening to set focus on. */
29
29
  parentElement: RefObject<any>;
30
+ /** Reference to the element that should get the focus when the lightbox opens. By default, the close button or the lightbox itself will take focus. */
31
+ focusElement?: RefObject<HTMLElement>;
30
32
  /** Whether to keep the dialog open on clickaway or escape press. */
31
33
  preventAutoClose?: boolean;
32
34
  /** Z-axis position. */
@@ -54,13 +56,17 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
54
56
  */
55
57
  export const Lightbox: Comp<LightboxProps, HTMLDivElement> = forwardRef((props, ref) => {
56
58
  const {
57
- ariaLabel,
59
+ 'aria-labelledby': propAriaLabelledBy,
60
+ ariaLabelledBy = propAriaLabelledBy,
61
+ 'aria-label': propAriaLabel,
62
+ ariaLabel = propAriaLabel,
58
63
  children,
59
64
  className,
60
65
  closeButtonProps,
61
66
  isOpen,
62
67
  onClose,
63
68
  parentElement,
69
+ focusElement,
64
70
  preventAutoClose,
65
71
  theme,
66
72
  zIndex,
@@ -75,6 +81,8 @@ export const Lightbox: Comp<LightboxProps, HTMLDivElement> = forwardRef((props,
75
81
  const childrenRef = useRef<any>(null);
76
82
  // eslint-disable-next-line react-hooks/rules-of-hooks
77
83
  const wrapperRef = useRef<HTMLDivElement>(null);
84
+ // eslint-disable-next-line react-hooks/rules-of-hooks
85
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
78
86
 
79
87
  // eslint-disable-next-line react-hooks/rules-of-hooks
80
88
  useDisableBodyScroll(isOpen && wrapperRef.current);
@@ -84,7 +92,12 @@ export const Lightbox: Comp<LightboxProps, HTMLDivElement> = forwardRef((props,
84
92
 
85
93
  // Handle focus trap.
86
94
  // eslint-disable-next-line react-hooks/rules-of-hooks
87
- useFocusTrap(isOpen && wrapperRef.current, childrenRef.current?.firstChild);
95
+ useFocusTrap(
96
+ // Focus trap zone
97
+ isOpen && wrapperRef.current,
98
+ // Focus element (fallback on close button and then on the dialog)
99
+ focusElement?.current || closeButtonRef.current || wrapperRef.current,
100
+ );
88
101
 
89
102
  // eslint-disable-next-line react-hooks/rules-of-hooks
90
103
  const previousOpen = useRef(isOpen);
@@ -116,7 +129,10 @@ export const Lightbox: Comp<LightboxProps, HTMLDivElement> = forwardRef((props,
116
129
  ref={mergeRefs(ref, wrapperRef)}
117
130
  {...forwardedProps}
118
131
  aria-label={ariaLabel}
132
+ aria-labelledby={ariaLabelledBy}
119
133
  aria-modal="true"
134
+ role="dialog"
135
+ tabIndex={-1}
120
136
  className={classNames(
121
137
  className,
122
138
  handleBasicClasses({
@@ -131,6 +147,7 @@ export const Lightbox: Comp<LightboxProps, HTMLDivElement> = forwardRef((props,
131
147
  {closeButtonProps && (
132
148
  <IconButton
133
149
  {...closeButtonProps}
150
+ ref={closeButtonRef}
134
151
  className={`${CLASSNAME}__close`}
135
152
  color={ColorPalette.light}
136
153
  emphasis={Emphasis.low}
@@ -7,7 +7,7 @@ import { forwardRefPolymorphic } from '@lumx/react/utils/forwardRefPolymorphic';
7
7
  import { ThemeContext } from '@lumx/react/utils/ThemeContext';
8
8
 
9
9
  type BaseNavigationItemProps = {
10
- /* Icon (SVG path). */
10
+ /** Icon (SVG path). */
11
11
  icon?: string;
12
12
  /** Label content. */
13
13
  label: ReactNode;
@@ -65,7 +65,7 @@ export const NavigationItem = Object.assign(
65
65
  prefix: `${CLASSNAME}__link`,
66
66
  isSelected: isCurrentPage,
67
67
  })}
68
- ref={ref}
68
+ ref={ref as React.Ref<any>}
69
69
  aria-current={isCurrentPage ? 'page' : undefined}
70
70
  {...buttonProps}
71
71
  {...forwardedProps}
@@ -56,12 +56,13 @@ export const TooltipWithDropdown = (props: any) => {
56
56
  const [isOpen, setOpen] = useState(false);
57
57
  return (
58
58
  <>
59
- <Tooltip label={!isOpen && 'Tooltip'} {...props}>
59
+ <br />
60
+ <Tooltip label={!isOpen && 'Tooltip'} {...props} placement="top">
60
61
  <Button ref={setButton} onClick={() => setOpen((o) => !o)}>
61
62
  Anchor
62
63
  </Button>
63
64
  </Tooltip>
64
- <Dropdown anchorRef={{ current: button }} isOpen={isOpen}>
65
+ <Dropdown anchorRef={{ current: button }} isOpen={isOpen} onClose={() => setOpen(false)}>
65
66
  Dropdown
66
67
  </Dropdown>
67
68
  </>
@@ -125,6 +125,38 @@ describe(`<${Tooltip.displayName}>`, () => {
125
125
  });
126
126
  });
127
127
 
128
+ it('should activate on hover anchor and then tooltip', async () => {
129
+ let { tooltip } = await setup({
130
+ label: 'Tooltip label',
131
+ children: <Button>Anchor</Button>,
132
+ forceOpen: false,
133
+ });
134
+
135
+ expect(tooltip).not.toBeInTheDocument();
136
+
137
+ // Hover anchor button
138
+ const button = getByClassName(document.body, Button.className as string);
139
+ await userEvent.hover(button);
140
+
141
+ // Tooltip opened
142
+ tooltip = await findByClassName(document.body, CLASSNAME);
143
+ expect(tooltip).toBeInTheDocument();
144
+ expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
145
+
146
+ // Hover tooltip
147
+ await userEvent.hover(tooltip);
148
+ expect(tooltip).toBeInTheDocument();
149
+ expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
150
+
151
+ // Un-hover tooltip
152
+ userEvent.unhover(tooltip);
153
+ await waitFor(() => {
154
+ expect(button).not.toHaveFocus();
155
+ // Tooltip closed
156
+ expect(tooltip).not.toBeInTheDocument();
157
+ });
158
+ });
159
+
128
160
  it('should activate on anchor focus', async () => {
129
161
  let { tooltip } = await setup({
130
162
  label: 'Tooltip label',
@@ -85,8 +85,9 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
85
85
  });
86
86
 
87
87
  const position = attributes?.popper?.['data-popper-placement'] ?? placement;
88
- const isOpen = useTooltipOpen(delay, anchorElement) || forceOpen;
89
- const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen as boolean, id);
88
+ const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
89
+ const isOpen = isActivated || forceOpen;
90
+ const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen, id);
90
91
 
91
92
  return (
92
93
  <>
@@ -94,7 +95,7 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
94
95
  {isOpen &&
95
96
  createPortal(
96
97
  <div
97
- ref={mergeRefs(ref, setPopperElement)}
98
+ ref={mergeRefs(ref, setPopperElement, onPopperMount)}
98
99
  {...forwardedProps}
99
100
  id={id}
100
101
  role="tooltip"
@@ -18,7 +18,7 @@ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
18
18
  export const useInjectTooltipRef = (
19
19
  children: ReactNode,
20
20
  setAnchorElement: (e: HTMLDivElement) => void,
21
- isOpen: boolean,
21
+ isOpen: boolean | undefined,
22
22
  id: string,
23
23
  ): ReactNode => {
24
24
  return useMemo(() => {
@@ -1,7 +1,7 @@
1
- import { onEscapePressed } from '@lumx/react/utils/event';
2
- import { useEffect, useState } from 'react';
1
+ import { MutableRefObject, useEffect, useRef, useState } from 'react';
3
2
  import { browserDoesNotSupportHover } from '@lumx/react/utils/browserDoesNotSupportHover';
4
3
  import { TOOLTIP_HOVER_DELAY, TOOLTIP_LONG_PRESS_DELAY } from '@lumx/react/constants';
4
+ import { useCallbackOnEscape } from '@lumx/react/hooks/useCallbackOnEscape';
5
5
 
6
6
  /**
7
7
  * Hook controlling tooltip visibility using mouse hover the anchor and delay.
@@ -10,9 +10,15 @@ import { TOOLTIP_HOVER_DELAY, TOOLTIP_LONG_PRESS_DELAY } from '@lumx/react/const
10
10
  * @param anchorElement Tooltip anchor element.
11
11
  * @return whether or not to show the tooltip.
12
12
  */
13
- export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLElement | null): boolean {
13
+ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLElement | null) {
14
14
  const [isOpen, setIsOpen] = useState(false);
15
15
 
16
+ const onPopperMount = useRef<any>(null) as MutableRefObject<(elem: HTMLElement | null) => void>;
17
+
18
+ // Global close on escape
19
+ const [closeCallback, setCloseCallback] = useState<undefined | (() => void)>(undefined);
20
+ useCallbackOnEscape(closeCallback);
21
+
16
22
  useEffect(() => {
17
23
  if (!anchorElement) {
18
24
  return undefined;
@@ -45,44 +51,58 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
45
51
  };
46
52
 
47
53
  // Close or cancel opening of tooltip
48
- const close = (overrideDelay = closeDelay) => {
54
+ const getClose = (overrideDelay = closeDelay) => {
49
55
  if (!shouldOpen && !timer) return;
50
56
  shouldOpen = false;
51
57
  deferUpdate(overrideDelay);
52
58
  };
53
- const closeImmediately = () => close(0);
54
-
55
- /**
56
- * Handle touchend event
57
- * If `touchend` comes before the open delay => cancel tooltip (close immediate).
58
- * Else if `touchend` comes after the open delay => tooltip takes priority, the anchor's default touch end event is prevented.
59
- */
60
- const touchEnd = (evt: Event) => {
61
- if (!openStartTime) return;
62
- if (Date.now() - openStartTime >= openDelay) {
63
- // Tooltip take priority, event prevented.
64
- evt.stopPropagation();
65
- evt.preventDefault();
66
- anchorElement.focus();
67
- // Close with delay.
68
- close();
69
- } else {
70
- // Close immediately.
71
- closeImmediately();
72
- }
73
- };
59
+ const close = () => getClose(closeDelay);
60
+ const closeImmediately = () => getClose(0);
61
+ setCloseCallback(() => closeImmediately);
74
62
 
75
63
  // Adapt event to browsers with or without `hover` support.
76
- const events: Array<[Node, Event['type'], any]> = hoverNotSupported
77
- ? [
78
- [anchorElement, hasTouch ? 'touchstart' : 'mousedown', open],
79
- [anchorElement, hasTouch ? 'touchend' : 'mouseup', touchEnd],
80
- ]
81
- : [
82
- [anchorElement, 'mouseenter', open],
83
- [anchorElement, 'mouseleave', close],
84
- [anchorElement, 'mouseup', closeImmediately],
85
- ];
64
+ const events: Array<[Node, Event['type'], any]> = [];
65
+ if (hoverNotSupported) {
66
+ /**
67
+ * Handle touchend event
68
+ * If end comes before the open delay => cancel tooltip (close immediate).
69
+ * Else if end comes after the open delay => tooltip takes priority, the anchor's default touch end event is prevented.
70
+ */
71
+ const longPressEnd = (evt: Event) => {
72
+ if (!openStartTime) return;
73
+ if (Date.now() - openStartTime >= openDelay) {
74
+ // Tooltip take priority, event prevented.
75
+ evt.stopPropagation();
76
+ evt.preventDefault();
77
+ anchorElement.focus();
78
+ // Close with delay.
79
+ close();
80
+ } else {
81
+ // Close immediately.
82
+ closeImmediately();
83
+ }
84
+ };
85
+
86
+ events.push(
87
+ [anchorElement, hasTouch ? 'touchstart' : 'mousedown', open],
88
+ [anchorElement, hasTouch ? 'touchend' : 'mouseup', longPressEnd],
89
+ );
90
+ } else {
91
+ events.push(
92
+ [anchorElement, 'mouseenter', open],
93
+ [anchorElement, 'mouseleave', close],
94
+ [anchorElement, 'mouseup', closeImmediately],
95
+ );
96
+
97
+ onPopperMount.current = (popperElement: HTMLElement | null) => {
98
+ if (!popperElement) return;
99
+ // Popper element hover
100
+ popperElement.addEventListener('mouseenter', open);
101
+ popperElement.addEventListener('mouseleave', close);
102
+ // Add to event list to remove on unmount
103
+ events.push([popperElement, 'mouseenter', open], [popperElement, 'mouseleave', close]);
104
+ };
105
+ }
86
106
 
87
107
  // Events always applied no matter the browser:.
88
108
  events.push(
@@ -90,8 +110,6 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
90
110
  [anchorElement, 'focusin', open],
91
111
  // Close on lost focus.
92
112
  [anchorElement, 'focusout', closeImmediately],
93
- // Close on ESC keydown
94
- [anchorElement, 'keydown', onEscapePressed(closeImmediately)],
95
113
  );
96
114
 
97
115
  // Attach events
@@ -109,5 +127,5 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
109
127
  };
110
128
  }, [anchorElement, delay]);
111
129
 
112
- return isOpen;
130
+ return { isOpen, onPopperMount: onPopperMount.current };
113
131
  }
@@ -72,7 +72,7 @@ describe(`<${Uploader.displayName}>`, () => {
72
72
  it('should render file input', () => {
73
73
  const label = 'Label';
74
74
  const accept = '*';
75
- const { uploader } = setup({ label, fileInputProps: { accept } });
75
+ const { uploader } = setup({ label, fileInputProps: { accept } as any });
76
76
 
77
77
  expect(uploader.tagName).toBe('LABEL');
78
78
  expect(uploader).toHaveTextContent(label);
@@ -22,6 +22,12 @@ export const SQUARE_IMAGES = { square1, square2 };
22
22
  export const SVG_IMAGES = { defaultSvg };
23
23
  export const EMPTY_IMAGES = { emptyImage };
24
24
  export const LANDSCAPE_IMAGES = { landscape1, landscape1s200, landscape2, landscape3 };
25
+ export const LANDSCAPE_IMAGES_ALT: { [key in keyof typeof LANDSCAPE_IMAGES]: string } = {
26
+ landscape1: 'A majestic snowy mountain range with a peak covered in glistening snow',
27
+ landscape1s200: 'A majestic snowy mountain range with a peak covered in glistening snow',
28
+ landscape2: 'A colorful rack of shirts displaying various hues and styles',
29
+ landscape3: 'An open book resting on a table, ready to be explored and read',
30
+ };
25
31
  export const PORTRAIT_IMAGES = { portrait1, portrait1s200, portrait2, portrait3 };
26
32
  export const IMAGES = {
27
33
  ...LANDSCAPE_IMAGES,
@@ -4,6 +4,6 @@ import { ComponentRef } from '@lumx/react/utils/type';
4
4
  /** Same as `React.forwardRef` but inferring Ref type from the `as` prop. */
5
5
  export function forwardRefPolymorphic<E extends ElementType, P extends { as?: E }>(
6
6
  render: (props: P, ref: ComponentRef<E>) => React.ReactElement | null,
7
- ) {
8
- return React.forwardRef(render as any) as (props: P & { ref?: ComponentRef<E> }) => React.ReactElement | null;
7
+ ): (props: P & { ref?: ComponentRef<E> }) => React.ReactElement | null {
8
+ return React.forwardRef(render as any) as any;
9
9
  }