@lumx/react 3.0.6-alpha.2 → 3.0.7-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.0.6-alpha.2",
11
- "@lumx/icons": "^3.0.6-alpha.2",
10
+ "@lumx/core": "^3.0.7-alpha.0",
11
+ "@lumx/icons": "^3.0.7-alpha.0",
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.2",
118
- "gitHead": "08b0d777dc906cf3a6816c9cb9e2d4b57ddaf8e1"
117
+ "version": "3.0.7-alpha.0",
118
+ "gitHead": "3f528f821c680dbf072fc5f78fee38d980227d17"
119
119
  }
@@ -1,4 +1,4 @@
1
- import { Color, ColorPalette } from '@lumx/react';
1
+ import { ColorPalette } from '@lumx/react';
2
2
  import { Comp, GenericProps } from '@lumx/react/utils/type';
3
3
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
4
4
  import classNames from 'classnames';
@@ -11,7 +11,7 @@ export interface BadgeProps extends GenericProps {
11
11
  /** Badge content. */
12
12
  children?: ReactNode;
13
13
  /** Color variant. */
14
- color?: Color;
14
+ color?: ColorPalette;
15
15
  }
16
16
 
17
17
  /**
@@ -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
  );
@@ -4,7 +4,7 @@ import isEmpty from 'lodash/isEmpty';
4
4
 
5
5
  import classNames from 'classnames';
6
6
 
7
- import { Color, ColorPalette, Emphasis, Size, Theme } from '@lumx/react';
7
+ import { ColorPalette, Emphasis, Size, Theme } from '@lumx/react';
8
8
  import { CSS_PREFIX } from '@lumx/react/constants';
9
9
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
10
10
  import { handleBasicClasses } from '@lumx/react/utils/className';
@@ -22,7 +22,7 @@ export interface BaseButtonProps
22
22
  Pick<AriaAttributes, 'aria-expanded' | 'aria-haspopup' | 'aria-pressed' | 'aria-label'>,
23
23
  HasTheme {
24
24
  /** Color variant. */
25
- color?: Color;
25
+ color?: ColorPalette;
26
26
  /** Emphasis variant. */
27
27
  emphasis?: Emphasis;
28
28
  /** Whether or not the button has a background color in low emphasis. */
@@ -39,6 +39,7 @@ describe(`<${Checkbox.displayName}>`, () => {
39
39
  expect(wrapper).toHaveClassName(CLASSNAME);
40
40
  expect(wrapper).not.toHaveClassName('lumx-checkbox--is-disabled');
41
41
  expect(wrapper).toHaveClassName('lumx-checkbox--is-unchecked');
42
+ expect(wrapper.find('input')).toHaveProp('disabled', undefined);
42
43
  });
43
44
  });
44
45
 
@@ -51,6 +52,7 @@ describe(`<${Checkbox.displayName}>`, () => {
51
52
  });
52
53
 
53
54
  expect(wrapper).toHaveClassName(getBasicClass({ prefix: CLASSNAME, type: 'disabled', value: true }));
55
+ expect(wrapper.find('input')).toHaveProp('disabled', true);
54
56
  expect(wrapper).toHaveClassName(getBasicClass({ prefix: CLASSNAME, type: 'checked', value: true }));
55
57
  });
56
58
 
