@lumx/react 3.0.6-alpha.1 → 3.0.6

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.0.6-alpha.1",
11
- "@lumx/icons": "^3.0.6-alpha.1",
10
+ "@lumx/core": "^3.0.6",
11
+ "@lumx/icons": "^3.0.6",
12
12
  "@popperjs/core": "^2.5.4",
13
13
  "body-scroll-lock": "^3.1.5",
14
14
  "classnames": "^2.2.6",
@@ -114,6 +114,6 @@
114
114
  "build:storybook": "cd storybook && ./build"
115
115
  },
116
116
  "sideEffects": false,
117
- "version": "3.0.6-alpha.1",
118
- "gitHead": "e230447f554272d8cd29c103842f961ec424c5f4"
117
+ "version": "3.0.6",
118
+ "gitHead": "1fed89f798ebc21e67fff1b6aee01e4aa86431a6"
119
119
  }
@@ -41,7 +41,10 @@ export interface AutocompleteProps extends GenericProps, HasTheme {
41
41
  */
42
42
  placement?: Placement;
43
43
  /**
44
- * Whether the dropdown should fit to the anchor width or not.
44
+ * Manage dropdown width:
45
+ * - `maxWidth`: dropdown not bigger than anchor
46
+ * - `minWidth` or `true`: dropdown not smaller than anchor
47
+ * - `width`: dropdown equal to the anchor.
45
48
  * @see {@link DropdownProps#fitToAnchorWidth}
46
49
  */
47
50
  fitToAnchorWidth?: DropdownProps['fitToAnchorWidth'];
@@ -1,6 +1,6 @@
1
1
  import React, { Fragment } from 'react';
2
2
  import { mdiSend } from '@lumx/icons';
3
- import { Button, ColorPalette, IconButton } from '@lumx/react';
3
+ import { Button, ColorPalette, IconButton, Text } from '@lumx/react';
4
4
  import { squareImageKnob } from '@lumx/react/stories/knobs/image';
5
5
  import { buttonSize } from '@lumx/react/stories/knobs/buttonKnob';
6
6
  import { emphasis } from '@lumx/react/stories/knobs/emphasisKnob';
@@ -30,6 +30,32 @@ export const SimpleButton = ({ theme }: any) => {
30
30
  );
31
31
  };
32
32
 
33
+ export const SimpleButtonWithTruncatedText = ({ theme }: any) => {
34
+ const buttonText =
35
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Potenti nullam ac tortor vitae. Lorem ipsum dolor sit amet. Diam sollicitudin tempor id eu nisl nunc mi ipsum. Elementum facilisis leo vel fringilla est ullamcorper eget nulla. Mollis aliquam ut porttitor leo a diam sollicitudin tempor. Ultrices tincidunt arcu non sodales neque sodales.';
36
+ return (
37
+ <Button
38
+ aria-pressed={boolean('isSelected', Boolean(DEFAULT_PROPS.isSelected))}
39
+ emphasis={emphasis('Emphasis', DEFAULT_PROPS.emphasis)}
40
+ theme={theme}
41
+ rightIcon={select('Right icon', { none: undefined, mdiSend }, undefined)}
42
+ leftIcon={select('Left icon', { none: undefined, mdiSend }, undefined)}
43
+ size={buttonSize()}
44
+ isSelected={boolean('isSelected', Boolean(DEFAULT_PROPS.isSelected))}
45
+ isDisabled={boolean('isDisabled', Boolean(DEFAULT_PROPS.isDisabled))}
46
+ color={select('color', ColorPalette, DEFAULT_PROPS.color)}
47
+ href={text('Button link', '')}
48
+ hasBackground={boolean('hasBackground', Boolean(DEFAULT_PROPS.hasBackground))}
49
+ fullWidth
50
+ title={buttonText}
51
+ >
52
+ <Text as="span" truncate>
53
+ {text('Button content', buttonText)}
54
+ </Text>
55
+ </Button>
56
+ );
57
+ };
58
+
33
59
  export const WithHref = () => <Button href="https://google.com">Button with redirection</Button>;
