@lumx/react 3.0.5 → 3.0.6-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.5",
11
- "@lumx/icons": "^3.0.5",
10
+ "@lumx/core": "^3.0.6-alpha.0",
11
+ "@lumx/icons": "^3.0.6-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.5",
118
- "gitHead": "83eccabdc29dbf61fd519c88729002a5bb8d4790"
117
+ "version": "3.0.6-alpha.0",
118
+ "gitHead": "22ae75c6b11ab36e3f9e194af19a5fdef291ce3c"
119
119
  }
@@ -2,7 +2,7 @@ import React, { forwardRef, ReactNode, SyntheticEvent, useRef } from 'react';
2
2
 
3
3
  import classNames from 'classnames';
4
4
 
5
- import { Dropdown, IconButtonProps, Offset, Placement, TextField, TextFieldProps } from '@lumx/react';
5
+ import { Dropdown, DropdownProps, IconButtonProps, Offset, Placement, TextField, TextFieldProps } from '@lumx/react';
6
6
 
7
7
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
8
8
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
@@ -41,10 +41,13 @@ 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
- fitToAnchorWidth?: boolean;
50
+ fitToAnchorWidth?: DropdownProps['fitToAnchorWidth'];
48
51
  /**
49
52
  * The error related to the component.
50
53
  * @see {@link TextFieldProps#error}
@@ -160,7 +163,7 @@ export interface AutocompleteProps extends GenericProps, HasTheme {
160
163
  * Only the props not managed by the Autocomplete can be set.
161
164
  * @see {@link TextFieldProps}
162
165
  */
163
- textFieldProps?: TextFieldProps;
166
+ textFieldProps?: Partial<TextFieldProps>;
164
167
  }
165
168
 
166
169
  /**
@@ -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. */
@@ -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. */
@@ -34,10 +34,13 @@ 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
- fitToAnchorWidth?: boolean;
43
+ fitToAnchorWidth?: PopoverProps['fitToAnchorWidth'];
41
44
  /**
42
45
  * Whether the dropdown should shrink to fit within the viewport height or not.
43
46
  * @see {@link PopoverProps#fitWithinViewportHeight}
@@ -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. */
@@ -34,7 +34,6 @@ export const ColorPalette = {
34
34
  grey: 'grey',
35
35
  } as const;
36
36
  export type ColorPalette = ValueOf<typeof ColorPalette>;
37
- export type Color = ColorPalette | string;
38
37
 
39
38
  /**
40
39
  * See SCSS variable $lumx-color-variants
@@ -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. */
@@ -314,3 +314,73 @@ export const NestedWithoutPortal = () => {
314
314
  </div>
315
315
  );
316
316
  };
