@hyphen/hyphen-components 5.8.0 → 6.0.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.
@@ -1,70 +1,102 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
- import { Placement } from '@popperjs/core';
4
- import { Popover } from './Popover';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import {
4
+ Popover,
5
+ PopoverTrigger,
6
+ PopoverContent,
7
+ PopoverPortal,
8
+ } from './Popover';
9
+
10
+ // Helper to render a controlled Popover
11
+ type ControlledPopoverProps = {
12
+ isOpen?: boolean;
13
+ onClickOutside?: (event: Event) => void;
14
+ placement?: string;
15
+ withPortal?: boolean;
16
+ portalTarget?: HTMLElement;
17
+ children: React.ReactNode;
18
+ contentProps?: Record<string, any>;
19
+ [key: string]: any;
20
+ };
21
+
22
+ function ControlledPopover({
23
+ isOpen = true,
24
+ onClickOutside,
25
+ placement = 'right',
26
+ withPortal = false,
27
+ portalTarget,
28
+ children,
29
+ contentProps = {},
30
+ ...rest
31
+ }: ControlledPopoverProps) {
32
+ return (
33
+ <Popover open={isOpen} {...rest}>
34
+ <PopoverTrigger asChild>
35
+ <button>trigger</button>
36
+ </PopoverTrigger>
37
+ {withPortal ? (
38
+ <PopoverPortal container={portalTarget}>
39
+ <PopoverContent
40
+ side={
41
+ placement.split('-')[0] as 'right' | 'top' | 'bottom' | 'left'
42
+ }
43
+ align={
44
+ (placement.split('-')[1] as
45
+ | 'center'
46
+ | 'end'
47
+ | 'start'
48
+ | undefined) || 'center'
49
+ }
50
+ {...contentProps}
51
+ onInteractOutside={onClickOutside}
52
+ >
53
+ {children}
54
+ </PopoverContent>
55
+ </PopoverPortal>
56
+ ) : (
57
+ <PopoverContent
58
+ side={placement.split('-')[0] as 'right' | 'top' | 'bottom' | 'left'}
59
+ align={
60
+ (placement.split('-')[1] as
61
+ | 'center'
62
+ | 'end'
63
+ | 'start'
64
+ | undefined) || 'center'
65
+ }
66
+ {...contentProps}
67
+ onInteractOutside={onClickOutside}
68
+ >
69
+ {children}
70
+ </PopoverContent>
71
+ )}
72
+ </Popover>
73
+ );
74
+ }
5
75
 
