@syscore/ui-library 1.25.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/ui/app-bar.tsx +299 -0
- package/client/components/ui/banner.tsx +108 -0
- package/client/components/ui/bottom-navigation.tsx +223 -0
- package/client/components/ui/card.tsx +108 -26
- package/client/components/ui/date-picker.tsx +188 -0
- package/client/components/ui/empty-state.tsx +197 -0
- package/client/components/ui/file-upload.tsx +214 -0
- package/client/components/ui/footer.tsx +179 -0
- package/client/components/ui/layout.tsx +381 -0
- package/client/components/ui/sidebar.tsx +11 -5
- package/client/components/ui/stepper.tsx +148 -0
- package/client/components/ui/system-bar.tsx +119 -0
- package/client/components/ui/timeline.tsx +181 -0
- package/client/global.css +2304 -41
- package/client/ui/AppBar/app-bar.stories.tsx +280 -0
- package/client/ui/Banner/banner.stories.tsx +238 -0
- package/client/ui/BottomNavigation/bottom-navigation.stories.tsx +285 -0
- package/client/ui/Card.stories.tsx +285 -168
- package/client/ui/DatePicker/DatePicker.stories.tsx +293 -0
- package/client/ui/EmptyState/empty-state.stories.tsx +251 -0
- package/client/ui/FileUpload/FileUpload.stories.tsx +218 -0
- package/client/ui/Footer/footer.stories.tsx +194 -0
- package/client/ui/Layout.stories.tsx +1481 -0
- package/client/ui/Stepper/Stepper.stories.tsx +344 -0
- package/client/ui/SystemBar/system-bar.stories.tsx +179 -0
- package/client/ui/Timeline/timeline.stories.tsx +404 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +219 -0
- package/dist/index.es.js +1504 -99
- package/package.json +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
// ─── AppBar Root ────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface AppBarProps extends React.HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Collapses the bar to show only the prepend slot */
|
|
8
|
+
collapse?: boolean;
|
|
9
|
+
/** Rounds the corners of the bar */
|
|
10
|
+
rounded?: boolean;
|
|
11
|
+
/** Elevates the bar with a shadow */
|
|
12
|
+
elevated?: boolean;
|
|
13
|
+
/** Makes the bar flat (no shadow, no border) */
|
|
14
|
+
flat?: boolean;
|
|
15
|
+
/** Renders bar as fixed (default) or sticky */
|
|
16
|
+
position?: "fixed" | "sticky" | "relative" | "static";
|
|
17
|
+
/** Color / surface variant */
|
|
18
|
+
color?: "default" | "primary" | "transparent";
|
|
19
|
+
/**
|
|
20
|
+
* Density of the bar — controls height.
|
|
21
|
+
* - "default" → 64px
|
|
22
|
+
* - "comfortable" → 56px
|
|
23
|
+
* - "compact" → 48px
|
|
24
|
+
* - "prominent" → 128px (title sits at the bottom)
|
|
25
|
+
*/
|
|
26
|
+
density?: "default" | "comfortable" | "compact" | "prominent";
|
|
27
|
+
/**
|
|
28
|
+
* URL for a background image on the app bar.
|
|
29
|
+
* Pair with `imageOverlay` for a gradient on top.
|
|
30
|
+
*/
|
|
31
|
+
image?: string;
|
|
32
|
+
/**
|
|
33
|
+
* CSS gradient string applied as an overlay on top of the `image`.
|
|
34
|
+
* Example: "to top right, rgba(19,84,122,.8), rgba(128,208,199,.8)"
|
|
35
|
+
* Renders as: linear-gradient(<value>)
|
|
36
|
+
*/
|
|
37
|
+
imageOverlay?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DENSITY_HEIGHT: Record<
|
|
41
|
+
NonNullable<AppBarProps["density"]>,
|
|
42
|
+
number
|
|
43
|
+
> = {
|
|
44
|
+
default: 64,
|
|
45
|
+
comfortable: 56,
|
|
46
|
+
compact: 48,
|
|
47
|
+
prominent: 128,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const AppBar = React.forwardRef<HTMLElement, AppBarProps>(
|
|
51
|
+
(
|
|
52
|
+
{
|
|
53
|
+
className,
|
|
54
|
+
children,
|
|
55
|
+
collapse = false,
|
|
56
|
+
rounded = false,
|
|
57
|
+
elevated = false,
|
|
58
|
+
flat = false,
|
|
59
|
+
position = "fixed",
|
|
60
|
+
color = "default",
|
|
61
|
+
density = "default",
|
|
62
|
+
image,
|
|
63
|
+
imageOverlay,
|
|
64
|
+
style,
|
|
65
|
+
...props
|
|
66
|
+
},
|
|
67
|
+
ref
|
|
68
|
+
) => {
|
|
69
|
+
const height = DENSITY_HEIGHT[density];
|
|
70
|
+
const isProminent = density === "prominent";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<header
|
|
74
|
+
ref={ref}
|
|
75
|
+
role="banner"
|
|
76
|
+
style={{
|
|
77
|
+
height: collapse ? undefined : height,
|
|
78
|
+
...style,
|
|
79
|
+
}}
|
|
80
|
+
className={cn(
|
|
81
|
+
"app-bar",
|
|
82
|
+
// position
|
|
83
|
+
position === "fixed" && "app-bar--fixed",
|
|
84
|
+
position === "sticky" && "app-bar--sticky",
|
|
85
|
+
position === "relative" && "app-bar--relative",
|
|
86
|
+
position === "static" && "app-bar--static",
|
|
87
|
+
// color variants
|
|
88
|
+
color === "primary" && "app-bar--primary",
|
|
89
|
+
color === "transparent" && "app-bar--transparent",
|
|
90
|
+
// density
|
|
91
|
+
density === "comfortable" && "app-bar--comfortable",
|
|
92
|
+
density === "compact" && "app-bar--compact",
|
|
93
|
+
isProminent && "app-bar--prominent",
|
|
94
|
+
// modifiers
|
|
95
|
+
rounded && "app-bar--rounded",
|
|
96
|
+
elevated && "app-bar--elevated",
|
|
97
|
+
flat && "app-bar--flat",
|
|
98
|
+
collapse && "app-bar--collapse",
|
|
99
|
+
// image
|
|
100
|
+
image && "app-bar--image",
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
{...props}
|
|
104
|
+
>
|
|
105
|
+
{/* Background image layer */}
|
|
106
|
+
{image && (
|
|
107
|
+
<span
|
|
108
|
+
className="app-bar-image-layer"
|
|
109
|
+
style={{ backgroundImage: `url(${image})` }}
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
>
|
|
112
|
+
{imageOverlay && (
|
|
113
|
+
<span
|
|
114
|
+
className="app-bar-image-overlay"
|
|
115
|
+
style={{
|
|
116
|
+
background: `linear-gradient(${imageOverlay})`,
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{/* Prominent layout: split children into top-row (prepend/append)
|
|
124
|
+
and bottom-row (title) so the title sits at the bottom */}
|
|
125
|
+
{isProminent ? (
|
|
126
|
+
<ProminentLayout>{children}</ProminentLayout>
|
|
127
|
+
) : (
|
|
128
|
+
children
|
|
129
|
+
)}
|
|
130
|
+
</header>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
AppBar.displayName = "AppBar";
|
|
135
|
+
|
|
136
|
+
// ─── AppBarNavIcon ───────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export interface AppBarNavIconProps
|
|
139
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
140
|
+
/** Custom icon; defaults to hamburger menu */
|
|
141
|
+
icon?: React.ReactNode;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const AppBarNavIcon = React.forwardRef<HTMLButtonElement, AppBarNavIconProps>(
|
|
145
|
+
({ className, icon, children, ...props }, ref) => (
|
|
146
|
+
<button
|
|
147
|
+
ref={ref}
|
|
148
|
+
type="button"
|
|
149
|
+
aria-label="Open navigation menu"
|
|
150
|
+
className={cn("app-bar-nav-icon", className)}
|
|
151
|
+
{...props}
|
|
152
|
+
>
|
|
153
|
+
{children ?? icon ?? <HamburgerIcon />}
|
|
154
|
+
</button>
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
AppBarNavIcon.displayName = "AppBarNavIcon";
|
|
158
|
+
|
|
159
|
+
// ─── AppBarTitle ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export interface AppBarTitleProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|
162
|
+
|
|
163
|
+
const AppBarTitle = React.forwardRef<HTMLDivElement, AppBarTitleProps>(
|
|
164
|
+
({ className, ...props }, ref) => (
|
|
165
|
+
<div
|
|
166
|
+
ref={ref}
|
|
167
|
+
className={cn("app-bar-title", className)}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
AppBarTitle.displayName = "AppBarTitle";
|
|
173
|
+
|
|
174
|
+
// ─── AppBarPrepend ───────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export interface AppBarPrependProps
|
|
177
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
178
|
+
|
|
179
|
+
const AppBarPrepend = React.forwardRef<HTMLDivElement, AppBarPrependProps>(
|
|
180
|
+
({ className, ...props }, ref) => (
|
|
181
|
+
<div
|
|
182
|
+
ref={ref}
|
|
183
|
+
className={cn("app-bar-prepend", className)}
|
|
184
|
+
{...props}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
AppBarPrepend.displayName = "AppBarPrepend";
|
|
189
|
+
|
|
190
|
+
// ─── AppBarAppend ─────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export interface AppBarAppendProps
|
|
193
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
194
|
+
|
|
195
|
+
const AppBarAppend = React.forwardRef<HTMLDivElement, AppBarAppendProps>(
|
|
196
|
+
({ className, ...props }, ref) => (
|
|
197
|
+
<div
|
|
198
|
+
ref={ref}
|
|
199
|
+
className={cn("app-bar-append", className)}
|
|
200
|
+
{...props}
|
|
201
|
+
/>
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
AppBarAppend.displayName = "AppBarAppend";
|
|
205
|
+
|
|
206
|
+
// ─── AppBarContent (fills remaining horizontal space) ────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface AppBarContentProps
|
|
209
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
210
|
+
|
|
211
|
+
const AppBarContent = React.forwardRef<HTMLDivElement, AppBarContentProps>(
|
|
212
|
+
({ className, ...props }, ref) => (
|
|
213
|
+
<div
|
|
214
|
+
ref={ref}
|
|
215
|
+
className={cn("app-bar-content", className)}
|
|
216
|
+
{...props}
|
|
217
|
+
/>
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
AppBarContent.displayName = "AppBarContent";
|
|
221
|
+
|
|
222
|
+
// ─── ProminentLayout helper ───────────────────────────────────────────────
|
|
223
|
+
// Splits children into two rows: prepend+append at top, title at bottom.
|
|
224
|
+
// Matches Vuetify's prominent bar layout.
|
|
225
|
+
|
|
226
|
+
function ProminentLayout({ children }: { children: React.ReactNode }) {
|
|
227
|
+
const kids = React.Children.toArray(children);
|
|
228
|
+
|
|
229
|
+
const topKids = kids.filter((child) => {
|
|
230
|
+
if (!React.isValidElement(child)) return false;
|
|
231
|
+
const name =
|
|
232
|
+
(child.type as { displayName?: string }).displayName ?? "";
|
|
233
|
+
return name === "AppBarPrepend" || name === "AppBarAppend";
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const bottomKids = kids.filter((child) => {
|
|
237
|
+
if (!React.isValidElement(child)) return false;
|
|
238
|
+
const name =
|
|
239
|
+
(child.type as { displayName?: string }).displayName ?? "";
|
|
240
|
+
return name === "AppBarTitle" || name === "AppBarContent";
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Anything not matched falls into the top row
|
|
244
|
+
const otherKids = kids.filter((child) => {
|
|
245
|
+
if (!React.isValidElement(child)) return true;
|
|
246
|
+
const name =
|
|
247
|
+
(child.type as { displayName?: string }).displayName ?? "";
|
|
248
|
+
return ![
|
|
249
|
+
"AppBarPrepend",
|
|
250
|
+
"AppBarAppend",
|
|
251
|
+
"AppBarTitle",
|
|
252
|
+
"AppBarContent",
|
|
253
|
+
].includes(name);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<span className="app-bar-prominent-inner">
|
|
258
|
+
<span className="app-bar-prominent-top">
|
|
259
|
+
{topKids}
|
|
260
|
+
{otherKids}
|
|
261
|
+
</span>
|
|
262
|
+
<span className="app-bar-prominent-bottom">{bottomKids}</span>
|
|
263
|
+
</span>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function HamburgerIcon() {
|
|
270
|
+
return (
|
|
271
|
+
<svg
|
|
272
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
273
|
+
width="24"
|
|
274
|
+
height="24"
|
|
275
|
+
viewBox="0 0 24 24"
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke="currentColor"
|
|
278
|
+
strokeWidth="2"
|
|
279
|
+
strokeLinecap="round"
|
|
280
|
+
strokeLinejoin="round"
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
>
|
|
283
|
+
<line x1="4" y1="6" x2="20" y2="6" />
|
|
284
|
+
<line x1="4" y1="12" x2="20" y2="12" />
|
|
285
|
+
<line x1="4" y1="18" x2="20" y2="18" />
|
|
286
|
+
</svg>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
export {
|
|
293
|
+
AppBar,
|
|
294
|
+
AppBarNavIcon,
|
|
295
|
+
AppBarTitle,
|
|
296
|
+
AppBarPrepend,
|
|
297
|
+
AppBarAppend,
|
|
298
|
+
AppBarContent,
|
|
299
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
// ─── Variants ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const bannerVariants = cva("banner", {
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "banner--default",
|
|
11
|
+
info: "banner--info",
|
|
12
|
+
success: "banner--success",
|
|
13
|
+
warning: "banner--warning",
|
|
14
|
+
destructive: "banner--destructive",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
variant: "default",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ─── Banner Root ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface BannerProps
|
|
25
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
26
|
+
VariantProps<typeof bannerVariants> {
|
|
27
|
+
/** Renders the banner fixed to the top of the viewport */
|
|
28
|
+
sticky?: boolean;
|
|
29
|
+
/** Called when the close button is clicked — also renders the close button */
|
|
30
|
+
onClose?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Banner = React.forwardRef<HTMLDivElement, BannerProps>(
|
|
34
|
+
({ className, variant, sticky = false, onClose, children, ...props }, ref) => (
|
|
35
|
+
<div
|
|
36
|
+
ref={ref}
|
|
37
|
+
role="banner"
|
|
38
|
+
className={cn(bannerVariants({ variant }), sticky && "banner--sticky", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<div className="banner-content">{children}</div>
|
|
42
|
+
{onClose && (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
className="banner-close"
|
|
46
|
+
aria-label="Dismiss banner"
|
|
47
|
+
onClick={onClose}
|
|
48
|
+
>
|
|
49
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
50
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
51
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
52
|
+
</svg>
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
Banner.displayName = "Banner";
|
|
59
|
+
|
|
60
|
+
// ─── BannerIcon ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const BannerIcon = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
|
|
63
|
+
({ className, ...props }, ref) => (
|
|
64
|
+
<span ref={ref} className={cn("banner-icon", className)} aria-hidden="true" {...props} />
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
BannerIcon.displayName = "BannerIcon";
|
|
68
|
+
|
|
69
|
+
// ─── BannerTitle ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const BannerTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
72
|
+
({ className, ...props }, ref) => (
|
|
73
|
+
<p ref={ref} className={cn("banner-title", className)} {...props} />
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
BannerTitle.displayName = "BannerTitle";
|
|
77
|
+
|
|
78
|
+
// ─── BannerDescription ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const BannerDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
81
|
+
({ className, ...props }, ref) => (
|
|
82
|
+
<p ref={ref} className={cn("banner-description", className)} {...props} />
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
BannerDescription.displayName = "BannerDescription";
|
|
86
|
+
|
|
87
|
+
// ─── BannerText ───────────────────────────────────────────────────────────────
|
|
88
|
+
// Wraps BannerTitle + BannerDescription so they stack vertically (2-line / 3-line layouts).
|
|
89
|
+
|
|
90
|
+
const BannerText = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
91
|
+
({ className, ...props }, ref) => (
|
|
92
|
+
<div ref={ref} className={cn("banner-text", className)} {...props} />
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
BannerText.displayName = "BannerText";
|
|
96
|
+
|
|
97
|
+
// ─── BannerAction ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const BannerAction = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
|
100
|
+
({ className, ...props }, ref) => (
|
|
101
|
+
<button ref={ref} type="button" className={cn("banner-action", className)} {...props} />
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
BannerAction.displayName = "BannerAction";
|
|
105
|
+
|
|
106
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export { Banner, BannerIcon, BannerTitle, BannerText, BannerDescription, BannerAction, bannerVariants };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface BottomNavContextValue {
|
|
7
|
+
value: string | number | null;
|
|
8
|
+
onChange: (val: string | number) => void;
|
|
9
|
+
grow: boolean;
|
|
10
|
+
horizontal: boolean;
|
|
11
|
+
shift: boolean;
|
|
12
|
+
activeColor: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BottomNavContext = React.createContext<BottomNavContextValue>({
|
|
16
|
+
value: null,
|
|
17
|
+
onChange: () => {},
|
|
18
|
+
grow: false,
|
|
19
|
+
horizontal: false,
|
|
20
|
+
shift: false,
|
|
21
|
+
activeColor: undefined,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ─── BottomNavigation Root ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface BottomNavigationProps
|
|
27
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> {
|
|
28
|
+
/**
|
|
29
|
+
* The currently active item value.
|
|
30
|
+
* Controlled: pass value + onChange together.
|
|
31
|
+
*/
|
|
32
|
+
value?: string | number;
|
|
33
|
+
/** Default selected value for uncontrolled usage */
|
|
34
|
+
defaultValue?: string | number;
|
|
35
|
+
/** Fires when the active item changes */
|
|
36
|
+
onChange?: (value: string | number) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Active item highlight color (CSS color or var token).
|
|
39
|
+
* Applies to icon + label of the active BottomNavigationItem.
|
|
40
|
+
*/
|
|
41
|
+
color?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Forces each item to fill all available width (max 168px per MD spec).
|
|
44
|
+
*/
|
|
45
|
+
grow?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Positions icon and label side-by-side (inline) instead of stacked.
|
|
48
|
+
*/
|
|
49
|
+
horizontal?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Hides labels of inactive items; only the active item shows its label.
|
|
52
|
+
* Labels must be wrapped in a <span> inside BottomNavigationItem.
|
|
53
|
+
*/
|
|
54
|
+
shift?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Controls visibility of the bar.
|
|
57
|
+
* When false the bar slides off-screen (bottom).
|
|
58
|
+
*/
|
|
59
|
+
active?: boolean;
|
|
60
|
+
/** Elevates with a shadow */
|
|
61
|
+
elevated?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const BottomNavigation = React.forwardRef<
|
|
65
|
+
HTMLElement,
|
|
66
|
+
BottomNavigationProps
|
|
67
|
+
>(
|
|
68
|
+
(
|
|
69
|
+
{
|
|
70
|
+
className,
|
|
71
|
+
children,
|
|
72
|
+
value: valueProp,
|
|
73
|
+
defaultValue,
|
|
74
|
+
onChange,
|
|
75
|
+
color,
|
|
76
|
+
grow = false,
|
|
77
|
+
horizontal = false,
|
|
78
|
+
shift = false,
|
|
79
|
+
active = true,
|
|
80
|
+
elevated = false,
|
|
81
|
+
...props
|
|
82
|
+
},
|
|
83
|
+
ref
|
|
84
|
+
) => {
|
|
85
|
+
const [internalValue, setInternalValue] = React.useState<
|
|
86
|
+
string | number | null
|
|
87
|
+
>(defaultValue ?? null);
|
|
88
|
+
|
|
89
|
+
const isControlled = valueProp !== undefined;
|
|
90
|
+
const currentValue = isControlled ? valueProp : internalValue;
|
|
91
|
+
|
|
92
|
+
const handleChange = React.useCallback(
|
|
93
|
+
(val: string | number) => {
|
|
94
|
+
if (!isControlled) setInternalValue(val);
|
|
95
|
+
onChange?.(val);
|
|
96
|
+
},
|
|
97
|
+
[isControlled, onChange]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<BottomNavContext.Provider
|
|
102
|
+
value={{
|
|
103
|
+
value: currentValue ?? null,
|
|
104
|
+
onChange: handleChange,
|
|
105
|
+
grow,
|
|
106
|
+
horizontal,
|
|
107
|
+
shift,
|
|
108
|
+
activeColor: color,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<nav
|
|
112
|
+
ref={ref}
|
|
113
|
+
role="navigation"
|
|
114
|
+
aria-label="Bottom navigation"
|
|
115
|
+
className={cn(
|
|
116
|
+
"bottom-nav",
|
|
117
|
+
grow && "bottom-nav--grow",
|
|
118
|
+
horizontal && "bottom-nav--horizontal",
|
|
119
|
+
shift && "bottom-nav--shift",
|
|
120
|
+
!active && "bottom-nav--hidden",
|
|
121
|
+
elevated && "bottom-nav--elevated",
|
|
122
|
+
className
|
|
123
|
+
)}
|
|
124
|
+
{...props}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</nav>
|
|
128
|
+
</BottomNavContext.Provider>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
BottomNavigation.displayName = "BottomNavigation";
|
|
133
|
+
|
|
134
|
+
// ─── BottomNavigationItem ─────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export interface BottomNavigationItemProps
|
|
137
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
138
|
+
/**
|
|
139
|
+
* Unique value for this item. If omitted, the item's index is used
|
|
140
|
+
* (assigned automatically by BottomNavigationGroup, see below).
|
|
141
|
+
*/
|
|
142
|
+
value?: string | number;
|
|
143
|
+
/** Index injected by BottomNavigationGroup — do not set manually */
|
|
144
|
+
_index?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const BottomNavigationItem = React.forwardRef<
|
|
148
|
+
HTMLButtonElement,
|
|
149
|
+
BottomNavigationItemProps
|
|
150
|
+
>(({ className, children, value: valueProp, _index, style, ...props }, ref) => {
|
|
151
|
+
const { value, onChange, grow, horizontal, shift, activeColor } =
|
|
152
|
+
React.useContext(BottomNavContext);
|
|
153
|
+
|
|
154
|
+
const itemValue = valueProp ?? _index ?? 0;
|
|
155
|
+
const isActive = value === itemValue;
|
|
156
|
+
|
|
157
|
+
const activeStyle =
|
|
158
|
+
isActive && activeColor ? ({ "--bottom-nav-active-color": activeColor } as React.CSSProperties) : {};
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<button
|
|
162
|
+
ref={ref}
|
|
163
|
+
type="button"
|
|
164
|
+
role="tab"
|
|
165
|
+
aria-selected={isActive}
|
|
166
|
+
aria-current={isActive ? "page" : undefined}
|
|
167
|
+
onClick={() => onChange(itemValue)}
|
|
168
|
+
style={{ ...activeStyle, ...style }}
|
|
169
|
+
className={cn(
|
|
170
|
+
"bottom-nav-item",
|
|
171
|
+
isActive && "bottom-nav-item--active",
|
|
172
|
+
grow && "bottom-nav-item--grow",
|
|
173
|
+
horizontal && "bottom-nav-item--horizontal",
|
|
174
|
+
shift && "bottom-nav-item--shift",
|
|
175
|
+
className
|
|
176
|
+
)}
|
|
177
|
+
{...props}
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</button>
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
BottomNavigationItem.displayName = "BottomNavigationItem";
|
|
184
|
+
|
|
185
|
+
// ─── BottomNavigationGroup ───────────────────────────────────────────────────
|
|
186
|
+
// Thin wrapper that injects _index into each BottomNavigationItem child
|
|
187
|
+
// so unvalued items still participate in selection by index.
|
|
188
|
+
|
|
189
|
+
interface BottomNavigationGroupProps
|
|
190
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
191
|
+
|
|
192
|
+
const BottomNavigationGroup = React.forwardRef<
|
|
193
|
+
HTMLDivElement,
|
|
194
|
+
BottomNavigationGroupProps
|
|
195
|
+
>(({ children, className, ...props }, ref) => {
|
|
196
|
+
const indexedChildren = React.Children.map(children, (child, index) => {
|
|
197
|
+
if (!React.isValidElement(child)) return child;
|
|
198
|
+
const el = child as React.ReactElement<BottomNavigationItemProps>;
|
|
199
|
+
if (
|
|
200
|
+
(el.type as { displayName?: string }).displayName ===
|
|
201
|
+
"BottomNavigationItem"
|
|
202
|
+
) {
|
|
203
|
+
return React.cloneElement(el, { _index: index });
|
|
204
|
+
}
|
|
205
|
+
return child;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
ref={ref}
|
|
211
|
+
role="tablist"
|
|
212
|
+
className={cn("bottom-nav-group", className)}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
{indexedChildren}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
BottomNavigationGroup.displayName = "BottomNavigationGroup";
|
|
220
|
+
|
|
221
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export { BottomNavigation, BottomNavigationItem, BottomNavigationGroup };
|