317
+
318
+ export const FitToAnchorWidth = ({ theme }: any) => {
319
+ const demoPopperStyle = {
320
+ alignItems: 'center',
321
+ display: 'flex',
322
+ height: 100,
323
+ justifyContent: 'center',
324
+ width: 200,
325
+ };
326
+
327
+ const container = {
328
+ alignItems: 'center',
329
+ display: 'flex',
330
+ justifyContent: 'center',
331
+ flexDirection: 'column',
332
+ gap: 150,
333
+ marginTop: 150,
334
+ } as const;
335
+
336
+ const maxWidthAnchorRef = useRef(null);
337
+ const widthSmallAnchorRef = useRef(null);
338
+ const widthLargeAnchorRef = useRef(null);
339
+ const minWidthAnchorRef = useRef(null);
340
+ const defaultWidthAnchorRef = useRef(null);
341
+
342
+ return (
343
+ <div style={container}>
344
+ <div>
345
+ <Chip ref={maxWidthAnchorRef} theme={theme} size={Size.s}>
346
+ Anchor
347
+ </Chip>
348
+ </div>
349
+ <Popover theme={theme} anchorRef={maxWidthAnchorRef} fitToAnchorWidth="maxWidth" isOpen placement="top">
350
+ <div style={demoPopperStyle}>Popover maxWidth</div>
351
+ </Popover>
352
+ <div>
353
+ <Chip ref={widthSmallAnchorRef} theme={theme} size={Size.s}>
354
+ Anchor
355
+ </Chip>
356
+ </div>
357
+ <Popover theme={theme} anchorRef={widthSmallAnchorRef} fitToAnchorWidth="width" isOpen placement="top">
358
+ <div style={demoPopperStyle}>Popover width small anchor</div>
359
+ </Popover>
360
+ <div>
361
+ <Chip ref={widthLargeAnchorRef} theme={theme} size={Size.s}>
362
+ VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLargeAnchor
363
+ </Chip>
364
+ </div>
365
+ <Popover theme={theme} anchorRef={widthLargeAnchorRef} fitToAnchorWidth="width" isOpen placement="top">
366
+ <div style={demoPopperStyle}>Popover width large anchor</div>
367
+ </Popover>
368
+ <div>
369
+ <Chip ref={minWidthAnchorRef} theme={theme} size={Size.s}>
370
+ VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLargeAnchor
371
+ </Chip>
372
+ </div>
373
+ <Popover theme={theme} anchorRef={minWidthAnchorRef} fitToAnchorWidth="minWidth" isOpen placement="top">
374
+ <div style={demoPopperStyle}>Popover minWidth</div>
375
+ </Popover>
376
+ <div>
377
+ <Chip ref={defaultWidthAnchorRef} theme={theme} size={Size.s}>
378
+ VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLargeAnchor
379
+ </Chip>
380
+ </div>
381
+ <Popover theme={theme} anchorRef={defaultWidthAnchorRef} isOpen placement="top">
382
+ <div style={demoPopperStyle}>Popover default</div>
383
+ </Popover>
384
+ </div>
385
+ );
386
+ };
@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
4
4
  import { usePopper } from 'react-popper';
5
5
 
6
6
  import classNames from 'classnames';
7
+ import memoize from 'lodash/memoize';
7
8
 
8
9
  import { DOCUMENT, WINDOW } from '@lumx/react/constants';
9
10
  import { useCallbackOnEscape } from '@lumx/react/hooks/useCallbackOnEscape';
@@ -15,6 +16,7 @@ import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/classNam
15
16
  import { mergeRefs } from '@lumx/react/utils/mergeRefs';
16
17
  import { useFocusWithin } from '@lumx/react/hooks/useFocusWithin';
17
18
  import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
19
+ import { useFocusTrap } from '@lumx/react/hooks/useFocusTrap';
18
20
 
19
21
  /**
20
22
  * Different possible placements for the popover.
@@ -62,6 +64,13 @@ export type Elevation = 1 | 2 | 3 | 4 | 5;
62
64
  */
63
65
  const ARROW_SIZE = 8;
64
66
 
67
+ const AnchorWidthOptions = {
68
+ MAX_WIDTH: 'maxWidth',
69
+ MIN_WIDTH: 'minWidth',
70
+ WIDTH: 'width',
71
+ } as const;
72
+ type AnchorWidthOption = ValueOf<typeof AnchorWidthOptions>;
73
+
65
74
  /**
66
75
  * Defines the props of the component.
67
76
  */