6
76
  describe('Popover', () => {
7
77
  describe('Default', () => {
8
78
  it('Renders a popover with default props', async () => {
9
- // NOTE: popperJS is throwing a warning due to missing act, but it is unclear how to fix these.
10
- // https://github.com/popperjs/react-popper/issues/368
11
79
  render(
12
- <Popover isOpen content={<>hello</>}>
13
- <p>trigger</p>
14
- </Popover>
80
+ <ControlledPopover>
81
+ <span>hello</span>
82
+ </ControlledPopover>
15
83
  );
16
-
17
84
  const popoverContent = screen.getByText('hello');
18
- const popoverContainer = screen.getByRole('dialog');
19
85
  const trigger = screen.getByText('trigger');
20
86
  expect(popoverContent).toBeInTheDocument();
21
87
  expect(trigger).toBeInTheDocument();
22
- expect(trigger).toHaveAttribute('role', 'button');
23
- expect(popoverContainer).toBeInTheDocument();
24
- expect(popoverContainer).toHaveAttribute('role', 'dialog');
25
- expect(popoverContainer).toHaveAttribute('aria-hidden', 'false');
26
- expect(popoverContainer).toHaveClass('background-color-primary');
27
- expect(popoverContainer).toHaveClass('p-sm');
28
- await waitFor(() =>
29
- expect(popoverContainer).toHaveAttribute(
30
- 'data-popper-placement',
31
- 'right'
32
- )
33
- );
34
- });
35
- });
36
-
37
- describe('Callbacks', () => {
38
- it('Fires a callback when a user clicks outside the popover', () => {
39
- const mockedOnClickOutside = jest.fn();
40
- const { container } = render(
41
- <Popover
42
- isOpen
43
- content={<>hello</>}
44
- onClickOutside={mockedOnClickOutside}
45
- >
46
- <p>trigger</p>
47
- </Popover>
48
- );
49
-
50
- const popover = screen.getByText('hello');
51
- const trigger = screen.getByText('trigger');
52
- expect(popover).toBeInTheDocument();
53
- fireEvent.click(popover);
54
- fireEvent.click(trigger);
55
- fireEvent.click(container);
56
- fireEvent.keyUp(container, { key: 'Escape' });
57
- expect(mockedOnClickOutside).toBeCalledTimes(2);
88
+ expect(trigger).toHaveAttribute('type', 'button');
89
+ // Radix PopoverContent does not have role="dialog" by default
90
+ expect(popoverContent.parentElement).toHaveClass('PopoverContent');
58
91
  });
59
92
  });
60
93
 
61
94
  describe('Placement', () => {
62
- // We do not test auto placements since those compute out to one of the below after detection.
63
- const positions: Placement[] = [
64
- 'top',
65
- 'bottom',
66
- 'right',
67
- 'left',
95
+ const positions = [
96
+ 'top-center',
97
+ 'bottom-center',
98
+ 'right-center',
99
+ 'left-center',
68
100
  'top-start',
69
101
  'top-end',
70
102
  'bottom-start',
@@ -74,22 +106,22 @@ describe('Popover', () => {
74
106
  'left-start',
75
107
  'left-end',
76
108
  ];
77
-
78
109
  positions.forEach((position) => {
79
- it(`Places the tooltop correctly in position: ${position} when prop is passed`, async () => {
110
+ it(`Places the popover correctly in position: ${position} when prop is passed`, async () => {
80
111
  render(
81
- <Popover isOpen content={<>hello</>} placement={position}>
82
- <p>trigger</p>
83
- </Popover>
84
- );
85
-
86
- const popoverContainer = screen.getByRole('dialog');
87
- await waitFor(() =>
88
- expect(popoverContainer).toHaveAttribute(
89
- 'data-popper-placement',
90
- position
91
- )
112
+ <ControlledPopover placement={position}>
113
+ <span>hello</span>
114
+ </ControlledPopover>
92
115
  );
116
+ const popoverContent = screen.getByText('hello').parentElement;
117
+ // Radix sets data-side and data-align
118
+ const [side, align] = position.split('-');
119
+ await waitFor(() => {
120
+ expect(popoverContent).toHaveAttribute('data-side', side);
121
+ if (align) {
122
+ expect(popoverContent).toHaveAttribute('data-align', align);
123
+ }
124
+ });
93
125
  });
94
126
  });
95
127
  });
@@ -97,31 +129,17 @@ describe('Popover', () => {
97
129
  describe('Portal', () => {
98
130
  it('Renders the Popover in the body if withPortal is true.', async () => {
99
131
  render(
100
- <>
101
- <div id="nest1">
102
- <div id="nest2">
103
- <Popover
104
- isOpen
105
- content={
106
- <button type="button" id="inside-button">
107
- hello
108
- </button>
109
- }
110
- withPortal
111
- portalTarget={document.body}
112
- >
113
- <p>trigger</p>
114
- </Popover>
115
- </div>
116
- </div>
117
- </>
132
+ <ControlledPopover withPortal portalTarget={document.body}>
133
+ <button type="button" id="inside-button">
134
+ hello
135
+ </button>
136
+ </ControlledPopover>
118
137
  );
119
-
120
138
  await waitFor(() => {
121
- expect(document.body.children[1]).toHaveAttribute(
122
- 'data-popper-placement',
123
- 'right'
124
- );
139
+ // Should be in the body
140
+ expect(
141
+ document.body.querySelector('#inside-button')
142
+ ).toBeInTheDocument();
125
143
  });
126
144
  });
127
145
  });
@@ -1,277 +1,35 @@
1
- import React, {
2
- cloneElement,
3
- FC,
4
- isValidElement,
5
- ReactNode,
6
- useEffect,
7
- useRef,
8
- useState,
9
- RefObject,
10
- } from 'react';
11
- import { createPortal } from 'react-dom';
12
- import { usePopper } from 'react-popper';
13
- import { Placement } from '@popperjs/core';
14
- import FocusTrap from 'focus-trap-react';
1
+ import React, { forwardRef, ElementRef, ComponentPropsWithoutRef } from 'react';
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
15
3
  import classNames from 'classnames';
16
- import { BackgroundColor } from '../../types';
17
4
  import styles from './Popover.module.scss';
18
- import { Box, BoxProps } from '../Box/Box';
19
- import { mergeRefs } from '../../lib';
20
5
 
21
- export type PopoverProps = {
22
- /**
23
- * Custom class to apply to the alert.
24
- */
25
- className?: string;
26
- /**
27
- * The trigger element
28
- */
29
- children: ReactNode;
30
- /**
31
- * Content of the tooltip. Can be any JSX node.
32
- */
33
- content: ReactNode;
34
- /**
35
- * The Popover is a controlled input, and will be shown when `isOpen === true`.
36
- */
37
- isOpen: boolean;
38
- /**
39
- * Color of the arrow background. NOTE: That the arrowColor will default to the
40
- * `background` color applied in the `contentContainerProps`, but can be overwritten
41
- * by passing a specific value here.
42
- */
43
- arrowColor?: BackgroundColor;
44
- /**
45
- * An object matching the interface of the `Box` component props.
46
- * This is useful for styling the tooltip container using all the options available in
47
- * a `Box`.
48
- */
49
- contentContainerProps?: BoxProps;
50
- /**
51
- * Whether the arrow is shown.
52
- */
53
- hasArrow?: boolean;
54
- /**
55
- * How far (in pixels) the Popover element will be from the target.
56
- * Note that this is from the edge of the target to the edge of the popover content,
57
- * and it DOES NOT include the arrow element.
58
- */
59
- offsetFromTarget?: number;
60
- /**
61
- * Callback function to handle when a user clicks outside the Popover
62
- */
63
- onClickOutside?: (event: MouseEvent | KeyboardEvent) => void;
64
- /**
65
- * The placement (position) of the Popover relative to its trigger.
66
- */
67
- placement?: Placement;
68
- /**
69
- * Whether you want to trap focus in the Popover element when it is open.
70
- * Read more about focus traps:
71
- * [Here](https://allyjs.io/tutorials/accessible-dialog.html#trapping-focus-inside-the-dialog)
72
- */
73
- trapFocus?: boolean;
74
- /**
75
- * Additional props to be spread to rendered element
76
- */
77
- [x: string]: any; // eslint-disable-line
78
- } & (
79
- | {
80
- /**
81
- * Whether the element should be rendered outside its DOM structure
82
- * for reasons of placement. Use this when the element is being cut-off or
83
- * re-positioned due to lack of space in the parent container.
84
- * NOTE: `portalTarget` is required if this is true.
85
- */
86
- withPortal: true;
87
- /**
88
- * The target element where the Popover will be portaled to, when `withPortal === true`.
89
- * `document.body` will work for many cases, but you can also use a custom container for this.
90
- * Only required if withPortal is true.
91
- */
92
- portalTarget: HTMLElement;
93
- }
94
- | {
95
- withPortal?: false;
96
- portalTarget?: never;
97
- }
98
- );
6
+ const Popover = PopoverPrimitive.Root;
99
7
 
100
- const contentContainerDefaults: BoxProps = {
101
- background: 'primary',
102
- padding: 'sm',
103
- radius: 'sm',
104
- shadow: 'md',
105
- };
106
-
107
- export const Popover: FC<PopoverProps> = ({
108
- className,
109
- isOpen,
110
- children,
111
- content,
112
- arrowColor = undefined,
113
- contentContainerProps = { ...contentContainerDefaults },
114
- hasArrow = true,
115
- offsetFromTarget = 12,
116
- onClickOutside = undefined,
117
- placement = 'right',
118
- withPortal = false,
119
- portalTarget,
120
- trapFocus = false,
121
- ...restProps
122
- }) => {
123
- const triggerRef = useRef<HTMLElement>(null);
124
- const popperRef = useRef<HTMLElement>(null);
125
- const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null);
126
-
127
- useEffect(() => {
128
- const handleClickOutside = (event: MouseEvent) => {
129
- const popover = popperRef.current;
130
- const trigger = triggerRef.current;
131
-
132
- if (!popover || !trigger) {
133
- return;
134
- }
135
-
136
- if (event.target === trigger || trigger?.contains(event.target as Node)) {
137
- return;
138
- }
139
-
140
- if (
141
- event.target !== popover &&
142
- !popover?.contains(event.target as Node)
143
- ) {
144
- if (onClickOutside) onClickOutside(event);
145
- }
146
- };
147
-
148
- const handleKeyUp = (event: KeyboardEvent) => {
149
- if (event.key === 'Escape') {
150
- if (onClickOutside) onClickOutside(event);
151
- }
152
- };
153
-
154
- if (onClickOutside) {
155
- document.body.addEventListener('click', handleClickOutside, false);
156
- document.body.addEventListener('keyup', handleKeyUp);
157
- }
158
-
159
- return () => {
160
- if (onClickOutside) {
161
- document.body.removeEventListener('click', handleClickOutside, false);
162
- document.body.removeEventListener('keyup', handleKeyUp);
163
- }
164
- };
165
- }, [onClickOutside]);
166
-
167
- const { styles: popperStyles, attributes } = usePopper(
168
- triggerRef.current,
169
- popperRef.current,
170
- {
171
- placement,
172
- modifiers: [
173
- {
174
- name: 'arrow',
175
- options: { element: arrowElement },
176
- },
177
- {
178
- name: 'offset',
179
- options: {
180
- offset: [0, offsetFromTarget],
181
- },
182
- },
183
- ],
184
- }
185
- );
186
-
187
- const containerBoxProps = {
188
- ...contentContainerDefaults,
189
- ...contentContainerProps,
190
- };
191
-
192
- const computedArrowColor = arrowColor || containerBoxProps.background;
193
-
194
- const arrowClasses = classNames(
195
- styles['popover-arrow'],
196
- `background-color-${computedArrowColor}`,
197
- {
198
- 'display-none': !hasArrow,
199
- }
200
- );
201
-
202
- const renderPopperContent = () => {
203
- const renderPopperBox = () => (
204
- <Box
205
- ref={popperRef}
206
- className={classNames(styles.popover, className)}
207
- style={popperStyles.popper}
208
- role="dialog"
209
- aria-label="Popover"
210
- aria-hidden={!isOpen}
211
- {...containerBoxProps}
212
- {...attributes.popper}
213
- {...restProps}
214
- >
215
- <div
216
- ref={setArrowElement}
217
- style={popperStyles.arrow}
218
- className={arrowClasses}
219
- data-popper-arrow
220
- />
221
- {content}
222
- </Box>
223
- );
224
-
225
- return trapFocus ? (
226
- <FocusTrap
227
- active={isOpen}
228
- focusTrapOptions={{
229
- clickOutsideDeactivates: true,
230
- }}
231
- >
232
- {renderPopperBox()}
233
- </FocusTrap>
234
- ) : (
235
- renderPopperBox()
236
- );
237
- };
238
-
239
- const childrenWithRef = React.Children.map(children, (child) => {
240
- const childProps = {
241
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
242
- ref: triggerRef as RefObject<HTMLElement> | ((instance: any) => void),
243
- role: 'button',
244
- 'aria-expanded': isOpen,
245
- 'aria-haspopup': true,
246
- };
247
-
248
- // Merge local ref with any ref passed originally to child component.
249
- // We have to cast with `as` so TS compiler doesn't complain since ReactNode/ReactChild types don't
250
- // explicitly declare ref as a property in the object.
251
- if ((child as ReactNode & { ref: any })?.ref) {
252
- // eslint-disable-line @typescript-eslint/no-explicit-any
253
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
254
- childProps.ref = mergeRefs([
255
- (child as ReactNode & { ref: any })?.ref,
256
- childProps.ref,
257
- ]);
258
- }
8
+ const PopoverTrigger = PopoverPrimitive.Trigger;
259
9
 
260
- if (isValidElement(child)) {
261
- return cloneElement(child, childProps);
262
- }
10
+ const PopoverAnchor = PopoverPrimitive.Anchor;
263
11
 
264
- return child;
265
- });
12
+ const PopoverPortal = PopoverPrimitive.Portal;
266
13
 
14
+ const PopoverContent = forwardRef<
15
+ ElementRef<typeof PopoverPrimitive.Content>,
16
+ ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
17
+ >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
267
18
  return (
268
- <>
269
- {childrenWithRef}
270
- {isOpen &&
271
- // portalTarget should always be defined if withPortal is true, but better safe than sorry here!
272
- (withPortal && portalTarget
273
- ? createPortal(renderPopperContent(), portalTarget)
274
- : renderPopperContent())}
275
- </>
19
+ <PopoverPrimitive.Content
20
+ ref={ref}
21
+ align={align}
22
+ sideOffset={sideOffset}
23
+ className={classNames(styles.PopoverContent, className)}
24
+ {...props}
25
+ />
276
26
  );
27
+ });
28
+
29
+ export {
30
+ Popover,
31
+ PopoverTrigger,
32
+ PopoverAnchor,
33
+ PopoverPortal,
34
+ PopoverContent,
277
35
  };
@@ -8,8 +8,4 @@ import * as Stories from './useOpenClose.stories';
8
8
 
9
9
  The `useOpenClose` hook helps handle common open, close, or toggle scenarios. It can be used to control components such as `Modal`, `MediaModal`, `Drawer`, `Details` and even `Popover`.
10
10
 
11
- <Canvas withSource="open" of={Stories.BasicUsage} />
12
-
13
- ## Parameters
14
-
15
- <ArgTypes of={Stories.BasicUsage} />
11
+ <Canvas sourceState="shown" of={Stories.BasicUsage} />
@@ -2,8 +2,13 @@ import React from 'react';
2
2
  import type { Meta } from '@storybook/react-vite';
3
3
  import { UseOpenCloseProps, useOpenClose } from './useOpenClose';
4
4
  import { Button } from '../../components/Button/Button';
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverPortal,
9
+ PopoverTrigger,
10
+ } from '../../components/Popover/Popover';
5
11
  import { Box } from '../../components/Box/Box';
6
- import { Popover } from '../../components/Popover/Popover';
7
12
 
8
13
  const meta: Meta<typeof useOpenClose> = {
9
14
  title: 'Hooks/useOpenClose',
@@ -16,26 +21,24 @@ export default meta;
16
21
 
17
22
  export const BasicUsage: React.FC<UseOpenCloseProps> = () => {
18
23
  const { isOpen, handleOpen, handleClose } = useOpenClose();
19
- const popoverContent = (
20
- <>
21
- <Box padding="lg" gap="md">
22
- <Box as="p">Hello!</Box>
23
- </Box>
24
- </>
25
- );
24
+
26
25
  return (
27
- <Popover
28
- placement="right-end"
29
- content={popoverContent}
30
- isOpen={isOpen}
31
- onClickOutside={handleClose}
32
- contentContainerProps={{
33
- padding: 'sm',
34
- }}
35
- >
36
- <Button variant="primary" type="button" onClick={handleOpen}>
37
- Open Popover
26
+ <Box gap="2xl" direction="row">
27
+ <Popover open={isOpen}>
28
+ <PopoverTrigger asChild>
29
+ <Button variant="primary" type="button" onClick={handleOpen}>
30
+ Open Popover
31
+ </Button>
32
+ </PopoverTrigger>
33
+ <PopoverPortal>
34
+ <PopoverContent onInteractOutside={handleClose}>
35
+ <p>Hello!</p>
36
+ </PopoverContent>
37
+ </PopoverPortal>
38
+ </Popover>
39
+ <Button variant="secondary" onClick={handleOpen}>
40
+ also opens
38
41
  </Button>
39
- </Popover>
42
+ </Box>
40
43
  );
41
44
  };
@@ -129,7 +129,7 @@ export type CssWhiteSpaceValue =
129
129
  | 'pre-wrap'
130
130
  | 'pre-line';
131
131
 
132
- export type CssWordBreakValue = 'normal' | 'break-all' | 'keep-all';
132
+ export type CssWordBreakValue = 'normal' | 'all' | 'keep';
133
133
 
134
134
  export type CssTextAlignValue = 'left' | 'center' | 'right';
135
135