paris 0.21.2 → 0.22.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +1 -1
  3. package/src/helpers/OpenChangeEffect.tsx +21 -0
  4. package/src/helpers/useControllableState.test.ts +88 -0
  5. package/src/helpers/useControllableState.ts +59 -0
  6. package/src/stories/accordionselect/AccordionSelect.test.tsx +72 -0
  7. package/src/stories/accordionselect/AccordionSelect.tsx +22 -12
  8. package/src/stories/checkbox/Checkbox.test.tsx +53 -0
  9. package/src/stories/checkbox/Checkbox.tsx +21 -6
  10. package/src/stories/combobox/Combobox.test.tsx +111 -0
  11. package/src/stories/combobox/Combobox.tsx +192 -137
  12. package/src/stories/drawer/Drawer.module.scss +56 -15
  13. package/src/stories/drawer/Drawer.stories.tsx +287 -109
  14. package/src/stories/drawer/Drawer.test.tsx +486 -11
  15. package/src/stories/drawer/Drawer.tsx +366 -240
  16. package/src/stories/drawer/DrawerActions.tsx +28 -0
  17. package/src/stories/drawer/DrawerBottomPanel.tsx +55 -0
  18. package/src/stories/drawer/DrawerContext.tsx +31 -0
  19. package/src/stories/drawer/DrawerPage.tsx +37 -0
  20. package/src/stories/drawer/DrawerPageContext.tsx +35 -0
  21. package/src/stories/drawer/DrawerPaginationContext.tsx +22 -0
  22. package/src/stories/drawer/DrawerProgressBar.tsx +72 -0
  23. package/src/stories/drawer/DrawerSlotContext.tsx +172 -0
  24. package/src/stories/drawer/DrawerTitle.tsx +35 -0
  25. package/src/stories/drawer/index.ts +9 -0
  26. package/src/stories/menu/Menu.test.tsx +43 -0
  27. package/src/stories/menu/Menu.tsx +13 -2
  28. package/src/stories/popover/Popover.tsx +8 -5
  29. package/src/stories/select/Select.module.scss +1 -1
  30. package/src/stories/select/Select.test.tsx +108 -0
  31. package/src/stories/select/Select.tsx +121 -92
  32. package/src/test/render.tsx +2 -2
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useId, useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { useIsPageActive } from './DrawerPageContext';
6
+ import { useDrawerSlotContext } from './DrawerSlotContext';
7
+
8
+ export type DrawerActionsProps = {
9
+ children: ReactNode;
10
+ };
11
+
12
+ export const DrawerActions = ({ children }: DrawerActionsProps) => {
13
+ const isActive = useIsPageActive();
14
+ const slotContext = useDrawerSlotContext();
15
+ const id = useId();
16
+
17
+ useLayoutEffect(() => {
18
+ if (!isActive || !slotContext) return;
19
+ const unregister = slotContext.registerActionsSlot();
20
+ return unregister;
21
+ }, [isActive, slotContext]);
22
+
23
+ if (!isActive || !slotContext?.actionsRef.current) {
24
+ return null;
25
+ }
26
+
27
+ return createPortal(children, slotContext.actionsRef.current, id);
28
+ };
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { clsx } from 'clsx';
4
+ import { type ComponentPropsWithoutRef, type ReactNode, useId, useLayoutEffect } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+ import styles from './Drawer.module.scss';
7
+ import { useIsPageActive } from './DrawerPageContext';
8
+ import { useDrawerSlotContext } from './DrawerSlotContext';
9
+
10
+ export type DrawerBottomPanelProps = ComponentPropsWithoutRef<'div'> & {
11
+ children: ReactNode;
12
+ /** 'replace' fully replaces the base bottomPanel. 'append' renders after the base. @default 'replace' */
13
+ mode?: 'replace' | 'append';
14
+ /** Render order for append mode. Lower = higher up. @default 0 */
15
+ priority?: number;
16
+ };
17
+
18
+ export const DrawerBottomPanel = ({
19
+ children,
20
+ className,
21
+ mode = 'replace',
22
+ priority = 0,
23
+ style,
24
+ ...rest
25
+ }: DrawerBottomPanelProps) => {
26
+ const isActive = useIsPageActive();
27
+ const slotContext = useDrawerSlotContext();
28
+ const id = useId();
29
+
30
+ const registerBottomPanel = slotContext?.registerBottomPanel;
31
+
32
+ useLayoutEffect(() => {
33
+ if (!isActive || !registerBottomPanel) return;
34
+ const unregister = registerBottomPanel({ id, mode, priority });
35
+ return unregister;
36
+ }, [isActive, registerBottomPanel, id, mode, priority]);
37
+
38
+ if (!isActive || !slotContext?.isBottomPanelMounted || !slotContext.bottomPanelRef.current) {
39
+ return null;
40
+ }
41
+
42
+ return createPortal(
43
+ <div
44
+ data-slot-mode={mode}
45
+ data-slot-priority={priority}
46
+ className={clsx(styles.bottomPanelSlotItem, className)}
47
+ style={{ order: priority, ...style }}
48
+ {...rest}
49
+ >
50
+ {children}
51
+ </div>,
52
+ slotContext.bottomPanelRef.current,
53
+ `${id}-${priority}`,
54
+ );
55
+ };
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
4
+
5
+ export type DrawerContextValue = {
6
+ close: () => void;
7
+ isOpen: boolean;
8
+ };
9
+
10
+ const DrawerContext = createContext<DrawerContextValue | null>(null);
11
+
12
+ export function useDrawer(): DrawerContextValue {
13
+ const context = useContext(DrawerContext);
14
+ if (!context) {
15
+ throw new Error('useDrawer must be used within a Drawer component');
16
+ }
17
+ return context;
18
+ }
19
+
20
+ export function DrawerProvider({
21
+ close,
22
+ isOpen,
23
+ children,
24
+ }: {
25
+ close: () => void;
26
+ isOpen: boolean;
27
+ children: ReactNode;
28
+ }) {
29
+ const value = useMemo(() => ({ close, isOpen }), [close, isOpen]);
30
+ return <DrawerContext.Provider value={value}>{children}</DrawerContext.Provider>;
31
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useEffect, useState } from 'react';
4
+ import { useIsPageActive } from './DrawerPageContext';
5
+
6
+ export type DrawerPageProps<TPageID extends string = string> = {
7
+ /** Unique identifier for this page. Must match a pagination page ID. */
8
+ id: TPageID;
9
+ /** Defer first mount until page becomes active. Once mounted, stays mounted. */
10
+ lazy?: boolean;
11
+ /** Page content. */
12
+ children: ReactNode;
13
+ };
14
+
15
+ export const DrawerPage = <TPageID extends string = string>({ lazy = false, children }: DrawerPageProps<TPageID>) => {
16
+ const isActive = useIsPageActive();
17
+ const [hasBeenActive, setHasBeenActive] = useState(isActive);
18
+
19
+ useEffect(() => {
20
+ if (isActive && !hasBeenActive) {
21
+ setHasBeenActive(true);
22
+ }
23
+ }, [isActive, hasBeenActive]);
24
+
25
+ if (lazy && !hasBeenActive) {
26
+ return null;
27
+ }
28
+
29
+ return <>{children}</>;
30
+ };
31
+
32
+ /** Type guard to check if a React element is a DrawerPage */
33
+ export const isDrawerPageElement = (element: unknown): element is React.ReactElement<DrawerPageProps> =>
34
+ element != null &&
35
+ typeof element === 'object' &&
36
+ 'type' in element &&
37
+ (element as { type: unknown }).type === DrawerPage;
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
4
+
5
+ export type DrawerPageContextValue = {
6
+ isActive: boolean;
7
+ pageID: string;
8
+ };
9
+
10
+ const DrawerPageContext = createContext<DrawerPageContextValue | null>(null);
11
+
12
+ export function useDrawerPageContext(): DrawerPageContextValue | null {
13
+ return useContext(DrawerPageContext);
14
+ }
15
+
16
+ export function useIsPageActive(): boolean {
17
+ const context = useContext(DrawerPageContext);
18
+ if (!context) {
19
+ return true;
20
+ }
21
+ return context.isActive;
22
+ }
23
+
24
+ export function DrawerPageProvider({
25
+ isActive,
26
+ pageID,
27
+ children,
28
+ }: {
29
+ isActive: boolean;
30
+ pageID: string;
31
+ children: ReactNode;
32
+ }) {
33
+ const value = useMemo(() => ({ isActive, pageID }), [isActive, pageID]);
34
+ return <DrawerPageContext.Provider value={value}>{children}</DrawerPageContext.Provider>;
35
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext } from 'react';
4
+ import type { PaginationState } from '../pagination';
5
+
6
+ type AnyPaginationState = PaginationState<string[] | readonly string[]>;
7
+
8
+ const DrawerPaginationContext = createContext<AnyPaginationState | null>(null);
9
+
10
+ export function useDrawerPagination(): AnyPaginationState | null {
11
+ return useContext(DrawerPaginationContext);
12
+ }
13
+
14
+ export function DrawerPaginationProvider({
15
+ pagination,
16
+ children,
17
+ }: {
18
+ pagination: AnyPaginationState | null;
19
+ children: ReactNode;
20
+ }) {
21
+ return <DrawerPaginationContext.Provider value={pagination}>{children}</DrawerPaginationContext.Provider>;
22
+ }
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ import { useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import styles from './Drawer.module.scss';
6
+ import { useDrawerPagination } from './DrawerPaginationContext';
7
+ import { useDrawerSlotContext } from './DrawerSlotContext';
8
+
9
+ export type DrawerProgressBarStyleProps = {
10
+ /** Fill color. @default 'var(--pte-new-colors-contentPrimary)' */
11
+ fill?: string;
12
+ /** Track (unfilled) color. @default 'var(--pte-new-colors-borderMedium)' */
13
+ track?: string;
14
+ /** Bar height as a CSS value. @default '2px' */
15
+ height?: string | number;
16
+ };
17
+
18
+ export type DrawerProgressBarProps = DrawerProgressBarStyleProps & {
19
+ /** Page IDs in order. Determines fill percentage based on the current page. */
20
+ steps?: readonly string[];
21
+ /** Fill percentage (0–100). When provided, takes precedence over the auto-calculated value from `steps`. */
22
+ value?: number;
23
+ };
24
+
25
+ export const DrawerProgressBar = ({ steps, value, fill, track, height }: DrawerProgressBarProps) => {
26
+ const pagination = useDrawerPagination();
27
+ const slotContext = useDrawerSlotContext();
28
+
29
+ const registerProgressBar = slotContext?.registerProgressBar;
30
+ useLayoutEffect(() => {
31
+ if (!registerProgressBar) return;
32
+ return registerProgressBar();
33
+ }, [registerProgressBar]);
34
+
35
+ let progress: number;
36
+ if (value != null) {
37
+ progress = Math.max(0, Math.min(100, value));
38
+ } else if (steps && steps.length > 0 && pagination) {
39
+ const activeIndex = steps.indexOf(pagination.currentPage);
40
+ progress = ((activeIndex + 1) / steps.length) * 100;
41
+ } else {
42
+ progress = 0;
43
+ }
44
+
45
+ if (!slotContext?.progressBarRef.current) {
46
+ return null;
47
+ }
48
+
49
+ return createPortal(
50
+ <div
51
+ className={styles.progressBar}
52
+ role="progressbar"
53
+ aria-valuemin={0}
54
+ aria-valuenow={Math.round(progress)}
55
+ aria-valuemax={100}
56
+ aria-label="Page progress"
57
+ style={{
58
+ ...(track && { background: track }),
59
+ ...(height != null && { height: typeof height === 'number' ? `${height}px` : height }),
60
+ }}
61
+ >
62
+ <div
63
+ className={styles.progressBarFill}
64
+ style={{
65
+ width: `${progress}%`,
66
+ ...(fill && { background: fill }),
67
+ }}
68
+ />
69
+ </div>,
70
+ slotContext.progressBarRef.current,
71
+ );
72
+ };
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ type RefObject,
7
+ useCallback,
8
+ useContext,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+
14
+ export type BottomPanelEntry = {
15
+ id: string;
16
+ mode: 'replace' | 'append';
17
+ priority: number;
18
+ };
19
+
20
+ export type DrawerSlotContextValue = {
21
+ titleRef: RefObject<HTMLDivElement | null>;
22
+ actionsRef: RefObject<HTMLDivElement | null>;
23
+ bottomPanelRef: RefObject<HTMLDivElement | null>;
24
+ /** Callback ref for the bottom panel portal target — triggers re-render when assigned */
25
+ bottomPanelCallbackRef: (node: HTMLDivElement | null) => void;
26
+ /** Whether the bottom panel portal target is mounted in the DOM */
27
+ isBottomPanelMounted: boolean;
28
+
29
+ /** Portal target for the progress bar, rendered at the very top of the bottom panel */
30
+ progressBarRef: RefObject<HTMLDivElement | null>;
31
+
32
+ hasTitleSlot: boolean;
33
+ hasActionsSlot: boolean;
34
+ hasProgressBar: boolean;
35
+
36
+ /** Whether a replace-mode bottom panel is registered */
37
+ hasBottomPanelReplace: boolean;
38
+ /** Whether any bottom panel slot (replace or append) is registered */
39
+ hasAnyBottomPanelSlot: boolean;
40
+ /** Sorted list of append-mode bottom panel entries */
41
+ bottomPanelAppendEntries: BottomPanelEntry[];
42
+
43
+ registerTitleSlot: () => () => void;
44
+ registerActionsSlot: () => () => void;
45
+ registerProgressBar: () => () => void;
46
+ registerBottomPanel: (entry: BottomPanelEntry) => () => void;
47
+ };
48
+
49
+ const DrawerSlotContext = createContext<DrawerSlotContextValue | null>(null);
50
+
51
+ export function useDrawerSlotContext(): DrawerSlotContextValue | null {
52
+ return useContext(DrawerSlotContext);
53
+ }
54
+
55
+ export function DrawerSlotProvider({ children }: { children: ReactNode }) {
56
+ const titleRef = useRef<HTMLDivElement | null>(null);
57
+ const actionsRef = useRef<HTMLDivElement | null>(null);
58
+ const progressBarRef = useRef<HTMLDivElement | null>(null);
59
+ const bottomPanelRef = useRef<HTMLDivElement | null>(null);
60
+ const [isBottomPanelMounted, setIsBottomPanelMounted] = useState(false);
61
+
62
+ const bottomPanelCallbackRef = useCallback((node: HTMLDivElement | null) => {
63
+ bottomPanelRef.current = node;
64
+ setIsBottomPanelMounted(node !== null);
65
+ }, []);
66
+
67
+ const [titleSlotCount, setTitleSlotCount] = useState(0);
68
+ const [actionsSlotCount, setActionsSlotCount] = useState(0);
69
+ const [progressBarCount, setProgressBarCount] = useState(0);
70
+ const [bottomPanelEntries, setBottomPanelEntries] = useState<BottomPanelEntry[]>([]);
71
+
72
+ const registerTitleSlot = useCallback((): (() => void) => {
73
+ setTitleSlotCount((prev) => {
74
+ const next = prev + 1;
75
+ if (process.env.NODE_ENV === 'development' && next > 1) {
76
+ console.warn(
77
+ 'DrawerSlotContext: Multiple title slots registered simultaneously. Only one is expected.',
78
+ );
79
+ }
80
+ return next;
81
+ });
82
+ return () => {
83
+ setTitleSlotCount((prev) => Math.max(0, prev - 1));
84
+ };
85
+ }, []);
86
+
87
+ const registerActionsSlot = useCallback((): (() => void) => {
88
+ setActionsSlotCount((prev) => {
89
+ const next = prev + 1;
90
+ if (process.env.NODE_ENV === 'development' && next > 1) {
91
+ console.warn(
92
+ 'DrawerSlotContext: Multiple actions slots registered simultaneously. Only one is expected.',
93
+ );
94
+ }
95
+ return next;
96
+ });
97
+ return () => {
98
+ setActionsSlotCount((prev) => Math.max(0, prev - 1));
99
+ };
100
+ }, []);
101
+
102
+ const registerProgressBar = useCallback((): (() => void) => {
103
+ setProgressBarCount((prev) => prev + 1);
104
+ return () => {
105
+ setProgressBarCount((prev) => Math.max(0, prev - 1));
106
+ };
107
+ }, []);
108
+
109
+ const registerBottomPanel = useCallback((entry: BottomPanelEntry): (() => void) => {
110
+ setBottomPanelEntries((prev) => {
111
+ if (process.env.NODE_ENV === 'development' && entry.mode === 'replace') {
112
+ const existingReplace = prev.find((e) => e.mode === 'replace');
113
+ if (existingReplace) {
114
+ console.warn(
115
+ `DrawerSlotContext: Multiple replace-mode bottom panels registered (existing: "${existingReplace.id}", new: "${entry.id}"). Only one replace-mode panel is expected.`,
116
+ );
117
+ }
118
+ }
119
+ return [...prev, entry];
120
+ });
121
+ return () => {
122
+ setBottomPanelEntries((prev) => prev.filter((e) => e.id !== entry.id));
123
+ };
124
+ }, []);
125
+
126
+ const hasTitleSlot = titleSlotCount > 0;
127
+ const hasActionsSlot = actionsSlotCount > 0;
128
+ const hasProgressBar = progressBarCount > 0;
129
+ const hasBottomPanelReplace = bottomPanelEntries.some((e) => e.mode === 'replace');
130
+ const hasAnyBottomPanelSlot = bottomPanelEntries.length > 0;
131
+ const bottomPanelAppendEntries = useMemo(
132
+ () => bottomPanelEntries.filter((e) => e.mode === 'append').sort((a, b) => a.priority - b.priority),
133
+ [bottomPanelEntries],
134
+ );
135
+
136
+ const value = useMemo<DrawerSlotContextValue>(
137
+ () => ({
138
+ titleRef,
139
+ actionsRef,
140
+ progressBarRef,
141
+ bottomPanelRef,
142
+ bottomPanelCallbackRef,
143
+ isBottomPanelMounted,
144
+ hasTitleSlot,
145
+ hasActionsSlot,
146
+ hasProgressBar,
147
+ hasBottomPanelReplace,
148
+ hasAnyBottomPanelSlot,
149
+ bottomPanelAppendEntries,
150
+ registerTitleSlot,
151
+ registerActionsSlot,
152
+ registerProgressBar,
153
+ registerBottomPanel,
154
+ }),
155
+ [
156
+ bottomPanelCallbackRef,
157
+ isBottomPanelMounted,
158
+ hasTitleSlot,
159
+ hasActionsSlot,
160
+ hasProgressBar,
161
+ hasBottomPanelReplace,
162
+ hasAnyBottomPanelSlot,
163
+ bottomPanelAppendEntries,
164
+ registerTitleSlot,
165
+ registerActionsSlot,
166
+ registerProgressBar,
167
+ registerBottomPanel,
168
+ ],
169
+ );
170
+
171
+ return <DrawerSlotContext.Provider value={value}>{children}</DrawerSlotContext.Provider>;
172
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useId, useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { TextWhenString } from '../utility/TextWhenString';
6
+ import { useIsPageActive } from './DrawerPageContext';
7
+ import { useDrawerSlotContext } from './DrawerSlotContext';
8
+
9
+ export type DrawerTitleProps = {
10
+ children: ReactNode;
11
+ };
12
+
13
+ export const DrawerTitle = ({ children }: DrawerTitleProps) => {
14
+ const isActive = useIsPageActive();
15
+ const slotContext = useDrawerSlotContext();
16
+ const id = useId();
17
+
18
+ useLayoutEffect(() => {
19
+ if (!isActive || !slotContext) return;
20
+ const unregister = slotContext.registerTitleSlot();
21
+ return unregister;
22
+ }, [isActive, slotContext]);
23
+
24
+ if (!isActive || !slotContext?.titleRef.current) {
25
+ return null;
26
+ }
27
+
28
+ return createPortal(
29
+ <TextWhenString kind="paragraphSmall" weight="medium">
30
+ {children}
31
+ </TextWhenString>,
32
+ slotContext.titleRef.current,
33
+ id,
34
+ );
35
+ };
@@ -1 +1,10 @@
1
1
  export * from './Drawer';
2
+ export { DrawerActions, type DrawerActionsProps } from './DrawerActions';
3
+ export { DrawerBottomPanel, type DrawerBottomPanelProps } from './DrawerBottomPanel';
4
+ export type { DrawerContextValue } from './DrawerContext';
5
+ export { useDrawer } from './DrawerContext';
6
+ export { DrawerPage, type DrawerPageProps } from './DrawerPage';
7
+ export { useIsPageActive } from './DrawerPageContext';
8
+ export { useDrawerPagination } from './DrawerPaginationContext';
9
+ export { DrawerProgressBar, type DrawerProgressBarProps, type DrawerProgressBarStyleProps } from './DrawerProgressBar';
10
+ export { DrawerTitle, type DrawerTitleProps } from './DrawerTitle';
@@ -208,4 +208,47 @@ describe('Menu', () => {
208
208
  const archiveItem = screen.getByText('Archive');
209
209
  expect(archiveItem.closest('[data-disabled]')).toBeInTheDocument();
210
210
  });
211
+
212
+ describe('onOpenChange', () => {
213
+ it('calls onOpenChange when the menu opens', async () => {
214
+ const handleOpenChange = vi.fn();
215
+ const { user } = render(
216
+ <Menu onOpenChange={handleOpenChange}>
217
+ <MenuButton>Options</MenuButton>
218
+ <MenuItems>
219
+ <MenuItem as="button">Edit</MenuItem>
220
+ </MenuItems>
221
+ </Menu>,
222
+ );
223
+
224
+ await user.click(screen.getByText('Options'));
225
+
226
+ await waitFor(() => {
227
+ expect(handleOpenChange).toHaveBeenCalledWith(true);
228
+ });
229
+ });
230
+
231
+ it('calls onOpenChange when the menu closes', async () => {
232
+ const handleOpenChange = vi.fn();
233
+ const { user } = render(
234
+ <Menu onOpenChange={handleOpenChange}>
235
+ <MenuButton>Options</MenuButton>
236
+ <MenuItems>
237
+ <MenuItem as="button">Edit</MenuItem>
238
+ </MenuItems>
239
+ </Menu>,
240
+ );
241
+
242
+ await user.click(screen.getByText('Options'));
243
+ await waitFor(() => {
244
+ expect(screen.getByText('Edit')).toBeInTheDocument();
245
+ });
246
+
247
+ await user.click(screen.getByText('Edit'));
248
+
249
+ await waitFor(() => {
250
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
251
+ });
252
+ });
253
+ });
211
254
  });