@@ -78,8 +87,13 @@ export interface PopoverProps extends GenericProps {
78
87
  closeOnEscape?: boolean;
79
88
  /** Shadow elevation. */
80
89
  elevation?: Elevation;
81
- /** Whether the dropdown should fit to the anchor width (if dropdown is smaller). */
82
- fitToAnchorWidth?: boolean;
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
+ */
96
+ fitToAnchorWidth?: AnchorWidthOption | boolean;
83
97
  /** Shrink popover if even after flipping there is not enough space. */
84
98
  fitWithinViewportHeight?: boolean;
85
99
  /** Element to focus when opening the popover. */
@@ -102,6 +116,8 @@ export interface PopoverProps extends GenericProps {
102
116
  zIndex?: number;
103
117
  /** On close callback (on click away or Escape pressed). */
104
118
  onClose?(): void;
119
+ /** Whether the popover should trap the focus within itself. Default to false. */
120
+ withFocusTrap?: boolean;
105
121
  }
106
122
 
107
123
  /**
@@ -127,20 +143,20 @@ const DEFAULT_PROPS: Partial<PopoverProps> = {
127
143
  /**
128
144
  * Popper js modifier to fit popover min width to the anchor width.
129
145
  */
130
- const sameWidth = {
146
+ const sameWidth = memoize((anchorWidthOption: AnchorWidthOption) => ({
131
147
  name: 'sameWidth',
132
148
  enabled: true,
133
149
  phase: 'beforeWrite',
134
150
  requires: ['computeStyles'],
135
151
  fn({ state }: any) {
136
152
  // eslint-disable-next-line no-param-reassign
137
- state.styles.popper.minWidth = `${state.rects.reference.width}px`;
153
+ state.styles.popper[anchorWidthOption] = `${state.rects.reference.width}px`;
138
154
  },
139
155
  effect({ state }: any) {
140
156
  // eslint-disable-next-line no-param-reassign
141
- state.elements.popper.style.minWidth = `${state.elements.reference.offsetWidth}px`;
157
+ state.elements.popper.style[anchorWidthOption] = `${state.elements.reference.offsetWidth}px`;
142
158
  },
143
- };
159
+ }));
144
160
 
145
161
  /**
146
162
  * Popper js modifier to compute max size of the popover.
@@ -222,6 +238,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
222
238
  usePortal,
223
239
  zIndex,
224
240
  focusAnchorOnClose = true,
241
+ withFocusTrap,
225
242
  ...forwardedProps
226
243
  } = props;
227
244
  // eslint-disable-next-line react-hooks/rules-of-hooks
@@ -230,6 +247,8 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
230
247
  const [arrowElement, setArrowElement] = useState<null | HTMLElement>(null);
231
248
  // eslint-disable-next-line react-hooks/rules-of-hooks
232
249
  const clickAwayRef = useRef<HTMLDivElement>(null);
250
+ // eslint-disable-next-line react-hooks/rules-of-hooks
251
+ const contentRef = useRef<HTMLDivElement>(null);
233
252
 
234
253
  /**
235
254
  * Track whether the focus is currently set in the
@@ -290,7 +309,9 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
290
309
  }
291
310
 
292
311
  if (fitToAnchorWidth) {
293
- modifiers.push(sameWidth);
312
+ const anchorWidthOption =
313
+ typeof fitToAnchorWidth === 'string' ? fitToAnchorWidth : AnchorWidthOptions.MIN_WIDTH;
314
+ modifiers.push(sameWidth(anchorWidthOption));
294
315
  }
295
316
  if (fitWithinViewportHeight) {
296
317
  modifiers.push({ ...maxSize, options: { boundary: boundaryRef?.current } }, applyMaxHeight);
@@ -325,8 +346,12 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
325
346
 
326
347
  // eslint-disable-next-line react-hooks/rules-of-hooks
327
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);
328
353
  // eslint-disable-next-line react-hooks/rules-of-hooks
329
- useFocus(focusElement?.current, isOpen && (state?.rects?.popper?.y ?? -1) >= 0);
354
+ useFocusTrap(withFocusTrap && isOpen && contentRef?.current, focusElement?.current);
330
355
 
331
356
  // eslint-disable-next-line react-hooks/rules-of-hooks
332
357
  const clickAwayRefs = useRef([clickAwayRef, anchorRef]);
@@ -335,7 +360,7 @@ export const Popover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, re
335
360
  ? renderPopover(
336
361
  <div
337
362
  {...forwardedProps}
338
- ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef)}
363
+ ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef, contentRef)}
339
364
  className={classNames(
340
365
  className,
341
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';