@sproutsocial/seeds-react-panel 0.3.7
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/.eslintrc.js +4 -0
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +139 -0
- package/dist/esm/index.js +366 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.ts +178 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +9 -0
- package/package.json +49 -0
- package/src/Panel.stories.tsx +376 -0
- package/src/Panel.tsx +139 -0
- package/src/PanelCloseButton.tsx +26 -0
- package/src/PanelContent.tsx +18 -0
- package/src/PanelContext.tsx +14 -0
- package/src/PanelFooter.tsx +11 -0
- package/src/PanelHeader.tsx +41 -0
- package/src/PanelMobileActionsHeader.tsx +73 -0
- package/src/PanelProvider.tsx +38 -0
- package/src/PanelTypes.ts +166 -0
- package/src/__tests__/Panel.test.tsx +354 -0
- package/src/__tests__/Panel.typetest.tsx +50 -0
- package/src/index.ts +11 -0
- package/src/styles.ts +71 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { PanelContext } from "./PanelContext";
|
|
3
|
+
import type { TypePanelContext, TypePanelProviderProps } from "./PanelTypes";
|
|
4
|
+
|
|
5
|
+
export const PanelProvider = ({
|
|
6
|
+
children,
|
|
7
|
+
defaultOpen = false,
|
|
8
|
+
open: controlledOpen,
|
|
9
|
+
onOpenChange,
|
|
10
|
+
}: TypePanelProviderProps) => {
|
|
11
|
+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
12
|
+
const isControlled = controlledOpen !== undefined;
|
|
13
|
+
const isPanelOpen = isControlled ? controlledOpen : internalOpen;
|
|
14
|
+
|
|
15
|
+
const setOpen = React.useCallback(
|
|
16
|
+
(next: boolean) => {
|
|
17
|
+
if (!isControlled) setInternalOpen(next);
|
|
18
|
+
onOpenChange?.(next);
|
|
19
|
+
},
|
|
20
|
+
[isControlled, onOpenChange]
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const openPanel = React.useCallback(() => setOpen(true), [setOpen]);
|
|
24
|
+
const closePanel = React.useCallback(() => setOpen(false), [setOpen]);
|
|
25
|
+
const togglePanel = React.useCallback(
|
|
26
|
+
() => setOpen(!isPanelOpen),
|
|
27
|
+
[setOpen, isPanelOpen]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const value = React.useMemo<TypePanelContext>(
|
|
31
|
+
() => ({ isPanelOpen, openPanel, closePanel, togglePanel }),
|
|
32
|
+
[isPanelOpen, openPanel, closePanel, togglePanel]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<PanelContext.Provider value={value}>{children}</PanelContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TypeSystemCommonProps,
|
|
4
|
+
TypeStyledComponentsCommonProps,
|
|
5
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
6
|
+
import type { TypeBoxProps } from "@sproutsocial/seeds-react-box";
|
|
7
|
+
import type { TypeButtonProps } from "@sproutsocial/seeds-react-button";
|
|
8
|
+
import type {
|
|
9
|
+
TypeDrawerSnapPoint,
|
|
10
|
+
TypeDrawerActionProps,
|
|
11
|
+
} from "@sproutsocial/seeds-react-drawer";
|
|
12
|
+
|
|
13
|
+
export type { TypeDrawerActionProps as TypePanelActionProps };
|
|
14
|
+
|
|
15
|
+
export type PanelDirection = "left" | "right" | "bottom";
|
|
16
|
+
|
|
17
|
+
export interface TypePanelContext {
|
|
18
|
+
isPanelOpen: boolean;
|
|
19
|
+
togglePanel: () => void;
|
|
20
|
+
openPanel: () => void;
|
|
21
|
+
closePanel: () => void;
|
|
22
|
+
closeButtonLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TypePanelProviderProps {
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
/** Initial open state for uncontrolled usage. */
|
|
28
|
+
defaultOpen?: boolean;
|
|
29
|
+
/** Controlled open state. When provided, the provider becomes controlled. */
|
|
30
|
+
open?: boolean;
|
|
31
|
+
/** Called whenever the open state changes. Required for controlled usage. */
|
|
32
|
+
onOpenChange?: (open: boolean) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TypePanelProps
|
|
36
|
+
extends TypeStyledComponentsCommonProps,
|
|
37
|
+
TypeSystemCommonProps,
|
|
38
|
+
Omit<React.ComponentPropsWithoutRef<"div">, "color"> {
|
|
39
|
+
children: React.ReactNode;
|
|
40
|
+
|
|
41
|
+
/** Label for the close button. Usually "Close". */
|
|
42
|
+
closeButtonLabel: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Custom header content. When provided, replaces the auto-rendered default
|
|
46
|
+
* header entirely — `title` and `titleId` are ignored.
|
|
47
|
+
*/
|
|
48
|
+
header?: React.ReactNode;
|
|
49
|
+
|
|
50
|
+
/** Custom footer content. Not rendered when omitted. */
|
|
51
|
+
footer?: React.ReactNode;
|
|
52
|
+
|
|
53
|
+
/** Title shown in the auto-rendered default header. Ignored when `header` is provided. */
|
|
54
|
+
title?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sets the `id` on the title element of the auto-rendered header. When the
|
|
58
|
+
* default header is used, Panel automatically wires `aria-labelledby` to this
|
|
59
|
+
* id so the dialog/aside has an accessible name. Pass an explicit
|
|
60
|
+
* `aria-labelledby` to override (e.g. when pointing at a different element).
|
|
61
|
+
* Ignored when `header` is provided — supply your own `aria-labelledby` in
|
|
62
|
+
* that case.
|
|
63
|
+
*/
|
|
64
|
+
titleId?: string;
|
|
65
|
+
|
|
66
|
+
/** Side the panel anchors to on desktop. Defaults to "right". */
|
|
67
|
+
direction?: PanelDirection;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Width (px) when direction is "left" or "right", or height (px) when
|
|
71
|
+
* direction is "bottom". Defaults to 384 to match the existing web-app-core
|
|
72
|
+
* Panel.
|
|
73
|
+
*/
|
|
74
|
+
width?: number;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Visual gap (px) between the panel and the adjacent content. Applied as
|
|
78
|
+
* margin on the side facing the content (left when direction="right",
|
|
79
|
+
* right when direction="left", top when direction="bottom"). Animates with
|
|
80
|
+
* the panel's open/close transition and collapses to 0 when closed so the
|
|
81
|
+
* gap does not remain visible alongside a zero-width panel. Defaults to 0.
|
|
82
|
+
*/
|
|
83
|
+
gap?: number;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Forwarded to `useIsMobile`. Accepts a px number or any CSS media-query
|
|
87
|
+
* length. Below this width, the panel renders as an overlay bottom sheet
|
|
88
|
+
* (via seeds-react-drawer). Defaults to the theme's `breakpoints.sm`.
|
|
89
|
+
*/
|
|
90
|
+
mobileBreakpoint?: number | string;
|
|
91
|
+
|
|
92
|
+
/** Applies only to the mobile bottom-sheet rendering. */
|
|
93
|
+
zIndex?: number;
|
|
94
|
+
|
|
95
|
+
/** Optional id used for data-qa-panel attributes. */
|
|
96
|
+
id?: string;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Snap points the panel can rest at when rendered as a mobile bottom sheet.
|
|
100
|
+
* Numbers 0–1 are viewport-height fractions; numbers > 1 are pixels; strings
|
|
101
|
+
* accept `px`/`rem` (e.g. `"480px"`, `"30rem"`). Order matters — the last
|
|
102
|
+
* entry is the "expanded" state. Ignored on the desktop side-panel rendering.
|
|
103
|
+
*/
|
|
104
|
+
snapPoints?: TypeDrawerSnapPoint[];
|
|
105
|
+
|
|
106
|
+
/** Initial snap point for uncontrolled use. Mobile only. */
|
|
107
|
+
defaultSnapPoint?: TypeDrawerSnapPoint | null;
|
|
108
|
+
|
|
109
|
+
/** Controlled active snap point. Pair with `onSnapPointChange`. Mobile only. */
|
|
110
|
+
snapPoint?: TypeDrawerSnapPoint | null;
|
|
111
|
+
|
|
112
|
+
/** Fires when the active snap point changes. Mobile only. */
|
|
113
|
+
onSnapPointChange?: (snapPoint: TypeDrawerSnapPoint | null) => void;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* When true, fast swipes can't skip past adjacent snap points. Mobile only.
|
|
117
|
+
*/
|
|
118
|
+
snapToSequentialPoints?: boolean;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Action buttons shown in a floating rail above the panel on mobile (the
|
|
122
|
+
* bottom-sheet rendering). Ignored on desktop side-panel rendering — pass
|
|
123
|
+
* actions through `header`/`footer` slots there. The rail owns the close
|
|
124
|
+
* button so the default header omits its built-in close affordance.
|
|
125
|
+
*/
|
|
126
|
+
actions?: TypeDrawerActionProps[];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Opt out of the floating mobile rail and render the `actions` inline in the
|
|
130
|
+
* header as pill buttons instead. Useful for nested bottom sheets and
|
|
131
|
+
* snap-point layouts where the floating rail would collide with surrounding
|
|
132
|
+
* UI. Defaults to `false`.
|
|
133
|
+
*
|
|
134
|
+
* When `header` is omitted, Panel auto-composes a mobile header that renders
|
|
135
|
+
* the title, the `actions` as pills, and a close-button pill. When `header`
|
|
136
|
+
* is provided, the consumer is responsible for rendering the actions inside
|
|
137
|
+
* their own header — `actions` is forwarded for context only.
|
|
138
|
+
*/
|
|
139
|
+
actionsInHeader?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TypePanelHeaderProps extends TypeBoxProps {
|
|
143
|
+
title?: string;
|
|
144
|
+
/**
|
|
145
|
+
* When children are provided, `<Panel.CloseButton />` is NOT rendered
|
|
146
|
+
* automatically. Include it yourself to preserve keyboard accessibility.
|
|
147
|
+
*/
|
|
148
|
+
children?: React.ReactNode;
|
|
149
|
+
/** Render-prop override that receives the parent panel context. */
|
|
150
|
+
render?: React.FC<TypePanelContext>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface TypePanelContentProps extends TypeBoxProps {
|
|
154
|
+
children?: React.ReactNode;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface TypePanelFooterProps extends TypeBoxProps {
|
|
158
|
+
children?: React.ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface TypePanelCloseButtonProps
|
|
162
|
+
extends Omit<TypeButtonProps, "children"> {
|
|
163
|
+
/** Render-prop override that receives the parent panel context. */
|
|
164
|
+
render?: React.FC<TypePanelContext>;
|
|
165
|
+
children?: React.ReactNode;
|
|
166
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/* eslint-disable testing-library/prefer-screen-queries */
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {
|
|
5
|
+
render as testRender,
|
|
6
|
+
waitFor,
|
|
7
|
+
screen,
|
|
8
|
+
act,
|
|
9
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
10
|
+
import Panel, { PanelProvider, usePanelContext } from "../index";
|
|
11
|
+
import type { TypePanelProps } from "../PanelTypes";
|
|
12
|
+
|
|
13
|
+
const ToggleButton = ({ label = "toggle" }: { label?: string }) => {
|
|
14
|
+
const { togglePanel } = usePanelContext();
|
|
15
|
+
return <button onClick={togglePanel}>{label}</button>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const renderPanel = ({
|
|
19
|
+
defaultOpen = false,
|
|
20
|
+
direction,
|
|
21
|
+
width,
|
|
22
|
+
children = (
|
|
23
|
+
<Panel.Content>
|
|
24
|
+
<p>Panel Content</p>
|
|
25
|
+
</Panel.Content>
|
|
26
|
+
),
|
|
27
|
+
title = "Panel Header",
|
|
28
|
+
titleId = "panel-1-header",
|
|
29
|
+
closeButtonLabel = "close button",
|
|
30
|
+
...rest
|
|
31
|
+
}: Partial<TypePanelProps> & { defaultOpen?: boolean } = {}) => {
|
|
32
|
+
return testRender(
|
|
33
|
+
<PanelProvider defaultOpen={defaultOpen}>
|
|
34
|
+
<div>
|
|
35
|
+
<ToggleButton />
|
|
36
|
+
<Panel
|
|
37
|
+
closeButtonLabel={closeButtonLabel}
|
|
38
|
+
direction={direction}
|
|
39
|
+
width={width}
|
|
40
|
+
title={title}
|
|
41
|
+
titleId={titleId}
|
|
42
|
+
{...rest}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</Panel>
|
|
46
|
+
</div>
|
|
47
|
+
</PanelProvider>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("Panel (desktop / inline mode)", () => {
|
|
52
|
+
it("renders children when open by default", () => {
|
|
53
|
+
renderPanel({ defaultOpen: true });
|
|
54
|
+
expect(screen.getByText(/panel content/i)).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("toggles open and closed via context", async () => {
|
|
58
|
+
const { user } = renderPanel({ defaultOpen: false });
|
|
59
|
+
const panel = screen.getByRole("complementary");
|
|
60
|
+
expect(panel).toHaveAttribute("data-qa-panel-isopen", "false");
|
|
61
|
+
|
|
62
|
+
await act(async () => {
|
|
63
|
+
await user.click(screen.getByText("toggle"));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(panel).toHaveAttribute("data-qa-panel-isopen", "true");
|
|
68
|
+
});
|
|
69
|
+
expect(screen.getByText(/panel content/i)).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("close button calls closePanel from context", async () => {
|
|
73
|
+
const { user } = renderPanel({ defaultOpen: true });
|
|
74
|
+
|
|
75
|
+
expect(screen.getByRole("complementary")).toHaveAttribute(
|
|
76
|
+
"data-qa-panel-isopen",
|
|
77
|
+
"true"
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await act(async () => {
|
|
81
|
+
await user.click(screen.getByLabelText("close button"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByRole("complementary")).toHaveAttribute(
|
|
86
|
+
"data-qa-panel-isopen",
|
|
87
|
+
"false"
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders the title element with the provided id", () => {
|
|
93
|
+
renderPanel({ defaultOpen: true });
|
|
94
|
+
expect(screen.getByText("Panel Header")).toHaveAttribute(
|
|
95
|
+
"id",
|
|
96
|
+
"panel-1-header"
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("Panel.Content sets data-panel-content attribute", () => {
|
|
101
|
+
renderPanel({ defaultOpen: true });
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByText(/panel content/i).closest("[data-panel-content]")
|
|
104
|
+
).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("accepts a custom header via the header prop", async () => {
|
|
108
|
+
const { user } = renderPanel({
|
|
109
|
+
defaultOpen: true,
|
|
110
|
+
header: (
|
|
111
|
+
<Panel.Header>
|
|
112
|
+
<h1>Custom Title</h1>
|
|
113
|
+
<Panel.CloseButton />
|
|
114
|
+
</Panel.Header>
|
|
115
|
+
),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(screen.getByText("Custom Title")).toBeInTheDocument();
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await user.click(screen.getByLabelText("close button"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(screen.getByRole("complementary")).toHaveAttribute(
|
|
126
|
+
"data-qa-panel-isopen",
|
|
127
|
+
"false"
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("uses the provided width on desktop", () => {
|
|
133
|
+
renderPanel({ defaultOpen: true, width: 500 });
|
|
134
|
+
expect(screen.getByRole("complementary")).toHaveStyle("flex-basis: 500px");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("collapses to flex-basis: 0 when closed", () => {
|
|
138
|
+
renderPanel({ defaultOpen: false });
|
|
139
|
+
expect(screen.getByRole("complementary")).toHaveStyle("flex-basis: 0px");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("Panel (mobile / drawer mode)", () => {
|
|
144
|
+
let originalMatchMedia: typeof window.matchMedia | undefined;
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
originalMatchMedia = window.matchMedia;
|
|
148
|
+
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
149
|
+
matches: true,
|
|
150
|
+
media: query,
|
|
151
|
+
onchange: null,
|
|
152
|
+
addEventListener: jest.fn(),
|
|
153
|
+
removeEventListener: jest.fn(),
|
|
154
|
+
addListener: jest.fn(),
|
|
155
|
+
removeListener: jest.fn(),
|
|
156
|
+
dispatchEvent: jest.fn(),
|
|
157
|
+
}));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
if (originalMatchMedia) {
|
|
162
|
+
window.matchMedia = originalMatchMedia;
|
|
163
|
+
} else {
|
|
164
|
+
// @ts-expect-error allow deleting a non-optional dom property in tests
|
|
165
|
+
delete window.matchMedia;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("defaults aria-labelledby to titleId when the default header is used", () => {
|
|
170
|
+
renderPanel({ defaultOpen: true });
|
|
171
|
+
|
|
172
|
+
expect(screen.getByRole("dialog")).toHaveAttribute(
|
|
173
|
+
"aria-labelledby",
|
|
174
|
+
"panel-1-header"
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not emit Drawer's missing-accessible-name warning", () => {
|
|
179
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
180
|
+
renderPanel({ defaultOpen: true });
|
|
181
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining("[Drawer] Missing accessible name")
|
|
183
|
+
);
|
|
184
|
+
warnSpy.mockRestore();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("consumer aria-labelledby wins over the titleId default", () => {
|
|
188
|
+
renderPanel({
|
|
189
|
+
defaultOpen: true,
|
|
190
|
+
"aria-labelledby": "external-heading",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(screen.getByRole("dialog")).toHaveAttribute(
|
|
194
|
+
"aria-labelledby",
|
|
195
|
+
"external-heading"
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("forwards aria-label to the Drawer", () => {
|
|
200
|
+
renderPanel({
|
|
201
|
+
defaultOpen: true,
|
|
202
|
+
"aria-label": "Inspector",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(screen.getByRole("dialog")).toHaveAttribute(
|
|
206
|
+
"aria-label",
|
|
207
|
+
"Inspector"
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("forwards arbitrary data-* props to the Drawer", () => {
|
|
212
|
+
renderPanel({
|
|
213
|
+
defaultOpen: true,
|
|
214
|
+
// @ts-expect-error custom data attribute not in the typed prop list
|
|
215
|
+
"data-testid": "panel-mobile",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(screen.getByTestId("panel-mobile")).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("does not default aria-labelledby when a custom header is provided", () => {
|
|
222
|
+
renderPanel({
|
|
223
|
+
defaultOpen: true,
|
|
224
|
+
header: <div>Custom Header</div>,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-labelledby");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("renders actions as pills in the header when actionsInHeader is set", async () => {
|
|
231
|
+
const onExpand = jest.fn();
|
|
232
|
+
const onShare = jest.fn();
|
|
233
|
+
const { user } = renderPanel({
|
|
234
|
+
defaultOpen: true,
|
|
235
|
+
actionsInHeader: true,
|
|
236
|
+
actions: [
|
|
237
|
+
{
|
|
238
|
+
"aria-label": "Expand",
|
|
239
|
+
iconName: "arrows-pointing-out-outline",
|
|
240
|
+
onClick: onExpand,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"aria-label": "Share",
|
|
244
|
+
iconName: "link-outline",
|
|
245
|
+
onClick: onShare,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const expand = screen.getByLabelText("Expand");
|
|
251
|
+
const share = screen.getByLabelText("Share");
|
|
252
|
+
expect(expand).toBeInTheDocument();
|
|
253
|
+
expect(share).toBeInTheDocument();
|
|
254
|
+
// The auto-composed header also renders its own close pill so the
|
|
255
|
+
// suppressed-rail combination still has a way to dismiss the sheet.
|
|
256
|
+
expect(screen.getByLabelText("close button")).toBeInTheDocument();
|
|
257
|
+
|
|
258
|
+
await act(async () => {
|
|
259
|
+
await user.click(expand);
|
|
260
|
+
});
|
|
261
|
+
expect(onExpand).toHaveBeenCalledTimes(1);
|
|
262
|
+
|
|
263
|
+
await act(async () => {
|
|
264
|
+
await user.click(share);
|
|
265
|
+
});
|
|
266
|
+
expect(onShare).toHaveBeenCalledTimes(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("forwards actions to the floating rail when actionsInHeader is not set", () => {
|
|
270
|
+
renderPanel({
|
|
271
|
+
defaultOpen: true,
|
|
272
|
+
actions: [
|
|
273
|
+
{
|
|
274
|
+
"aria-label": "Expand",
|
|
275
|
+
iconName: "arrows-pointing-out-outline",
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Rail-mode: the rail owns the close button (with closeButtonLabel) and
|
|
281
|
+
// the action button is rendered inside the rail rather than as a header
|
|
282
|
+
// pill. Both are present, the rail's aria-label is set, and the
|
|
283
|
+
// auto-composed header is not used (no duplicate close pill).
|
|
284
|
+
expect(screen.getByLabelText("Drawer quick actions")).toBeInTheDocument();
|
|
285
|
+
expect(screen.getByLabelText("Expand")).toBeInTheDocument();
|
|
286
|
+
expect(screen.getAllByLabelText("close button")).toHaveLength(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("does not auto-render the actions header when a custom header is provided", () => {
|
|
290
|
+
renderPanel({
|
|
291
|
+
defaultOpen: true,
|
|
292
|
+
actionsInHeader: true,
|
|
293
|
+
actions: [
|
|
294
|
+
{
|
|
295
|
+
"aria-label": "Expand",
|
|
296
|
+
iconName: "arrows-pointing-out-outline",
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
header: <div>Custom Header</div>,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(screen.getByText("Custom Header")).toBeInTheDocument();
|
|
303
|
+
// The consumer owns rendering — Panel must not duplicate the action pill.
|
|
304
|
+
expect(screen.queryByLabelText("Expand")).not.toBeInTheDocument();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("PanelProvider (controlled)", () => {
|
|
309
|
+
it("calls onOpenChange when the panel toggles", async () => {
|
|
310
|
+
const onOpenChange = jest.fn();
|
|
311
|
+
const Controlled = () => {
|
|
312
|
+
const [open, setOpen] = React.useState(false);
|
|
313
|
+
return (
|
|
314
|
+
<PanelProvider
|
|
315
|
+
open={open}
|
|
316
|
+
onOpenChange={(next) => {
|
|
317
|
+
onOpenChange(next);
|
|
318
|
+
setOpen(next);
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<ToggleButton />
|
|
322
|
+
<Panel closeButtonLabel="close button">
|
|
323
|
+
<Panel.Content>
|
|
324
|
+
<p>controlled content</p>
|
|
325
|
+
</Panel.Content>
|
|
326
|
+
</Panel>
|
|
327
|
+
</PanelProvider>
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const { user } = testRender(<Controlled />);
|
|
332
|
+
await act(async () => {
|
|
333
|
+
await user.click(screen.getByText("toggle"));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("usePanelContext", () => {
|
|
341
|
+
it("throws when used outside a PanelProvider", () => {
|
|
342
|
+
const Crash = () => {
|
|
343
|
+
usePanelContext();
|
|
344
|
+
return null;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Suppress React's expected error console output for this assertion.
|
|
348
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
349
|
+
expect(() => testRender(<Crash />)).toThrow(
|
|
350
|
+
/usePanelContext must be used within a <PanelProvider>/
|
|
351
|
+
);
|
|
352
|
+
spy.mockRestore();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import Panel, {
|
|
3
|
+
PanelProvider,
|
|
4
|
+
usePanelContext,
|
|
5
|
+
type TypePanelProps,
|
|
6
|
+
type PanelDirection,
|
|
7
|
+
} from "../index";
|
|
8
|
+
|
|
9
|
+
// Compile-only type assertions. Not executed; presence of `_` flags unused warnings.
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11
|
+
function _typeTests() {
|
|
12
|
+
// Required prop: closeButtonLabel
|
|
13
|
+
// @ts-expect-error closeButtonLabel is required
|
|
14
|
+
const _missing: React.ReactElement = <Panel>content</Panel>;
|
|
15
|
+
|
|
16
|
+
// Valid directions only
|
|
17
|
+
const _dir: PanelDirection = "right";
|
|
18
|
+
// @ts-expect-error "top" is not a supported direction
|
|
19
|
+
const _badDir: PanelDirection = "top";
|
|
20
|
+
|
|
21
|
+
// PanelProvider props
|
|
22
|
+
const _provider = (
|
|
23
|
+
<PanelProvider defaultOpen open={false} onOpenChange={() => {}}>
|
|
24
|
+
child
|
|
25
|
+
</PanelProvider>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// usePanelContext shape
|
|
29
|
+
const ctx: ReturnType<typeof usePanelContext> = {
|
|
30
|
+
isPanelOpen: true,
|
|
31
|
+
openPanel: () => {},
|
|
32
|
+
closePanel: () => {},
|
|
33
|
+
togglePanel: () => {},
|
|
34
|
+
};
|
|
35
|
+
void ctx;
|
|
36
|
+
|
|
37
|
+
// Full Panel props
|
|
38
|
+
const _props: TypePanelProps = {
|
|
39
|
+
children: null,
|
|
40
|
+
closeButtonLabel: "Close",
|
|
41
|
+
direction: "left",
|
|
42
|
+
width: 384,
|
|
43
|
+
mobileBreakpoint: 640,
|
|
44
|
+
};
|
|
45
|
+
void _props;
|
|
46
|
+
void _missing;
|
|
47
|
+
void _dir;
|
|
48
|
+
void _badDir;
|
|
49
|
+
void _provider;
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Panel from "./Panel";
|
|
2
|
+
|
|
3
|
+
export default Panel;
|
|
4
|
+
export { Panel };
|
|
5
|
+
export { PanelContext, usePanelContext } from "./PanelContext";
|
|
6
|
+
export { PanelProvider } from "./PanelProvider";
|
|
7
|
+
export { PanelHeader } from "./PanelHeader";
|
|
8
|
+
export { PanelContent } from "./PanelContent";
|
|
9
|
+
export { PanelCloseButton } from "./PanelCloseButton";
|
|
10
|
+
export { PanelFooter } from "./PanelFooter";
|
|
11
|
+
export * from "./PanelTypes";
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import styled, { css } from "styled-components";
|
|
2
|
+
import { COMMON } from "@sproutsocial/seeds-react-system-props";
|
|
3
|
+
import type { TypeSystemCommonProps } from "@sproutsocial/seeds-react-system-props";
|
|
4
|
+
import { MOTION_DURATION_MEDIUM } from "@sproutsocial/seeds-motion/unitless";
|
|
5
|
+
import Box from "@sproutsocial/seeds-react-box";
|
|
6
|
+
import type { PanelDirection } from "./PanelTypes";
|
|
7
|
+
|
|
8
|
+
interface PanelContainerProps extends TypeSystemCommonProps {
|
|
9
|
+
$isOpen: boolean;
|
|
10
|
+
$direction: PanelDirection;
|
|
11
|
+
$width: number;
|
|
12
|
+
$gap: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const gapSide = (direction: PanelDirection): "left" | "right" | "top" => {
|
|
16
|
+
if (direction === "right") return "left";
|
|
17
|
+
if (direction === "left") return "right";
|
|
18
|
+
return "top";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const PanelContainer = styled.aside<PanelContainerProps>`
|
|
22
|
+
background: ${(props) => props.theme.colors.container.background.base};
|
|
23
|
+
flex-grow: 0;
|
|
24
|
+
flex-shrink: 0;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
transition: flex-basis ${MOTION_DURATION_MEDIUM}s ease-in-out,
|
|
27
|
+
margin ${MOTION_DURATION_MEDIUM}s ease-in-out;
|
|
28
|
+
flex-basis: ${(props) => (props.$isOpen ? `${props.$width}px` : "0px")};
|
|
29
|
+
margin-${(props) => gapSide(props.$direction)}: ${(props) =>
|
|
30
|
+
props.$isOpen && props.$gap ? `${props.$gap}px` : "0px"};
|
|
31
|
+
border-radius: ${({ theme }) => theme.radii[800]};
|
|
32
|
+
|
|
33
|
+
${(props) =>
|
|
34
|
+
props.$direction === "bottom"
|
|
35
|
+
? css`
|
|
36
|
+
width: 100%;
|
|
37
|
+
`
|
|
38
|
+
: css`
|
|
39
|
+
height: 100%;
|
|
40
|
+
`}
|
|
41
|
+
|
|
42
|
+
${COMMON}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
export const PanelInner = styled.div<{
|
|
46
|
+
$direction: PanelDirection;
|
|
47
|
+
$width: number;
|
|
48
|
+
}>`
|
|
49
|
+
${(props) =>
|
|
50
|
+
props.$direction === "bottom"
|
|
51
|
+
? css`
|
|
52
|
+
min-height: ${props.$width}px;
|
|
53
|
+
width: 100%;
|
|
54
|
+
`
|
|
55
|
+
: css`
|
|
56
|
+
min-width: ${props.$width}px;
|
|
57
|
+
height: 100%;
|
|
58
|
+
`}
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
export const Content = styled(Box)`
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
export const Footer = styled(Box)`
|
|
69
|
+
flex: 0 0 auto;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
`;
|