@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.
@@ -15,32 +15,44 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
15
15
  loading?: boolean;
16
16
  }
17
17
 
18
- // NOTE: this is a placeholder implementation to verify the build/Storybook
19
- // pipeline end-to-end. The figma-to-react skill should overwrite this with
20
- // the full per-hierarchy/per-size token mapping from design.md §3 (all 5
21
- // sizes, all 5 hierarchies, skeuomorphic shadow stack on Primary/Secondary).
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 below use Tailwind arbitrary values ([14px], [10px], etc)
24
- // rather than the spacing-* scale on purpose — design.md §3 confirms the
25
- // real Figma component uses raw px at md/lg/xl, not the nearest token.
26
- // Don't "fix" these to spacing-md/spacing-lg; that would silently diverge
27
- // from the source design.
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] hover:bg-bg-brand-solid-hover",
32
- Secondary: "bg-bg-primary text-text-secondary border border-border-primary",
33
- Tertiary: "bg-transparent text-text-tertiary border-0",
34
- "Link color": "bg-transparent text-text-brand-secondary border-0 p-0",
35
- "Link gray": "bg-transparent text-text-tertiary border-0 p-0",
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
- "font-body font-semibold rounded-md cursor-pointer transition-colors",
59
- "disabled:opacity-50 disabled:cursor-not-allowed",
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 ? "Loading…" : children}
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
+ }
@@ -0,0 +1,2 @@
1
+ export { Modal } from "./Modal";
2
+ export type { ModalProps, ModalSize } from "./Modal";
@@ -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 w-[280px] items-start gap-xl border-t border-border-secondary px-md pt-2xl",
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={avatar} name={name} email={email} />
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 w-[280px]", className)} {...rest}>
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={avatar} name={name} email={email} />
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",
@@ -1,2 +1,6 @@
1
1
  export { NavAccountCard } from "./NavAccountCard";
2
- export type { NavAccountCardProps, NavAccountCardVariant } from "./NavAccountCard";
2
+ export type {
3
+ NavAccountCardProps,
4
+ NavAccountCardVariant,
5
+ NavAccountCardBreakpoint,
6
+ } from "./NavAccountCard";
@@ -79,7 +79,7 @@ export function SidebarNavigation({
79
79
  return (
80
80
  <>
81
81
  {/* Desktop rail */}
82
- <aside className="hidden h-full md:block">{rail}</aside>
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
+ }
@@ -0,0 +1,2 @@
1
+ export { SlideoutMenu } from "./SlideoutMenu";
2
+ export type { SlideoutMenuProps, SlideoutMenuSide, SlideoutMenuSize } from "./SlideoutMenu";
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";