34
60
 
35
61
  export const Disabled = () => <Button isDisabled>Disabled button</Button>;
@@ -3,8 +3,8 @@ import React, { forwardRef, ReactNode } from 'react';
3
3
  import classNames from 'classnames';
4
4
  import isEmpty from 'lodash/isEmpty';
5
5
 
6
- import { Emphasis, Icon, Size, Theme } from '@lumx/react';
7
- import { Comp } from '@lumx/react/utils/type';
6
+ import { Emphasis, Icon, Size, Theme, Text } from '@lumx/react';
7
+ import { Comp, isComponent } from '@lumx/react/utils/type';
8
8
  import { getBasicClass, getRootClassName } from '@lumx/react/utils/className';
9
9
  import { BaseButtonProps, ButtonRoot } from './ButtonRoot';
10
10
 
@@ -71,7 +71,7 @@ export const Button: Comp<ButtonProps, HTMLButtonElement | HTMLAnchorElement> =
71
71
  variant="button"
72
72
  >
73
73
  {leftIcon && !isEmpty(leftIcon) && <Icon icon={leftIcon} />}
74
- {children && <span>{children}</span>}
74
+ {children && (isComponent(Text)(children) ? children : <span>{children}</span>)}
75
75
  {rightIcon && !isEmpty(rightIcon) && <Icon icon={rightIcon} />}
76
76
  </ButtonRoot>
77
77
  );
