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.
- package/CHANGELOG.md +39 -0
- package/package.json +1 -1
- package/src/helpers/OpenChangeEffect.tsx +21 -0
- package/src/helpers/useControllableState.test.ts +88 -0
- package/src/helpers/useControllableState.ts +59 -0
- package/src/stories/accordionselect/AccordionSelect.test.tsx +72 -0
- package/src/stories/accordionselect/AccordionSelect.tsx +22 -12
- package/src/stories/checkbox/Checkbox.test.tsx +53 -0
- package/src/stories/checkbox/Checkbox.tsx +21 -6
- package/src/stories/combobox/Combobox.test.tsx +111 -0
- package/src/stories/combobox/Combobox.tsx +192 -137
- package/src/stories/drawer/Drawer.module.scss +56 -15
- package/src/stories/drawer/Drawer.stories.tsx +287 -109
- package/src/stories/drawer/Drawer.test.tsx +486 -11
- package/src/stories/drawer/Drawer.tsx +366 -240
- package/src/stories/drawer/DrawerActions.tsx +28 -0
- package/src/stories/drawer/DrawerBottomPanel.tsx +55 -0
- package/src/stories/drawer/DrawerContext.tsx +31 -0
- package/src/stories/drawer/DrawerPage.tsx +37 -0
- package/src/stories/drawer/DrawerPageContext.tsx +35 -0
- package/src/stories/drawer/DrawerPaginationContext.tsx +22 -0
- package/src/stories/drawer/DrawerProgressBar.tsx +72 -0
- package/src/stories/drawer/DrawerSlotContext.tsx +172 -0
- package/src/stories/drawer/DrawerTitle.tsx +35 -0
- package/src/stories/drawer/index.ts +9 -0
- package/src/stories/menu/Menu.test.tsx +43 -0
- package/src/stories/menu/Menu.tsx +13 -2
- package/src/stories/popover/Popover.tsx +8 -5
- package/src/stories/select/Select.module.scss +1 -1
- package/src/stories/select/Select.test.tsx +108 -0
- package/src/stories/select/Select.tsx +121 -92
- 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
|
|
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
|
-
{
|
|
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
|
|
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] =
|
|
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={
|
|
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
|
>
|