@borisj74/bv-ds 0.1.0 → 0.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/dist/index.cjs +194 -52
- package/dist/index.d.cts +58 -3
- package/dist/index.d.ts +58 -3
- package/dist/index.js +194 -54
- package/package.json +1 -1
- package/src/components/Button/Button.tsx +42 -22
- package/src/components/ButtonDestructive/ButtonDestructive.tsx +3 -3
- package/src/components/Modal/Modal.tsx +111 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/ModalHeader/ModalHeader.tsx +4 -1
- package/src/components/NavAccountCard/NavAccountCard.tsx +41 -4
- package/src/components/NavAccountCard/index.ts +5 -1
- package/src/components/SidebarNavigation/SidebarNavigation.tsx +1 -1
- package/src/components/SlideoutMenu/SlideoutMenu.tsx +86 -0
- package/src/components/SlideoutMenu/index.ts +2 -0
- package/src/index.ts +2 -0
- package/tailwind-preset.js +223 -210
|
@@ -15,32 +15,44 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
15
15
|
loading?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// Token class names use the doubled-prefix form that matches the nested
|
|
19
|
+
// tailwind-preset.js color groups (v0.1.2+):
|
|
20
|
+
// colors.bg["brand-solid"] → bg-bg-brand-solid
|
|
21
|
+
// colors.text["secondary"] → text-text-secondary
|
|
22
|
+
// colors.border["primary"] → border-border-primary
|
|
22
23
|
//
|
|
23
|
-
// Padding values
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// Padding values use Tailwind arbitrary values ([14px], [10px], etc) rather
|
|
25
|
+
// than the spacing-* scale — design.md §3 confirms the real Figma component
|
|
26
|
+
// uses raw px at certain sizes. Don't "fix" these to spacing tokens; that
|
|
27
|
+
// would silently diverge from the source design.
|
|
28
|
+
//
|
|
29
|
+
// Primary/Secondary use the skeuomorphic shadow stack confirmed in design.md:
|
|
30
|
+
// shadow-xs outer + inner border highlight + inner bottom shading.
|
|
28
31
|
|
|
29
32
|
const hierarchyClasses: Record<ButtonHierarchy, string> = {
|
|
30
33
|
Primary:
|
|
31
|
-
"bg-brand-solid text-white border-2 border-white/[0.12]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
"bg-bg-brand-solid text-text-white border-2 border-white/[0.12] shadow-skeuomorphic " +
|
|
35
|
+
"hover:bg-bg-brand-solid-hover",
|
|
36
|
+
Secondary:
|
|
37
|
+
"bg-bg-primary text-text-secondary border border-border-border-primary shadow-skeuomorphic " +
|
|
38
|
+
"hover:bg-bg-primary-hover",
|
|
39
|
+
Tertiary:
|
|
40
|
+
"bg-transparent text-text-tertiary border-0 " +
|
|
41
|
+
"hover:bg-bg-secondary",
|
|
42
|
+
"Link color":
|
|
43
|
+
"bg-transparent text-text-brand-secondary border-0 p-0 " +
|
|
44
|
+
"hover:text-text-brand-secondary-hover",
|
|
45
|
+
"Link gray":
|
|
46
|
+
"bg-transparent text-text-tertiary border-0 p-0 " +
|
|
47
|
+
"hover:text-text-tertiary-hover",
|
|
36
48
|
};
|
|
37
49
|
|
|
38
50
|
const sizeClasses: Record<ButtonSize, string> = {
|
|
39
|
-
xs: "px-[10px] py-sm text-xs",
|
|
40
|
-
sm: "px-lg py-md text-xs",
|
|
41
|
-
md: "px-[14px] py-[10px] text-sm",
|
|
42
|
-
lg: "px-xl py-[10px] text-md",
|
|
43
|
-
xl: "px-[18px] py-lg text-md",
|
|
51
|
+
xs: "px-[10px] py-sm gap-xs text-xs font-semibold",
|
|
52
|
+
sm: "px-lg py-md gap-xs text-xs font-semibold",
|
|
53
|
+
md: "px-[14px] py-[10px] gap-xs text-sm font-semibold",
|
|
54
|
+
lg: "px-xl py-[10px] gap-sm text-md font-semibold",
|
|
55
|
+
xl: "px-[18px] py-lg gap-sm text-md font-semibold",
|
|
44
56
|
};
|
|
45
57
|
|
|
46
58
|
export function Button({
|
|
@@ -55,8 +67,10 @@ export function Button({
|
|
|
55
67
|
return (
|
|
56
68
|
<button
|
|
57
69
|
className={clsx(
|
|
58
|
-
"
|
|
59
|
-
"
|
|
70
|
+
"inline-flex items-center justify-center font-body rounded-md",
|
|
71
|
+
"cursor-pointer transition-colors",
|
|
72
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-brand focus-visible:ring-offset-2",
|
|
73
|
+
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none",
|
|
60
74
|
hierarchyClasses[hierarchy],
|
|
61
75
|
sizeClasses[size],
|
|
62
76
|
className,
|
|
@@ -65,7 +79,13 @@ export function Button({
|
|
|
65
79
|
aria-disabled={disabled || loading}
|
|
66
80
|
{...rest}
|
|
67
81
|
>
|
|
68
|
-
{loading ?
|
|
82
|
+
{loading ? (
|
|
83
|
+
<span className="opacity-70">Loading…</span>
|
|
84
|
+
) : (
|
|
85
|
+
children
|
|
86
|
+
)}
|
|
69
87
|
</button>
|
|
70
88
|
);
|
|
71
89
|
}
|
|
90
|
+
|
|
91
|
+
export default Button;
|
|
@@ -20,9 +20,9 @@ export interface ButtonDestructiveProps
|
|
|
20
20
|
// error ring (border-error).
|
|
21
21
|
const hierarchyClasses: Record<ButtonDestructiveHierarchy, string> = {
|
|
22
22
|
Primary:
|
|
23
|
-
"bg-error-solid text-white border-2 border-white/[0.12] shadow-skeuomorphic hover:bg-error-solid-hover",
|
|
23
|
+
"bg-bg-error-solid text-text-white border-2 border-white/[0.12] shadow-skeuomorphic hover:bg-bg-error-solid-hover",
|
|
24
24
|
Secondary:
|
|
25
|
-
"bg-bg-primary text-text-error-primary border border-border-error-subtle shadow-skeuomorphic hover:bg-bg-error-primary",
|
|
25
|
+
"bg-bg-primary text-text-error-primary border border-border-border-error-subtle shadow-skeuomorphic hover:bg-bg-error-primary",
|
|
26
26
|
Tertiary:
|
|
27
27
|
"bg-transparent text-text-error-primary border-0 hover:bg-bg-error-primary",
|
|
28
28
|
Link: "bg-transparent text-text-error-primary border-0 p-0 hover:text-text-error-primary",
|
|
@@ -51,7 +51,7 @@ export function ButtonDestructive({
|
|
|
51
51
|
<button
|
|
52
52
|
className={clsx(
|
|
53
53
|
"font-body font-semibold rounded-md cursor-pointer transition-colors",
|
|
54
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-error focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
|
|
54
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-border-error focus-visible:ring-offset-2 focus-visible:ring-offset-bg-bg-primary",
|
|
55
55
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
56
56
|
hierarchyClasses[hierarchy],
|
|
57
57
|
hierarchy === "Link" ? "" : sizeClasses[size],
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useRef,
|
|
8
|
+
type HTMLAttributes,
|
|
9
|
+
type MouseEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import clsx from "clsx";
|
|
13
|
+
import { ModalHeader } from "../ModalHeader";
|
|
14
|
+
|
|
15
|
+
export type ModalSize = "sm" | "md" | "lg" | "xl";
|
|
16
|
+
|
|
17
|
+
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
18
|
+
/** Controls visibility — renders nothing when false. */
|
|
19
|
+
open: boolean;
|
|
20
|
+
/** Fired on backdrop click-outside, Escape, or the header's x-close. */
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
/** Panel max-width: sm=400 / md=560 / lg=720 / xl=960 px. Full width on mobile. */
|
|
23
|
+
size?: ModalSize;
|
|
24
|
+
/** Modal contents — compose `ModalHeader`, a body, and `ModalActions` here. */
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sizeMaxWidth: Record<ModalSize, string> = {
|
|
29
|
+
sm: "max-w-[400px]",
|
|
30
|
+
md: "max-w-[560px]",
|
|
31
|
+
lg: "max-w-[720px]",
|
|
32
|
+
xl: "max-w-[960px]",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Modal shell — fixed backdrop + centred panel. Layout/overlay only: it does NOT
|
|
37
|
+
* render a header or footer, those are composed as children (`ModalHeader` /
|
|
38
|
+
* `ModalActions`). Backdrop click-outside and Escape both call `onClose`; clicks
|
|
39
|
+
* inside the panel are stopped from bubbling. On open, focus moves into the panel
|
|
40
|
+
* and is restored to the previously-focused element on close. If a direct
|
|
41
|
+
* `ModalHeader` child is present its title is wired to `aria-labelledby`.
|
|
42
|
+
* Renders `null` while `open` is false.
|
|
43
|
+
*/
|
|
44
|
+
export function Modal({
|
|
45
|
+
open,
|
|
46
|
+
onClose,
|
|
47
|
+
size = "md",
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
...rest
|
|
51
|
+
}: ModalProps) {
|
|
52
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
54
|
+
const titleId = useId();
|
|
55
|
+
|
|
56
|
+
// Escape-to-close + focus management. Hooks run before the early return below.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!open) return;
|
|
59
|
+
|
|
60
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
|
61
|
+
panelRef.current?.focus();
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
64
|
+
if (e.key === "Escape") onClose();
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
70
|
+
previousFocusRef.current?.focus();
|
|
71
|
+
};
|
|
72
|
+
}, [open, onClose]);
|
|
73
|
+
|
|
74
|
+
if (!open) return null;
|
|
75
|
+
|
|
76
|
+
const handlePanelClick = (e: MouseEvent<HTMLDivElement>) => e.stopPropagation();
|
|
77
|
+
|
|
78
|
+
// Wire aria-labelledby to a ModalHeader title if one is a direct child.
|
|
79
|
+
let hasLabelledHeader = false;
|
|
80
|
+
const labelledChildren = Children.map(children, (child) => {
|
|
81
|
+
if (isValidElement(child) && child.type === ModalHeader) {
|
|
82
|
+
hasLabelledHeader = true;
|
|
83
|
+
return cloneElement(child as React.ReactElement<{ titleId?: string }>, { titleId });
|
|
84
|
+
}
|
|
85
|
+
return child;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-bg-overlay/70 backdrop-blur-sm"
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
ref={panelRef}
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-labelledby={hasLabelledHeader ? titleId : undefined}
|
|
98
|
+
tabIndex={-1}
|
|
99
|
+
onClick={handlePanelClick}
|
|
100
|
+
className={clsx(
|
|
101
|
+
"mx-4 flex w-full flex-col overflow-hidden rounded-2xl bg-bg-primary shadow-xl outline-none sm:mx-0",
|
|
102
|
+
sizeMaxWidth[size],
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
{...rest}
|
|
106
|
+
>
|
|
107
|
+
{labelledChildren}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface ModalHeaderProps
|
|
|
19
19
|
divider?: boolean;
|
|
20
20
|
/** Renders an x-close button top-right when provided. */
|
|
21
21
|
onClose?: () => void;
|
|
22
|
+
/** id placed on the title element so a parent `Modal` can wire `aria-labelledby`. */
|
|
23
|
+
titleId?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
const FeaturedIcon = ({ children }: { children: ReactNode }) => (
|
|
@@ -45,6 +47,7 @@ export function ModalHeader({
|
|
|
45
47
|
type = "left",
|
|
46
48
|
divider = true,
|
|
47
49
|
onClose,
|
|
50
|
+
titleId,
|
|
48
51
|
className,
|
|
49
52
|
...rest
|
|
50
53
|
}: ModalHeaderProps) {
|
|
@@ -75,7 +78,7 @@ export function ModalHeader({
|
|
|
75
78
|
isCenter && "items-center text-center",
|
|
76
79
|
)}
|
|
77
80
|
>
|
|
78
|
-
<p className="w-full text-md font-semibold text-text-primary">{title}</p>
|
|
81
|
+
<p id={titleId} className="w-full text-md font-semibold text-text-primary">{title}</p>
|
|
79
82
|
{description && (
|
|
80
83
|
<p className="w-full text-sm text-text-tertiary">{description}</p>
|
|
81
84
|
)}
|
|
@@ -2,6 +2,7 @@ import { type HTMLAttributes, type ReactNode } from "react";
|
|
|
2
2
|
import clsx from "clsx";
|
|
3
3
|
|
|
4
4
|
export type NavAccountCardVariant = "simple" | "card";
|
|
5
|
+
export type NavAccountCardBreakpoint = "desktop" | "mobile";
|
|
5
6
|
|
|
6
7
|
export interface NavAccountCardProps
|
|
7
8
|
extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
@@ -9,7 +10,14 @@ export interface NavAccountCardProps
|
|
|
9
10
|
variant?: NavAccountCardVariant;
|
|
10
11
|
/** card only — whether the dropdown menu is shown. */
|
|
11
12
|
open?: boolean;
|
|
13
|
+
/** Width: `desktop` = 280px · `mobile` = 256px. */
|
|
14
|
+
breakpoint?: NavAccountCardBreakpoint;
|
|
15
|
+
/** Custom avatar node — takes precedence over the built-in `src` treatment. */
|
|
12
16
|
avatar?: ReactNode;
|
|
17
|
+
/** Image URL for the built-in layered avatar (used when `avatar` is not supplied). */
|
|
18
|
+
src?: string;
|
|
19
|
+
/** Status dot on the built-in avatar — `true` online (green) · `false` offline (gray) · omit for none. */
|
|
20
|
+
online?: boolean;
|
|
13
21
|
name?: ReactNode;
|
|
14
22
|
email?: ReactNode;
|
|
15
23
|
/** card only — toggles the dropdown (fires on trigger click). */
|
|
@@ -41,6 +49,28 @@ const LogOut = () => (
|
|
|
41
49
|
</svg>
|
|
42
50
|
);
|
|
43
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Layered avatar treatment from Figma (node 7891:87996): white outer ring +
|
|
54
|
+
* inner hairline border + status dot. `border-[rgba(0,0,0,0.16)]` is a one-off
|
|
55
|
+
* value (no matching token) per the Figma spec. Used when `src` is supplied and
|
|
56
|
+
* no `avatar` slot is given.
|
|
57
|
+
*/
|
|
58
|
+
const LayeredAvatar = ({ src, online }: { src?: string; online?: boolean }) => (
|
|
59
|
+
<span className="relative inline-flex size-10 shrink-0 rounded-full border-[0.75px] border-border-secondary-alt bg-bg-primary p-[1px] shadow-xs">
|
|
60
|
+
<span className="flex size-full overflow-hidden rounded-full border-[0.5px] border-[rgba(0,0,0,0.16)]">
|
|
61
|
+
<img src={src} alt="" className="size-full rounded-full object-cover" />
|
|
62
|
+
</span>
|
|
63
|
+
{online !== undefined && (
|
|
64
|
+
<span
|
|
65
|
+
className={clsx(
|
|
66
|
+
"absolute bottom-[-2px] right-[-2px] size-[14px] rounded-full border-[1.5px] border-bg-primary",
|
|
67
|
+
online ? "bg-fg-success-secondary" : "bg-utility-neutral-300",
|
|
68
|
+
)}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
|
|
44
74
|
const AvatarLabel = ({ avatar, name, email }: { avatar?: ReactNode; name?: ReactNode; email?: ReactNode }) => (
|
|
45
75
|
<div className="flex min-w-0 flex-1 items-center gap-md">
|
|
46
76
|
{avatar && <span className="shrink-0">{avatar}</span>}
|
|
@@ -65,7 +95,10 @@ const AvatarLabel = ({ avatar, name, email }: { avatar?: ReactNode; name?: React
|
|
|
65
95
|
export function NavAccountCard({
|
|
66
96
|
variant = "card",
|
|
67
97
|
open = false,
|
|
98
|
+
breakpoint = "desktop",
|
|
68
99
|
avatar,
|
|
100
|
+
src,
|
|
101
|
+
online,
|
|
69
102
|
name,
|
|
70
103
|
email,
|
|
71
104
|
onToggle,
|
|
@@ -74,16 +107,20 @@ export function NavAccountCard({
|
|
|
74
107
|
className,
|
|
75
108
|
...rest
|
|
76
109
|
}: NavAccountCardProps) {
|
|
110
|
+
const widthClass = breakpoint === "mobile" ? "w-[256px]" : "w-[280px]";
|
|
111
|
+
const avatarNode = avatar ?? (src !== undefined ? <LayeredAvatar src={src} online={online} /> : undefined);
|
|
112
|
+
|
|
77
113
|
if (variant === "simple") {
|
|
78
114
|
return (
|
|
79
115
|
<div
|
|
80
116
|
className={clsx(
|
|
81
|
-
"relative flex
|
|
117
|
+
"relative flex items-start gap-xl border-t border-border-secondary px-md pt-2xl",
|
|
118
|
+
widthClass,
|
|
82
119
|
className,
|
|
83
120
|
)}
|
|
84
121
|
{...rest}
|
|
85
122
|
>
|
|
86
|
-
<AvatarLabel avatar={
|
|
123
|
+
<AvatarLabel avatar={avatarNode} name={name} email={email} />
|
|
87
124
|
<button
|
|
88
125
|
type="button"
|
|
89
126
|
onClick={onSignOut}
|
|
@@ -97,13 +134,13 @@ export function NavAccountCard({
|
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
return (
|
|
100
|
-
<div className={clsx("relative
|
|
137
|
+
<div className={clsx("relative", widthClass, className)} {...rest}>
|
|
101
138
|
<button
|
|
102
139
|
type="button"
|
|
103
140
|
onClick={onToggle}
|
|
104
141
|
className="relative flex w-full items-start gap-xl rounded-xl border border-border-secondary bg-bg-primary-alt p-lg text-left shadow-xs"
|
|
105
142
|
>
|
|
106
|
-
<AvatarLabel avatar={
|
|
143
|
+
<AvatarLabel avatar={avatarNode} name={name} email={email} />
|
|
107
144
|
<span
|
|
108
145
|
className={clsx(
|
|
109
146
|
"absolute right-[7px] top-[7px] flex items-center justify-center rounded-sm p-sm text-fg-quaternary",
|
|
@@ -79,7 +79,7 @@ export function SidebarNavigation({
|
|
|
79
79
|
return (
|
|
80
80
|
<>
|
|
81
81
|
{/* Desktop rail */}
|
|
82
|
-
<aside className="hidden h-full md:
|
|
82
|
+
<aside className="hidden h-full md:flex">{rail}</aside>
|
|
83
83
|
|
|
84
84
|
{/* Mobile trigger + drawer */}
|
|
85
85
|
<div className="md:hidden">
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
type HTMLAttributes,
|
|
4
|
+
type MouseEvent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import clsx from "clsx";
|
|
8
|
+
|
|
9
|
+
export type SlideoutMenuSide = "right" | "left";
|
|
10
|
+
export type SlideoutMenuSize = "sm" | "md" | "lg";
|
|
11
|
+
|
|
12
|
+
export interface SlideoutMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
13
|
+
/** Controls visibility. The panel stays mounted to animate the slide in/out. */
|
|
14
|
+
open: boolean;
|
|
15
|
+
/** Fired on backdrop click-outside, Escape, or the header's x-close. */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** Edge the panel docks to and slides in from. */
|
|
18
|
+
side?: SlideoutMenuSide;
|
|
19
|
+
/** Panel width: sm=360 / md=480 / lg=600 px. Full width on mobile. */
|
|
20
|
+
size?: SlideoutMenuSize;
|
|
21
|
+
/** Drawer contents — compose `SlideOutMenuHeader`, a body, and a footer here. */
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sizeWidth: Record<SlideoutMenuSize, string> = {
|
|
26
|
+
sm: "sm:w-[360px]",
|
|
27
|
+
md: "sm:w-[480px]",
|
|
28
|
+
lg: "sm:w-[600px]",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Slide-out / drawer shell — fixed backdrop + edge-docked panel that slides in
|
|
33
|
+
* from `side` via a `translate-x` transition. Layout/overlay only: the header is
|
|
34
|
+
* composed as a child (`SlideOutMenuHeader`), not reimplemented. Backdrop
|
|
35
|
+
* click-outside and Escape both call `onClose`. The panel stays mounted (toggled
|
|
36
|
+
* via transform + the backdrop's pointer-events) so both enter and exit animate.
|
|
37
|
+
*/
|
|
38
|
+
export function SlideoutMenu({
|
|
39
|
+
open,
|
|
40
|
+
onClose,
|
|
41
|
+
side = "right",
|
|
42
|
+
size = "md",
|
|
43
|
+
children,
|
|
44
|
+
className,
|
|
45
|
+
...rest
|
|
46
|
+
}: SlideoutMenuProps) {
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === "Escape") onClose();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
53
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
54
|
+
}, [open, onClose]);
|
|
55
|
+
|
|
56
|
+
const handlePanelClick = (e: MouseEvent<HTMLDivElement>) => e.stopPropagation();
|
|
57
|
+
|
|
58
|
+
const closedTransform = side === "right" ? "translate-x-full" : "-translate-x-full";
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
onClick={onClose}
|
|
63
|
+
aria-hidden={!open}
|
|
64
|
+
className={clsx(
|
|
65
|
+
"fixed inset-0 z-50 bg-bg-overlay/70 backdrop-blur-sm transition-opacity duration-300",
|
|
66
|
+
open ? "opacity-100" : "pointer-events-none opacity-0",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<div
|
|
70
|
+
role="dialog"
|
|
71
|
+
aria-modal="true"
|
|
72
|
+
onClick={handlePanelClick}
|
|
73
|
+
className={clsx(
|
|
74
|
+
"fixed bottom-0 top-0 flex w-full flex-col bg-bg-primary shadow-xl transition-transform duration-300 ease-in-out",
|
|
75
|
+
side === "right" ? "right-0" : "left-0",
|
|
76
|
+
sizeWidth[size],
|
|
77
|
+
open ? "translate-x-0" : closedTransform,
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...rest}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -86,6 +86,7 @@ export * from "./components/MessageActionPanel";
|
|
|
86
86
|
export * from "./components/MessageReaction";
|
|
87
87
|
export * from "./components/MessageStatusIcon";
|
|
88
88
|
export * from "./components/MetricItem";
|
|
89
|
+
export * from "./components/Modal";
|
|
89
90
|
export * from "./components/ModalActions";
|
|
90
91
|
export * from "./components/ModalHeader";
|
|
91
92
|
export * from "./components/MultiSelect";
|
|
@@ -121,6 +122,7 @@ export * from "./components/Select";
|
|
|
121
122
|
export * from "./components/SelectMenuItem";
|
|
122
123
|
export * from "./components/SidebarNavigation";
|
|
123
124
|
export * from "./components/SlideOutMenuHeader";
|
|
125
|
+
export * from "./components/SlideoutMenu";
|
|
124
126
|
export * from "./components/Slider";
|
|
125
127
|
export * from "./components/SocialButton";
|
|
126
128
|
export * from "./components/StatusIcon";
|