@@ -106,6 +106,7 @@ export const Checkbox: Comp<CheckboxProps, HTMLDivElement> = forwardRef((props,
106
106
  type="checkbox"
107
107
  id={inputId}
108
108
  className={`${CLASSNAME}__input-native`}
109
+ disabled={isDisabled}
109
110
  tabIndex={isDisabled ? -1 : 0}
110
111
  name={name}
111
112
  value={value}
@@ -1,4 +1,4 @@
1
- import { Color, ColorPalette, Size, Theme } from '@lumx/react';
1
+ import { ColorPalette, Size, Theme } from '@lumx/react';
2
2
  import { useStopPropagation } from '@lumx/react/hooks/useStopPropagation';
3
3
 
4
4
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
@@ -24,7 +24,7 @@ export interface ChipProps extends GenericProps, HasTheme {
24
24
  /** A component to be rendered before the content. */
25
25
  before?: ReactNode;
26
26
  /** Color variant. */
27
- color?: Color;
27
+ color?: ColorPalette;
28
28
  /** Whether the component is clickable or not. */
29
29
  isClickable?: boolean;
30
30
  /** Whether the component is disabled or not. */
@@ -2,7 +2,7 @@ import React, { forwardRef } from 'react';
2
2
 
3
3
  import classNames from 'classnames';
4
4
 
5
- import { Color, ColorPalette, ColorVariant, Size, Theme } from '@lumx/react';
5
+ import { ColorPalette, ColorVariant, Size, Theme } from '@lumx/react';
6
6
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
7
7
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
8
8
  import { mdiAlertCircle } from '@lumx/icons';
@@ -14,7 +14,7 @@ export type IconSizes = Extract<Size, 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'x
14
14
  */
15
15
  export interface IconProps extends GenericProps, HasTheme {
16
16
  /** Color variant. */
17
- color?: Color;
17
+ color?: ColorPalette;
18
18
  /** Lightened or darkened variant of the selected icon color. */
19
19
  colorVariant?: ColorVariant;
20
20
  /** Whether the icon has a shape. */
@@ -4,7 +4,7 @@ import isEmpty from 'lodash/isEmpty';
4
4
 
5
5
  import classNames from 'classnames';
6
6
 
7
- import { Color, ColorVariant, Icon, Size, Typography } from '@lumx/react';
7
+ import { ColorPalette, ColorVariant, Icon, Size, Typography } from '@lumx/react';
8
8
  import { Comp, GenericProps } from '@lumx/react/utils/type';
9
9
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
10
10
  import { renderLink } from '@lumx/react/utils/renderLink';
@@ -16,7 +16,7 @@ type HTMLAnchorProps = React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAn
16
16
  */
17
17
  export interface LinkProps extends GenericProps {
18
18
  /** Color variant. */
19
- color?: Color;
19
+ color?: ColorPalette;
20
20
  /** Lightened or darkened variant of the selected icon color. */
21
21
  colorVariant?: ColorVariant;
22
22
  /** Link href. */
@@ -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.
@@ -115,6 +116,8 @@ export interface PopoverProps extends GenericProps {
115
116
  zIndex?: number;
116
117
  /** On close callback (on click away or Escape pressed). */
117
118
  onClose?(): void;
119
+ /** Whether the popover should trap the focus within itself. Default to false. */
120
+ withFocusTrap?: boolean;
118
121
  }
119
122
 
120
123
  /**
@@ -235,6 +238,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
235
238
  usePortal,
236
239
  zIndex,
237
240
  focusAnchorOnClose = true,
241
+ withFocusTrap,
238
242
  ...forwardedProps
239
243
  } = props;
240
244
  // eslint-disable-next-line react-hooks/rules-of-hooks
@@ -243,6 +247,8 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
243
247
  const [arrowElement, setArrowElement] = useState<null | HTMLElement>(null);
244
248
  // eslint-disable-next-line react-hooks/rules-of-hooks
245
249
  const clickAwayRef = useRef<HTMLDivElement>(null);
250
+ // eslint-disable-next-line react-hooks/rules-of-hooks
251
+ const contentRef = useRef<HTMLDivElement>(null);
246
252
 
247
253
  /**
248
254
  * Track whether the focus is currently set in the
@@ -340,8 +346,12 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
340
346
 
341
347
  // eslint-disable-next-line react-hooks/rules-of-hooks
342
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);
343
353
  // eslint-disable-next-line react-hooks/rules-of-hooks
344
- useFocus(focusElement?.current, isOpen && (state?.rects?.popper?.y ?? -1) >= 0);
354
+ useFocusTrap(withFocusTrap && isOpen && contentRef?.current, focusElement?.current);
345
355
 
346
356
  // eslint-disable-next-line react-hooks/rules-of-hooks
347
357
  const clickAwayRefs = useRef([clickAwayRef, anchorRef]);
@@ -350,7 +360,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
350
360
  ? renderPopover(
351
361
  <div
352
362
  {...forwardedProps}
353
- ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef)}
363
+ ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef, contentRef)}
354
364
  className={classNames(
355
365
  className,
356
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
+ };