canvas-ui-sdk 0.3.13 → 0.3.15
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/README.md +58 -71
- package/dist/index.js +60 -60
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/prompts/CLAUDE.md +85 -0
- package/prompts/bake-theme.md +194 -0
- package/registry/blocks/activity-feed.json +1 -1
- package/registry/blocks/circular-progress-bar-list.json +1 -1
- package/registry/blocks/faqs-table.json +1 -1
- package/registry/blocks/fixed-column-data-table.json +1 -1
- package/registry/blocks/form-group.json +1 -1
- package/registry/blocks/grid-tiles-list.json +1 -1
- package/registry/blocks/image-feed-with-nested-comments.json +1 -1
- package/registry/blocks/large-image-labels-list.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +1 -1
- package/registry/blocks/nested-comments-table.json +1 -1
- package/registry/blocks/nested-data-table.json +1 -1
- package/registry/blocks/profile-grid-tiles-list.json +1 -1
- package/registry/blocks/reviews-table.json +1 -1
- package/registry/blocks/slideshow-grid-tiles.json +1 -1
- package/registry/blocks/social-feed.json +1 -1
- package/registry/blocks/standard-data-table.json +1 -1
- package/registry/blocks/standard-list-with-image.json +1 -1
- package/registry/blocks/upvoting-posts-table.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-ui-sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A comprehensive UI component library with design tokens for building beautiful interfaces",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"dist",
|
|
31
31
|
"styles",
|
|
32
32
|
"registry",
|
|
33
|
-
"mcp/dist"
|
|
33
|
+
"mcp/dist",
|
|
34
|
+
"prompts"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
|
36
37
|
"build": "tsup && node -e \"const fs = require('fs'); for (const f of ['dist/index.js','dist/charts.js']) { const c = fs.readFileSync(f,'utf8'); fs.writeFileSync(f, '\\\"use client\\\";\\n' + c); }\" && npm run cli:build",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Canvas Consumer App
|
|
2
|
+
|
|
3
|
+
This is a Next.js app that uses the Canvas UI SDK component library.
|
|
4
|
+
|
|
5
|
+
## Adding Components
|
|
6
|
+
|
|
7
|
+
This project uses a **shadcn-style CLI** to copy component source files from `canvas-ui-sdk` into the project for full customization.
|
|
8
|
+
|
|
9
|
+
### Available commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# See all available components (145 total: layouts, blocks, UI primitives, hooks)
|
|
13
|
+
npx canvas-ui list
|
|
14
|
+
|
|
15
|
+
# Copy components into the project (auto-resolves dependencies)
|
|
16
|
+
npx canvas-ui add <component-name> [component-name...]
|
|
17
|
+
|
|
18
|
+
# Examples:
|
|
19
|
+
npx canvas-ui add button # Copies button + utils
|
|
20
|
+
npx canvas-ui add dashboard-shell # Copies shell + sidebar + header + 15 deps
|
|
21
|
+
npx canvas-ui add standard-data-table # Copies table + pagination + deps
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### How it works
|
|
25
|
+
|
|
26
|
+
- Components are copied as **editable source files** into `src/components/`
|
|
27
|
+
- UI primitives go to `src/components/ui/`
|
|
28
|
+
- Layout shells go to `src/components/layout/`
|
|
29
|
+
- Block components go to `src/components/blocks/<category>/`
|
|
30
|
+
- Hooks go to `src/hooks/`
|
|
31
|
+
- Utilities go to `src/lib/`
|
|
32
|
+
- Import paths use `@/` aliases (e.g. `@/components/ui/button`, `@/lib/utils`)
|
|
33
|
+
- npm dependencies (like `@radix-ui/*`, `class-variance-authority`) are auto-installed
|
|
34
|
+
|
|
35
|
+
### When building a page
|
|
36
|
+
|
|
37
|
+
1. Decide which layout shell fits (run `npx canvas-ui list` to see options)
|
|
38
|
+
2. Add the layout: `npx canvas-ui add <shell-name>`
|
|
39
|
+
3. Add any blocks needed: `npx canvas-ui add <block-name> [block-name...]`
|
|
40
|
+
4. Import components from their local paths (e.g. `import { Button } from "@/components/ui/button"`)
|
|
41
|
+
5. Customize the copied source files as needed
|
|
42
|
+
|
|
43
|
+
### What stays in the npm package (do NOT copy these)
|
|
44
|
+
|
|
45
|
+
- Design tokens / CSS variables (`canvas-ui-sdk/styles`)
|
|
46
|
+
- Tailwind theme mappings (`canvas-ui-sdk/tailwind`)
|
|
47
|
+
- Theme context and providers (`canvas-ui-sdk`)
|
|
48
|
+
|
|
49
|
+
These are imported directly from the npm package, not copied.
|
|
50
|
+
|
|
51
|
+
## Design Tokens
|
|
52
|
+
|
|
53
|
+
When creating or modifying components, always use the canvas design token CSS variables. Never use raw Tailwind color classes (`bg-white`, `text-gray-500`, `border-gray-200`) or hardcoded pixel values for font sizes. Use the token system instead:
|
|
54
|
+
|
|
55
|
+
- **Colors**: `var(--canvas-background)`, `var(--canvas-text)`, `var(--canvas-primary)`, `var(--canvas-border)`, etc.
|
|
56
|
+
- **Typography**: `var(--typo-body-s-size)`, `var(--typo-body-m-size)`, `var(--typo-h4-size)`, `var(--typo-global-font)`, etc.
|
|
57
|
+
- **Spacing**: `var(--spacing-sm)`, `var(--spacing-md)`, `var(--spacing-lg)`, etc.
|
|
58
|
+
- **Radii**: `var(--radius-xs)`, `var(--radius-sm)`, `var(--radius-md)`, etc.
|
|
59
|
+
- **Inputs**: `var(--canvas-border-input)`, `var(--input-standard-height)`, `var(--input-standard-radius)`, etc.
|
|
60
|
+
- **Buttons**: `var(--btn-primary-bg)`, `var(--btn-standard-height)`, etc.
|
|
61
|
+
|
|
62
|
+
Reference the SDK's `styles/tokens.reference.css` for the full list of available tokens.
|
|
63
|
+
|
|
64
|
+
## CSS Setup
|
|
65
|
+
|
|
66
|
+
The app's `src/app/globals.css` imports:
|
|
67
|
+
```css
|
|
68
|
+
@import "tailwindcss";
|
|
69
|
+
@import "canvas-ui-sdk/tailwind";
|
|
70
|
+
@import "canvas-ui-sdk/styles";
|
|
71
|
+
@source "../../node_modules/canvas-ui-sdk/dist";
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Project Structure
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
src/
|
|
78
|
+
app/ # Next.js app router pages
|
|
79
|
+
components/ # Copied Canvas UI components (editable)
|
|
80
|
+
ui/ # Primitives (button, input, dialog, etc.)
|
|
81
|
+
layout/ # Layout shells (dashboard-shell, sidebar, etc.)
|
|
82
|
+
blocks/ # Feature blocks (data-tables, forms, cards, etc.)
|
|
83
|
+
hooks/ # Copied hooks
|
|
84
|
+
lib/ # Utilities (utils.ts with cn() helper)
|
|
85
|
+
```
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Bake Theme Into Source Code
|
|
2
|
+
|
|
3
|
+
## What this does
|
|
4
|
+
|
|
5
|
+
Reads the saved theme from `.data/theme.json` and applies it as source-code defaults
|
|
6
|
+
so the app loads with the correct styling on first paint — no flash of unstyled content.
|
|
7
|
+
|
|
8
|
+
After baking, `.data/theme.json` is deleted and the ThemeDrawer starts fresh.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
### 1. Read the saved theme
|
|
15
|
+
|
|
16
|
+
Read `.data/theme.json`. It has this shape:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"overrides": { "--canvas-primary": "#ee4c11", "--typo-global-font": "Lato", ... },
|
|
21
|
+
"branding": { "iconShape": "rounded", "iconName": "WifiHigh", ... },
|
|
22
|
+
"images": { "logoLight": "", "logoDark": "", ... },
|
|
23
|
+
"customButtonStyles": []
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If the file doesn't exist or is empty, stop — there's nothing to bake.
|
|
28
|
+
|
|
29
|
+
### 2. Update `src/app/globals.css`
|
|
30
|
+
|
|
31
|
+
For each entry in `overrides`, add or update the CSS variable in the correct block:
|
|
32
|
+
|
|
33
|
+
| Variable pattern | Block | Notes |
|
|
34
|
+
|-----------------|-------|-------|
|
|
35
|
+
| `--canvas-*` (colors) | `:root { }` | e.g. `--canvas-primary: #ee4c11;` |
|
|
36
|
+
| `--typo-*-size`, `--typo-*-weight`, `--typo-*-spacing`, `--typo-*-height` | `body { }` | e.g. `--typo-menu-label-size: 40;` |
|
|
37
|
+
| `--typo-global-font` | `body { }` | Set as: `--typo-global-font: var(--font-FONTNAME), "FontName", sans-serif;` where FONTNAME is the lowercased, hyphenated font name. Also update the existing `--typo-global-font` line if present. |
|
|
38
|
+
| `--typo-*-font` (per-component) | `body { }` | Only if the value is a real font name (not empty). Set as: `--typo-COMPONENT-font: "FontName", sans-serif;`. Remove the corresponding `--typo-*-font: initial;` line from `:root` if present. |
|
|
39
|
+
| `--btn-*` (button styles) | `body { }` | e.g. `--btn-border-radius: 8;` |
|
|
40
|
+
|
|
41
|
+
**Important:** Keep any existing variables that aren't in the overrides. Only add/update the ones from the saved theme.
|
|
42
|
+
|
|
43
|
+
### 3. Add Google Font imports in `src/app/layout.tsx`
|
|
44
|
+
|
|
45
|
+
For each unique font name found in the overrides (values of `--typo-*-font` variables):
|
|
46
|
+
|
|
47
|
+
1. Add an import from `next/font/google` at the top of the file:
|
|
48
|
+
```typescript
|
|
49
|
+
import { ExistingFont, NewFont } from "next/font/google";
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
2. Create the font instance:
|
|
53
|
+
```typescript
|
|
54
|
+
const newFont = NewFont({
|
|
55
|
+
variable: "--font-new-font",
|
|
56
|
+
subsets: ["latin"],
|
|
57
|
+
weight: ["400", "500", "600", "700"],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
3. Add the font's variable class to the `<body>` className:
|
|
62
|
+
```typescript
|
|
63
|
+
<body className={`${existingFont.variable} ${newFont.variable} antialiased`}>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Variable naming convention:** The CSS variable name is `--font-` followed by the font name in lowercase with spaces replaced by hyphens. For example, "Open Sans" becomes `--font-open-sans`.
|
|
67
|
+
|
|
68
|
+
**Keep existing fonts.** Don't remove Inter, Geist, or Geist_Mono — they're still needed.
|
|
69
|
+
|
|
70
|
+
### 4. Update `src/lib/theme-config.ts`
|
|
71
|
+
|
|
72
|
+
Write the `branding` and `images` objects from the saved theme into this file:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import type { BrandingState, ImageKey } from "canvas-ui-sdk";
|
|
76
|
+
|
|
77
|
+
export const savedBranding: BrandingState = {
|
|
78
|
+
// paste values from theme.json branding
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const savedImages: Record<ImageKey, string> = {
|
|
82
|
+
// paste values from theme.json images
|
|
83
|
+
};
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 5. Delete the saved theme file
|
|
87
|
+
|
|
88
|
+
Remove `.data/theme.json` — the overrides are now baked into the source code.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
rm .data/theme.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 6. Verify
|
|
95
|
+
|
|
96
|
+
Run `npm run dev` and confirm:
|
|
97
|
+
- The page loads with the correct colors, fonts, and branding on first paint
|
|
98
|
+
- No flash of default styles
|
|
99
|
+
- The theme drawer opens with no unsaved changes
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Example
|
|
104
|
+
|
|
105
|
+
Given this `.data/theme.json`:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"overrides": {
|
|
110
|
+
"--canvas-primary": "#ee4c11",
|
|
111
|
+
"--canvas-primary-dark": "#772609",
|
|
112
|
+
"--typo-global-font": "Lato",
|
|
113
|
+
"--typo-menu-label-font": "Open Sans",
|
|
114
|
+
"--typo-menu-label-size": "40",
|
|
115
|
+
"--typo-menu-label-weight": "700"
|
|
116
|
+
},
|
|
117
|
+
"branding": {
|
|
118
|
+
"iconShape": "rounded",
|
|
119
|
+
"iconName": "WifiHigh",
|
|
120
|
+
"bgColor": "var(--canvas-primary)",
|
|
121
|
+
"iconColor": "var(--canvas-primary-foreground)",
|
|
122
|
+
"wordmark": "ohhh"
|
|
123
|
+
},
|
|
124
|
+
"images": {
|
|
125
|
+
"logoLight": "",
|
|
126
|
+
"logoDark": "",
|
|
127
|
+
"faviconLight": "",
|
|
128
|
+
"faviconDark": ""
|
|
129
|
+
},
|
|
130
|
+
"customButtonStyles": []
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**globals.css** `:root` block gets:
|
|
135
|
+
```css
|
|
136
|
+
:root {
|
|
137
|
+
--canvas-primary: #ee4c11;
|
|
138
|
+
--canvas-primary-dark: #772609;
|
|
139
|
+
/* ... keep other existing variables ... */
|
|
140
|
+
|
|
141
|
+
/* Remove --typo-menu-label-font: initial; since it now has a real value */
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**globals.css** `body` block gets:
|
|
146
|
+
```css
|
|
147
|
+
body {
|
|
148
|
+
--typo-global-font: var(--font-lato), "Lato", sans-serif;
|
|
149
|
+
--typo-menu-label-font: "Open Sans", sans-serif;
|
|
150
|
+
--typo-menu-label-size: 40;
|
|
151
|
+
--typo-menu-label-weight: 700;
|
|
152
|
+
/* ... keep existing body styles ... */
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**layout.tsx** gets new font imports:
|
|
157
|
+
```typescript
|
|
158
|
+
import { Inter, Geist, Geist_Mono, Lato, Open_Sans } from "next/font/google";
|
|
159
|
+
|
|
160
|
+
const lato = Lato({
|
|
161
|
+
variable: "--font-lato",
|
|
162
|
+
subsets: ["latin"],
|
|
163
|
+
weight: ["400", "700"],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const openSans = Open_Sans({
|
|
167
|
+
variable: "--font-open-sans",
|
|
168
|
+
subsets: ["latin"],
|
|
169
|
+
weight: ["400", "500", "600", "700"],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// In the body className:
|
|
173
|
+
<body className={`${inter.variable} ${geistSans.variable} ${geistMono.variable} ${lato.variable} ${openSans.variable} antialiased`}>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**theme-config.ts** gets:
|
|
177
|
+
```typescript
|
|
178
|
+
import type { BrandingState, ImageKey } from "canvas-ui-sdk";
|
|
179
|
+
|
|
180
|
+
export const savedBranding: BrandingState = {
|
|
181
|
+
iconShape: "rounded",
|
|
182
|
+
iconName: "WifiHigh",
|
|
183
|
+
bgColor: "var(--canvas-primary)",
|
|
184
|
+
iconColor: "var(--canvas-primary-foreground)",
|
|
185
|
+
wordmark: "ohhh",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const savedImages: Record<ImageKey, string> = {
|
|
189
|
+
logoLight: "",
|
|
190
|
+
logoDark: "",
|
|
191
|
+
faviconLight: "",
|
|
192
|
+
faviconDark: "",
|
|
193
|
+
};
|
|
194
|
+
```
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/activity-feed.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Check, Heart, MessageCircle, FileText } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ActivityAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface ActivityAttachment {\n id: string;\n name: string;\n size: string;\n type?: \"document\" | \"image\" | \"other\";\n}\n\nexport interface BaseActivityItem {\n id: string;\n author: ActivityAuthor;\n timestamp: string;\n}\n\nexport interface StatusChangeActivity extends BaseActivityItem {\n type: \"status_change\";\n action: \"completed\" | \"updated\" | \"started\" | \"archived\";\n projectName: string;\n}\n\nexport interface CommentActivity extends BaseActivityItem {\n type: \"comment\";\n projectName: string;\n content: string;\n likes: number;\n replies: number;\n isLiked?: boolean;\n}\n\nexport interface AttachmentActivity extends BaseActivityItem {\n type: \"attachment\";\n action: \"completed\" | \"uploaded\" | \"shared\";\n projectName: string;\n attachment: ActivityAttachment;\n}\n\nexport type ActivityItem = StatusChangeActivity | CommentActivity | AttachmentActivity;\n\nexport interface ActivityFeedProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Activity items to display */\n items?: ActivityItem[];\n /** Callback when like button is clicked */\n onLike?: (itemId: string) => void;\n /** Callback when reply button is clicked */\n onReply?: (itemId: string) => void;\n /** Callback when attachment is clicked */\n onAttachmentClick?: (itemId: string, attachmentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ActivityItem[] = [\n {\n id: \"1\",\n type: \"status_change\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n action: \"completed\",\n projectName: \"Acme Project\",\n timestamp: \"Today at 8:15 AM\",\n },\n {\n id: \"2\",\n type: \"comment\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n projectName: \"Acme Project\",\n content: \"Thank you Mary, the invoice looks great! Could you email it to Jeffrey and the Acme team and ask them to please pay by tomorrow?\",\n likes: 30,\n replies: 10,\n isLiked: true,\n timestamp: \"Yesterday at 11:25 AM\",\n },\n {\n id: \"3\",\n type: \"attachment\",\n author: {\n id: \"mary\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n action: \"completed\",\n projectName: \"Acme Project\",\n attachment: {\n id: \"inv-1\",\n name: \"Invoice #23J2KF\",\n size: \"10 MB\",\n type: \"document\",\n },\n timestamp: \"3 days ago\",\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ActivityLineProps {\n showLine: boolean;\n height?: string;\n}\n\nfunction ActivityLine({ showLine, height = \"64px\" }: ActivityLineProps) {\n if (!showLine) return null;\n return (\n <div\n style={{\n width: \"1px\",\n height,\n backgroundColor: \"var(--canvas-border-disabled)\",\n }}\n />\n );\n}\n\ninterface StatusIconProps {\n status: \"completed\" | \"updated\" | \"started\" | \"archived\";\n}\n\nfunction StatusIcon({ status }: StatusIconProps) {\n if (status === \"completed\") {\n return (\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <Check size={20} color=\"white\" strokeWidth={2.5} />\n </div>\n );\n }\n \n // Default fallback for other statuses\n return (\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n backgroundColor: \"var(--canvas-surface)\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n />\n );\n}\n\ninterface ActivityAvatarProps {\n avatarUrl?: string;\n name: string;\n}\n\nfunction ActivityAvatar({ avatarUrl, name }: ActivityAvatarProps) {\n return (\n <Avatar\n className=\"shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback>\n {name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\ninterface AttachmentCardProps {\n attachment: ActivityAttachment;\n onClick?: () => void;\n}\n\nfunction AttachmentCard({ attachment, onClick }: AttachmentCardProps) {\n return (\n <div\n className=\"flex items-center cursor-pointer\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-2xl)\",\n padding: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0, 0, 0, 0.03)\",\n gap: \"0\",\n }}\n onClick={onClick}\n >\n {/* Icon container */}\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"64px\",\n height: \"64px\",\n backgroundColor: \"var(--canvas-surface-brand)\",\n border: \"1px solid var(--canvas-primary)\",\n borderRadius: \"var(--radius-md)\",\n }}\n >\n <FileText size={32} style={{ color: \"var(--canvas-primary)\" }} />\n </div>\n \n {/* File info */}\n <div\n className=\"flex flex-col justify-center\"\n style={{\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {attachment.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {attachment.size}\n </span>\n </div>\n </div>\n );\n}\n\ninterface CommentCardProps {\n content: string;\n likes: number;\n replies: number;\n isLiked?: boolean;\n onLike?: () => void;\n onReply?: () => void;\n}\n\nfunction CommentCard({ content, likes, replies, isLiked, onLike, onReply }: CommentCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md)\",\n padding: \"var(--spacing-4xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0, 0, 0, 0.03)\",\n gap: \"var(--spacing-lg)\",\n maxWidth: \"580px\",\n }}\n >\n {/* Comment content */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {content}\n </p>\n \n {/* Action icons */}\n <div\n className=\"flex items-center\"\n style={{\n gap: \"var(--spacing-lg)\",\n paddingTop: \"var(--spacing-xxs)\",\n paddingBottom: \"var(--spacing-xxs)\",\n }}\n >\n <button\n onClick={onLike}\n className=\"flex items-center justify-center p-0 border-0 bg-transparent cursor-pointer\"\n >\n <Heart\n size={20}\n fill={isLiked ? \"var(--canvas-destructive)\" : \"none\"}\n color={isLiked ? \"var(--canvas-destructive)\" : \"var(--canvas-text)\"}\n />\n </button>\n <button\n onClick={onReply}\n className=\"flex items-center justify-center p-0 border-0 bg-transparent cursor-pointer\"\n >\n <MessageCircle size={20} style={{ color: \"var(--canvas-text)\" }} />\n </button>\n </div>\n \n {/* Stats */}\n <div\n className=\"flex items-start\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-xs)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {likes} likes\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {replies} replies\n </span>\n </div>\n </div>\n );\n}\n\nfunction getActionText(action: string): string {\n switch (action) {\n case \"completed\":\n return \"marked\";\n case \"updated\":\n return \"updated\";\n case \"started\":\n return \"started\";\n case \"uploaded\":\n return \"uploaded\";\n case \"shared\":\n return \"shared\";\n default:\n return action;\n }\n}\n\nfunction getActionSuffix(action: string): string {\n switch (action) {\n case \"completed\":\n return \"as complete\";\n case \"updated\":\n return \"\";\n case \"started\":\n return \"\";\n default:\n return \"\";\n }\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Activity Feed Block\n * \n * A timeline-style activity feed showing user actions, comments, and file\n * attachments with connecting lines. Useful for project updates, notifications,\n * and collaboration views.\n * \n * @example\n * ```tsx\n * <ActivityFeed\n * title=\"Project status\"\n * subtitle=\"Last updated today\"\n * items={activityItems}\n * onLike={(id) => console.log(\"Liked\", id)}\n * />\n * ```\n */\nexport function ActivityFeed({\n title = \"Project status\",\n subtitle = \"Last updated today\",\n items = defaultItems,\n onLike,\n onReply,\n onAttachmentClick,\n className,\n}: ActivityFeedProps) {\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full overflow-hidden\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Activity List */}\n <div className=\"flex flex-col w-full overflow-hidden\">\n {items.map((item, index) => {\n const isLast = index === items.length - 1;\n \n return (\n <div\n key={item.id}\n className=\"flex flex-col w-full\"\n style={{\n paddingTop: index === 0 ? \"0\" : \"var(--spacing-xl)\",\n paddingBottom: isLast ? \"0\" : \"var(--spacing-xl)\",\n }}\n >\n <div\n className=\"flex w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Left column - Avatar/Icon with line */}\n <div\n className=\"flex flex-col items-center shrink-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {item.type === \"status_change\" ? (\n <StatusIcon status={item.action} />\n ) : (\n <ActivityAvatar\n avatarUrl={item.author.avatarUrl}\n name={item.author.name}\n />\n )}\n <ActivityLine showLine={!isLast} height={item.type === \"comment\" ? \"100%\" : \"64px\"} />\n </div>\n\n {/* Right column - Content */}\n <div\n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Activity header row */}\n <div\n className=\"flex flex-col justify-center\"\n style={{\n minHeight: \"48px\",\n gap: \"0\",\n }}\n >\n {/* Title line */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n margin: 0,\n }}\n >\n <span\n style={{\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.author.name}\n </span>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.type === \"comment\" ? \"comments on\" : getActionText((item as StatusChangeActivity | AttachmentActivity).action)}\n </span>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.type === \"comment\" \n ? (item as CommentActivity).projectName\n : (item as StatusChangeActivity | AttachmentActivity).projectName\n }\n </span>\n {item.type !== \"comment\" && getActionSuffix((item as StatusChangeActivity | AttachmentActivity).action) && (\n <>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {getActionSuffix((item as StatusChangeActivity | AttachmentActivity).action)}\n </span>\n </>\n )}\n </p>\n \n {/* Timestamp */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n margin: 0,\n }}\n >\n {item.timestamp}\n </p>\n </div>\n\n {/* Additional content based on type */}\n {item.type === \"comment\" && (\n <CommentCard\n content={(item as CommentActivity).content}\n likes={(item as CommentActivity).likes}\n replies={(item as CommentActivity).replies}\n isLiked={(item as CommentActivity).isLiked}\n onLike={() => onLike?.(item.id)}\n onReply={() => onReply?.(item.id)}\n />\n )}\n\n {item.type === \"attachment\" && (\n <AttachmentCard\n attachment={(item as AttachmentActivity).attachment}\n onClick={() => onAttachmentClick?.(item.id, (item as AttachmentActivity).attachment.id)}\n />\n )}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Check, Heart, MessageCircle, FileText } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ActivityAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface ActivityAttachment {\n id: string;\n name: string;\n size: string;\n type?: \"document\" | \"image\" | \"other\";\n}\n\nexport interface BaseActivityItem {\n id: string;\n author: ActivityAuthor;\n timestamp: string;\n}\n\nexport interface StatusChangeActivity extends BaseActivityItem {\n type: \"status_change\";\n action: \"completed\" | \"updated\" | \"started\" | \"archived\";\n projectName: string;\n}\n\nexport interface CommentActivity extends BaseActivityItem {\n type: \"comment\";\n projectName: string;\n content: string;\n likes: number;\n replies: number;\n isLiked?: boolean;\n}\n\nexport interface AttachmentActivity extends BaseActivityItem {\n type: \"attachment\";\n action: \"completed\" | \"uploaded\" | \"shared\";\n projectName: string;\n attachment: ActivityAttachment;\n}\n\nexport type ActivityItem = StatusChangeActivity | CommentActivity | AttachmentActivity;\n\nexport interface ActivityFeedProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Activity items to display */\n items?: ActivityItem[];\n /** Callback when like button is clicked */\n onLike?: (itemId: string) => void;\n /** Callback when reply button is clicked */\n onReply?: (itemId: string) => void;\n /** Callback when attachment is clicked */\n onAttachmentClick?: (itemId: string, attachmentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ActivityItem[] = [\n {\n id: \"1\",\n type: \"status_change\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n action: \"completed\",\n projectName: \"Acme Project\",\n timestamp: \"Today at 8:15 AM\",\n },\n {\n id: \"2\",\n type: \"comment\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n projectName: \"Acme Project\",\n content: \"Thank you Mary, the invoice looks great! Could you email it to Jeffrey and the Acme team and ask them to please pay by tomorrow?\",\n likes: 30,\n replies: 10,\n isLiked: true,\n timestamp: \"Yesterday at 11:25 AM\",\n },\n {\n id: \"3\",\n type: \"attachment\",\n author: {\n id: \"mary\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n action: \"completed\",\n projectName: \"Acme Project\",\n attachment: {\n id: \"inv-1\",\n name: \"Invoice #23J2KF\",\n size: \"10 MB\",\n type: \"document\",\n },\n timestamp: \"3 days ago\",\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ActivityLineProps {\n showLine: boolean;\n height?: string;\n}\n\nfunction ActivityLine({ showLine, height = \"64px\" }: ActivityLineProps) {\n if (!showLine) return null;\n return (\n <div\n style={{\n width: \"1px\",\n height,\n backgroundColor: \"var(--canvas-border-disabled)\",\n }}\n />\n );\n}\n\ninterface StatusIconProps {\n status: \"completed\" | \"updated\" | \"started\" | \"archived\";\n}\n\nfunction StatusIcon({ status }: StatusIconProps) {\n if (status === \"completed\") {\n return (\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <Check size={20} color=\"white\" strokeWidth={2.5} />\n </div>\n );\n }\n \n // Default fallback for other statuses\n return (\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n backgroundColor: \"var(--canvas-surface)\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n />\n );\n}\n\ninterface ActivityAvatarProps {\n avatarUrl?: string;\n name: string;\n}\n\nfunction ActivityAvatar({ avatarUrl, name }: ActivityAvatarProps) {\n return (\n <Avatar\n className=\"shrink-0\"\n style={{\n width: \"48px\",\n height: \"48px\",\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback>\n {name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\ninterface AttachmentCardProps {\n attachment: ActivityAttachment;\n onClick?: () => void;\n}\n\nfunction AttachmentCard({ attachment, onClick }: AttachmentCardProps) {\n return (\n <div\n className=\"flex items-center cursor-pointer\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-2xl)\",\n padding: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0, 0, 0, 0.03)\",\n gap: \"0\",\n }}\n onClick={onClick}\n >\n {/* Icon container */}\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"64px\",\n height: \"64px\",\n backgroundColor: \"var(--canvas-surface-brand)\",\n border: \"1px solid var(--canvas-primary)\",\n borderRadius: \"var(--radius-md)\",\n }}\n >\n <FileText size={32} style={{ color: \"var(--canvas-primary)\" }} />\n </div>\n \n {/* File info */}\n <div\n className=\"flex flex-col justify-center\"\n style={{\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {attachment.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {attachment.size}\n </span>\n </div>\n </div>\n );\n}\n\ninterface CommentCardProps {\n content: string;\n likes: number;\n replies: number;\n isLiked?: boolean;\n onLike?: () => void;\n onReply?: () => void;\n}\n\nfunction CommentCard({ content, likes, replies, isLiked, onLike, onReply }: CommentCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md)\",\n padding: \"var(--spacing-4xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0, 0, 0, 0.03)\",\n gap: \"var(--spacing-lg)\",\n maxWidth: \"580px\",\n }}\n >\n {/* Comment content */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {content}\n </p>\n \n {/* Action icons */}\n <div\n className=\"flex items-center\"\n style={{\n gap: \"var(--spacing-lg)\",\n paddingTop: \"var(--spacing-xxs)\",\n paddingBottom: \"var(--spacing-xxs)\",\n }}\n >\n <button\n onClick={onLike}\n className=\"flex items-center justify-center p-0 border-0 bg-transparent cursor-pointer\"\n >\n <Heart\n size={20}\n fill={isLiked ? \"var(--canvas-destructive)\" : \"none\"}\n color={isLiked ? \"var(--canvas-destructive)\" : \"var(--canvas-text)\"}\n />\n </button>\n <button\n onClick={onReply}\n className=\"flex items-center justify-center p-0 border-0 bg-transparent cursor-pointer\"\n >\n <MessageCircle size={20} style={{ color: \"var(--canvas-text)\" }} />\n </button>\n </div>\n \n {/* Stats */}\n <div\n className=\"flex items-start\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-xs)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {likes} likes\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {replies} replies\n </span>\n </div>\n </div>\n );\n}\n\nfunction getActionText(action: string): string {\n switch (action) {\n case \"completed\":\n return \"marked\";\n case \"updated\":\n return \"updated\";\n case \"started\":\n return \"started\";\n case \"uploaded\":\n return \"uploaded\";\n case \"shared\":\n return \"shared\";\n default:\n return action;\n }\n}\n\nfunction getActionSuffix(action: string): string {\n switch (action) {\n case \"completed\":\n return \"as complete\";\n case \"updated\":\n return \"\";\n case \"started\":\n return \"\";\n default:\n return \"\";\n }\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Activity Feed Block\n * \n * A timeline-style activity feed showing user actions, comments, and file\n * attachments with connecting lines. Useful for project updates, notifications,\n * and collaboration views.\n * \n * @example\n * ```tsx\n * <ActivityFeed\n * title=\"Project status\"\n * subtitle=\"Last updated today\"\n * items={activityItems}\n * onLike={(id) => console.log(\"Liked\", id)}\n * />\n * ```\n */\nexport function ActivityFeed({\n title = \"Project status\",\n subtitle = \"Last updated today\",\n items = defaultItems,\n onLike,\n onReply,\n onAttachmentClick,\n className,\n}: ActivityFeedProps) {\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-end w-full overflow-hidden\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Activity List */}\n <div className=\"flex flex-col w-full overflow-hidden\">\n {items.map((item, index) => {\n const isLast = index === items.length - 1;\n \n return (\n <div\n key={item.id}\n className=\"flex flex-col w-full\"\n style={{\n paddingTop: index === 0 ? \"0\" : \"var(--spacing-xl)\",\n paddingBottom: isLast ? \"0\" : \"var(--spacing-xl)\",\n }}\n >\n <div\n className=\"flex w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Left column - Avatar/Icon with line */}\n <div\n className=\"flex flex-col items-center shrink-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {item.type === \"status_change\" ? (\n <StatusIcon status={item.action} />\n ) : (\n <ActivityAvatar\n avatarUrl={item.author.avatarUrl}\n name={item.author.name}\n />\n )}\n <ActivityLine showLine={!isLast} height={item.type === \"comment\" ? \"100%\" : \"64px\"} />\n </div>\n\n {/* Right column - Content */}\n <div\n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Activity header row */}\n <div\n className=\"flex flex-col justify-center\"\n style={{\n minHeight: \"48px\",\n gap: \"0\",\n }}\n >\n {/* Title line */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n margin: 0,\n }}\n >\n <span\n style={{\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.author.name}\n </span>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.type === \"comment\" ? \"comments on\" : getActionText((item as StatusChangeActivity | AttachmentActivity).action)}\n </span>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.type === \"comment\" \n ? (item as CommentActivity).projectName\n : (item as StatusChangeActivity | AttachmentActivity).projectName\n }\n </span>\n {item.type !== \"comment\" && getActionSuffix((item as StatusChangeActivity | AttachmentActivity).action) && (\n <>\n {\" \"}\n <span\n style={{\n fontWeight: \"var(--typo-body-s-weight)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {getActionSuffix((item as StatusChangeActivity | AttachmentActivity).action)}\n </span>\n </>\n )}\n </p>\n \n {/* Timestamp */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n margin: 0,\n }}\n >\n {item.timestamp}\n </p>\n </div>\n\n {/* Additional content based on type */}\n {item.type === \"comment\" && (\n <CommentCard\n content={(item as CommentActivity).content}\n likes={(item as CommentActivity).likes}\n replies={(item as CommentActivity).replies}\n isLiked={(item as CommentActivity).isLiked}\n onLike={() => onLike?.(item.id)}\n onReply={() => onReply?.(item.id)}\n />\n )}\n\n {item.type === \"attachment\" && (\n <AttachmentCard\n attachment={(item as AttachmentActivity).attachment}\n onClick={() => onAttachmentClick?.(item.id, (item as AttachmentActivity).attachment.id)}\n />\n )}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/circular-progress-bar-list.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProgressListItem {\n id: string;\n title: string;\n description: string;\n /** Progress percentage (0-100) */\n progress: number;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface CircularProgressBarProps {\n /** Progress percentage (0-100) */\n progress: number;\n /** Size of the progress bar in pixels */\n size?: number;\n /** Stroke width of the progress arc */\n strokeWidth?: number;\n /** Additional class names */\n className?: string;\n}\n\nexport interface CircularProgressBarListProps {\n /** List title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** List items data */\n items?: ProgressListItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when item action is clicked */\n onItemAction?: (action: string, item: ProgressListItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProgressListItem[] = [\n {\n id: \"1\",\n title: \"Product Design 101\",\n description: \"From ideation to prototyping, learn how to turn innovative ideas into tangible products that meet user needs and market demands.\",\n progress: 50,\n },\n {\n id: \"2\",\n title: \"Product Analytics\",\n description: \"Dive deep into understanding user behavior, optimizing features, and maximizing product performance through insightful data analysis and experimentation methodologies.\",\n progress: 75,\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"progress-asc\", label: \"Progress (Low to High)\" },\n { id: \"progress-desc\", label: \"Progress (High to Low)\" },\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All courses\" },\n { id: \"in-progress\", label: \"In progress\" },\n { id: \"completed\", label: \"Completed\" },\n { id: \"not-started\", label: \"Not started\" },\n];\n\n// ============================================\n// CircularProgressBar Component\n// ============================================\n\n/**\n * Canvas Design System - Circular Progress Bar\n * \n * An SVG-based circular progress indicator that displays a percentage.\n * Uses CSS variables for theming support.\n */\nexport function CircularProgressBar({\n progress,\n size = 80,\n strokeWidth = 8,\n className,\n}: CircularProgressBarProps) {\n // Clamp progress between 0 and 100\n const clampedProgress = Math.min(100, Math.max(0, progress));\n \n // Calculate SVG dimensions\n const radius = (size - strokeWidth) / 2;\n const circumference = 2 * Math.PI * radius;\n const offset = circumference - (clampedProgress / 100) * circumference;\n const center = size / 2;\n\n return (\n <div \n className={cn(\"relative inline-flex items-center justify-center\", className)}\n style={{ width: size, height: size }}\n >\n <svg\n width={size}\n height={size}\n viewBox={`0 0 ${size} ${size}`}\n className=\"transform -rotate-90\"\n >\n {/* Background circle (track) */}\n <circle\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n stroke=\"var(--progress-bar-track-color)\"\n strokeWidth={strokeWidth}\n />\n {/* Progress arc (fill) */}\n <circle\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n stroke=\"var(--progress-bar-fill-color)\"\n strokeWidth={strokeWidth}\n strokeDasharray={circumference}\n strokeDashoffset={offset}\n strokeLinecap=\"round\"\n style={{\n transition: \"stroke-dashoffset 0.3s ease-in-out\",\n }}\n />\n </svg>\n {/* Percentage text */}\n <span\n className=\"absolute inset-0 flex items-center justify-center\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: 1,\n color: \"var(--progress-bar-text-color)\",\n }}\n >\n {Math.round(clampedProgress)}%\n </span>\n </div>\n );\n}\n\n// ============================================\n// CircularProgressBarList Component\n// ============================================\n\n/**\n * Canvas Design System - Circular Progress Bar List Block\n * \n * A list block displaying items with circular progress indicators.\n * Features a header section with title, result count, and sort/filter controls.\n * Each item shows a progress bar, title, description, and action menu.\n * \n * @example\n * ```tsx\n * <CircularProgressBarList\n * title=\"Courses\"\n * items={[\n * { id: \"1\", title: \"Design 101\", description: \"Learn design basics\", progress: 50 }\n * ]}\n * />\n * ```\n */\nexport function CircularProgressBarList({\n title = \"Courses\",\n resultCount,\n resultCountText,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n onSort,\n onFilter,\n onItemAction,\n className,\n}: CircularProgressBarListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? items.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n </div>\n\n {/* List Section */}\n <div \n className=\"flex flex-col w-full\"\n style={{ \n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex w-full\"\n style={{\n borderBottom: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-3xl) 0\",\n gap: \"var(--spacing-3xl)\",\n }}\n >\n {/* Progress Bar */}\n <div className=\"shrink-0\">\n <CircularProgressBar\n progress={item.progress}\n size={80}\n strokeWidth={8}\n />\n </div>\n\n {/* Content */}\n <div \n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {/* Title Row with Actions */}\n <div \n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <h3\n className=\"flex-1 min-w-0\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {item.title}\n </h3>\n <div className=\"shrink-0\">\n <MenufocusTemplate\n ariaLabel=\"Item actions\"\n items={[\n { id: \"view\", label: \"View details\", onClick: () => onItemAction?.(\"view\", item) },\n { id: \"continue\", label: \"Continue\", onClick: () => onItemAction?.(\"continue\", item) },\n { id: \"reset\", label: \"Reset progress\", variant: \"destructive\", onClick: () => onItemAction?.(\"reset\", item) },\n ]}\n />\n </div>\n </div>\n\n {/* Description */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {item.description}\n </p>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProgressListItem {\n id: string;\n title: string;\n description: string;\n /** Progress percentage (0-100) */\n progress: number;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface CircularProgressBarProps {\n /** Progress percentage (0-100) */\n progress: number;\n /** Size of the progress bar in pixels */\n size?: number;\n /** Stroke width of the progress arc */\n strokeWidth?: number;\n /** Additional class names */\n className?: string;\n}\n\nexport interface CircularProgressBarListProps {\n /** List title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** List items data */\n items?: ProgressListItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when item action is clicked */\n onItemAction?: (action: string, item: ProgressListItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProgressListItem[] = [\n {\n id: \"1\",\n title: \"Product Design 101\",\n description: \"From ideation to prototyping, learn how to turn innovative ideas into tangible products that meet user needs and market demands.\",\n progress: 50,\n },\n {\n id: \"2\",\n title: \"Product Analytics\",\n description: \"Dive deep into understanding user behavior, optimizing features, and maximizing product performance through insightful data analysis and experimentation methodologies.\",\n progress: 75,\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"progress-asc\", label: \"Progress (Low to High)\" },\n { id: \"progress-desc\", label: \"Progress (High to Low)\" },\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All courses\" },\n { id: \"in-progress\", label: \"In progress\" },\n { id: \"completed\", label: \"Completed\" },\n { id: \"not-started\", label: \"Not started\" },\n];\n\n// ============================================\n// CircularProgressBar Component\n// ============================================\n\n/**\n * Canvas Design System - Circular Progress Bar\n * \n * An SVG-based circular progress indicator that displays a percentage.\n * Uses CSS variables for theming support.\n */\nexport function CircularProgressBar({\n progress,\n size = 80,\n strokeWidth = 8,\n className,\n}: CircularProgressBarProps) {\n // Clamp progress between 0 and 100\n const clampedProgress = Math.min(100, Math.max(0, progress));\n \n // Calculate SVG dimensions\n const radius = (size - strokeWidth) / 2;\n const circumference = 2 * Math.PI * radius;\n const offset = circumference - (clampedProgress / 100) * circumference;\n const center = size / 2;\n\n return (\n <div \n className={cn(\"relative inline-flex items-center justify-center\", className)}\n style={{ width: size, height: size }}\n >\n <svg\n width={size}\n height={size}\n viewBox={`0 0 ${size} ${size}`}\n className=\"transform -rotate-90\"\n >\n {/* Background circle (track) */}\n <circle\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n stroke=\"var(--progress-bar-track-color)\"\n strokeWidth={strokeWidth}\n />\n {/* Progress arc (fill) */}\n <circle\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n stroke=\"var(--progress-bar-fill-color)\"\n strokeWidth={strokeWidth}\n strokeDasharray={circumference}\n strokeDashoffset={offset}\n strokeLinecap=\"round\"\n style={{\n transition: \"stroke-dashoffset 0.3s ease-in-out\",\n }}\n />\n </svg>\n {/* Percentage text */}\n <span\n className=\"absolute inset-0 flex items-center justify-center\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: 1,\n color: \"var(--progress-bar-text-color)\",\n }}\n >\n {Math.round(clampedProgress)}%\n </span>\n </div>\n );\n}\n\n// ============================================\n// CircularProgressBarList Component\n// ============================================\n\n/**\n * Canvas Design System - Circular Progress Bar List Block\n * \n * A list block displaying items with circular progress indicators.\n * Features a header section with title, result count, and sort/filter controls.\n * Each item shows a progress bar, title, description, and action menu.\n * \n * @example\n * ```tsx\n * <CircularProgressBarList\n * title=\"Courses\"\n * items={[\n * { id: \"1\", title: \"Design 101\", description: \"Learn design basics\", progress: 50 }\n * ]}\n * />\n * ```\n */\nexport function CircularProgressBarList({\n title = \"Courses\",\n resultCount,\n resultCountText,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n onSort,\n onFilter,\n onItemAction,\n className,\n}: CircularProgressBarListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? items.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div\n className=\"flex items-end justify-end shrink-0 max-sm:w-full max-sm:flex-wrap\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n </div>\n\n {/* List Section */}\n <div \n className=\"flex flex-col w-full\"\n style={{ \n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex w-full\"\n style={{\n borderBottom: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-3xl) 0\",\n gap: \"var(--spacing-3xl)\",\n }}\n >\n {/* Progress Bar */}\n <div className=\"shrink-0\">\n <CircularProgressBar\n progress={item.progress}\n size={80}\n strokeWidth={8}\n />\n </div>\n\n {/* Content */}\n <div \n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {/* Title Row with Actions */}\n <div \n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <h3\n className=\"flex-1 min-w-0\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {item.title}\n </h3>\n <div className=\"shrink-0\">\n <MenufocusTemplate\n ariaLabel=\"Item actions\"\n items={[\n { id: \"view\", label: \"View details\", onClick: () => onItemAction?.(\"view\", item) },\n { id: \"continue\", label: \"Continue\", onClick: () => onItemAction?.(\"continue\", item) },\n { id: \"reset\", label: \"Reset progress\", variant: \"destructive\", onClick: () => onItemAction?.(\"reset\", item) },\n ]}\n />\n </div>\n </div>\n\n {/* Description */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {item.description}\n </p>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/faqs-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Plus, Minus } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface FAQItem {\n id: string;\n question: string;\n answer: string;\n}\n\nexport interface FaqsTableProps {\n /** Table title */\n title?: string;\n /** FAQ items to display */\n items?: FAQItem[];\n /** ID of the item that should be expanded by default */\n defaultExpandedId?: string;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: FAQItem[] = [\n {\n id: \"1\",\n question: \"What is Canvas?\",\n answer:\n \"Canvas uses a modular approach that allows you to easily plug in standardized components for most of your application's UX. That allows you to not reinvent the wheel and focus most of your efforts on your product's unique parts.\",\n },\n {\n id: \"2\",\n question: \"How can I purchase a license?\",\n answer:\n \"You can purchase a license directly from our website. We offer individual, team, and enterprise plans to suit your needs. All plans include access to our full component library and design system.\",\n },\n {\n id: \"3\",\n question: \"Do you offer refunds?\",\n answer:\n \"Yes, we offer a 30-day money-back guarantee. If you're not satisfied with your purchase, contact our support team for a full refund.\",\n },\n {\n id: \"4\",\n question: \"Can I use Canvas for commercial projects?\",\n answer:\n \"Absolutely! All our licenses allow for unlimited commercial projects. You can use Canvas components in client work, SaaS products, and internal tools.\",\n },\n];\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - FAQs Table Block\n *\n * An expandable FAQ list with plus/minus toggle icons.\n * Displays one item expanded at a time (accordion behavior).\n *\n * @example\n * ```tsx\n * <FaqsTable\n * title=\"Common questions\"\n * items={[\n * { id: \"1\", question: \"What is Canvas?\", answer: \"...\" }\n * ]}\n * />\n * ```\n */\nexport function FaqsTable({\n title = \"Common questions\",\n items = defaultItems,\n defaultExpandedId,\n className,\n}: FaqsTableProps) {\n const [expandedId, setExpandedId] = useState<string | null>(\n defaultExpandedId ?? items[0]?.id ?? null\n );\n\n const toggleItem = (id: string) => {\n setExpandedId((current) => (current === id ? null : id));\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\n <div\n className=\"flex flex-wrap items-
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Plus, Minus } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface FAQItem {\n id: string;\n question: string;\n answer: string;\n}\n\nexport interface FaqsTableProps {\n /** Table title */\n title?: string;\n /** FAQ items to display */\n items?: FAQItem[];\n /** ID of the item that should be expanded by default */\n defaultExpandedId?: string;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: FAQItem[] = [\n {\n id: \"1\",\n question: \"What is Canvas?\",\n answer:\n \"Canvas uses a modular approach that allows you to easily plug in standardized components for most of your application's UX. That allows you to not reinvent the wheel and focus most of your efforts on your product's unique parts.\",\n },\n {\n id: \"2\",\n question: \"How can I purchase a license?\",\n answer:\n \"You can purchase a license directly from our website. We offer individual, team, and enterprise plans to suit your needs. All plans include access to our full component library and design system.\",\n },\n {\n id: \"3\",\n question: \"Do you offer refunds?\",\n answer:\n \"Yes, we offer a 30-day money-back guarantee. If you're not satisfied with your purchase, contact our support team for a full refund.\",\n },\n {\n id: \"4\",\n question: \"Can I use Canvas for commercial projects?\",\n answer:\n \"Absolutely! All our licenses allow for unlimited commercial projects. You can use Canvas components in client work, SaaS products, and internal tools.\",\n },\n];\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - FAQs Table Block\n *\n * An expandable FAQ list with plus/minus toggle icons.\n * Displays one item expanded at a time (accordion behavior).\n *\n * @example\n * ```tsx\n * <FaqsTable\n * title=\"Common questions\"\n * items={[\n * { id: \"1\", question: \"What is Canvas?\", answer: \"...\" }\n * ]}\n * />\n * ```\n */\nexport function FaqsTable({\n title = \"Common questions\",\n items = defaultItems,\n defaultExpandedId,\n className,\n}: FaqsTableProps) {\n const [expandedId, setExpandedId] = useState<string | null>(\n defaultExpandedId ?? items[0]?.id ?? null\n );\n\n const toggleItem = (id: string) => {\n setExpandedId((current) => (current === id ? null : id));\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\n <div\n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n </div>\n </div>\n\n {/* FAQ List */}\n <div className=\"flex flex-col w-full\">\n {items.map((item, index) => {\n const isExpanded = expandedId === item.id;\n const isFirst = index === 0;\n const isLast = index === items.length - 1;\n\n return (\n <div\n key={item.id}\n className=\"flex flex-col w-full\"\n style={{\n borderTop: isFirst ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-xl)\",\n paddingBottom: \"var(--spacing-xl)\",\n }}\n >\n {/* Question Row */}\n <button\n onClick={() => toggleItem(item.id)}\n className=\"cursor-pointer flex items-center justify-between w-full text-left\"\n style={{ gap: \"var(--spacing-xl)\" }}\n aria-expanded={isExpanded}\n aria-controls={`faq-answer-${item.id}`}\n >\n <span\n className=\"flex-1\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.question}\n </span>\n <div\n className=\"flex items-center justify-center shrink-0\"\n style={{\n width: \"32px\",\n height: \"32px\",\n padding: \"var(--spacing-md)\",\n borderRadius: \"var(--radius-full)\",\n }}\n >\n {isExpanded ? (\n <Minus\n size={20}\n style={{ color: \"var(--canvas-text)\" }}\n strokeWidth={1.5}\n />\n ) : (\n <Plus\n size={20}\n style={{ color: \"var(--canvas-text)\" }}\n strokeWidth={1.5}\n />\n )}\n </div>\n </button>\n\n {/* Answer (collapsible) */}\n {isExpanded && (\n <div\n id={`faq-answer-${item.id}`}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n paddingTop: \"var(--spacing-md)\",\n }}\n >\n {item.answer}\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/fixed-column-data-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\n\n// ============================================\n// Types\n// ============================================\n\nexport type FixedColumnTableStatus = \"pending\" | \"paid\" | \"overdue\";\n\nexport interface FixedColumnTableRow {\n id: string;\n name: string;\n avatarUrl?: string;\n amount: string;\n status: FixedColumnTableStatus;\n logoUrl?: string;\n company: string;\n dateSent: string;\n}\n\nexport interface FixedColumnTableColumn {\n id: string;\n label: string;\n width?: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface FixedColumnDataTableProps {\n /** Table title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** Table data rows */\n data?: FixedColumnTableRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when add/action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when row action is clicked */\n onRowAction?: (action: string, row: FixedColumnTableRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: FixedColumnTableRow[] = [\n {\n id: \"1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n amount: \"$3,200\",\n status: \"pending\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"5/23/2024\",\n },\n {\n id: \"2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n amount: \"$2,400\",\n status: \"paid\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"2/19/2024\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"amount-high\", label: \"Amount (High)\" },\n { id: \"amount-low\", label: \"Amount (Low)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All statuses\" },\n { id: \"pending\", label: \"Pending\" },\n { id: \"paid\", label: \"Paid\" },\n { id: \"overdue\", label: \"Overdue\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableHeaderCell({ children, className, isFixed }: TableHeaderCellProps) {\n return (\n <th\n className={cn(\n \"text-left h-8\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n }}\n >\n {children}\n </th>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableCell({ children, className, isFixed }: TableCellProps) {\n return (\n <td\n className={cn(\n \"h-12\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {children}\n </td>\n );\n}\n\ninterface StatusBadgeProps {\n status: FixedColumnTableStatus;\n}\n\nfunction StatusBadge({ status }: StatusBadgeProps) {\n const statusConfig: Record<FixedColumnTableStatus, { label: string; bgColor: string; textColor: string }> = {\n pending: {\n label: \"Pending\",\n bgColor: \"var(--canvas-surface-brand)\",\n textColor: \"var(--canvas-primary)\",\n },\n paid: {\n label: \"Paid\",\n bgColor: \"var(--canvas-success-surface)\",\n textColor: \"var(--canvas-success)\",\n },\n overdue: {\n label: \"Overdue\",\n bgColor: \"var(--canvas-destructive-surface)\",\n textColor: \"var(--canvas-destructive)\",\n },\n };\n\n const config = statusConfig[status];\n\n return (\n <span\n className=\"inline-flex items-center justify-center whitespace-nowrap\"\n style={{\n backgroundColor: config.bgColor,\n color: config.textColor,\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n padding: \"var(--spacing-md) var(--spacing-xl)\",\n borderRadius: \"var(--spacing-3xl)\",\n height: \"32px\",\n }}\n >\n {config.label}\n </span>\n );\n}\n\ninterface CompanyLogoProps {\n logoUrl?: string;\n company: string;\n}\n\nfunction CompanyLogo({ logoUrl, company }: CompanyLogoProps) {\n // Default favicon-style logo if no URL provided\n if (!logoUrl) {\n return (\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--radius-xs)\",\n }}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"var(--canvas-primary-foreground)\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" />\n <polyline points=\"9,22 9,12 15,12 15,22\" />\n </svg>\n </div>\n );\n }\n\n return (\n <img\n src={logoUrl}\n alt={`${company} logo`}\n className=\"object-contain\"\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n }}\n />\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Fixed Column Data Table Block\n * \n * A data table with a fixed first column (Name with avatar) that stays\n * visible during horizontal scrolling. Ideal for invoice-style tables\n * with many columns that need horizontal scrolling on smaller screens.\n * \n * @example\n * ```tsx\n * <FixedColumnDataTable\n * title=\"Invoices\"\n * data={[\n * { id: \"1\", name: \"John\", amount: \"$1,000\", status: \"paid\", company: \"Acme\", dateSent: \"1/1/2024\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function FixedColumnDataTable({\n title = \"Invoices\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onRowAction,\n className,\n}: FixedColumnDataTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0 gap-3\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Table Section with horizontal scroll */}\n <div className=\"w-full overflow-x-auto\">\n <table className=\"w-full min-w-max border-collapse\">\n <thead>\n <tr style={{ borderBottom: \"1px solid var(--canvas-border)\" }}>\n {/* Fixed Name Column Header */}\n <TableHeaderCell isFixed className=\"pr-8 min-w-[200px]\">\n Name\n </TableHeaderCell>\n {/* Scrollable Column Headers */}\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Amount\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Status\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[120px]\">\n Logo\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Company\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Date Sent\n </TableHeaderCell>\n <TableHeaderCell className=\"w-12 px-4\">\n \n </TableHeaderCell>\n </tr>\n </thead>\n <tbody>\n {data.map((row) => (\n <tr\n key={row.id}\n style={{ borderBottom: \"1px solid var(--canvas-border)\" }}\n >\n {/* Fixed Name Column */}\n <TableCell isFixed className=\"pr-8\">\n <div className=\"flex items-center gap-2\">\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={row.avatarUrl} alt={row.name} />\n <AvatarFallback>\n {row.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{row.name}</span>\n </div>\n </TableCell>\n {/* Scrollable Columns */}\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.amount}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <StatusBadge status={row.status} />\n </TableCell>\n <TableCell className=\"px-6\">\n <CompanyLogo logoUrl={row.logoUrl} company={row.company} />\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.company}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.dateSent}</span>\n </TableCell>\n <TableCell className=\"px-4\">\n <MenufocusTemplate\n ariaLabel=\"Row actions\"\n items={[\n { id: \"edit\", label: \"Edit\", onClick: () => onRowAction?.(\"edit\", row) },\n { id: \"view\", label: \"View details\", onClick: () => onRowAction?.(\"view\", row) },\n { id: \"download\", label: \"Download\", onClick: () => onRowAction?.(\"download\", row) },\n { id: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </TableCell>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\n\n// ============================================\n// Types\n// ============================================\n\nexport type FixedColumnTableStatus = \"pending\" | \"paid\" | \"overdue\";\n\nexport interface FixedColumnTableRow {\n id: string;\n name: string;\n avatarUrl?: string;\n amount: string;\n status: FixedColumnTableStatus;\n logoUrl?: string;\n company: string;\n dateSent: string;\n}\n\nexport interface FixedColumnTableColumn {\n id: string;\n label: string;\n width?: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface FixedColumnDataTableProps {\n /** Table title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** Table data rows */\n data?: FixedColumnTableRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when add/action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when row action is clicked */\n onRowAction?: (action: string, row: FixedColumnTableRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: FixedColumnTableRow[] = [\n {\n id: \"1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n amount: \"$3,200\",\n status: \"pending\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"5/23/2024\",\n },\n {\n id: \"2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n amount: \"$2,400\",\n status: \"paid\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"2/19/2024\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"amount-high\", label: \"Amount (High)\" },\n { id: \"amount-low\", label: \"Amount (Low)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All statuses\" },\n { id: \"pending\", label: \"Pending\" },\n { id: \"paid\", label: \"Paid\" },\n { id: \"overdue\", label: \"Overdue\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableHeaderCell({ children, className, isFixed }: TableHeaderCellProps) {\n return (\n <th\n className={cn(\n \"text-left h-8\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n }}\n >\n {children}\n </th>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableCell({ children, className, isFixed }: TableCellProps) {\n return (\n <td\n className={cn(\n \"h-12\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {children}\n </td>\n );\n}\n\ninterface StatusBadgeProps {\n status: FixedColumnTableStatus;\n}\n\nfunction StatusBadge({ status }: StatusBadgeProps) {\n const statusConfig: Record<FixedColumnTableStatus, { label: string; bgColor: string; textColor: string }> = {\n pending: {\n label: \"Pending\",\n bgColor: \"var(--canvas-surface-brand)\",\n textColor: \"var(--canvas-primary)\",\n },\n paid: {\n label: \"Paid\",\n bgColor: \"var(--canvas-success-surface)\",\n textColor: \"var(--canvas-success)\",\n },\n overdue: {\n label: \"Overdue\",\n bgColor: \"var(--canvas-destructive-surface)\",\n textColor: \"var(--canvas-destructive)\",\n },\n };\n\n const config = statusConfig[status];\n\n return (\n <span\n className=\"inline-flex items-center justify-center whitespace-nowrap\"\n style={{\n backgroundColor: config.bgColor,\n color: config.textColor,\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n padding: \"var(--spacing-md) var(--spacing-xl)\",\n borderRadius: \"var(--spacing-3xl)\",\n height: \"32px\",\n }}\n >\n {config.label}\n </span>\n );\n}\n\ninterface CompanyLogoProps {\n logoUrl?: string;\n company: string;\n}\n\nfunction CompanyLogo({ logoUrl, company }: CompanyLogoProps) {\n // Default favicon-style logo if no URL provided\n if (!logoUrl) {\n return (\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--radius-xs)\",\n }}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"var(--canvas-primary-foreground)\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" />\n <polyline points=\"9,22 9,12 15,12 15,22\" />\n </svg>\n </div>\n );\n }\n\n return (\n <img\n src={logoUrl}\n alt={`${company} logo`}\n className=\"object-contain\"\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n }}\n />\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Fixed Column Data Table Block\n * \n * A data table with a fixed first column (Name with avatar) that stays\n * visible during horizontal scrolling. Ideal for invoice-style tables\n * with many columns that need horizontal scrolling on smaller screens.\n * \n * @example\n * ```tsx\n * <FixedColumnDataTable\n * title=\"Invoices\"\n * data={[\n * { id: \"1\", name: \"John\", amount: \"$1,000\", status: \"paid\", company: \"Acme\", dateSent: \"1/1/2024\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function FixedColumnDataTable({\n title = \"Invoices\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onRowAction,\n className,\n}: FixedColumnDataTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-end justify-end shrink-0 gap-3 max-sm:w-full max-sm:flex-wrap\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Table Section with horizontal scroll */}\n <div className=\"w-full overflow-x-auto\">\n <table className=\"w-full min-w-max border-collapse\">\n <thead>\n <tr style={{ borderBottom: \"1px solid var(--canvas-border)\" }}>\n {/* Fixed Name Column Header */}\n <TableHeaderCell isFixed className=\"pr-8 min-w-[200px]\">\n Name\n </TableHeaderCell>\n {/* Scrollable Column Headers */}\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Amount\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Status\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[120px]\">\n Logo\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Company\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Date Sent\n </TableHeaderCell>\n <TableHeaderCell className=\"w-12 px-4\">\n \n </TableHeaderCell>\n </tr>\n </thead>\n <tbody>\n {data.map((row) => (\n <tr\n key={row.id}\n style={{ borderBottom: \"1px solid var(--canvas-border)\" }}\n >\n {/* Fixed Name Column */}\n <TableCell isFixed className=\"pr-8\">\n <div className=\"flex items-center gap-2\">\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={row.avatarUrl} alt={row.name} />\n <AvatarFallback>\n {row.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{row.name}</span>\n </div>\n </TableCell>\n {/* Scrollable Columns */}\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.amount}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <StatusBadge status={row.status} />\n </TableCell>\n <TableCell className=\"px-6\">\n <CompanyLogo logoUrl={row.logoUrl} company={row.company} />\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.company}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.dateSent}</span>\n </TableCell>\n <TableCell className=\"px-4\">\n <MenufocusTemplate\n ariaLabel=\"Row actions\"\n items={[\n { id: \"edit\", label: \"Edit\", onClick: () => onRowAction?.(\"edit\", row) },\n { id: \"view\", label: \"View details\", onClick: () => onRowAction?.(\"view\", row) },\n { id: \"download\", label: \"Download\", onClick: () => onRowAction?.(\"download\", row) },\n { id: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </TableCell>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|