canvas-ui-sdk 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +28 -1
- package/dist/index.js +198 -182
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/registry/blocks/activity-feed.json +5 -4
- package/registry/blocks/bottom-input-chat-widget.json +4 -3
- package/registry/blocks/chat-message.json +1 -1
- package/registry/blocks/circular-progress-bar-list.json +3 -3
- package/registry/blocks/component-search.json +2 -2
- package/registry/blocks/content-dropzone.json +1 -1
- package/registry/blocks/credit-card-display.json +1 -1
- package/registry/blocks/custom-component-helper.json +2 -2
- package/registry/blocks/demo-avatars.json +14 -0
- package/registry/blocks/empty-state.json +1 -1
- package/registry/blocks/faqs-table.json +2 -2
- package/registry/blocks/filter-popover.json +11 -11
- package/registry/blocks/fixed-column-data-table.json +6 -5
- package/registry/blocks/flair-banner.json +1 -1
- package/registry/blocks/form-group.json +15 -15
- package/registry/blocks/gradient-banner.json +1 -1
- package/registry/blocks/graph-metric-tiles.json +1 -1
- package/registry/blocks/grid-tiles-list.json +2 -2
- package/registry/blocks/image-feed-with-nested-comments.json +6 -5
- package/registry/blocks/large-image-labels-list.json +2 -2
- package/registry/blocks/loader.json +2 -2
- package/registry/blocks/login-branding-panel.json +1 -1
- package/registry/blocks/menu-section.json +1 -1
- package/registry/blocks/menufocus-template.json +2 -2
- package/registry/blocks/messenger-sidebar.json +4 -3
- package/registry/blocks/mobile-bottom-nav.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +2 -2
- package/registry/blocks/nested-comments-table.json +7 -6
- package/registry/blocks/nested-data-table.json +6 -5
- package/registry/blocks/page-header-section.json +2 -2
- package/registry/blocks/page-previews.json +4 -4
- package/registry/blocks/pagination.json +3 -3
- package/registry/blocks/participant-list.json +2 -2
- package/registry/blocks/persona-card.json +1 -1
- package/registry/blocks/pill-tabs.json +2 -2
- package/registry/blocks/profile-card.json +3 -3
- package/registry/blocks/profile-grid-tiles-list.json +5 -4
- package/registry/blocks/profile-image-uploader.json +2 -2
- package/registry/blocks/profile-info-cards.json +2 -2
- package/registry/blocks/progress-bar.json +1 -1
- package/registry/blocks/prompt-template.json +1 -1
- package/registry/blocks/reviews-grid.json +1 -1
- package/registry/blocks/reviews-table.json +5 -4
- package/registry/blocks/screen-flowchart.json +1 -1
- package/registry/blocks/screen-prompt-builder.json +2 -2
- package/registry/blocks/screen-prompt-template.json +1 -1
- package/registry/blocks/search-bar.json +2 -2
- package/registry/blocks/search-sidebar.json +8 -8
- package/registry/blocks/settings-list-row.json +3 -3
- package/registry/blocks/sidebar-cards.json +1 -1
- package/registry/blocks/sidebar-profile-card.json +4 -4
- package/registry/blocks/slideshow-grid-tiles.json +5 -4
- package/registry/blocks/social-feed.json +6 -5
- package/registry/blocks/standard-data-table.json +6 -5
- package/registry/blocks/standard-list-with-image.json +3 -3
- package/registry/blocks/step-tracker.json +1 -1
- package/registry/blocks/team-cards-grid.json +1 -1
- package/registry/blocks/team-circular-grid.json +1 -1
- package/registry/blocks/testimonial-carousel.json +1 -1
- package/registry/blocks/title-group.json +4 -4
- package/registry/blocks/upvoting-posts-table.json +7 -6
- package/registry/blocks/vertical-step-tracker.json +2 -2
- package/registry/blocks/video-chat-controls.json +1 -1
- package/registry/blocks/video-content-section.json +1 -1
- package/registry/blocks/video-playlist.json +1 -1
- package/registry/blocks/webcam-preview.json +1 -1
- package/registry/blocks/youtube-player.json +1 -1
- package/registry/index.json +5 -0
- package/registry/layout/account-settings-shell.json +3 -3
- package/registry/layout/dashboard-shell.json +5 -5
- package/registry/layout/double-sidebar-shell.json +5 -5
- package/registry/layout/double-sidebar.json +2 -2
- package/registry/layout/header.json +6 -5
- package/registry/layout/icon-sidebar-shell.json +5 -5
- package/registry/layout/icon-sidebar.json +1 -1
- package/registry/layout/mobile-menu-shell.json +4 -4
- package/registry/layout/multistep-progressbar-shell.json +8 -8
- package/registry/layout/multistep-shell.json +6 -6
- package/registry/layout/multistep-sidebar-shell.json +7 -7
- package/registry/layout/project-context-shell.json +2 -2
- package/registry/layout/search-bar-shell.json +7 -7
- package/registry/layout/sidebar-nav.json +1 -1
- package/registry/layout/sidebar.json +3 -3
- package/registry/layout/standard-page-shell.json +6 -6
- package/registry/layout/vertical-multistep-shell.json +8 -8
- package/registry/ui/avatar.json +1 -1
- package/registry/ui/button.json +1 -1
- package/registry/ui/calendar.json +2 -2
- package/registry/ui/checkbox.json +1 -1
- package/registry/ui/date-input.json +1 -1
- package/registry/ui/dialog.json +1 -1
- package/registry/ui/dropdown-menu.json +1 -1
- package/registry/ui/file-uploader.json +1 -1
- package/registry/ui/image-uploader.json +1 -1
- package/registry/ui/input.json +1 -1
- package/registry/ui/label.json +1 -1
- package/registry/ui/line-tabs.json +1 -1
- package/registry/ui/multiselect-checkbox-field.json +1 -1
- package/registry/ui/multiselect-tags.json +1 -1
- package/registry/ui/popover.json +1 -1
- package/registry/ui/radio-group.json +1 -1
- package/registry/ui/range-input.json +2 -2
- package/registry/ui/scroll-area.json +1 -1
- package/registry/ui/searchbox.json +1 -1
- package/registry/ui/select.json +1 -1
- package/registry/ui/selectable-pills.json +1 -1
- package/registry/ui/separator.json +1 -1
- package/registry/ui/sheet.json +1 -1
- package/registry/ui/sidebar.json +8 -8
- package/registry/ui/skeleton.json +1 -1
- package/registry/ui/slider.json +1 -1
- package/registry/ui/switch.json +1 -1
- package/registry/ui/tabs.json +1 -1
- package/registry/ui/text-input.json +1 -1
- package/registry/ui/textarea.json +1 -1
- package/registry/ui/tooltip.json +1 -1
- package/registry/ui/typography.json +1 -1
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/nested-data-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { TitleGroup } from \"./title-group\";\nimport { ChevronRight, ChevronDown, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ChildRow {\n id: string;\n name: string;\n avatarUrl?: string;\n email: string;\n phone: string;\n}\n\nexport interface ParentRow {\n id: string;\n location: string;\n ftes: number;\n contractors: number;\n hrContact: {\n name: string;\n phone: string;\n };\n children: ChildRow[];\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface NestedDataTableProps {\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?: ParentRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\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 parent row action is clicked */\n onRowAction?: (action: string, row: ParentRow) => void;\n /** Callback when child row action is clicked */\n onChildAction?: (action: string, child: ChildRow, parent: ParentRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: ParentRow[] = [\n {\n id: \"1\",\n location: \"San Francisco, CA\",\n ftes: 320,\n contractors: 66,\n hrContact: {\n name: \"Mary Trott\",\n phone: \"415-232-3434\",\n },\n children: [\n {\n id: \"1-1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n email: \"jconner@gmail.com\",\n phone: \"508-343-5334\",\n },\n {\n id: \"1-2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n email: \"eperez@gmail.com\",\n phone: \"234-989-6675\",\n },\n {\n id: \"1-3\",\n name: \"Lily Sun\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n email: \"rmishra@gmail.com\",\n phone: \"205-443-4324\",\n },\n ],\n },\n {\n id: \"2\",\n location: \"New York, NY\",\n ftes: 80,\n contractors: 8,\n hrContact: {\n name: \"Raj Mishra\",\n phone: \"206-646-9834\",\n },\n children: [],\n },\n {\n id: \"3\",\n location: \"Seattle, WA\",\n ftes: 98,\n contractors: 5,\n hrContact: {\n name: \"James Clayton\",\n phone: \"312-687-8675\",\n },\n children: [],\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"location-asc\", label: \"Location (A-Z)\" },\n { id: \"location-desc\", label: \"Location (Z-A)\" },\n { id: \"ftes-high\", label: \"FTEs (High-Low)\" },\n { id: \"ftes-low\", label: \"FTEs (Low-High)\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ExpandButtonProps {\n expanded: boolean;\n onClick: () => void;\n disabled?: boolean;\n}\n\nfunction ExpandButton({ expanded, onClick, disabled }: ExpandButtonProps) {\n return (\n <button\n onClick={onClick}\n disabled={disabled}\n className={cn(\n \"flex items-center justify-center shrink-0 transition-colors\",\n disabled ? \"opacity-40 cursor-not-allowed\" : \"cursor-pointer hover:bg-[var(--canvas-surface)]\"\n )}\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n }}\n aria-label={expanded ? \"Collapse row\" : \"Expand row\"}\n >\n {expanded ? (\n <ChevronDown \n size={20} \n style={{ color: \"var(--canvas-text)\" }}\n />\n ) : (\n <ChevronRight \n size={20} \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n )}\n </button>\n );\n}\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableHeaderCell({ children, className }: TableHeaderCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableCell({ children, className }: TableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction NestedTableCell({ children, className }: NestedTableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableProps {\n children: ChildRow[];\n onChildAction?: (action: string, child: ChildRow) => void;\n}\n\nfunction NestedTable({ children, onChildAction }: NestedTableProps) {\n if (children.length === 0) return null;\n\n return (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Nested Table Header */}\n <div\n className=\"grid items-center\"\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Name</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Email</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Phone number</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Nested Table Rows */}\n {children.map((child, index) => (\n <div\n key={child.id}\n className=\"grid items-center hover:bg-[var(--canvas-surface)] transition-colors rounded-sm\"\n style={{\n borderBottom: index < children.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n padding: \"var(--spacing-md) 0\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={child.avatarUrl} alt={child.name} />\n <AvatarFallback>\n {child.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{child.name}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.email}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.phone}</span>\n </NestedTableCell>\n </div>\n <div className=\"flex justify-center\" style={{ padding: \"0 var(--spacing-lg)\" }}>\n <button\n onClick={() => onChildAction?.(\"view\", child)}\n className=\"cursor-pointer flex items-center justify-center size-8 rounded-full hover:bg-[var(--canvas-surface)] transition-colors\"\n aria-label={`View ${child.name}`}\n >\n <Eye size={20} style={{ color: \"var(--canvas-text-muted)\" }} />\n </button>\n </div>\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Data Table Block\n * \n * An expandable data table with parent rows that reveal nested child tables.\n * Ideal for displaying hierarchical data like locations with employees,\n * departments with team members, or categories with items.\n * \n * @example\n * ```tsx\n * <NestedDataTable\n * title=\"FTEs & Contractors by Location\"\n * data={locationData}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function NestedDataTable({\n title = \"FTEs & Contractors by Location\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onRowAction,\n onChildAction,\n className,\n}: NestedDataTableProps) {\n const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set([\"1\"])); // First row expanded by default\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const toggleRow = (rowId: string) => {\n setExpandedRows(prev => {\n const next = new Set(prev);\n if (next.has(rowId)) {\n next.delete(rowId);\n } else {\n next.add(rowId);\n }\n return next;\n });\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 <TitleGroup title={title} subtitle={displayResultText} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Table Section */}\n <div className=\"w-full overflow-x-auto\">\n <div className=\"min-w-[800px]\">\n {/* Table Header */}\n <div\n className=\"grid items-center\"\n style={{ \n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n <div style={{ paddingLeft: \"var(--spacing-6xl)\" }}>\n <TableHeaderCell>Location</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>FTEs</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>Contractors</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>HR Contact</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Table Rows */}\n {data.map((row, index) => {\n const isExpanded = expandedRows.has(row.id);\n const hasChildren = row.children && row.children.length > 0;\n\n return (\n <div\n key={row.id}\n className=\"flex flex-col\"\n style={{\n borderBottom: index < data.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n paddingBottom: isExpanded ? \"var(--spacing-xl)\" : \"0\",\n }}\n >\n {/* Parent Row */}\n <div\n className=\"grid items-center hover:bg-[var(--canvas-surface)] transition-colors rounded-sm\"\n style={{\n padding: \"var(--spacing-md) 0\",\n minHeight: \"64px\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n {/* Location with Expand Button */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xl)\" }}>\n <ExpandButton\n expanded={isExpanded}\n onClick={() => toggleRow(row.id)}\n disabled={!hasChildren}\n />\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.location}</span>\n </TableCell>\n </div>\n\n {/* FTEs */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.ftes}</span>\n </TableCell>\n </div>\n\n {/* Contractors */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.contractors}</span>\n </TableCell>\n </div>\n\n {/* HR Contact */}\n <div>\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xxs, 2px)\" }}>\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 whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.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-muted)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.phone}\n </span>\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex justify-center\">\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: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </div>\n </div>\n\n {/* Nested Table */}\n {isExpanded && hasChildren && (\n <div\n style={{\n paddingLeft: \"var(--spacing-6xl)\",\n paddingRight: \"var(--spacing-lg)\",\n }}\n >\n <NestedTable\n children={row.children}\n onChildAction={(action, child) => onChildAction?.(action, child, row)}\n />\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { TitleGroup } from \"./title-group\";\nimport { AVATAR_MARCUS_WEBB, AVATAR_SARAH_CHEN, AVATAR_MAYA_JOHNSON } from \"./demo-avatars\";\nimport { ChevronRight, ChevronDown, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ChildRow {\n id: string;\n name: string;\n avatarUrl?: string;\n email: string;\n phone: string;\n}\n\nexport interface ParentRow {\n id: string;\n location: string;\n ftes: number;\n contractors: number;\n hrContact: {\n name: string;\n phone: string;\n };\n children: ChildRow[];\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface NestedDataTableProps {\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?: ParentRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\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 parent row action is clicked */\n onRowAction?: (action: string, row: ParentRow) => void;\n /** Callback when child row action is clicked */\n onChildAction?: (action: string, child: ChildRow, parent: ParentRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: ParentRow[] = [\n {\n id: \"1\",\n location: \"San Francisco, CA\",\n ftes: 320,\n contractors: 66,\n hrContact: {\n name: \"Sarah Chen\",\n phone: \"415-232-3434\",\n },\n children: [\n {\n id: \"1-1\",\n name: \"Marcus Webb\",\n avatarUrl: AVATAR_MARCUS_WEBB,\n email: \"mwebb@gmail.com\",\n phone: \"508-343-5334\",\n },\n {\n id: \"1-2\",\n name: \"Sarah Chen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n email: \"schen@gmail.com\",\n phone: \"234-989-6675\",\n },\n {\n id: \"1-3\",\n name: \"Maya Johnson\",\n avatarUrl: AVATAR_MAYA_JOHNSON,\n email: \"mjohnson@gmail.com\",\n phone: \"205-443-4324\",\n },\n ],\n },\n {\n id: \"2\",\n location: \"New York, NY\",\n ftes: 80,\n contractors: 8,\n hrContact: {\n name: \"Ethan Brooks\",\n phone: \"206-646-9834\",\n },\n children: [],\n },\n {\n id: \"3\",\n location: \"Seattle, WA\",\n ftes: 98,\n contractors: 5,\n hrContact: {\n name: \"James Clayton\",\n phone: \"312-687-8675\",\n },\n children: [],\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"location-asc\", label: \"Location (A-Z)\" },\n { id: \"location-desc\", label: \"Location (Z-A)\" },\n { id: \"ftes-high\", label: \"FTEs (High-Low)\" },\n { id: \"ftes-low\", label: \"FTEs (Low-High)\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ExpandButtonProps {\n expanded: boolean;\n onClick: () => void;\n disabled?: boolean;\n}\n\nfunction ExpandButton({ expanded, onClick, disabled }: ExpandButtonProps) {\n return (\n <button\n onClick={onClick}\n disabled={disabled}\n className={cn(\n \"flex items-center justify-center shrink-0 transition-colors\",\n disabled ? \"opacity-40 cursor-not-allowed\" : \"cursor-pointer hover:bg-[var(--canvas-surface)]\"\n )}\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n }}\n aria-label={expanded ? \"Collapse row\" : \"Expand row\"}\n >\n {expanded ? (\n <ChevronDown \n size={20} \n style={{ color: \"var(--canvas-text)\" }}\n />\n ) : (\n <ChevronRight \n size={20} \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n )}\n </button>\n );\n}\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableHeaderCell({ children, className }: TableHeaderCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableCell({ children, className }: TableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction NestedTableCell({ children, className }: NestedTableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableProps {\n children: ChildRow[];\n onChildAction?: (action: string, child: ChildRow) => void;\n}\n\nfunction NestedTable({ children, onChildAction }: NestedTableProps) {\n if (children.length === 0) return null;\n\n return (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Nested Table Header */}\n <div\n className=\"grid items-center\"\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Name</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Email</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Phone number</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Nested Table Rows */}\n {children.map((child, index) => (\n <div\n key={child.id}\n className=\"grid items-center hover:bg-[var(--canvas-surface)] transition-colors rounded-sm\"\n style={{\n borderBottom: index < children.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n padding: \"var(--spacing-md) 0\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={child.avatarUrl} alt={child.name} />\n <AvatarFallback>\n {child.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{child.name}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.email}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.phone}</span>\n </NestedTableCell>\n </div>\n <div className=\"flex justify-center\" style={{ padding: \"0 var(--spacing-lg)\" }}>\n <button\n onClick={() => onChildAction?.(\"view\", child)}\n className=\"cursor-pointer flex items-center justify-center size-8 rounded-full hover:bg-[var(--canvas-surface)] transition-colors\"\n aria-label={`View ${child.name}`}\n >\n <Eye size={20} style={{ color: \"var(--canvas-text-muted)\" }} />\n </button>\n </div>\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Data Table Block\n * \n * An expandable data table with parent rows that reveal nested child tables.\n * Ideal for displaying hierarchical data like locations with employees,\n * departments with team members, or categories with items.\n * \n * @example\n * ```tsx\n * <NestedDataTable\n * title=\"FTEs & Contractors by Location\"\n * data={locationData}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function NestedDataTable({\n title = \"FTEs & Contractors by Location\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onRowAction,\n onChildAction,\n className,\n}: NestedDataTableProps) {\n const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set([\"1\"])); // First row expanded by default\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const toggleRow = (rowId: string) => {\n setExpandedRows(prev => {\n const next = new Set(prev);\n if (next.has(rowId)) {\n next.delete(rowId);\n } else {\n next.add(rowId);\n }\n return next;\n });\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 <TitleGroup title={title} subtitle={displayResultText} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Table Section */}\n <div className=\"w-full overflow-x-auto\">\n <div className=\"min-w-[800px]\">\n {/* Table Header */}\n <div\n className=\"grid items-center\"\n style={{ \n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n <div style={{ paddingLeft: \"var(--spacing-6xl)\" }}>\n <TableHeaderCell>Location</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>FTEs</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>Contractors</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>HR Contact</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Table Rows */}\n {data.map((row, index) => {\n const isExpanded = expandedRows.has(row.id);\n const hasChildren = row.children && row.children.length > 0;\n\n return (\n <div\n key={row.id}\n className=\"flex flex-col\"\n style={{\n borderBottom: index < data.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n paddingBottom: isExpanded ? \"var(--spacing-xl)\" : \"0\",\n }}\n >\n {/* Parent Row */}\n <div\n className=\"grid items-center hover:bg-[var(--canvas-surface)] transition-colors rounded-sm\"\n style={{\n padding: \"var(--spacing-md) 0\",\n minHeight: \"64px\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n {/* Location with Expand Button */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xl)\" }}>\n <ExpandButton\n expanded={isExpanded}\n onClick={() => toggleRow(row.id)}\n disabled={!hasChildren}\n />\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.location}</span>\n </TableCell>\n </div>\n\n {/* FTEs */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.ftes}</span>\n </TableCell>\n </div>\n\n {/* Contractors */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.contractors}</span>\n </TableCell>\n </div>\n\n {/* HR Contact */}\n <div>\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xxs, 2px)\" }}>\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 whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.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-muted)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.phone}\n </span>\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex justify-center\">\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: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </div>\n </div>\n\n {/* Nested Table */}\n {isExpanded && hasChildren && (\n <div\n style={{\n paddingLeft: \"var(--spacing-6xl)\",\n paddingRight: \"var(--spacing-lg)\",\n }}\n >\n <NestedTable\n children={row.children}\n onChildAction={(action, child) => onChildAction?.(action, child, row)}\n />\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"avatar",
|
|
18
|
-
"menufocus-template",
|
|
19
|
-
"title-group"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/avatar",
|
|
18
|
+
"blocks/menufocus-template",
|
|
19
|
+
"blocks/title-group",
|
|
20
|
+
"blocks/demo-avatars"
|
|
20
21
|
]
|
|
21
22
|
}
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"content-dropzone",
|
|
15
|
-
"login-branding-panel",
|
|
16
|
-
"messenger-sidebar",
|
|
17
|
-
"video-chat-controls"
|
|
14
|
+
"blocks/content-dropzone",
|
|
15
|
+
"blocks/login-branding-panel",
|
|
16
|
+
"blocks/messenger-sidebar",
|
|
17
|
+
"blocks/video-chat-controls"
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/profile-grid-tiles-list.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { TitleGroup } from \"./title-group\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MapPin, BookOpen, Video, DollarSign, Star } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProfileTileItem {\n id: string;\n name: string;\n avatarUrl?: string;\n isOnline?: boolean;\n subject: string;\n pricePerHour: number;\n rating: number;\n reviewCount: string;\n certification?: string;\n location: string;\n education: string;\n sessionsCount: string;\n earnings: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface ProfileGridTilesListProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"23 english tutors near you\") */\n subtitle?: string;\n /** Array of profile tile items */\n items?: ProfileTileItem[];\n /** Number of columns in the grid (2-5) */\n columns?: 2 | 3 | 4 | 5;\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 a profile is clicked */\n onItemClick?: (item: ProfileTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProfileTileItem[] = [\n {\n id: \"1\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 80,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"San Francisco\",\n education: \"UCLA\",\n sessionsCount: \"105 sessions\",\n earnings: \"5.2k earned\",\n },\n {\n id: \"2\",\n name: \"Stacy Jones\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"Native speaker\",\n location: \"New York\",\n education: \"Columbia\",\n sessionsCount: \"23 sessions\",\n earnings: \"2k earned\",\n },\n {\n id: \"3\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Newark\",\n education: \"Rutgers\",\n sessionsCount: \"34 sessions\",\n earnings: \"2.1k earned\",\n },\n {\n id: \"4\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Connecticut\",\n education: \"Yale\",\n sessionsCount: \"75 sessions\",\n earnings: \"91k earned\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"rating\", label: \"Highest Rated\" },\n { id: \"price-low\", label: \"Price: Low to High\" },\n { id: \"price-high\", label: \"Price: High to Low\" },\n { id: \"sessions\", label: \"Most Sessions\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ProfileTileCardProps {\n item: ProfileTileItem;\n onClick?: (item: ProfileTileItem) => void;\n}\n\nfunction ProfileTileCard({ item, onClick }: ProfileTileCardProps) {\n return (\n <div\n className=\"flex flex-col\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n overflow: \"hidden\",\n }}\n >\n {/* Main Content */}\n <div\n className=\"flex flex-col items-center w-full\"\n style={{\n padding: \"var(--spacing-4xl) var(--spacing-4xl) 0\",\n gap: \"var(--spacing-2xl)\",\n }}\n >\n {/* Avatar Section */}\n <div className=\"flex flex-col items-center w-full\" style={{ gap: \"var(--radius-md, 8px)\" }}>\n {/* Avatar with Online Indicator */}\n <div className=\"relative shrink-0\" style={{ width: \"120px\", height: \"120px\" }}>\n <Avatar\n className=\"w-full h-full\"\n style={{\n width: \"120px\",\n height: \"120px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.avatarUrl} alt={item.name} />\n <AvatarFallback style={{ fontSize: \"var(--typo-h4-size)\" }}>\n {item.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {item.isOnline && (\n <div\n className=\"absolute\"\n style={{\n width: \"20px\",\n height: \"20px\",\n right: \"4px\",\n bottom: \"7px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--radius-full, 50%)\",\n border: \"2px solid var(--canvas-background)\",\n }}\n />\n )}\n </div>\n\n {/* Name, Subject, Price */}\n <div\n className=\"flex flex-col items-center text-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <p\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 margin: 0,\n }}\n >\n {item.name}\n </p>\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 {item.subject}\n </p>\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 <span style={{ fontWeight: 600 }}>${item.pricePerHour}</span> / hour\n </p>\n </div>\n </div>\n\n {/* Rating Row */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{ gap: \"4px\" }}\n >\n <Star\n className=\"w-5 h-5\"\n style={{ color: \"var(--canvas-primary)\", fill: \"var(--canvas-primary)\" }}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.rating}\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 ({item.reviewCount})\n </span>\n </div>\n\n {/* Divider */}\n <div\n className=\"w-full\"\n style={{\n height: \"1px\",\n backgroundColor: \"var(--canvas-border)\",\n }}\n />\n\n {/* Certification Badge */}\n {item.certification && (\n <p\n className=\"text-center\"\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.certification}\n </p>\n )}\n\n {/* Metadata Rows */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Location */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <MapPin\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.location}\n </span>\n </div>\n\n {/* Education */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <BookOpen\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.education}\n </span>\n </div>\n\n {/* Sessions */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <Video\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.sessionsCount}\n </span>\n </div>\n\n {/* Earnings */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <DollarSign\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.earnings}\n </span>\n </div>\n </div>\n </div>\n\n {/* Footer - View Profile Button */}\n <div\n className=\"flex items-center justify-center w-full cursor-pointer\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-xl)\",\n marginTop: \"var(--spacing-2xl)\",\n }}\n onClick={() => onClick?.(item)}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n View profile >\n </span>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Grid Constants\n// ============================================\n\nconst CARD_MIN_WIDTH = 300;\nconst GRID_GAP_PX = 32; // --spacing-4xl = 32px\n\n/**\n * Get inline styles for the grid based on columns configuration\n * Uses CSS Grid with auto-fill and minmax for:\n * - Minimum card width of 300px\n * - Equal width for all cards (via 1fr)\n * - Automatic wrapping when container is narrower\n * - Maximum columns limited by the columns prop\n */\nfunction getGridStyle(columns: 2 | 3 | 4 | 5): React.CSSProperties {\n // Calculate max-width to limit the number of columns\n // Formula: columns * minWidth + (columns - 1) * gap\n const maxGridWidth = columns * CARD_MIN_WIDTH + (columns - 1) * GRID_GAP_PX;\n \n return {\n display: 'grid',\n gridTemplateColumns: `repeat(auto-fill, minmax(${CARD_MIN_WIDTH}px, 1fr))`,\n gap: 'var(--spacing-4xl)',\n width: '100%',\n maxWidth: `${maxGridWidth}px`,\n };\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Profile Grid Tiles List Block\n *\n * A responsive grid of profile cards with avatar, ratings, certifications,\n * and metadata. Supports 2-5 column layouts that adapt to screen size.\n *\n * @example\n * ```tsx\n * <ProfileGridTilesList\n * title=\"Tutors near you\"\n * subtitle=\"23 english tutors near you\"\n * columns={4}\n * onItemClick={(item) => console.log(\"Clicked\", item)}\n * />\n * ```\n */\nexport function ProfileGridTilesList({\n title = \"Tutors near you\",\n subtitle,\n items = defaultItems,\n columns = 4,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onItemClick,\n className,\n}: ProfileGridTilesListProps) {\n const displaySubtitle = subtitle ?? `${items.length} tutors available`;\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 <TitleGroup title={title} subtitle={displaySubtitle} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Grid Section */}\n <div style={getGridStyle(columns)}>\n {items.map((item) => (\n <ProfileTileCard\n key={item.id}\n item={item}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { TitleGroup } from \"./title-group\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MapPin, BookOpen, Video, DollarSign, Star } from \"lucide-react\";\nimport { AVATAR_MARCUS_WEBB, AVATAR_SARAH_CHEN, AVATAR_ETHAN_BROOKS, AVATAR_MAYA_JOHNSON } from \"./demo-avatars\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProfileTileItem {\n id: string;\n name: string;\n avatarUrl?: string;\n isOnline?: boolean;\n subject: string;\n pricePerHour: number;\n rating: number;\n reviewCount: string;\n certification?: string;\n location: string;\n education: string;\n sessionsCount: string;\n earnings: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface ProfileGridTilesListProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"23 english tutors near you\") */\n subtitle?: string;\n /** Array of profile tile items */\n items?: ProfileTileItem[];\n /** Number of columns in the grid (2-5) */\n columns?: 2 | 3 | 4 | 5;\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 a profile is clicked */\n onItemClick?: (item: ProfileTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProfileTileItem[] = [\n {\n id: \"1\",\n name: \"Marcus Webb\",\n avatarUrl: AVATAR_MARCUS_WEBB,\n isOnline: true,\n subject: \"English\",\n pricePerHour: 80,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"San Francisco\",\n education: \"UCLA\",\n sessionsCount: \"105 sessions\",\n earnings: \"5.2k earned\",\n },\n {\n id: \"2\",\n name: \"Sarah Chen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"Native speaker\",\n location: \"New York\",\n education: \"Columbia\",\n sessionsCount: \"23 sessions\",\n earnings: \"2k earned\",\n },\n {\n id: \"3\",\n name: \"Ethan Brooks\",\n avatarUrl: AVATAR_ETHAN_BROOKS,\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Newark\",\n education: \"Rutgers\",\n sessionsCount: \"34 sessions\",\n earnings: \"2.1k earned\",\n },\n {\n id: \"4\",\n name: \"Maya Johnson\",\n avatarUrl: AVATAR_MAYA_JOHNSON,\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Connecticut\",\n education: \"Yale\",\n sessionsCount: \"75 sessions\",\n earnings: \"91k earned\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"rating\", label: \"Highest Rated\" },\n { id: \"price-low\", label: \"Price: Low to High\" },\n { id: \"price-high\", label: \"Price: High to Low\" },\n { id: \"sessions\", label: \"Most Sessions\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ProfileTileCardProps {\n item: ProfileTileItem;\n onClick?: (item: ProfileTileItem) => void;\n}\n\nfunction ProfileTileCard({ item, onClick }: ProfileTileCardProps) {\n return (\n <div\n className=\"flex flex-col\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n overflow: \"hidden\",\n }}\n >\n {/* Main Content */}\n <div\n className=\"flex flex-col items-center w-full\"\n style={{\n padding: \"var(--spacing-4xl) var(--spacing-4xl) 0\",\n gap: \"var(--spacing-2xl)\",\n }}\n >\n {/* Avatar Section */}\n <div className=\"flex flex-col items-center w-full\" style={{ gap: \"var(--radius-md, 8px)\" }}>\n {/* Avatar with Online Indicator */}\n <div className=\"relative shrink-0\" style={{ width: \"120px\", height: \"120px\" }}>\n <Avatar\n className=\"w-full h-full\"\n style={{\n width: \"120px\",\n height: \"120px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.avatarUrl} alt={item.name} />\n <AvatarFallback style={{ fontSize: \"var(--typo-h4-size)\" }}>\n {item.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {item.isOnline && (\n <div\n className=\"absolute\"\n style={{\n width: \"20px\",\n height: \"20px\",\n right: \"4px\",\n bottom: \"7px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--radius-full, 50%)\",\n border: \"2px solid var(--canvas-background)\",\n }}\n />\n )}\n </div>\n\n {/* Name, Subject, Price */}\n <div\n className=\"flex flex-col items-center text-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <p\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 margin: 0,\n }}\n >\n {item.name}\n </p>\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 {item.subject}\n </p>\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 <span style={{ fontWeight: 600 }}>${item.pricePerHour}</span> / hour\n </p>\n </div>\n </div>\n\n {/* Rating Row */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{ gap: \"4px\" }}\n >\n <Star\n className=\"w-5 h-5\"\n style={{ color: \"var(--canvas-primary)\", fill: \"var(--canvas-primary)\" }}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.rating}\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 ({item.reviewCount})\n </span>\n </div>\n\n {/* Divider */}\n <div\n className=\"w-full\"\n style={{\n height: \"1px\",\n backgroundColor: \"var(--canvas-border)\",\n }}\n />\n\n {/* Certification Badge */}\n {item.certification && (\n <p\n className=\"text-center\"\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.certification}\n </p>\n )}\n\n {/* Metadata Rows */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Location */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <MapPin\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.location}\n </span>\n </div>\n\n {/* Education */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <BookOpen\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.education}\n </span>\n </div>\n\n {/* Sessions */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <Video\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.sessionsCount}\n </span>\n </div>\n\n {/* Earnings */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <DollarSign\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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-placeholder)\",\n }}\n >\n {item.earnings}\n </span>\n </div>\n </div>\n </div>\n\n {/* Footer - View Profile Button */}\n <div\n className=\"flex items-center justify-center w-full cursor-pointer\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-xl)\",\n marginTop: \"var(--spacing-2xl)\",\n }}\n onClick={() => onClick?.(item)}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n View profile >\n </span>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Grid Constants\n// ============================================\n\nconst CARD_MIN_WIDTH = 300;\nconst GRID_GAP_PX = 32; // --spacing-4xl = 32px\n\n/**\n * Get inline styles for the grid based on columns configuration\n * Uses CSS Grid with auto-fill and minmax for:\n * - Minimum card width of 300px\n * - Equal width for all cards (via 1fr)\n * - Automatic wrapping when container is narrower\n * - Maximum columns limited by the columns prop\n */\nfunction getGridStyle(columns: 2 | 3 | 4 | 5): React.CSSProperties {\n // Calculate max-width to limit the number of columns\n // Formula: columns * minWidth + (columns - 1) * gap\n const maxGridWidth = columns * CARD_MIN_WIDTH + (columns - 1) * GRID_GAP_PX;\n \n return {\n display: 'grid',\n gridTemplateColumns: `repeat(auto-fill, minmax(${CARD_MIN_WIDTH}px, 1fr))`,\n gap: 'var(--spacing-4xl)',\n width: '100%',\n maxWidth: `${maxGridWidth}px`,\n };\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Profile Grid Tiles List Block\n *\n * A responsive grid of profile cards with avatar, ratings, certifications,\n * and metadata. Supports 2-5 column layouts that adapt to screen size.\n *\n * @example\n * ```tsx\n * <ProfileGridTilesList\n * title=\"Tutors near you\"\n * subtitle=\"23 english tutors near you\"\n * columns={4}\n * onItemClick={(item) => console.log(\"Clicked\", item)}\n * />\n * ```\n */\nexport function ProfileGridTilesList({\n title = \"Tutors near you\",\n subtitle,\n items = defaultItems,\n columns = 4,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onItemClick,\n className,\n}: ProfileGridTilesListProps) {\n const displaySubtitle = subtitle ?? `${items.length} tutors available`;\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 <TitleGroup title={title} subtitle={displaySubtitle} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Grid Section */}\n <div style={getGridStyle(columns)}>\n {items.map((item) => (\n <ProfileTileCard\n key={item.id}\n item={item}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"title-group",
|
|
18
|
-
"avatar"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"blocks/title-group",
|
|
18
|
+
"ui/avatar",
|
|
19
|
+
"blocks/demo-avatars"
|
|
19
20
|
]
|
|
20
21
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/marketing/reviews-grid.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { Typography } from \"../../ui/typography\";\n\ninterface Review {\n id: string;\n quote: string;\n author: string;\n location: string;\n avatar: string;\n}\n\nconst defaultReviews: Review[] = [\n {\n id: \"1\",\n quote: '\"The deals you get here are so much better!\"',\n author: \"
|
|
9
|
+
"content": "\"use client\";\n\nimport { Typography } from \"../../ui/typography\";\nimport { AVATAR_CLAIRE_DONOVAN, AVATAR_JASON_MORALES, AVATAR_JORDAN_MITCHELL, AVATAR_HANNAH_KIM, AVATAR_MIA_SANTOS, AVATAR_RACHEL_TORRES } from \"../demo-avatars\";\n\ninterface Review {\n id: string;\n quote: string;\n author: string;\n location: string;\n avatar: string;\n}\n\nconst defaultReviews: Review[] = [\n {\n id: \"1\",\n quote: '\"The deals you get here are so much better!\"',\n author: \"Claire Donovan\",\n location: \"Mexico\",\n avatar: AVATAR_CLAIRE_DONOVAN,\n },\n {\n id: \"2\",\n quote: '\"I was able to find the perfect place that fit my budget and needs. Highly recommend!\"',\n author: \"Jason Morales\",\n location: \"United Kingdom\",\n avatar: AVATAR_JASON_MORALES,\n },\n {\n id: \"3\",\n quote: '\"This is now my go-to platform for booking vacation accommodations.\"',\n author: \"Jordan Mitchell\",\n location: \"Los Angeles, CA\",\n avatar: AVATAR_JORDAN_MITCHELL,\n },\n {\n id: \"4\",\n quote: '\"The experience of booking an accommodation on this platform was a breeze. I would definitely use it again in the future.\"',\n author: \"Hannah Kim\",\n location: \"France\",\n avatar: AVATAR_HANNAH_KIM,\n },\n {\n id: \"5\",\n quote: '\"You get free travel insurance!\"',\n author: \"Mia Santos\",\n location: \"Germany\",\n avatar: AVATAR_MIA_SANTOS,\n },\n {\n id: \"6\",\n quote: '\"I had a great experience using this platform. The process was smooth from start to finish, and I was able to quickly find a place that met my criteria.\"',\n author: \"Rachel Torres\",\n location: \"Chicago, IL\",\n avatar: AVATAR_RACHEL_TORRES,\n },\n];\n\ninterface ReviewsGridProps {\n title?: string;\n subtitle?: string;\n reviews?: Review[];\n}\n\nexport function ReviewsGrid({ \n title = \"Loved by people worldwide\",\n subtitle = \"TESTIMONIALS\",\n reviews = defaultReviews \n}: ReviewsGridProps) {\n // Split reviews into 3 columns\n const columns = [\n reviews.slice(0, 2),\n reviews.slice(2, 4),\n reviews.slice(4, 6),\n ];\n\n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-10 py-10 md:py-16\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n <div className=\"w-full max-w-[1240px] mx-auto\">\n {/* Header */}\n <div style={{ marginBottom: \"var(--spacing-7xl)\" }}>\n <Typography variant=\"body-s\" as=\"p\" color=\"muted\" style={{ marginBottom: \"var(--spacing-lg)\" }}>\n {subtitle}\n </Typography>\n <Typography variant=\"h3\" as=\"h2\">\n {title}\n </Typography>\n </div>\n\n {/* Reviews Grid - 3 columns */}\n <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10\">\n {columns.map((column, colIndex) => (\n <div key={colIndex} className=\"flex flex-col gap-8\">\n {column.map((review) => (\n <div \n key={review.id}\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--spacing-md)\",\n gap: \"var(--spacing-2xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0, 0, 0, 0.03)\",\n }}\n >\n <Typography variant=\"body-l\" color=\"muted\">\n {review.quote}\n </Typography>\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xl)\" }}>\n <div \n className=\"shrink-0 overflow-hidden\"\n style={{\n width: \"48px\",\n height: \"48px\",\n borderRadius: \"var(--radius-full)\",\n }}\n >\n <img \n src={review.avatar} \n alt={review.author}\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div>\n <Typography variant=\"body-m\" as=\"p\" style={{ fontWeight: 700 }}>\n {review.author}\n </Typography>\n <Typography variant=\"body-m\" color=\"muted\">\n {review.location}\n </Typography>\n </div>\n </div>\n </div>\n ))}\n </div>\n ))}\n </div>\n </div>\n </section>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/reviews-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { TitleGroup } from \"./title-group\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ReviewItem {\n id: string;\n name: string;\n avatarUrl?: string;\n rating: number; // 1-5\n date: string;\n reviewText: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface ReviewsTableProps {\n /** Table title */\n title?: string;\n /** Number of reviews to display in subtitle */\n reviewCount?: number;\n /** Custom review count text (overrides default \"{count} customer reviews\") */\n reviewCountText?: string;\n /** Review data */\n reviews?: ReviewItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\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 \"More\" is clicked on a review */\n onReadMore?: (review: ReviewItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultReviews: ReviewItem[] = [\n {\n id: \"1\",\n name: \"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { TitleGroup } from \"./title-group\";\nimport { AVATAR_MARCUS_WEBB, AVATAR_ETHAN_BROOKS } from \"./demo-avatars\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ReviewItem {\n id: string;\n name: string;\n avatarUrl?: string;\n rating: number; // 1-5\n date: string;\n reviewText: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface ReviewsTableProps {\n /** Table title */\n title?: string;\n /** Number of reviews to display in subtitle */\n reviewCount?: number;\n /** Custom review count text (overrides default \"{count} customer reviews\") */\n reviewCountText?: string;\n /** Review data */\n reviews?: ReviewItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\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 \"More\" is clicked on a review */\n onReadMore?: (review: ReviewItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultReviews: ReviewItem[] = [\n {\n id: \"1\",\n name: \"Marcus Webb\",\n avatarUrl: AVATAR_MARCUS_WEBB,\n rating: 5,\n date: \"Jul 22, 2024\",\n reviewText: \"Absolutely loved my experience at Sushi Ro! The ambiance was cozy, and the staff were incredibly attentive. The food was outstanding, with a diverse menu offering authentic flavors. Highly recommend trying the omakase...\",\n },\n {\n id: \"2\",\n name: \"Ethan Brooks\",\n avatarUrl: AVATAR_ETHAN_BROOKS,\n rating: 5,\n date: \"Jul 12, 2024\",\n reviewText: \"From the moment we walked in, we were greeted warmly and seated promptly. The omakase menu offered a delightful range of dishes, each bursting with flavor and beautifully presented...\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"rating-high\", label: \"Highest rated\" },\n { id: \"rating-low\", label: \"Lowest rated\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface StarRatingProps {\n rating: number;\n maxRating?: number;\n}\n\nfunction StarRating({ rating, maxRating = 5 }: StarRatingProps) {\n return (\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {Array.from({ length: maxRating }, (_, i) => (\n <svg\n key={i}\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M10 1.66667L12.575 6.88334L18.3333 7.725L14.1667 11.7833L15.15 17.5167L10 14.8083L4.85 17.5167L5.83333 11.7833L1.66667 7.725L7.425 6.88334L10 1.66667Z\"\n fill={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n );\n}\n\ninterface ReviewListItemProps {\n review: ReviewItem;\n isFirst?: boolean;\n isLast?: boolean;\n onReadMore?: (review: ReviewItem) => void;\n}\n\nfunction ReviewListItem({ review, isFirst, isLast, onReadMore }: ReviewListItemProps) {\n return (\n <div\n className=\"w-full\"\n style={{\n borderTop: isFirst ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-3xl)\",\n paddingBottom: \"var(--spacing-3xl)\",\n }}\n >\n <div \n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Top Row: Avatar + Name/Rating + Date */}\n <div \n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Avatar */}\n <Avatar \n className=\"shrink-0\"\n style={{ \n width: \"48px\", \n height: \"48px\",\n borderRadius: \"var(--spacing-3xl)\",\n }}\n >\n <AvatarImage src={review.avatarUrl} alt={review.name} />\n <AvatarFallback\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {review.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name and Rating */}\n <div \n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <p\n className=\"m-0\"\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 {review.name}\n </p>\n <StarRating rating={review.rating} />\n </div>\n\n {/* Date */}\n <p\n className=\"m-0 shrink-0\"\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 }}\n >\n {review.date}\n </p>\n </div>\n\n {/* Review Text */}\n <p\n className=\"m-0 w-full\"\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 }}\n >\n {review.reviewText}\n </p>\n\n {/* More Link */}\n <button\n onClick={() => onReadMore?.(review)}\n className=\"p-0 border-none bg-transparent cursor-pointer text-left\"\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-primary)\",\n }}\n >\n More\n </button>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Reviews Table Block\n * \n * A configurable reviews listing with header section including title,\n * sort/filter dropdowns, and action button. Displays review items\n * with avatar, star rating, date, and expandable text.\n * \n * @example\n * ```tsx\n * <ReviewsTable\n * title=\"Reviews\"\n * reviews={[\n * { id: \"1\", name: \"John Doe\", rating: 5, date: \"Jul 22, 2024\", reviewText: \"Great experience!\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function ReviewsTable({\n title = \"Reviews\",\n reviewCount,\n reviewCountText,\n reviews = defaultReviews,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onReadMore,\n className,\n}: ReviewsTableProps) {\n const displayReviewCount = reviewCount ?? reviews.length;\n const displayReviewText = reviewCountText ?? `${displayReviewCount} customer reviews`;\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 <TitleGroup title={title} subtitle={displayReviewText} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Reviews List */}\n <div className=\"flex flex-col w-full\">\n {reviews.map((review, index) => (\n <ReviewListItem\n key={review.id}\n review={review}\n isFirst={index === 0}\n isLast={index === reviews.length - 1}\n onReadMore={onReadMore}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"utils",
|
|
15
|
-
"avatar",
|
|
16
|
-
"title-group"
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/avatar",
|
|
16
|
+
"blocks/title-group",
|
|
17
|
+
"blocks/demo-avatars"
|
|
17
18
|
]
|
|
18
19
|
}
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"checkbox",
|
|
18
|
-
"radio-group",
|
|
19
|
-
"switch",
|
|
20
|
-
"slider",
|
|
21
|
-
"range-input",
|
|
22
|
-
"selectable-pills",
|
|
23
|
-
"label"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/checkbox",
|
|
18
|
+
"ui/radio-group",
|
|
19
|
+
"ui/switch",
|
|
20
|
+
"ui/slider",
|
|
21
|
+
"ui/range-input",
|
|
22
|
+
"ui/selectable-pills",
|
|
23
|
+
"ui/label"
|
|
24
24
|
]
|
|
25
25
|
}
|