@@ -34,7 +34,10 @@ export interface DropdownProps extends GenericProps {
34
34
  */
35
35
  closeOnEscape?: boolean;
36
36
  /**
37
- * Whether the dropdown should fit to the anchor width (if dropdown is smaller) or not.
37
+ * Manage dropdown width:
38
+ * - `maxWidth`: dropdown not bigger than anchor
39
+ * - `minWidth` or `true`: dropdown not smaller than anchor
40
+ * - `width`: dropdown equal to the anchor.
38
41
  * @see {@link PopoverProps#fitToAnchorWidth}
39
42
  */
40
43
  fitToAnchorWidth?: PopoverProps['fitToAnchorWidth'];
@@ -16,6 +16,7 @@ import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/classNam
16
16
  import { mergeRefs } from '@lumx/react/utils/mergeRefs';
17
17
  import { useFocusWithin } from '@lumx/react/hooks/useFocusWithin';
18
18
  import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
19
+ import { useFocusTrap } from '@lumx/react/hooks/useFocusTrap';
19
20
 
20
21
  /**
21
22
  * Different possible placements for the popover.
@@ -86,7 +87,12 @@ export interface PopoverProps extends GenericProps {
86
87
  closeOnEscape?: boolean;
87
88
  /** Shadow elevation. */
88
89
  elevation?: Elevation;
89
- /** manage popover size to not be bigger (`width` & `maxWidth`) or smaller (`width`, `minWidth`, `true`) than the anchor. */
90
+ /**
91
+ * Manage popover width:
92
+ * - `maxWidth`: popover not bigger than anchor
93
+ * - `minWidth` or `true`: popover not smaller than anchor
94
+ * - `width`: popover equal to the anchor.
95
+ */
90
96
  fitToAnchorWidth?: AnchorWidthOption | boolean;
91
97
  /** Shrink popover if even after flipping there is not enough space. */
92
98
  fitWithinViewportHeight?: boolean;
@@ -110,6 +116,8 @@ export interface PopoverProps extends GenericProps {
110
116
  zIndex?: number;
111
117
  /** On close callback (on click away or Escape pressed). */
112
118
  onClose?(): void;
119
+ /** Whether the popover should trap the focus within itself. Default to false. */
120
+ withFocusTrap?: boolean;
113
121
  }
114
122
 
115
123
  /**
@@ -230,6 +238,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
230
238
  usePortal,
231
239
  zIndex,
232
240
  focusAnchorOnClose = true,
241
+ withFocusTrap,
233
242
  ...forwardedProps
234
243
  } = props;
235
244
  // eslint-disable-next-line react-hooks/rules-of-hooks
@@ -238,6 +247,8 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
238
247
  const [arrowElement, setArrowElement] = useState<null | HTMLElement>(null);
239
248
  // eslint-disable-next-line react-hooks/rules-of-hooks
240
249
  const clickAwayRef = useRef<HTMLDivElement>(null);
250
+ // eslint-disable-next-line react-hooks/rules-of-hooks
251
+ const contentRef = useRef<HTMLDivElement>(null);
241
252
 
242
253
  /**
243
254
  * Track whether the focus is currently set in the
@@ -335,8 +346,12 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
335
346
 
336
347
  // eslint-disable-next-line react-hooks/rules-of-hooks
337
348
  useCallbackOnEscape(handleClose, isOpen && closeOnEscape);
349
+
350
+ /** Only set focus within if the focus trap is disabled as they interfere with one another. */
351
+ // eslint-disable-next-line react-hooks/rules-of-hooks
352
+ useFocus(focusElement?.current, !withFocusTrap && isOpen && (state?.rects?.popper?.y ?? -1) >= 0);
338
353
  // eslint-disable-next-line react-hooks/rules-of-hooks
339
- useFocus(focusElement?.current, isOpen && (state?.rects?.popper?.y ?? -1) >= 0);
354
+ useFocusTrap(withFocusTrap && isOpen && contentRef?.current, focusElement?.current);
340
355
 
341
356
  // eslint-disable-next-line react-hooks/rules-of-hooks
342
357
  const clickAwayRefs = useRef([clickAwayRef, anchorRef]);
@@ -345,7 +360,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
345
360
  ? renderPopover(
346
361
  <div
347
362
  {...forwardedProps}
348
- ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef)}
363
+ ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef, contentRef)}
349
364
  className={classNames(
350
365
  className,
351
366
  handleBasicClasses({ prefix: CLASSNAME, elevation: Math.min(elevation || 0, 5), position }),
@@ -0,0 +1,75 @@
1
+ import { mdiMenu } from '@lumx/icons/index';
2
+ import { mdiSettings } from '@lumx/icons/v4-to-v5-aliases';
3
+ import React, { useRef, useState } from 'react';
4
+ import { PopoverDialog, PopoverDialogProps } from '.';
5
+ import { Emphasis, Orientation, Size, Typography } from '..';
6
+ import { Button, IconButton } from '../button';
7
+ import { FlexBox } from '../flex-box';
8
+ import { Heading } from '../heading';
9
+ import { List, ListItem } from '../list';
10
+ import { Placement } from '../popover/Popover';
11
+ import { Toolbar } from '../toolbar';
12
+
13
+ const WithButton = (Story: any, context: any) => {
14
+ const anchorRef = useRef(null);
15
+ const [isOpen, setIsOpen] = useState<boolean>(context?.args?.isOpen || false);
16
+
17
+ return (
18
+ <>
19
+ <Button ref={anchorRef} onClick={() => setIsOpen((current) => !current)}>
20
+ Open popover
21
+ </Button>
22
+ <Story anchorRef={anchorRef} isOpen={isOpen} onClose={() => setIsOpen(false)} />
23
+ </>
24
+ );
25
+ };
26
+
27
+ const dialogHeaderId = 'dialog-header';
28
+
29
+ const DemoPopoverContent = () => (
30
+ <FlexBox orientation={Orientation.vertical}>
31
+ <Toolbar
32
+ label={
33
+ <Heading id="dialogHeaderId" typography={Typography.headline}>
34
+ Title
35
+ </Heading>
36
+ }
37
+ after={<IconButton label="Settings" icon={mdiSettings} emphasis={Emphasis.low} />}
38
+ />
39
+ <List>
40
+ <ListItem size={Size.huge} after={<IconButton label="Menu" icon={mdiMenu} size={Size.s} />}>
41
+ List Item With Actions
42
+ </ListItem>
43
+ <ListItem
44
+ size={Size.huge}
45
+ linkProps={{
46
+ href: 'http://google.com',
47
+ }}
48
+ >
49
+ Clickable list item
50
+ </ListItem>
51
+ </List>
52
+ </FlexBox>
53
+ );
54
+
55
+ export default {
56
+ title: 'LumX components/popover-dialog/PopoverDialog',
57
+ component: PopoverDialog,
58
+ decorators: [WithButton],
59
+ args: {
60
+ children: <DemoPopoverContent />,
61
+ 'aria-labelledby': dialogHeaderId,
62
+ placement: Placement.BOTTOM,
63
+ },
64
+ };
65
+
66
+ const Template = (args: PopoverDialogProps, context: any) => {
67
+ const { anchorRef, isOpen, onClose } = context;
68
+ return (
69
+ <PopoverDialog {...args} anchorRef={anchorRef} isOpen={isOpen} onClose={onClose}>
70
+ {args.children}
71
+ </PopoverDialog>
72
+ );
73
+ };
74
+
75
+ export const Default = Template.bind({});
@@ -0,0 +1,65 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { render, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+
5
+ import { PopoverDialog } from './PopoverDialog';
6
+
7
+ const DialogWithButton = (forwardedProps: any) => {
8
+ const anchorRef = useRef(null);
9
+ const [isOpen, setIsOpen] = useState<boolean>(false);
10
+
11
+ return (
12
+ <>
13
+ <button type="button" ref={anchorRef} onClick={() => setIsOpen((current) => !current)}>
14
+ Open popover
15
+ </button>
16
+
17
+ <PopoverDialog {...forwardedProps} anchorRef={anchorRef} isOpen={isOpen} onClose={() => setIsOpen(false)}>
18
+ <button type="button">Button 1</button>
19
+ <button type="button">Button 2</button>
20
+ </PopoverDialog>
21
+ {/* This should never have focus while popover is opened */}
22
+ <button type="button">External button</button>
23
+ </>
24
+ );
25
+ };
26
+
27
+ describe(`<${PopoverDialog.displayName}>`, () => {
28
+ it('should behave like a dialog', async () => {
29
+ const label = 'Test Label';
30
+
31
+ render(<DialogWithButton label={label} />);
32
+
33
+ /** Open the popover */
34
+ const triggerElement = screen.getByRole('button', { name: 'Open popover' });
35
+ await userEvent.click(triggerElement);
36
+
37
+ const dialog = await screen.findByRole('dialog', { name: label });
38
+ const withinDialog = within(dialog);
39
+
40
+ /** Get buttons within dialog */
41
+ const dialogButtons = withinDialog.getAllByRole('button');
42
+
43
+ // First button should have focus by default on opening
44
+ expect(dialogButtons[0]).toHaveFocus();
45
+
46
+ // Tab to next button
47
+ await userEvent.tab();
48
+
49
+ // Second button should have focus
50
+ expect(dialogButtons[1]).toHaveFocus();
51
+
52
+ // Tab to next button
53
+ await userEvent.tab();
54
+
55
+ // As there is no more button, focus should loop back to first button.
56
+ expect(dialogButtons[0]).toHaveFocus();
57
+
58
+ // Close the popover
59
+ await userEvent.keyboard('{escape}');
60
+
61
+ expect(screen.queryByRole('dialog', { name: label })).not.toBeInTheDocument();
62
+ /** Anchor should retrieve the focus */
63
+ expect(triggerElement).toHaveFocus();
64
+ });
65
+ });
@@ -0,0 +1,65 @@
1
+ import React, { forwardRef } from 'react';
2
+ import classNames from 'classnames';
3
+
4
+ import { Comp, HasAriaLabelOrLabelledBy } from '@lumx/react/utils/type';
5
+ import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
6
+
7
+ import { Popover, PopoverProps } from '../popover/Popover';
8
+
9
+ /**
10
+ * PopoverDialog props.
11
+ * The PopoverDialog has the same props as the Popover but requires an accessible label.
12
+ */
13
+ export type PopoverDialogProps = PopoverProps & HasAriaLabelOrLabelledBy;
14
+
15
+ /**
16
+ * Component display name.
17
+ */
18
+ const COMPONENT_NAME = 'PopoverDialog';
19
+
20
+ /**
21
+ * Component default class name and class prefix.
22
+ */
23
+ const CLASSNAME = getRootClassName(COMPONENT_NAME);
24
+
25
+ /**
26
+ * Component default props.
27
+ */
28
+ const DEFAULT_PROPS: Partial<PopoverDialogProps> = {};
29
+
30
+ /**
31
+ * PopoverDialog component.
32
+ * Defines a popover that acts like a dialog
33
+ * * Has a dialog aria role
34
+ * * Sets a focus trap within the popover
35
+ * * Closes on click away and escape.
36
+ */
37
+ export const PopoverDialog: Comp<PopoverDialogProps, HTMLDivElement> = forwardRef((props, ref) => {
38
+ const { children, isOpen, focusElement, label, className, ...forwardedProps } = props;
39
+
40
+ return (
41
+ <Popover
42
+ {...forwardedProps}
43
+ ref={ref}
44
+ className={classNames(className, handleBasicClasses({ prefix: CLASSNAME }))}
45
+ role="dialog"
46
+ aria-modal="true"
47
+ /**
48
+ * If a label is set, set as aria-label.
49
+ * If it is undefined, the label can be set using the `aria-label` and `aria-labelledby` props
50
+ */
51
+ aria-label={label}
52
+ isOpen={isOpen}
53
+ focusElement={focusElement}
54
+ closeOnClickAway
55
+ closeOnEscape
56
+ withFocusTrap
57
+ >
58
+ {children}
59
+ </Popover>
60
+ );
61
+ });
62
+
63
+ PopoverDialog.displayName = COMPONENT_NAME;
64
+ PopoverDialog.className = CLASSNAME;
65
+ PopoverDialog.defaultProps = DEFAULT_PROPS;
@@ -0,0 +1 @@
1
+ export * from './PopoverDialog';
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from './components/message';
35
35
  export * from './components/mosaic';
36
36
  export * from './components/notification';
37
37
  export * from './components/popover';
38
+ export * from './components/popover-dialog';
38
39
  export * from './components/post-block';
39
40
  export * from './components/progress';
40
41
  export * from './components/progress-tracker';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * File generated when storybook is started. Do not edit directly!
3
+ */
4
+ export default { title: 'LumX components/popover-dialog/PopoverDialog Demos' };
5
+
6
+ export { App as Default } from './default';
package/src/utils/type.ts CHANGED
@@ -91,3 +91,23 @@ export const isComponentType = (type: ReactElement['type']) => (node: ReactNode)
91
91
  * (excluding `NaN` as it can't be distinguished from `number`)
92
92
  */
93
93
  export type Falsy = false | undefined | null | 0 | '';
94
+
95
+ /**
96
+ * Require either `aria-label` or `arial-labelledby` prop.
97
+ * If none are set, the order will prioritize `aria-labelledby` over `aria-label` as it
98
+ * needs a visible element.
99
+ */
100
+ export type HasAriaLabelOrLabelledBy<T = string | undefined> = T extends string
101
+ ? {
102
+ /**
103
+ * The id of the element to use as title of the dialog. Can be within or out of the dialog.
104
+ * Although it is not recommended, aria-label can be used instead if no visible element is available.
105
+ */
106
+ 'aria-labelledby': T;
107
+ /** The label of the dialog. */
108
+ 'aria-label'?: undefined;
109
+ }
110
+ : {
111
+ 'aria-label': string;
112
+ 'aria-labelledby'?: undefined;
113
+ };