@devadeboye/react-material-components 0.0.1 → 0.0.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/styles.css +7 -0
- package/dist/styles.css.map +1 -0
- package/dist/styles.d.mts +2 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +5 -3
- package/src/base.css +42 -0
- package/src/components/button/button.stories.tsx +131 -0
- package/src/components/button/index.tsx +162 -0
- package/src/components/card.stories.tsx +109 -0
- package/src/components/card.tsx +92 -0
- package/src/components/fabs/Fab.tsx +81 -0
- package/src/components/fabs/extended-fab.tsx +96 -0
- package/src/components/fabs/fab.stories.tsx +101 -0
- package/src/components/navigation-rail/action.tsx +44 -0
- package/src/components/navigation-rail/index.tsx +126 -0
- package/src/components/navigation-rail/menu-button.tsx +33 -0
- package/src/components/navigation-rail/navigation-rail-destination.tsx +83 -0
- package/src/components/navigation-rail/navigation-rail.stories.tsx +123 -0
- package/src/components/ripple.tsx +64 -0
- package/src/index.ts +10 -0
- package/src/styles.css +2 -0
package/dist/styles.css
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* src/base.css */
|
|
4
|
+
@theme inline { --animate-ripple: ripple 600ms linear; --color-surface-container-low: #f7f2fa; --color-surface-container-highest: #e6e0e9; --color-outline: #79747e; --color-primary: #65558F; --color-on-primary: #FFFFFF; --color-primary-container: #EADDFF; --color-on-primary-container: #21005D; --color-secondary-container: #E8DEF8; --color-on-secondary-container: #1D192B; --color-on-surface: #1D1B20; --color-on-surface-variant: #49454F; --shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); --shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); --radius-xl: 12px; --font-label-l: 500 14px/20px var(--font-sans); --font-label-m: 500 12px/16px var(--font-sans); --font-label-s: 500 11px/16px var(--font-sans); --font-title-m: 500 16px/24px var(--font-sans); @keyframes ripple { 0% { transform: scale(0); opacity: 0.35; } 100% { transform: scale(4); opacity: 0; } } }
|
|
5
|
+
|
|
6
|
+
/* src/styles.css */
|
|
7
|
+
/*# sourceMappingURL=styles.css.map */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/base.css"],"sourcesContent":["@theme inline {\n --animate-ripple: ripple 600ms linear;\n\n /* M3 Surface Container Colors */\n --color-surface-container-low: #f7f2fa;\n --color-surface-container-highest: #e6e0e9;\n --color-outline: #79747e;\n \n /* M3 Colors */\n --color-primary: #65558F;\n --color-on-primary: #FFFFFF;\n --color-primary-container: #EADDFF;\n --color-on-primary-container: #21005D;\n --color-secondary-container: #E8DEF8;\n --color-on-secondary-container: #1D192B;\n --color-on-surface: #1D1B20;\n --color-on-surface-variant: #49454F;\n\n /* M3 Elevations */\n --shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n --shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n\n /* M3 Corner Radius */\n --radius-xl: 12px;\n\n /* M3 Typography (Simplified for demo) */\n --font-label-l: 500 14px/20px var(--font-sans);\n --font-label-m: 500 12px/16px var(--font-sans);\n --font-label-s: 500 11px/16px var(--font-sans);\n --font-title-m: 500 16px/24px var(--font-sans);\n\n @keyframes ripple {\n 0% {\n transform: scale(0);\n opacity: 0.35;\n }\n 100% {\n transform: scale(4);\n opacity: 0;\n }\n }\n}\n"],"mappings":";;;AAAA,OAAO,OAAO,EACZ,gBAAgB,EAAE,OAAO,MAAM,MAAM,EAGrC,6BAA6B,EAAE,OAAO,EACtC,iCAAiC,EAAE,OAAO,EAC1C,eAAe,EAAE,OAAO,EAGxB,eAAe,EAAE,OAAO,EACxB,kBAAkB,EAAE,OAAO,EAC3B,yBAAyB,EAAE,OAAO,EAClC,4BAA4B,EAAE,OAAO,EACrC,2BAA2B,EAAE,OAAO,EACpC,8BAA8B,EAAE,OAAO,EACvC,kBAAkB,EAAE,OAAO,EAC3B,0BAA0B,EAAE,OAAO,EAGnC,oBAAoB,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAC7F,oBAAoB,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAG7F,WAAW,EAAE,IAAI,EAGjB,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAE9C,WAAW,OAAO,EAChB,GAAG,EACD,SAAS,EAAE,MAAM,EAAE,EACnB,OAAO,EAAE,IAAI,IAEf,KAAK,EACH,SAAS,EAAE,MAAM,EAAE,EACnB,OAAO,EAAE,CAAC;","names":[]}
|
package/dist/styles.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devadeboye/react-material-components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist"
|
|
7
|
+
"dist",
|
|
8
|
+
"src"
|
|
8
9
|
],
|
|
9
10
|
"main": "./dist/index.js",
|
|
10
11
|
"module": "./dist/index.mjs",
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
"types": "./dist/index.d.ts",
|
|
15
16
|
"import": "./dist/index.mjs",
|
|
16
17
|
"require": "./dist/index.js"
|
|
17
|
-
}
|
|
18
|
+
},
|
|
19
|
+
"./styles.css": "./dist/styles.css"
|
|
18
20
|
},
|
|
19
21
|
"peerDependencies": {
|
|
20
22
|
"react": ">=19",
|
package/src/base.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@theme inline {
|
|
2
|
+
--animate-ripple: ripple 600ms linear;
|
|
3
|
+
|
|
4
|
+
/* M3 Surface Container Colors */
|
|
5
|
+
--color-surface-container-low: #f7f2fa;
|
|
6
|
+
--color-surface-container-highest: #e6e0e9;
|
|
7
|
+
--color-outline: #79747e;
|
|
8
|
+
|
|
9
|
+
/* M3 Colors */
|
|
10
|
+
--color-primary: #65558F;
|
|
11
|
+
--color-on-primary: #FFFFFF;
|
|
12
|
+
--color-primary-container: #EADDFF;
|
|
13
|
+
--color-on-primary-container: #21005D;
|
|
14
|
+
--color-secondary-container: #E8DEF8;
|
|
15
|
+
--color-on-secondary-container: #1D192B;
|
|
16
|
+
--color-on-surface: #1D1B20;
|
|
17
|
+
--color-on-surface-variant: #49454F;
|
|
18
|
+
|
|
19
|
+
/* M3 Elevations */
|
|
20
|
+
--shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
|
|
21
|
+
--shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
|
|
22
|
+
|
|
23
|
+
/* M3 Corner Radius */
|
|
24
|
+
--radius-xl: 12px;
|
|
25
|
+
|
|
26
|
+
/* M3 Typography (Simplified for demo) */
|
|
27
|
+
--font-label-l: 500 14px/20px var(--font-sans);
|
|
28
|
+
--font-label-m: 500 12px/16px var(--font-sans);
|
|
29
|
+
--font-label-s: 500 11px/16px var(--font-sans);
|
|
30
|
+
--font-title-m: 500 16px/24px var(--font-sans);
|
|
31
|
+
|
|
32
|
+
@keyframes ripple {
|
|
33
|
+
0% {
|
|
34
|
+
transform: scale(0);
|
|
35
|
+
opacity: 0.35;
|
|
36
|
+
}
|
|
37
|
+
100% {
|
|
38
|
+
transform: scale(4);
|
|
39
|
+
opacity: 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { fn } from "@storybook/test";
|
|
3
|
+
import { Button } from ".";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Components/Button",
|
|
7
|
+
component: Button,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "centered",
|
|
10
|
+
},
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
argTypes: {
|
|
13
|
+
variant: {
|
|
14
|
+
control: "select",
|
|
15
|
+
options: ["filled", "tonal", "outlined", "text", "elevated"],
|
|
16
|
+
},
|
|
17
|
+
size: {
|
|
18
|
+
control: "radio",
|
|
19
|
+
options: ["xs", "s", "m", "l", "xl"],
|
|
20
|
+
},
|
|
21
|
+
shape: {
|
|
22
|
+
control: "radio",
|
|
23
|
+
options: ["round", "square"],
|
|
24
|
+
},
|
|
25
|
+
disabled: {
|
|
26
|
+
control: "boolean",
|
|
27
|
+
},
|
|
28
|
+
fullWidth: {
|
|
29
|
+
control: "boolean",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
args: {
|
|
33
|
+
onClick: fn(),
|
|
34
|
+
},
|
|
35
|
+
} satisfies Meta<typeof Button>;
|
|
36
|
+
|
|
37
|
+
export default meta;
|
|
38
|
+
type Story = StoryObj<typeof meta>;
|
|
39
|
+
|
|
40
|
+
export const Filled: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
variant: "filled",
|
|
43
|
+
children: "Filled Button",
|
|
44
|
+
size: "m",
|
|
45
|
+
shape: "round",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const Tonal: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
variant: "tonal",
|
|
52
|
+
children: "Tonal Button",
|
|
53
|
+
size: "m",
|
|
54
|
+
shape: "round",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Outlined: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
variant: "outlined",
|
|
61
|
+
children: "Outlined Button",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Text: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
variant: "text",
|
|
68
|
+
children: "Text Button",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const Small: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
size: "xs",
|
|
75
|
+
children: "Small",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Large: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
size: "l",
|
|
82
|
+
children: "Large",
|
|
83
|
+
shape: "round",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Disabled: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
disabled: true,
|
|
90
|
+
children: "Disabled",
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const Elevated: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
variant: "elevated",
|
|
97
|
+
children: "Elevated Button",
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const CustomColor: Story = {
|
|
102
|
+
args: {
|
|
103
|
+
variant: "filled",
|
|
104
|
+
className: "bg-orange-500 text-white hover:bg-orange-600",
|
|
105
|
+
children: "Custom Color",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const PlusIcon = (
|
|
110
|
+
<svg
|
|
111
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
112
|
+
width="100%"
|
|
113
|
+
height="100%"
|
|
114
|
+
viewBox="0 0 24 24"
|
|
115
|
+
fill="none"
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
strokeWidth="2"
|
|
118
|
+
strokeLinecap="round"
|
|
119
|
+
strokeLinejoin="round"
|
|
120
|
+
>
|
|
121
|
+
<path d="M5 12h14"></path>
|
|
122
|
+
<path d="M12 5v14"></path>
|
|
123
|
+
</svg>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
export const WithLeadingIcon: Story = {
|
|
127
|
+
args: {
|
|
128
|
+
children: "Add Item",
|
|
129
|
+
leadingIcon: PlusIcon,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buttons prompt most actions in a UI
|
|
3
|
+
* https://m3.material.io/components/buttons/specs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
8
|
+
import { clsx, type ClassValue } from "clsx";
|
|
9
|
+
import { twMerge } from "tailwind-merge";
|
|
10
|
+
import { Ripple } from "../ripple";
|
|
11
|
+
|
|
12
|
+
// Utils
|
|
13
|
+
function cn(...inputs: ClassValue[]) {
|
|
14
|
+
return twMerge(clsx(inputs));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const buttonVariants = cva(
|
|
18
|
+
"relative inline-flex items-center justify-center gap-2 font-medium transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden",
|
|
19
|
+
{
|
|
20
|
+
variants: {
|
|
21
|
+
variant: {
|
|
22
|
+
filled:
|
|
23
|
+
"bg-primary text-on-primary hover:bg-primary/90 shadow-sm hover:shadow-md",
|
|
24
|
+
tonal:
|
|
25
|
+
"bg-secondary-container text-on-secondary-container hover:bg-secondary-container/90",
|
|
26
|
+
outlined:
|
|
27
|
+
"border border-outline bg-transparent text-primary hover:bg-primary/10 active:bg-primary/10",
|
|
28
|
+
text: "bg-transparent text-primary hover:bg-primary/10 active:bg-primary/10",
|
|
29
|
+
elevated:
|
|
30
|
+
"bg-surface-container-low text-primary shadow-elevation-1 hover:shadow-elevation-2 hover:bg-surface-container-low/80",
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
size: {
|
|
34
|
+
/** extra small */
|
|
35
|
+
xs: "h-8 px-3 text-label-s",
|
|
36
|
+
/** small */
|
|
37
|
+
s: "h-9 px-3 text-label-l",
|
|
38
|
+
/** medium (M3 Default) */
|
|
39
|
+
m: "h-10 px-6 text-label-l",
|
|
40
|
+
/** large */
|
|
41
|
+
l: "h-12 px-8 text-label-l",
|
|
42
|
+
/** extra large */
|
|
43
|
+
xl: "h-14 px-10 text-title-m",
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
shape: {
|
|
48
|
+
round: "rounded-full",
|
|
49
|
+
square: "",
|
|
50
|
+
},
|
|
51
|
+
fullWidth: {
|
|
52
|
+
true: "w-full",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
compoundVariants: [
|
|
57
|
+
{
|
|
58
|
+
shape: "square",
|
|
59
|
+
size: "xs",
|
|
60
|
+
className: "rounded-xl active:rounded-lg",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
shape: "square",
|
|
64
|
+
size: "s",
|
|
65
|
+
className: "rounded-lg active:rounded-lg",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
shape: "square",
|
|
69
|
+
size: "m",
|
|
70
|
+
className: "rounded-2xl active:rounded-xl",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
shape: "square",
|
|
74
|
+
size: "l",
|
|
75
|
+
className: "rounded-[28px] active:rounded-2xl",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
shape: "square",
|
|
79
|
+
size: "xl",
|
|
80
|
+
className: "rounded-[28px] active:rounded-2xl",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
defaultVariants: {
|
|
85
|
+
variant: "filled",
|
|
86
|
+
size: "m",
|
|
87
|
+
shape: "round",
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export interface ButtonProps
|
|
93
|
+
extends
|
|
94
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
95
|
+
VariantProps<typeof buttonVariants> {
|
|
96
|
+
asChild?: boolean;
|
|
97
|
+
leadingIcon?: React.ReactNode;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** determines the gap between leading icon and button text */
|
|
101
|
+
const determineGapAndIconSize = (
|
|
102
|
+
size: "xs" | "s" | "m" | "l" | "xl" | null | undefined
|
|
103
|
+
) => {
|
|
104
|
+
switch (size) {
|
|
105
|
+
case "xs":
|
|
106
|
+
return ["gap-1", "w-5 h-5"];
|
|
107
|
+
case "s":
|
|
108
|
+
return ["gap-2", "w-5 h-5"];
|
|
109
|
+
case "m":
|
|
110
|
+
return ["gap-2", "w-6 h-6"];
|
|
111
|
+
case "l":
|
|
112
|
+
return ["gap-3", "w-8 h-8"];
|
|
113
|
+
case "xl":
|
|
114
|
+
return ["gap-4", "w-10 h-10"];
|
|
115
|
+
default:
|
|
116
|
+
return ["gap-2", "w-6 h-6"];
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
121
|
+
(
|
|
122
|
+
{
|
|
123
|
+
className,
|
|
124
|
+
variant,
|
|
125
|
+
size,
|
|
126
|
+
shape,
|
|
127
|
+
fullWidth,
|
|
128
|
+
children,
|
|
129
|
+
leadingIcon,
|
|
130
|
+
...props
|
|
131
|
+
},
|
|
132
|
+
ref
|
|
133
|
+
) => {
|
|
134
|
+
let [gap, iconSize] = determineGapAndIconSize(size);
|
|
135
|
+
return (
|
|
136
|
+
<button
|
|
137
|
+
className={cn(
|
|
138
|
+
buttonVariants({ variant, size, shape, fullWidth, className })
|
|
139
|
+
)}
|
|
140
|
+
ref={ref}
|
|
141
|
+
{...props}
|
|
142
|
+
>
|
|
143
|
+
{/* State Layer (Hover/Reset) handled via hover:bg utilities or overlay below */}
|
|
144
|
+
<div className="absolute inset-0 z-0 bg-current opacity-0 transition-opacity hover:opacity-[0.08] focus:opacity-[0.12] active:opacity-[0.12]" />
|
|
145
|
+
|
|
146
|
+
<span
|
|
147
|
+
className={`relative z-10 flex items-center justify-center ${gap}`}
|
|
148
|
+
>
|
|
149
|
+
{leadingIcon && (
|
|
150
|
+
<div className={`flex items-center justify-center ${iconSize}`}>
|
|
151
|
+
{leadingIcon}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{children}
|
|
156
|
+
</span>
|
|
157
|
+
<Ripple />
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Card } from "./card";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Card> = {
|
|
5
|
+
title: "Components/Card",
|
|
6
|
+
component: Card,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: "centered",
|
|
9
|
+
},
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
argTypes: {
|
|
12
|
+
variant: {
|
|
13
|
+
control: "select",
|
|
14
|
+
options: ["elevated", "filled", "outlined"],
|
|
15
|
+
},
|
|
16
|
+
padding: {
|
|
17
|
+
control: "select",
|
|
18
|
+
options: ["none", "sm", "md", "lg"],
|
|
19
|
+
},
|
|
20
|
+
radius: {
|
|
21
|
+
control: "select",
|
|
22
|
+
options: ["none", "sm", "md", "lg", "xl", "2xl"],
|
|
23
|
+
},
|
|
24
|
+
interactive: {
|
|
25
|
+
control: "boolean",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<typeof Card>;
|
|
32
|
+
|
|
33
|
+
export const Elevated: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
variant: "elevated",
|
|
36
|
+
children: (
|
|
37
|
+
<div className="flex flex-col gap-2">
|
|
38
|
+
<h3 className="text-xl font-medium">Elevated Card</h3>
|
|
39
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
40
|
+
This is an elevated card with a default shadow and color.
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
className: "w-[300px]",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Filled: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
variant: "filled",
|
|
51
|
+
children: (
|
|
52
|
+
<div className="flex flex-col gap-2">
|
|
53
|
+
<h3 className="text-xl font-medium">Filled Card</h3>
|
|
54
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
55
|
+
This is a filled card with a darker background.
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
),
|
|
59
|
+
className: "w-[300px]",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const Outlined: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
variant: "outlined",
|
|
66
|
+
children: (
|
|
67
|
+
<div className="flex flex-col gap-2">
|
|
68
|
+
<h3 className="text-xl font-medium">Outlined Card</h3>
|
|
69
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
70
|
+
This is an outlined card with a thin border.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
className: "w-[300px]",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const Interactive: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
variant: "elevated",
|
|
81
|
+
interactive: true,
|
|
82
|
+
children: (
|
|
83
|
+
<div className="flex flex-col gap-2">
|
|
84
|
+
<h3 className="text-xl font-medium">Interactive Card</h3>
|
|
85
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
86
|
+
Click me to see the ripple effect and state layer!
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
className: "w-[300px]",
|
|
91
|
+
onClick: () => alert("Card clicked!"),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const CustomPadding: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
variant: "filled",
|
|
98
|
+
padding: "lg",
|
|
99
|
+
children: (
|
|
100
|
+
<div className="flex flex-col gap-2">
|
|
101
|
+
<h3 className="text-xl font-medium">Large Padding</h3>
|
|
102
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
103
|
+
This card has extra padding for a more spacious feel.
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
),
|
|
107
|
+
className: "w-[300px]",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { clsx, type ClassValue } from "clsx";
|
|
4
|
+
import { twMerge } from "tailwind-merge";
|
|
5
|
+
import { Ripple } from "./ripple";
|
|
6
|
+
|
|
7
|
+
// Utils
|
|
8
|
+
function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const cardVariants = cva(
|
|
13
|
+
"relative overflow-hidden transition-all duration-200",
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
elevated:
|
|
18
|
+
"bg-surface-container-low shadow-elevation-1 hover:shadow-elevation-2 dark:bg-gray-900 dark:text-gray-100",
|
|
19
|
+
filled:
|
|
20
|
+
"bg-surface-container-highest dark:bg-gray-800 dark:text-gray-100",
|
|
21
|
+
outlined:
|
|
22
|
+
"border border-outline bg-transparent dark:border-gray-700 dark:text-gray-100",
|
|
23
|
+
},
|
|
24
|
+
padding: {
|
|
25
|
+
none: "p-0",
|
|
26
|
+
sm: "p-4",
|
|
27
|
+
md: "p-6",
|
|
28
|
+
lg: "p-8",
|
|
29
|
+
},
|
|
30
|
+
radius: {
|
|
31
|
+
none: "rounded-none",
|
|
32
|
+
sm: "rounded-sm",
|
|
33
|
+
md: "rounded-md",
|
|
34
|
+
lg: "rounded-lg",
|
|
35
|
+
xl: "rounded-xl", // M3 default (12px)
|
|
36
|
+
"2xl": "rounded-2xl",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
defaultVariants: {
|
|
40
|
+
variant: "elevated",
|
|
41
|
+
padding: "md",
|
|
42
|
+
radius: "xl",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export interface CardProps
|
|
48
|
+
extends
|
|
49
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
50
|
+
VariantProps<typeof cardVariants> {
|
|
51
|
+
interactive?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
55
|
+
(
|
|
56
|
+
{
|
|
57
|
+
className,
|
|
58
|
+
variant,
|
|
59
|
+
padding,
|
|
60
|
+
radius,
|
|
61
|
+
interactive,
|
|
62
|
+
children,
|
|
63
|
+
onClick,
|
|
64
|
+
...props
|
|
65
|
+
},
|
|
66
|
+
ref
|
|
67
|
+
) => {
|
|
68
|
+
const isInteractive = interactive || !!onClick;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
ref={ref}
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
className={cn(
|
|
75
|
+
cardVariants({ variant, padding, radius, className }),
|
|
76
|
+
isInteractive && "hover:cursor-pointer group"
|
|
77
|
+
)}
|
|
78
|
+
{...props}
|
|
79
|
+
>
|
|
80
|
+
{/* State Layer for interactive cards */}
|
|
81
|
+
{isInteractive && (
|
|
82
|
+
<div className="absolute inset-0 z-0 bg-current opacity-0 transition-opacity group-hover:opacity-[0.08] group-focus:opacity-[0.12] group-active:opacity-[0.12]" />
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
<div className="relative z-10 w-full h-full">{children}</div>
|
|
86
|
+
|
|
87
|
+
{isInteractive && <Ripple />}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
Card.displayName = "Card";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating action buttons (FABs) help people take primary actions.
|
|
3
|
+
* https://m3.material.io/components/floating-action-button/specs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
8
|
+
import { Ripple } from "../ripple";
|
|
9
|
+
import { cn } from "../../../lib/utils/helpers";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const fabVariants = cva(
|
|
13
|
+
"relative inline-flex items-center justify-center transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden",
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
color: {
|
|
17
|
+
surface: "bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100",
|
|
18
|
+
primary: "bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100",
|
|
19
|
+
secondary: "bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100",
|
|
20
|
+
tertiary: "bg-pink-100 text-pink-900 dark:bg-pink-900 dark:text-pink-100",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
small: "w-10 h-10 rounded-xl",
|
|
24
|
+
regular: "w-14 h-14 rounded-2xl",
|
|
25
|
+
medium: "w-20 h-20 rounded-3xl", // M3 Expressive
|
|
26
|
+
large: "w-24 h-24 rounded-[28px]",
|
|
27
|
+
},
|
|
28
|
+
variant: {
|
|
29
|
+
standard: "shadow-md hover:shadow-lg focus:shadow-md active:shadow-sm",
|
|
30
|
+
lowered: "shadow-sm hover:shadow-md focus:shadow-sm active:shadow-none",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
color: "surface",
|
|
35
|
+
size: "regular",
|
|
36
|
+
variant: "standard",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export interface FABProps
|
|
42
|
+
extends
|
|
43
|
+
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
|
|
44
|
+
VariantProps<typeof fabVariants> {
|
|
45
|
+
children?: React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const determineIconSize = (size: "small" | "regular" | "medium" | "large" | null | undefined) => {
|
|
49
|
+
switch (size) {
|
|
50
|
+
case "small":
|
|
51
|
+
return "w-6 h-6";
|
|
52
|
+
case "regular":
|
|
53
|
+
return "w-6 h-6";
|
|
54
|
+
case "medium":
|
|
55
|
+
return "w-7 h-7"; // 28x28dp
|
|
56
|
+
case "large":
|
|
57
|
+
return "w-9 h-9"; // 36x36dp
|
|
58
|
+
default:
|
|
59
|
+
return "w-6 h-6";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const FAB = React.forwardRef<HTMLButtonElement, FABProps>(
|
|
64
|
+
({ className, color, size, variant, children, ...props }, ref) => {
|
|
65
|
+
const iconSize = determineIconSize(size);
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
className={cn(fabVariants({ color, size, variant, className }))}
|
|
69
|
+
ref={ref}
|
|
70
|
+
{...props}
|
|
71
|
+
>
|
|
72
|
+
<span className={cn("relative z-10 flex items-center justify-center", iconSize)}>
|
|
73
|
+
{children}
|
|
74
|
+
</span>
|
|
75
|
+
<Ripple />
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
FAB.displayName = "FAB";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended Floating Action Buttons (FABs) help people take primary actions.
|
|
3
|
+
* https://m3.material.io/components/extended-fab/specs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
8
|
+
import { clsx, type ClassValue } from "clsx";
|
|
9
|
+
import { twMerge } from "tailwind-merge";
|
|
10
|
+
import { Ripple } from "../ripple";
|
|
11
|
+
|
|
12
|
+
// Utils
|
|
13
|
+
function cn(...inputs: ClassValue[]) {
|
|
14
|
+
return twMerge(clsx(inputs));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const extendedFabVariants = cva(
|
|
18
|
+
"relative inline-flex flex-row items-center justify-center transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden h-14 w-fit rounded-2xl pr-5 pl-4 gap-2",
|
|
19
|
+
{
|
|
20
|
+
variants: {
|
|
21
|
+
color: {
|
|
22
|
+
surface: "bg-surface-container-high text-primary hover:bg-surface-container-highest", // M3 default for surface-colored FAB
|
|
23
|
+
primary: "bg-primary-container text-on-primary-container hover:bg-primary-container/90",
|
|
24
|
+
secondary: "bg-secondary-container text-on-secondary-container hover:bg-secondary-container/90",
|
|
25
|
+
tertiary: "bg-tertiary-container text-on-tertiary-container hover:bg-tertiary-container/90",
|
|
26
|
+
},
|
|
27
|
+
size: {
|
|
28
|
+
small: "h-14 rounded-2xl px-4 text-[16px] font-medium tracking-[0.15px]",
|
|
29
|
+
medium: "h-20 rounded-[20px] px-6.5 text-[22px] font-normal tracking-normal",
|
|
30
|
+
large: "h-24 rounded-[28px] px-7 text-2xl font-medium tracking-normal"
|
|
31
|
+
},
|
|
32
|
+
variant: {
|
|
33
|
+
standard: "shadow-md hover:shadow-lg focus:shadow-md active:shadow-sm",
|
|
34
|
+
lowered: "shadow-sm hover:shadow-md focus:shadow-sm active:shadow-none",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
defaultVariants: {
|
|
38
|
+
color: "primary",
|
|
39
|
+
variant: "standard",
|
|
40
|
+
size: "medium"
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export interface ExtendedFABProps
|
|
46
|
+
extends
|
|
47
|
+
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
|
|
48
|
+
VariantProps<typeof extendedFabVariants> {
|
|
49
|
+
icon?: React.ReactNode;
|
|
50
|
+
label: string;
|
|
51
|
+
size?: "small" | "medium" | "large";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const determineGapAndIconSize = (
|
|
55
|
+
size: "small" | "medium" | "large" | null | undefined
|
|
56
|
+
) => {
|
|
57
|
+
switch (size) {
|
|
58
|
+
case "small":
|
|
59
|
+
return ["gap-2", "w-6 h-6"];
|
|
60
|
+
case "medium":
|
|
61
|
+
return ["gap-3", "w-7 h-7"];
|
|
62
|
+
case "large":
|
|
63
|
+
return ["gap-4", "w-9 h-9"];
|
|
64
|
+
default:
|
|
65
|
+
return ["gap-3", "w-7 h-7"];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const ExtendedFAB = React.forwardRef<HTMLButtonElement, ExtendedFABProps>(
|
|
70
|
+
({ className, color, variant, icon, label, size, ...props }, ref) => {
|
|
71
|
+
let [gap, iconSize] = determineGapAndIconSize(size);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
className={`${cn(extendedFabVariants({ color, variant, size, className }))} ${gap}`}
|
|
76
|
+
ref={ref}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
{/* State Layer (Hover/Reset) handled via hover:bg utilities */}
|
|
80
|
+
{icon && (
|
|
81
|
+
<span
|
|
82
|
+
className={`relative z-10 flex items-center justify-center ${iconSize}`}
|
|
83
|
+
>
|
|
84
|
+
{icon}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
<span className="relative z-10 font-medium text-sm tracking-wide">
|
|
88
|
+
{label}
|
|
89
|
+
</span>
|
|
90
|
+
<Ripple />
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
ExtendedFAB.displayName = "ExtendedFAB";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { FAB } from "./fab";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof FAB> = {
|
|
5
|
+
title: "Components/FAB",
|
|
6
|
+
component: FAB,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: "centered",
|
|
9
|
+
},
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
argTypes: {
|
|
12
|
+
color: {
|
|
13
|
+
control: "select",
|
|
14
|
+
options: ["surface", "primary", "secondary", "tertiary"],
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
control: "select",
|
|
18
|
+
options: ["small", "regular", "medium", "large"],
|
|
19
|
+
},
|
|
20
|
+
variant: {
|
|
21
|
+
control: "select",
|
|
22
|
+
options: ["standard", "lowered"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof FAB>;
|
|
29
|
+
|
|
30
|
+
const PlusIcon = (
|
|
31
|
+
<svg
|
|
32
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
33
|
+
width="100%"
|
|
34
|
+
height="100%"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeWidth="2"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
>
|
|
42
|
+
<path d="M5 12h14" />
|
|
43
|
+
<path d="M12 5v14" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export const Regular: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
size: "regular",
|
|
50
|
+
children: PlusIcon,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const Small: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
size: "small",
|
|
57
|
+
children: PlusIcon,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const MediumExpressive: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
size: "medium",
|
|
64
|
+
children: PlusIcon,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Large: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
size: "large",
|
|
71
|
+
children: PlusIcon,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const Primary: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
color: "primary",
|
|
78
|
+
children: PlusIcon,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Secondary: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
color: "secondary",
|
|
85
|
+
children: PlusIcon,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const Tertiary: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
color: "tertiary",
|
|
92
|
+
children: PlusIcon,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const Lowered: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
variant: "lowered",
|
|
99
|
+
children: PlusIcon,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ExtendedFAB, ExtendedFABProps } from "../fabs/extended-fab";
|
|
2
|
+
import { FAB } from "../fabs/fab";
|
|
3
|
+
import { cn } from "../../../lib/utils/helpers";
|
|
4
|
+
import { clsx } from "clsx";
|
|
5
|
+
import { FabConfig } from "../../../types/navigation-rail";
|
|
6
|
+
|
|
7
|
+
interface ActionProps {
|
|
8
|
+
isExpanded: boolean;
|
|
9
|
+
fabConfig: FabConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Action = ({ isExpanded, fabConfig }: ActionProps) => {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className={cn(
|
|
16
|
+
`px-4 py-2 flex-col ${clsx({ "items-center": !isExpanded })} min-h-14 transition-all duration-300`,
|
|
17
|
+
!isExpanded ? "hidden md:flex" : "flex"
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
{isExpanded ? (
|
|
21
|
+
<ExtendedFAB
|
|
22
|
+
icon={fabConfig.icon}
|
|
23
|
+
label={fabConfig.label}
|
|
24
|
+
onClick={fabConfig.onClick}
|
|
25
|
+
color={fabConfig.color as ExtendedFABProps["color"]}
|
|
26
|
+
variant={fabConfig.variant}
|
|
27
|
+
size={"small"}
|
|
28
|
+
className={fabConfig.className}
|
|
29
|
+
/>
|
|
30
|
+
) : (
|
|
31
|
+
<FAB
|
|
32
|
+
size="regular" // Standard 56dp container
|
|
33
|
+
color={fabConfig.color}
|
|
34
|
+
variant={fabConfig.variant}
|
|
35
|
+
onClick={fabConfig.onClick}
|
|
36
|
+
aria-label={fabConfig.label}
|
|
37
|
+
className={fabConfig.className}
|
|
38
|
+
>
|
|
39
|
+
{fabConfig.icon}
|
|
40
|
+
</FAB>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Navigation rails provide access to primary destinations in apps.
|
|
5
|
+
* https://m3.material.io/components/navigation-rail/overview
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { cva } from "class-variance-authority";
|
|
10
|
+
import { clsx } from "clsx";
|
|
11
|
+
import {
|
|
12
|
+
NavRailProps,
|
|
13
|
+
NavRailDestinationConfig,
|
|
14
|
+
} from "../../../types/navigation-rail";
|
|
15
|
+
export type { NavRailProps, NavRailDestinationConfig };
|
|
16
|
+
import { NavigationRailDestination } from "./navigation-rail-destination";
|
|
17
|
+
import { cn } from "../../../lib/utils/helpers";
|
|
18
|
+
import { MenuButton } from "./menu-button";
|
|
19
|
+
import { Action } from "./action";
|
|
20
|
+
|
|
21
|
+
export const NavigationRail = ({
|
|
22
|
+
expanded: expandedProp,
|
|
23
|
+
destinations,
|
|
24
|
+
activeId,
|
|
25
|
+
showFab = false,
|
|
26
|
+
fabConfig,
|
|
27
|
+
showMenuButton = true,
|
|
28
|
+
onExpandedChange,
|
|
29
|
+
className,
|
|
30
|
+
alignment = "center",
|
|
31
|
+
backgroundColor = "bg-surface",
|
|
32
|
+
textColor = "text-on-surface hover:text-on-surface",
|
|
33
|
+
activeBgColor,
|
|
34
|
+
activeTextColor,
|
|
35
|
+
...props
|
|
36
|
+
}: NavRailProps) => {
|
|
37
|
+
const [internalExpanded, setInternalExpanded] = React.useState(false);
|
|
38
|
+
const isExpanded = expandedProp ?? internalExpanded;
|
|
39
|
+
|
|
40
|
+
const handleToggle = () => {
|
|
41
|
+
const newState = !isExpanded;
|
|
42
|
+
if (expandedProp == null) {
|
|
43
|
+
setInternalExpanded(newState);
|
|
44
|
+
}
|
|
45
|
+
onExpandedChange?.(newState);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const visibleDestinations = destinations.filter((d) => !d.hidden).slice(0, 6);
|
|
49
|
+
|
|
50
|
+
// Handle backdrop click to close on mobile
|
|
51
|
+
const handleBackdropClick = () => {
|
|
52
|
+
if (expandedProp == null) {
|
|
53
|
+
setInternalExpanded(false);
|
|
54
|
+
}
|
|
55
|
+
onExpandedChange?.(false);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const navRailVariants = cva(
|
|
59
|
+
"flex flex-col transition-all duration-300 ease-[cubic-bezier(0.2,0,0,1)] z-50 scrollbar-none py-4",
|
|
60
|
+
{
|
|
61
|
+
variants: {
|
|
62
|
+
expanded: {
|
|
63
|
+
true: `fixed ${backgroundColor} inset-y-0 left-0 w-72 shadow-2xl h-dvh md:relative md:w-80 md:shadow-none`, // Mobile: Fixed Drawer. Desktop: Relative Rail.
|
|
64
|
+
false: `w-fit h-auto bg-transparent absolute md:relative md:w-24 md:${backgroundColor} md:h-dvh`, // Mobile: Just button. Desktop: Standard Rail.
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
defaultVariants: {
|
|
68
|
+
expanded: false,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{/* Mobile Backdrop */}
|
|
76
|
+
{isExpanded && (
|
|
77
|
+
<div
|
|
78
|
+
className="fixed inset-0 bg-black/40 z-40 md:hidden transition-opacity duration-300"
|
|
79
|
+
onClick={handleBackdropClick}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<nav
|
|
85
|
+
className={cn(navRailVariants({ expanded: isExpanded, className }))}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<MenuButton
|
|
90
|
+
isExpanded={isExpanded}
|
|
91
|
+
showMenuButton={showMenuButton}
|
|
92
|
+
handleToggle={handleToggle}
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
{/* FAB - Hidden on mobile collapsed */}
|
|
96
|
+
{showFab && fabConfig && (
|
|
97
|
+
<Action isExpanded={isExpanded} fabConfig={fabConfig} />
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* destinations - Hidden on mobile collapsed */}
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
`flex-1 flex-col gap-1 py-4 ${clsx({ "justify-center": alignment === "center" })}`,
|
|
105
|
+
!isExpanded ? "hidden md:flex" : "flex"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<div>
|
|
109
|
+
{visibleDestinations.map((dest) => (
|
|
110
|
+
<NavigationRailDestination
|
|
111
|
+
key={dest.id}
|
|
112
|
+
icon={dest.icon}
|
|
113
|
+
label={dest.label}
|
|
114
|
+
badge={dest.badge}
|
|
115
|
+
active={dest.id === activeId}
|
|
116
|
+
expanded={isExpanded}
|
|
117
|
+
onClick={dest.onClick}
|
|
118
|
+
href={dest.href}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</nav>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MenuIcon } from "../../../lib/icons/menu-icon";
|
|
2
|
+
import { MenuOpenIcon } from "../../../lib/icons/menu-open-icon";
|
|
3
|
+
import { Ripple } from "../ripple";
|
|
4
|
+
import { clsx } from "clsx";
|
|
5
|
+
|
|
6
|
+
interface MenuButtonProps {
|
|
7
|
+
isExpanded: boolean;
|
|
8
|
+
showMenuButton?: boolean;
|
|
9
|
+
handleToggle: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MenuButton = ({
|
|
13
|
+
isExpanded,
|
|
14
|
+
showMenuButton = true,
|
|
15
|
+
handleToggle,
|
|
16
|
+
}: MenuButtonProps) => {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={`p-4 flex flex-col ${clsx({ "items-center": !isExpanded, "block md:hidden": !showMenuButton })}`}
|
|
20
|
+
>
|
|
21
|
+
<button
|
|
22
|
+
onClick={handleToggle}
|
|
23
|
+
className="w-12 h-12 flex items-center justify-center rounded-full hover:bg-on-surface/10 transition-colors cursor-pointer relative overflow-hidden"
|
|
24
|
+
aria-label={isExpanded ? "Collapse navigation" : "Expand navigation"}
|
|
25
|
+
>
|
|
26
|
+
<div className="relative z-10">
|
|
27
|
+
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
|
|
28
|
+
</div>
|
|
29
|
+
<Ripple />
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
import { Ripple } from "../ripple";
|
|
3
|
+
import { NavRailDestinationProps } from "../../../types/navigation-rail";
|
|
4
|
+
import {cn} from "../../../lib/utils/helpers";
|
|
5
|
+
|
|
6
|
+
const navItemVariants = cva(
|
|
7
|
+
"relative flex items-center group cursor-pointer transition-all duration-300 ease-[cubic-bezier(0.2,0,1)] px-4 py-3 outline-none focus-visible:outline-2 focus-visible:outline-primary w-fit",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
expanded: {
|
|
11
|
+
true: "flex-row gap-4 h-14 rounded-full mx-3",
|
|
12
|
+
false: "flex-col gap-1 h-auto min-h-14 justify-center",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
expanded: false,
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const NavigationRailDestination = ({
|
|
22
|
+
icon,
|
|
23
|
+
label,
|
|
24
|
+
active = false,
|
|
25
|
+
expanded = false,
|
|
26
|
+
badge,
|
|
27
|
+
activeBgColor = "bg-secondary-container",
|
|
28
|
+
activeTextColor = "text-on-secondary-container",
|
|
29
|
+
className,
|
|
30
|
+
href,
|
|
31
|
+
...props
|
|
32
|
+
}: NavRailDestinationProps) => {
|
|
33
|
+
const Component = href ? "a" : "div";
|
|
34
|
+
const activeIndicatorVariants = cva(
|
|
35
|
+
"absolute rounded-full z-0 transition-all duration-300 ease-[cubic-bezier(0.2,0,0,1)]" +
|
|
36
|
+
` ${activeBgColor} ${activeTextColor}`,
|
|
37
|
+
{
|
|
38
|
+
variants: {
|
|
39
|
+
active: {
|
|
40
|
+
true: "opacity-100",
|
|
41
|
+
false: "opacity-0 scale-x-50",
|
|
42
|
+
},
|
|
43
|
+
expanded: {
|
|
44
|
+
true: "inset-0 h-14 px-4 w-full",
|
|
45
|
+
false: "inset-x-0 mx-auto top-3 h-8 w-14", // 56dp indicator in collapsed
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Component
|
|
53
|
+
className={cn(navItemVariants({ expanded, className }), "no-underline")}
|
|
54
|
+
href={href}
|
|
55
|
+
{...(props as any)}
|
|
56
|
+
>
|
|
57
|
+
<div className={cn(activeIndicatorVariants({ active, expanded }))} />
|
|
58
|
+
|
|
59
|
+
<div className="relative z-10 flex items-center justify-center w-14 h-8">
|
|
60
|
+
<div className="relative">
|
|
61
|
+
{icon}
|
|
62
|
+
{badge !== undefined && (
|
|
63
|
+
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-error text-on-error text-[10px] font-medium border-2 border-surface">
|
|
64
|
+
{badge}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<span
|
|
71
|
+
className={cn(
|
|
72
|
+
"relative z-10 transition-all duration-300 font-medium text-center",
|
|
73
|
+
expanded
|
|
74
|
+
? "text-base whitespace-nowrap overflow-hidden text-ellipsis"
|
|
75
|
+
: "text-xs"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{label}
|
|
79
|
+
</span>
|
|
80
|
+
<Ripple />
|
|
81
|
+
</Component>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { NavigationRail, type NavRailDestinationConfig } from ".";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof NavigationRail> = {
|
|
6
|
+
title: "Components/NavigationRail",
|
|
7
|
+
component: NavigationRail,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "fullscreen",
|
|
10
|
+
},
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
const HomeIcon = () => (
|
|
17
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
18
|
+
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const SearchIcon = () => (
|
|
23
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
24
|
+
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const InboxIcon = () => (
|
|
29
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
30
|
+
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5v-3h3.56c.69 1.19 1.97 2 3.44 2s2.75-.81 3.44-2H19v3zm0-5h-4.99c0 1.1-.9 2-2 2s-2-.9-2-2H5V5h14v9z" />
|
|
31
|
+
</svg>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const SettingsIcon = () => (
|
|
35
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
36
|
+
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const demoDestinations: NavRailDestinationConfig[] = [
|
|
41
|
+
{ id: "home", icon: <HomeIcon />, label: "Home" },
|
|
42
|
+
{ id: "search", icon: <SearchIcon />, label: "Search" },
|
|
43
|
+
{ id: "inbox", icon: <InboxIcon />, label: "Inbox", badge: 3 },
|
|
44
|
+
{ id: "settings", icon: <SettingsIcon />, label: "Settings" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const Interactive: StoryObj<typeof NavigationRail> = {
|
|
48
|
+
render: () => {
|
|
49
|
+
const [active, setActive] = useState("home");
|
|
50
|
+
|
|
51
|
+
const destinationsWithClick = demoDestinations.map((d) => ({
|
|
52
|
+
...d,
|
|
53
|
+
onClick: () => setActive(d.id),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex h-screen bg-surface-container-low">
|
|
58
|
+
<NavigationRail destinations={destinationsWithClick} activeId={active} />
|
|
59
|
+
<main className="flex-1 p-8">
|
|
60
|
+
<h1 className="text-2xl font-bold">Content Area</h1>
|
|
61
|
+
<p className="mt-4 text-on-surface-variant">The menu button is built-in and state-aware.</p>
|
|
62
|
+
</main>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Collapsed: StoryObj<typeof NavigationRail> = {
|
|
69
|
+
args: {
|
|
70
|
+
expanded: false,
|
|
71
|
+
destinations: demoDestinations,
|
|
72
|
+
activeId: "home",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Expanded: StoryObj<typeof NavigationRail> = {
|
|
77
|
+
args: {
|
|
78
|
+
expanded: true,
|
|
79
|
+
destinations: demoDestinations,
|
|
80
|
+
activeId: "home",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const HiddenSlots: StoryObj<typeof NavigationRail> = {
|
|
85
|
+
args: {
|
|
86
|
+
expanded: false,
|
|
87
|
+
destinations: [
|
|
88
|
+
...demoDestinations,
|
|
89
|
+
{ id: "hidden", icon: <SettingsIcon />, label: "Hidden", hidden: true },
|
|
90
|
+
],
|
|
91
|
+
activeId: "home",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const NoMenuButton: StoryObj<typeof NavigationRail> = {
|
|
96
|
+
args: {
|
|
97
|
+
showMenuButton: false,
|
|
98
|
+
destinations: demoDestinations,
|
|
99
|
+
activeId: "home",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
export const WithLinks: StoryObj<typeof NavigationRail> = {
|
|
103
|
+
args: {
|
|
104
|
+
destinations: [
|
|
105
|
+
{ id: "home", icon: <HomeIcon />, label: "Home", href: "/" },
|
|
106
|
+
{ id: "search", icon: <SearchIcon />, label: "Search", href: "/search" },
|
|
107
|
+
{
|
|
108
|
+
id: "inbox",
|
|
109
|
+
icon: <InboxIcon />,
|
|
110
|
+
label: "Inbox",
|
|
111
|
+
badge: 3,
|
|
112
|
+
href: "/inbox",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "settings",
|
|
116
|
+
icon: <SettingsIcon />,
|
|
117
|
+
label: "Settings",
|
|
118
|
+
href: "/settings",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
activeId: "home",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
interface RippleType {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
size: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A material design ripple effect component.
|
|
14
|
+
* To use this, the parent container must have `relative` and `overflow-hidden`.
|
|
15
|
+
*/
|
|
16
|
+
export const Ripple = () => {
|
|
17
|
+
const [ripples, setRipples] = useState<RippleType[]>([]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let bounce: NodeJS.Timeout | null = null;
|
|
21
|
+
if (ripples.length > 0) {
|
|
22
|
+
bounce = setTimeout(() => {
|
|
23
|
+
setRipples([]);
|
|
24
|
+
}, 600);
|
|
25
|
+
}
|
|
26
|
+
return () => {
|
|
27
|
+
if (bounce) clearTimeout(bounce);
|
|
28
|
+
};
|
|
29
|
+
}, [ripples.length]);
|
|
30
|
+
|
|
31
|
+
const addRipple = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
32
|
+
const container = event.currentTarget.getBoundingClientRect();
|
|
33
|
+
const size =
|
|
34
|
+
container.width > container.height ? container.width : container.height;
|
|
35
|
+
const x = event.clientX - container.left - size / 2;
|
|
36
|
+
const y = event.clientY - container.top - size / 2;
|
|
37
|
+
const newRipple = { x, y, size };
|
|
38
|
+
|
|
39
|
+
setRipples((prev) => [...prev, newRipple]);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className="absolute inset-0 z-0 overflow-hidden rounded-[inherit]"
|
|
45
|
+
onMouseDown={addRipple}
|
|
46
|
+
>
|
|
47
|
+
{ripples.map((ripple, index) => {
|
|
48
|
+
return (
|
|
49
|
+
<span
|
|
50
|
+
key={index}
|
|
51
|
+
className="absolute animate-ripple rounded-full bg-white opacity-25"
|
|
52
|
+
style={{
|
|
53
|
+
top: ripple.y,
|
|
54
|
+
left: ripple.x,
|
|
55
|
+
width: ripple.size,
|
|
56
|
+
height: ripple.size,
|
|
57
|
+
transform: "scale(0)",
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./components/button";
|
|
2
|
+
export * from "./components/card";
|
|
3
|
+
export * from "./components/fabs/fab";
|
|
4
|
+
export * from "./components/navigation-rail";
|
|
5
|
+
// Ripple is internal-ish but we can export it if users want raw ripples
|
|
6
|
+
export { Ripple } from "./components/ripple";
|
|
7
|
+
export * from "./components/fabs/extended-fab";
|
|
8
|
+
|
|
9
|
+
// types
|
|
10
|
+
export * from "../types/navigation-rail";
|
package/src/styles.css
ADDED