@@ -7,6 +7,7 @@ import {
7
7
  } from '@headlessui/react';
8
8
  import { clsx } from 'clsx';
9
9
  import type { FC } from 'react';
10
+ import { OpenChangeEffect } from '../../helpers/OpenChangeEffect';
10
11
 
11
12
  import styles from './Menu.module.scss';
12
13
 
@@ -27,9 +28,19 @@ import styles from './Menu.module.scss';
27
28
  * </Menu>
28
29
  * ```
29
30
  */
30
- export const Menu: FC<MenuProps<React.ElementType>> = ({ className, children, ...props }) => (
31
+ export const Menu: FC<MenuProps<React.ElementType> & { onOpenChange?: (open: boolean) => void }> = ({
32
+ className,
33
+ children,
34
+ onOpenChange,
35
+ ...props
36
+ }) => (
31
37
  <HeadlessMenu as="div" className={clsx(styles.menu, className)} {...props}>
32
- {children}
38
+ {({ open, close }) => (
39
+ <>
40
+ <OpenChangeEffect open={open} onOpenChange={onOpenChange} />
41
+ {typeof children === 'function' ? children({ open, close }) : children}
42
+ </>
43
+ )}
33
44
  </HeadlessMenu>
34
45
  );
35
46
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { clsx } from 'clsx';
4
4
  import type { ComponentPropsWithoutRef, FC, ReactElement, ReactNode } from 'react';
5
- import { forwardRef, useState } from 'react';
5
+ import { forwardRef } from 'react';
6
6
  import type { PopoverProps as RTPopoverProps } from 'react-tiny-popover';
7
7
  import { Popover as RTPopover } from 'react-tiny-popover';
8
+ import { useControllableState } from '../../helpers/useControllableState';
8
9
  import typography from '../text/Typography.module.css';
9
10
  import styles from './Popover.module.scss';
10
11
 
@@ -57,25 +58,27 @@ export const Popover: FC<PopoverProps> = ({
57
58
  setIsOpen,
58
59
  ...props
59
60
  }) => {
60
- const [open, setOpen] = useState(false);
61
+ const [open, setOpen] = useControllableState({
62
+ value: isOpen,
63
+ defaultValue: false,
64
+ onChange: setIsOpen,
65
+ });
61
66
 
62
67
  return (
63
68
  <RTPopover
64
69
  positions={positions || ['bottom', 'top', 'left', 'right']}
65
70
  align={align || 'start'}
66
71
  padding={padding || 8}
67
- isOpen={typeof isOpen === 'undefined' ? open : isOpen}
72
+ isOpen={open}
68
73
  containerClassName={clsx(typography.paragraphSmall, styles.content)}
69
74
  content={<>{children}</>}
70
75
  onClickOutside={() => {
71
- setIsOpen?.(false);
72
76
  setOpen(false);
73
77
  }}
74
78
  {...props}
75
79
  >
76
80
  <Trigger
77
81
  onClick={() => {
78
- setIsOpen?.(!isOpen);
79
82
  setOpen((cur) => !cur);
80
83
  }}
81
84
  >
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  .options {
28
- width: var(--anchor-width);
28
+ width: max(var(--button-width, 0px), var(--input-width, 0px));
29
29
  max-height: var(--options-maxHeight, auto);
30
30
  overflow-y: auto;
31
31
  overflow-x: hidden;