@sproutsocial/seeds-react-panel 1.0.11 → 1.1.2
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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +46 -0
- package/dist/esm/index.js +442 -164
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +28 -7
- package/dist/index.d.ts +28 -7
- package/dist/index.js +446 -168
- package/dist/index.js.map +1 -1
- package/dist/panel.css +92 -0
- package/package.json +17 -9
- package/src/Panel.stories.tsx +33 -0
- package/src/Panel.tsx +13 -137
- package/src/PanelContent.tsx +64 -1
- package/src/PanelFooter.tsx +48 -1
- package/src/PanelHeader.tsx +66 -17
- package/src/PanelHybrid.tsx +52 -0
- package/src/PanelMobile.tsx +90 -0
- package/src/PanelMobileActionsHeader.tsx +8 -12
- package/src/PanelShared.tsx +189 -0
- package/src/PanelStyled.tsx +62 -0
- package/src/PanelTailwind.tsx +86 -0
- package/src/__tests__/Panel.test.tsx +47 -1
- package/src/css.d.ts +1 -0
- package/src/panel.css +92 -0
package/src/PanelFooter.tsx
CHANGED
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { needsStyledComponents } from "@sproutsocial/seeds-react-system-props";
|
|
2
3
|
import { Footer } from "./styles";
|
|
4
|
+
import { cn } from "./PanelShared";
|
|
3
5
|
import type { TypePanelFooterProps } from "./PanelTypes";
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Styled-components footer — the backward-compatible fallback. Routed to when
|
|
9
|
+
* the consumer passes styled-system props (e.g. `<Panel.Footer bg="...">`).
|
|
10
|
+
*/
|
|
11
|
+
const PanelFooterStyled = ({ children, ...rest }: TypePanelFooterProps) => (
|
|
6
12
|
<Footer p={450} data-panel-footer="" {...rest}>
|
|
7
13
|
{children}
|
|
8
14
|
</Footer>
|
|
9
15
|
);
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Tailwind/CSS-class footer — the preferred path. Renders a plain
|
|
19
|
+
* `<div data-panel-footer>` with the `seeds-panel-footer` class from panel.css.
|
|
20
|
+
*
|
|
21
|
+
* `margin-bottom: var(--bleed, 0px)` lifts the footer above the Drawer's mobile
|
|
22
|
+
* bottom-sheet bleed (see styles.ts). It is asserted by the jsdom tests and the
|
|
23
|
+
* `--bleed` var is only set at runtime by the mobile Drawer, so it is applied
|
|
24
|
+
* inline (falling back to 0px on desktop).
|
|
25
|
+
*/
|
|
26
|
+
const PanelFooterTailwind = ({ children, ...rest }: TypePanelFooterProps) => {
|
|
27
|
+
const { className, style, ...domRest } = rest as {
|
|
28
|
+
className?: string;
|
|
29
|
+
style?: React.CSSProperties;
|
|
30
|
+
} & Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={cn("seeds-panel-footer", className)}
|
|
35
|
+
style={{ marginBottom: "var(--bleed, 0px)", ...style }}
|
|
36
|
+
data-panel-footer=""
|
|
37
|
+
{...domRest}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hybrid footer router. Routes on the leftover props after the footer's own
|
|
46
|
+
* props are removed.
|
|
47
|
+
*/
|
|
48
|
+
export const PanelFooter = (props: TypePanelFooterProps) => {
|
|
49
|
+
const { children, ...rest } = props;
|
|
50
|
+
|
|
51
|
+
if (needsStyledComponents(rest)) {
|
|
52
|
+
return <PanelFooterStyled {...props} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return <PanelFooterTailwind {...props} />;
|
|
56
|
+
};
|
|
57
|
+
|
|
11
58
|
PanelFooter.displayName = "Panel.Footer";
|
package/src/PanelHeader.tsx
CHANGED
|
@@ -1,41 +1,90 @@
|
|
|
1
1
|
import Box from "@sproutsocial/seeds-react-box";
|
|
2
2
|
import Text from "@sproutsocial/seeds-react-text";
|
|
3
|
+
import { needsStyledComponents } from "@sproutsocial/seeds-react-system-props";
|
|
3
4
|
import { usePanelContext } from "./PanelContext";
|
|
4
5
|
import { PanelCloseButton } from "./PanelCloseButton";
|
|
6
|
+
import { cn } from "./PanelShared";
|
|
5
7
|
import type { TypePanelHeaderProps } from "./PanelTypes";
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Styled-components header — the backward-compatible fallback. The hybrid
|
|
11
|
+
* router sends consumers here whenever they pass styled-system props (e.g.
|
|
12
|
+
* `<Panel.Header alignItems="center">`) or a `styled()` extension, so the full
|
|
13
|
+
* `Box` prop surface keeps working.
|
|
14
|
+
*/
|
|
15
|
+
const PanelHeaderStyled = ({
|
|
8
16
|
title = "",
|
|
9
17
|
id = undefined,
|
|
10
18
|
children,
|
|
11
19
|
render,
|
|
12
20
|
...rest
|
|
13
|
-
}: TypePanelHeaderProps) =>
|
|
14
|
-
|
|
21
|
+
}: TypePanelHeaderProps) => (
|
|
22
|
+
<Box
|
|
23
|
+
display="flex"
|
|
24
|
+
flex="0 0 auto"
|
|
25
|
+
justifyContent="space-between"
|
|
26
|
+
alignItems="center"
|
|
27
|
+
p={400}
|
|
28
|
+
borderBottom="1px solid"
|
|
29
|
+
borderColor="container.border.base"
|
|
30
|
+
{...rest}
|
|
31
|
+
>
|
|
32
|
+
{children || (
|
|
33
|
+
<>
|
|
34
|
+
<Text.Headline id={id}>{title}</Text.Headline>
|
|
35
|
+
<PanelCloseButton />
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
15
40
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Tailwind/CSS-class header — the preferred path. Renders a plain `<div>` with
|
|
43
|
+
* the `seeds-panel-header` class from panel.css instead of a styled `Box`. The
|
|
44
|
+
* `render` prop is handled by the hybrid before reaching here.
|
|
45
|
+
*/
|
|
46
|
+
const PanelHeaderTailwind = ({
|
|
47
|
+
title = "",
|
|
48
|
+
id = undefined,
|
|
49
|
+
children,
|
|
50
|
+
render,
|
|
51
|
+
...rest
|
|
52
|
+
}: TypePanelHeaderProps) => {
|
|
53
|
+
const { className, ...domRest } = rest as {
|
|
54
|
+
className?: string;
|
|
55
|
+
} & Record<string, unknown>;
|
|
19
56
|
|
|
20
57
|
return (
|
|
21
|
-
<
|
|
22
|
-
display="flex"
|
|
23
|
-
flex="0 0 auto"
|
|
24
|
-
justifyContent="space-between"
|
|
25
|
-
alignItems="center"
|
|
26
|
-
p={400}
|
|
27
|
-
borderBottom="1px solid"
|
|
28
|
-
borderColor="container.border.base"
|
|
29
|
-
{...rest}
|
|
30
|
-
>
|
|
58
|
+
<div className={cn("seeds-panel-header", className)} {...domRest}>
|
|
31
59
|
{children || (
|
|
32
60
|
<>
|
|
33
61
|
<Text.Headline id={id}>{title}</Text.Headline>
|
|
34
62
|
<PanelCloseButton />
|
|
35
63
|
</>
|
|
36
64
|
)}
|
|
37
|
-
</
|
|
65
|
+
</div>
|
|
38
66
|
);
|
|
39
67
|
};
|
|
40
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Hybrid header router. The `render` escape hatch short-circuits before any
|
|
71
|
+
* routing (matching the original always-styled behaviour); otherwise it routes
|
|
72
|
+
* on the leftover props after the header's own props are removed.
|
|
73
|
+
*/
|
|
74
|
+
export const PanelHeader = (props: TypePanelHeaderProps) => {
|
|
75
|
+
const panelContext = usePanelContext();
|
|
76
|
+
|
|
77
|
+
if (props.render) {
|
|
78
|
+
return props.render(panelContext);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { title, id, children, render, ...rest } = props;
|
|
82
|
+
|
|
83
|
+
if (needsStyledComponents(rest)) {
|
|
84
|
+
return <PanelHeaderStyled {...props} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return <PanelHeaderTailwind {...props} />;
|
|
88
|
+
};
|
|
89
|
+
|
|
41
90
|
PanelHeader.displayName = "Panel.Header";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { needsStyledComponents } from "@sproutsocial/seeds-react-system-props";
|
|
2
|
+
import { PanelStyled } from "./PanelStyled";
|
|
3
|
+
import { PanelTailwind } from "./PanelTailwind";
|
|
4
|
+
import type { TypePanelProps } from "./PanelTypes";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hybrid Panel router. Chooses the styled-components path when the consumer
|
|
8
|
+
* passes a styled-system (`COMMON`) prop or a `styled()` extension, otherwise
|
|
9
|
+
* the preferred Tailwind path.
|
|
10
|
+
*
|
|
11
|
+
* The routing decision runs on the *leftover* props after Panel's own domain
|
|
12
|
+
* props are destructured — critically, `width` and `zIndex` are Panel domain
|
|
13
|
+
* props whose names also appear in `STYLED_SYSTEM_PROPS`. Gating on the full
|
|
14
|
+
* prop object would push almost every real Panel usage (anything setting
|
|
15
|
+
* `width`) onto the styled path and defeat the migration, so we exclude the
|
|
16
|
+
* domain props first and gate only on `rest`.
|
|
17
|
+
*/
|
|
18
|
+
export const PanelHybrid = (props: TypePanelProps) => {
|
|
19
|
+
const {
|
|
20
|
+
children,
|
|
21
|
+
closeButtonLabel,
|
|
22
|
+
header,
|
|
23
|
+
headerless,
|
|
24
|
+
footer,
|
|
25
|
+
title,
|
|
26
|
+
titleId,
|
|
27
|
+
direction,
|
|
28
|
+
width,
|
|
29
|
+
gap,
|
|
30
|
+
mobileBreakpoint,
|
|
31
|
+
zIndex,
|
|
32
|
+
id,
|
|
33
|
+
snapPoints,
|
|
34
|
+
defaultSnapPoint,
|
|
35
|
+
snapPoint,
|
|
36
|
+
onSnapPointChange,
|
|
37
|
+
snapToSequentialPoints,
|
|
38
|
+
actions,
|
|
39
|
+
actionsInHeader,
|
|
40
|
+
"aria-labelledby": ariaLabelledBy,
|
|
41
|
+
"aria-label": ariaLabel,
|
|
42
|
+
...rest
|
|
43
|
+
} = props;
|
|
44
|
+
|
|
45
|
+
if (needsStyledComponents(rest)) {
|
|
46
|
+
return <PanelStyled {...props} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return <PanelTailwind {...props} />;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
PanelHybrid.displayName = "Panel";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import Drawer from "@sproutsocial/seeds-react-drawer";
|
|
3
|
+
import { PanelContext } from "./PanelContext";
|
|
4
|
+
import { PanelContent } from "./PanelContent";
|
|
5
|
+
import { PanelMobileActionsHeader } from "./PanelMobileActionsHeader";
|
|
6
|
+
import type { TypePanelShell } from "./PanelShared";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mobile bottom-sheet rendering, shared verbatim by both the styled and the
|
|
10
|
+
* Tailwind shells. The mobile path always renders through `Drawer`, which is
|
|
11
|
+
* itself already migrated to the hybrid pattern, so nothing here depends on the
|
|
12
|
+
* root routing gate — the two shells render an identical mobile tree.
|
|
13
|
+
*/
|
|
14
|
+
export const PanelMobile = ({ shell }: { shell: TypePanelShell }) => {
|
|
15
|
+
const {
|
|
16
|
+
rest,
|
|
17
|
+
children,
|
|
18
|
+
closeButtonLabel,
|
|
19
|
+
header,
|
|
20
|
+
headerless,
|
|
21
|
+
footer,
|
|
22
|
+
title,
|
|
23
|
+
titleId,
|
|
24
|
+
zIndex,
|
|
25
|
+
snapPoints,
|
|
26
|
+
defaultSnapPoint,
|
|
27
|
+
snapPoint,
|
|
28
|
+
onSnapPointChange,
|
|
29
|
+
snapToSequentialPoints,
|
|
30
|
+
actions,
|
|
31
|
+
actionsInHeader,
|
|
32
|
+
ariaLabel,
|
|
33
|
+
isPanelOpen,
|
|
34
|
+
closePanel,
|
|
35
|
+
enrichedValue,
|
|
36
|
+
effectiveAriaLabelledBy,
|
|
37
|
+
} = shell;
|
|
38
|
+
|
|
39
|
+
// With `actionsInHeader`, Drawer suppresses the floating rail and expects the
|
|
40
|
+
// consumer to render the actions inside a custom header. Auto-compose one
|
|
41
|
+
// (title + action pills + close pill) when the consumer hasn't supplied their
|
|
42
|
+
// own header; otherwise we'd drop `actions` on the floor.
|
|
43
|
+
const shouldRenderActionsHeader =
|
|
44
|
+
!headerless &&
|
|
45
|
+
header == null &&
|
|
46
|
+
actionsInHeader === true &&
|
|
47
|
+
!!actions &&
|
|
48
|
+
actions.length > 0;
|
|
49
|
+
|
|
50
|
+
// Headerless suppresses the built-in header entirely; the children own all
|
|
51
|
+
// chrome in that mode.
|
|
52
|
+
let defaultMobileHeader: React.ReactNode = null;
|
|
53
|
+
if (!headerless) {
|
|
54
|
+
defaultMobileHeader = shouldRenderActionsHeader ? (
|
|
55
|
+
<PanelMobileActionsHeader
|
|
56
|
+
title={title}
|
|
57
|
+
titleId={titleId}
|
|
58
|
+
actions={actions!}
|
|
59
|
+
/>
|
|
60
|
+
) : (
|
|
61
|
+
<Drawer.Header title={title} id={titleId} />
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<PanelContext.Provider value={enrichedValue}>
|
|
67
|
+
<Drawer
|
|
68
|
+
{...rest}
|
|
69
|
+
open={isPanelOpen}
|
|
70
|
+
onClose={closePanel}
|
|
71
|
+
direction="bottom"
|
|
72
|
+
closeButtonLabel={closeButtonLabel}
|
|
73
|
+
zIndex={zIndex}
|
|
74
|
+
snapPoints={snapPoints}
|
|
75
|
+
defaultSnapPoint={defaultSnapPoint}
|
|
76
|
+
snapPoint={snapPoint}
|
|
77
|
+
onSnapPointChange={onSnapPointChange}
|
|
78
|
+
snapToSequentialPoints={snapToSequentialPoints}
|
|
79
|
+
actions={actions}
|
|
80
|
+
actionsInHeader={actionsInHeader}
|
|
81
|
+
aria-labelledby={effectiveAriaLabelledBy}
|
|
82
|
+
aria-label={ariaLabel}
|
|
83
|
+
>
|
|
84
|
+
{header ?? defaultMobileHeader}
|
|
85
|
+
<PanelContent>{children}</PanelContent>
|
|
86
|
+
{footer}
|
|
87
|
+
</Drawer>
|
|
88
|
+
</PanelContext.Provider>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import Box from "@sproutsocial/seeds-react-box";
|
|
3
2
|
import Button from "@sproutsocial/seeds-react-button";
|
|
4
3
|
import Drawer from "@sproutsocial/seeds-react-drawer";
|
|
5
4
|
import Icon from "@sproutsocial/seeds-react-icon";
|
|
@@ -20,6 +19,10 @@ interface TypePanelMobileActionsHeaderProps {
|
|
|
20
19
|
* pattern in `seeds-react-peek-in/PeekIn.tsx`. The Drawer's floating rail is
|
|
21
20
|
* suppressed in this combination (`actionsInHeader` opts out), so without this
|
|
22
21
|
* header the actions would render nowhere.
|
|
22
|
+
*
|
|
23
|
+
* This header is composed internally by Panel (it receives no consumer
|
|
24
|
+
* styled-system props), so its chrome is emitted directly as `seeds-panel-*`
|
|
25
|
+
* classes from panel.css rather than through a styled `Box`.
|
|
23
26
|
*/
|
|
24
27
|
export const PanelMobileActionsHeader = ({
|
|
25
28
|
title,
|
|
@@ -28,14 +31,7 @@ export const PanelMobileActionsHeader = ({
|
|
|
28
31
|
}: TypePanelMobileActionsHeaderProps) => (
|
|
29
32
|
<Drawer.Header
|
|
30
33
|
render={(ctx) => (
|
|
31
|
-
<
|
|
32
|
-
display="flex"
|
|
33
|
-
alignItems="center"
|
|
34
|
-
justifyContent="space-between"
|
|
35
|
-
pt={400}
|
|
36
|
-
px={450}
|
|
37
|
-
flex="0 0 auto"
|
|
38
|
-
>
|
|
34
|
+
<div className="seeds-panel-mobile-actions-header">
|
|
39
35
|
<Text
|
|
40
36
|
as="h2"
|
|
41
37
|
fontSize={400}
|
|
@@ -45,7 +41,7 @@ export const PanelMobileActionsHeader = ({
|
|
|
45
41
|
>
|
|
46
42
|
{title}
|
|
47
43
|
</Text>
|
|
48
|
-
<
|
|
44
|
+
<div className="seeds-panel-mobile-actions-buttons">
|
|
49
45
|
{actions.map((action, i) => (
|
|
50
46
|
<Button
|
|
51
47
|
key={i}
|
|
@@ -64,8 +60,8 @@ export const PanelMobileActionsHeader = ({
|
|
|
64
60
|
>
|
|
65
61
|
<Icon aria-hidden name="x-outline" />
|
|
66
62
|
</Button>
|
|
67
|
-
</
|
|
68
|
-
</
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
69
65
|
)}
|
|
70
66
|
/>
|
|
71
67
|
);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useIsMobile } from "@sproutsocial/seeds-react-hooks";
|
|
3
|
+
import { usePanelContext } from "./PanelContext";
|
|
4
|
+
import type {
|
|
5
|
+
PanelDirection,
|
|
6
|
+
TypePanelProps,
|
|
7
|
+
TypePanelContext,
|
|
8
|
+
} from "./PanelTypes";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Class-name merge helper. Copied intentionally rather than imported from
|
|
12
|
+
* `@sproutsocial/seeds-react-utilities` — `cn` is not exported there, so each
|
|
13
|
+
* migrated Tailwind component carries its own copy (mirrors AvatarTailwind /
|
|
14
|
+
* DrawerShared). Because Panel is a multi-part component, the helper lives once
|
|
15
|
+
* here and is imported by every Tailwind sub-component instead of being
|
|
16
|
+
* duplicated in each file.
|
|
17
|
+
*/
|
|
18
|
+
export function cn(
|
|
19
|
+
...inputs: (string | undefined | null | false | Record<string, boolean>)[]
|
|
20
|
+
): string {
|
|
21
|
+
const classes: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const input of inputs) {
|
|
24
|
+
if (!input) continue;
|
|
25
|
+
|
|
26
|
+
if (typeof input === "string") {
|
|
27
|
+
classes.push(input);
|
|
28
|
+
} else if (typeof input === "object") {
|
|
29
|
+
for (const [key, value] of Object.entries(input)) {
|
|
30
|
+
if (value) {
|
|
31
|
+
classes.push(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return classes.join(" ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dynamic geometry for the outer `<aside>` on the Tailwind path. These values
|
|
42
|
+
* depend on runtime props (open state, width, gap, direction) and are asserted
|
|
43
|
+
* as computed styles by the jsdom tests, so they must be inline `style` rather
|
|
44
|
+
* than CSS-variable-driven classes (panel.css is not loaded under jsdom). The
|
|
45
|
+
* static chrome (background, radius, transition, overflow, flex-grow/shrink)
|
|
46
|
+
* lives in panel.css.
|
|
47
|
+
*
|
|
48
|
+
* The gap sits on the margin edge facing the adjacent content: left when the
|
|
49
|
+
* panel anchors right, right when it anchors left, top when it anchors to the
|
|
50
|
+
* bottom (mirrors `gapSide` in styles.ts).
|
|
51
|
+
*/
|
|
52
|
+
export const getPanelContainerStyle = (
|
|
53
|
+
direction: PanelDirection,
|
|
54
|
+
width: number,
|
|
55
|
+
gap: number,
|
|
56
|
+
isPanelOpen: boolean
|
|
57
|
+
): React.CSSProperties => {
|
|
58
|
+
const gapValue = isPanelOpen && gap ? `${gap}px` : "0px";
|
|
59
|
+
const style: React.CSSProperties = {
|
|
60
|
+
flexBasis: isPanelOpen ? `${width}px` : "0px",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (direction === "bottom") {
|
|
64
|
+
style.width = "100%";
|
|
65
|
+
} else {
|
|
66
|
+
style.alignSelf = "stretch";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (direction === "right") {
|
|
70
|
+
style.marginLeft = gapValue;
|
|
71
|
+
} else if (direction === "left") {
|
|
72
|
+
style.marginRight = gapValue;
|
|
73
|
+
} else {
|
|
74
|
+
style.marginTop = gapValue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return style;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dynamic geometry for the inner layout `<div>` on the Tailwind path. The
|
|
82
|
+
* direction-dependent min-width/min-height come from the `width` prop.
|
|
83
|
+
*/
|
|
84
|
+
export const getPanelInnerStyle = (
|
|
85
|
+
direction: PanelDirection,
|
|
86
|
+
width: number
|
|
87
|
+
): React.CSSProperties =>
|
|
88
|
+
direction === "bottom"
|
|
89
|
+
? { minHeight: `${width}px`, width: "100%" }
|
|
90
|
+
: { minWidth: `${width}px`, height: "100%" };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Shared render logic for both the styled and the Tailwind shells: reads the
|
|
94
|
+
* PanelProvider context, enriches it (closeButtonLabel/headerless), freezes the
|
|
95
|
+
* last-rendered children during the collapse, resolves the mobile breakpoint,
|
|
96
|
+
* and computes the auto-wired accessible name. Centralising it here guarantees
|
|
97
|
+
* the two shells stay behaviourally identical — only the desktop container
|
|
98
|
+
* markup differs between them.
|
|
99
|
+
*/
|
|
100
|
+
export const usePanelShell = (props: TypePanelProps) => {
|
|
101
|
+
const {
|
|
102
|
+
children,
|
|
103
|
+
closeButtonLabel,
|
|
104
|
+
header,
|
|
105
|
+
headerless = false,
|
|
106
|
+
footer,
|
|
107
|
+
title,
|
|
108
|
+
titleId,
|
|
109
|
+
direction = "right",
|
|
110
|
+
width = 384,
|
|
111
|
+
gap = 4,
|
|
112
|
+
mobileBreakpoint,
|
|
113
|
+
zIndex,
|
|
114
|
+
id,
|
|
115
|
+
snapPoints,
|
|
116
|
+
defaultSnapPoint,
|
|
117
|
+
snapPoint,
|
|
118
|
+
onSnapPointChange,
|
|
119
|
+
snapToSequentialPoints,
|
|
120
|
+
actions,
|
|
121
|
+
actionsInHeader,
|
|
122
|
+
"aria-labelledby": ariaLabelledByProp,
|
|
123
|
+
"aria-label": ariaLabel,
|
|
124
|
+
...rest
|
|
125
|
+
} = props;
|
|
126
|
+
|
|
127
|
+
const panelContext = usePanelContext();
|
|
128
|
+
const { isPanelOpen, closePanel } = panelContext;
|
|
129
|
+
const isMobile = useIsMobile(mobileBreakpoint);
|
|
130
|
+
|
|
131
|
+
// Expose closeButtonLabel through context so PanelCloseButton can read it
|
|
132
|
+
// without prop drilling — same pattern as DrawerContext.
|
|
133
|
+
const enrichedValue = React.useMemo<TypePanelContext>(
|
|
134
|
+
() => ({ ...panelContext, closeButtonLabel, headerless }),
|
|
135
|
+
[panelContext, closeButtonLabel, headerless]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Freeze the last-rendered children during the collapse so consumers that
|
|
139
|
+
// conditionally render content don't produce an empty panel shell while the
|
|
140
|
+
// flex-basis animation runs.
|
|
141
|
+
const prevChildrenRef = React.useRef<React.ReactNode>(null);
|
|
142
|
+
if (isPanelOpen) {
|
|
143
|
+
prevChildrenRef.current = children;
|
|
144
|
+
}
|
|
145
|
+
const renderedChildren = isPanelOpen ? children : prevChildrenRef.current;
|
|
146
|
+
|
|
147
|
+
// When the consumer relies on the default header, its title element renders
|
|
148
|
+
// with `id={titleId}`. Auto-wire `aria-labelledby` so the dialog/aside has an
|
|
149
|
+
// accessible name without forcing every caller to repeat the id.
|
|
150
|
+
const effectiveAriaLabelledBy =
|
|
151
|
+
ariaLabelledByProp ?? (!headerless && header == null ? titleId : undefined);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
rest,
|
|
155
|
+
children,
|
|
156
|
+
closeButtonLabel,
|
|
157
|
+
header,
|
|
158
|
+
headerless,
|
|
159
|
+
footer,
|
|
160
|
+
title,
|
|
161
|
+
titleId,
|
|
162
|
+
direction,
|
|
163
|
+
width,
|
|
164
|
+
gap,
|
|
165
|
+
zIndex,
|
|
166
|
+
id,
|
|
167
|
+
snapPoints,
|
|
168
|
+
defaultSnapPoint,
|
|
169
|
+
snapPoint,
|
|
170
|
+
onSnapPointChange,
|
|
171
|
+
snapToSequentialPoints,
|
|
172
|
+
actions,
|
|
173
|
+
actionsInHeader,
|
|
174
|
+
ariaLabel,
|
|
175
|
+
isPanelOpen,
|
|
176
|
+
closePanel,
|
|
177
|
+
isMobile,
|
|
178
|
+
enrichedValue,
|
|
179
|
+
renderedChildren,
|
|
180
|
+
effectiveAriaLabelledBy,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The shared shell values consumed by both the styled and the Tailwind desktop
|
|
186
|
+
* shells and by the shared mobile render. Inferred from `usePanelShell` so the
|
|
187
|
+
* `rest` pass-through keeps its precise type (mirrors `TypeDrawerShell`).
|
|
188
|
+
*/
|
|
189
|
+
export type TypePanelShell = ReturnType<typeof usePanelShell>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PanelContext } from "./PanelContext";
|
|
2
|
+
import { PanelHeader } from "./PanelHeader";
|
|
3
|
+
import { PanelContent } from "./PanelContent";
|
|
4
|
+
import { PanelMobile } from "./PanelMobile";
|
|
5
|
+
import { PanelContainer, PanelInner } from "./styles";
|
|
6
|
+
import { usePanelShell } from "./PanelShared";
|
|
7
|
+
import type { TypePanelProps } from "./PanelTypes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Styled-components shell — the backward-compatible fallback. The hybrid router
|
|
11
|
+
* sends consumers here whenever they pass a `COMMON`-mixin styled-system prop
|
|
12
|
+
* (e.g. `mt`, `mb`, `mr`) or a `styled()` extension, so the full styled prop
|
|
13
|
+
* surface on the outer container keeps working exactly as before.
|
|
14
|
+
*/
|
|
15
|
+
export const PanelStyled = (props: TypePanelProps) => {
|
|
16
|
+
const shell = usePanelShell(props);
|
|
17
|
+
const {
|
|
18
|
+
rest,
|
|
19
|
+
header,
|
|
20
|
+
headerless,
|
|
21
|
+
footer,
|
|
22
|
+
title,
|
|
23
|
+
titleId,
|
|
24
|
+
direction,
|
|
25
|
+
width,
|
|
26
|
+
gap,
|
|
27
|
+
id,
|
|
28
|
+
ariaLabel,
|
|
29
|
+
isPanelOpen,
|
|
30
|
+
isMobile,
|
|
31
|
+
enrichedValue,
|
|
32
|
+
renderedChildren,
|
|
33
|
+
effectiveAriaLabelledBy,
|
|
34
|
+
} = shell;
|
|
35
|
+
|
|
36
|
+
if (isMobile) {
|
|
37
|
+
return <PanelMobile shell={shell} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<PanelContext.Provider value={enrichedValue}>
|
|
42
|
+
<PanelContainer
|
|
43
|
+
{...rest}
|
|
44
|
+
$isOpen={isPanelOpen}
|
|
45
|
+
$direction={direction}
|
|
46
|
+
$width={width}
|
|
47
|
+
$gap={gap}
|
|
48
|
+
data-qa-panel={id}
|
|
49
|
+
data-qa-panel-isopen={isPanelOpen}
|
|
50
|
+
aria-labelledby={effectiveAriaLabelledBy}
|
|
51
|
+
aria-label={ariaLabel}
|
|
52
|
+
>
|
|
53
|
+
<PanelInner $direction={direction} $width={width}>
|
|
54
|
+
{header ??
|
|
55
|
+
(headerless ? null : <PanelHeader title={title} id={titleId} />)}
|
|
56
|
+
<PanelContent>{renderedChildren}</PanelContent>
|
|
57
|
+
{footer}
|
|
58
|
+
</PanelInner>
|
|
59
|
+
</PanelContainer>
|
|
60
|
+
</PanelContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { PanelContext } from "./PanelContext";
|
|
3
|
+
import { PanelHeader } from "./PanelHeader";
|
|
4
|
+
import { PanelContent } from "./PanelContent";
|
|
5
|
+
import { PanelMobile } from "./PanelMobile";
|
|
6
|
+
import {
|
|
7
|
+
cn,
|
|
8
|
+
usePanelShell,
|
|
9
|
+
getPanelContainerStyle,
|
|
10
|
+
getPanelInnerStyle,
|
|
11
|
+
} from "./PanelShared";
|
|
12
|
+
import type { TypePanelProps } from "./PanelTypes";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tailwind/CSS-class shell — the preferred path, routed to by the hybrid when
|
|
16
|
+
* the consumer passes no styled-system props or `styled()` extension. Renders a
|
|
17
|
+
* plain `<aside>` (implicit `complementary` role) with the `seeds-panel` classes
|
|
18
|
+
* from panel.css instead of a styled container. Runtime geometry (flex-basis,
|
|
19
|
+
* gap margin, direction shape, inner min-width/min-height) is applied inline
|
|
20
|
+
* because the jsdom tests assert those as computed styles and panel.css is not
|
|
21
|
+
* loaded there. The mobile path renders identically to the styled shell via
|
|
22
|
+
* the shared `PanelMobile`.
|
|
23
|
+
*/
|
|
24
|
+
export const PanelTailwind = (props: TypePanelProps) => {
|
|
25
|
+
const shell = usePanelShell(props);
|
|
26
|
+
const {
|
|
27
|
+
rest,
|
|
28
|
+
header,
|
|
29
|
+
headerless,
|
|
30
|
+
footer,
|
|
31
|
+
title,
|
|
32
|
+
titleId,
|
|
33
|
+
direction,
|
|
34
|
+
width,
|
|
35
|
+
gap,
|
|
36
|
+
id,
|
|
37
|
+
ariaLabel,
|
|
38
|
+
isPanelOpen,
|
|
39
|
+
isMobile,
|
|
40
|
+
enrichedValue,
|
|
41
|
+
renderedChildren,
|
|
42
|
+
effectiveAriaLabelledBy,
|
|
43
|
+
} = shell;
|
|
44
|
+
|
|
45
|
+
if (isMobile) {
|
|
46
|
+
return <PanelMobile shell={shell} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `rest` carries only non-styled pass-through props on this path (the gate
|
|
50
|
+
// routes styled-system props to PanelStyled). Peel off className/style so we
|
|
51
|
+
// can merge them onto the component's own class and inline geometry rather
|
|
52
|
+
// than letting a spread clobber them.
|
|
53
|
+
const { className, style, ...domRest } = rest as {
|
|
54
|
+
className?: string;
|
|
55
|
+
style?: React.CSSProperties;
|
|
56
|
+
} & Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
const containerStyle = getPanelContainerStyle(
|
|
59
|
+
direction,
|
|
60
|
+
width,
|
|
61
|
+
gap,
|
|
62
|
+
isPanelOpen
|
|
63
|
+
);
|
|
64
|
+
const innerStyle = getPanelInnerStyle(direction, width);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<PanelContext.Provider value={enrichedValue}>
|
|
68
|
+
<aside
|
|
69
|
+
{...domRest}
|
|
70
|
+
className={cn("seeds-panel", className)}
|
|
71
|
+
style={{ ...containerStyle, ...style }}
|
|
72
|
+
data-qa-panel={id}
|
|
73
|
+
data-qa-panel-isopen={isPanelOpen}
|
|
74
|
+
aria-labelledby={effectiveAriaLabelledBy}
|
|
75
|
+
aria-label={ariaLabel}
|
|
76
|
+
>
|
|
77
|
+
<div className="seeds-panel-inner" style={innerStyle}>
|
|
78
|
+
{header ??
|
|
79
|
+
(headerless ? null : <PanelHeader title={title} id={titleId} />)}
|
|
80
|
+
<PanelContent>{renderedChildren}</PanelContent>
|
|
81
|
+
{footer}
|
|
82
|
+
</div>
|
|
83
|
+
</aside>
|
|
84
|
+
</PanelContext.Provider>
|
|
85
|
+
);
|
|
86
|
+
};
|