From 117c5e17c1fea5dc2610357c34e1de53ab79de4a Mon Sep 17 00:00:00 2001 From: william garrity Date: Thu, 29 Jan 2026 20:44:51 -0500 Subject: [PATCH] feat(Table): add interactive Playground story with column controls - Add column count slider (1-12 columns) - Add show/hide footer toggle - Add pin columns control (0-4 sticky columns) - Add row selection with checkboxes and select-all - Add column header dropdown menus with sort, filter, pin, hide - Add hidden column restore chips - Fix z-index on dropdown menus to render above badges --- src/components/Skeleton/Skeleton.stories.tsx | 562 +++++++++++++++---- src/components/Table/Table.stories.tsx | 561 ++++++++++++++++++ 2 files changed, 1025 insertions(+), 98 deletions(-) diff --git a/src/components/Skeleton/Skeleton.stories.tsx b/src/components/Skeleton/Skeleton.stories.tsx index 9889f26..5bbc9a8 100644 --- a/src/components/Skeleton/Skeleton.stories.tsx +++ b/src/components/Skeleton/Skeleton.stories.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Skeleton, @@ -6,174 +7,498 @@ import { SkeletonTable, } from './Skeleton'; -const meta: Meta = { +// ============================================================================= +// Example Types +// ============================================================================= + +type ExampleType = + | 'custom' + | 'default' + | 'text' + | 'title' + | 'avatar' + | 'button' + | 'image' + | 'textBlock' + | 'card' + | 'cardWithoutImage' + | 'cardMinimal' + | 'table' + | 'profileCard' + | 'listItems'; + +// ============================================================================= +// Wrapper Component +// ============================================================================= + +interface SkeletonExampleProps { + example: ExampleType; + /** Custom width (CSS value like '100%' or '200px') */ + customWidth?: string; + /** Custom height in pixels */ + customHeight?: number; + /** Custom variant */ + customVariant?: + | 'default' + | 'text' + | 'title' + | 'avatar' + | 'button' + | 'card' + | 'image'; + /** Custom border radius */ + customRounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + /** Avatar size in pixels */ + avatarSize?: number; + /** Number of text lines (for textBlock) */ + textLines?: number; + /** Last line width (for textBlock) */ + lastLineWidth?: string; + /** Text gap size (for textBlock) */ + textGap?: 'sm' | 'md' | 'lg'; + /** Show image in card */ + cardShowImage?: boolean; + /** Show avatar in card */ + cardShowAvatar?: boolean; + /** Number of text lines in card */ + cardTextLines?: number; + /** Number of table rows */ + tableRows?: number; + /** Number of table columns */ + tableColumns?: number; + /** Number of list items */ + listItemCount?: number; +} + +function SkeletonExample({ + example, + customWidth = '100%', + customHeight = 40, + customVariant = 'default', + customRounded = 'md', + avatarSize = 48, + textLines = 4, + lastLineWidth = '70%', + textGap = 'sm', + cardShowImage = true, + cardShowAvatar = true, + cardTextLines = 2, + tableRows = 5, + tableColumns = 4, + listItemCount = 4, +}: SkeletonExampleProps) { + const roundedClasses = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full', + }; + + switch (example) { + case 'custom': + return ( +
+ +
+ ); + + case 'default': + return ( +
+ +
+ ); + + case 'text': + return ( +
+ +
+ ); + + case 'title': + return ( +
+ +
+ ); + + case 'avatar': + return ( +
+ +
+ ); + + case 'button': + return ( +
+ +
+ ); + + case 'image': + return ( +
+ +
+ ); + + case 'textBlock': + return ( +
+ +
+ ); + + case 'card': + return ( +
+ +
+ ); + + case 'cardWithoutImage': + return ( +
+ +
+ ); + + case 'cardMinimal': + return ( +
+ +
+ ); + + case 'table': + return ( +
+ +
+ ); + + case 'profileCard': + return ( +
+
+ +
+ + +
+
+ +
+ + +
+
+ ); + + case 'listItems': + return ( +
+ {Array.from({ length: listItemCount }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); + + default: + return null; + } +} + +// ============================================================================= +// Meta Configuration +// ============================================================================= + +const meta = { title: 'Components/Skeleton', - component: Skeleton, + component: SkeletonExample, parameters: { layout: 'centered', }, tags: ['autodocs'], argTypes: { - variant: { + example: { control: 'select', options: [ + 'custom', 'default', 'text', 'title', 'avatar', 'button', - 'card', 'image', + 'textBlock', + 'card', + 'cardWithoutImage', + 'cardMinimal', + 'table', + 'profileCard', + 'listItems', ], - description: 'The visual style variant of the skeleton', - table: { - defaultValue: { summary: 'default' }, - }, + description: 'Type of skeleton example to display', }, - width: { + customWidth: { control: 'text', - description: - 'Width of the skeleton (number for px or string for CSS value)', + description: 'Width (CSS value like "100%" or "200px")', }, - height: { + customHeight: { + control: { type: 'number', min: 8, max: 400 }, + description: 'Height in pixels', + }, + customVariant: { + control: 'select', + options: [ + 'default', + 'text', + 'title', + 'avatar', + 'button', + 'card', + 'image', + ], + description: 'Variant type', + }, + customRounded: { + control: 'select', + options: ['none', 'sm', 'md', 'lg', 'full'], + description: 'Border radius', + }, + avatarSize: { + control: { type: 'number', min: 24, max: 128 }, + description: 'Avatar size in pixels', + if: { arg: 'example', eq: 'avatar' }, + }, + textLines: { + control: { type: 'number', min: 1, max: 10 }, + description: 'Number of text lines', + if: { arg: 'example', eq: 'textBlock' }, + }, + lastLineWidth: { control: 'text', - description: - 'Height of the skeleton (number for px or string for CSS value)', + description: 'Width of the last line', + if: { arg: 'example', eq: 'textBlock' }, + }, + textGap: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Gap between text lines', + if: { arg: 'example', eq: 'textBlock' }, }, - circle: { + cardShowImage: { control: 'boolean', - description: 'Whether to render as a circle (rounded-full)', + description: 'Show image in card', + if: { arg: 'example', eq: 'card' }, }, - className: { - control: 'text', - description: 'Additional CSS classes', + cardShowAvatar: { + control: 'boolean', + description: 'Show avatar in card', + if: { arg: 'example', eq: 'card' }, + }, + cardTextLines: { + control: { type: 'number', min: 0, max: 10 }, + description: 'Number of text lines in card', + if: { arg: 'example', eq: 'card' }, + }, + tableRows: { + control: { type: 'number', min: 1, max: 20 }, + description: 'Number of table rows', + if: { arg: 'example', eq: 'table' }, + }, + tableColumns: { + control: { type: 'number', min: 1, max: 10 }, + description: 'Number of table columns', + if: { arg: 'example', eq: 'table' }, + }, + listItemCount: { + control: { type: 'number', min: 1, max: 10 }, + description: 'Number of list items', + if: { arg: 'example', eq: 'listItems' }, }, }, -}; +} satisfies Meta; export default meta; type Story = StoryObj; +// ============================================================================= +// Stories with Args (Controls Work) +// ============================================================================= + export const Default: Story = { - render: () => ( -
- -
- ), + args: { + example: 'custom', + customWidth: '200px', + customHeight: 40, + customVariant: 'default', + customRounded: 'md', + }, }; export const Text: Story = { - render: () => ( -
- -
- ), + args: { + example: 'text', + }, }; export const Title: Story = { - render: () => ( -
- -
- ), + args: { + example: 'title', + }, }; export const Avatar: Story = { - render: () => ( -
- - - -
- ), + args: { + example: 'avatar', + avatarSize: 48, + }, }; export const Button: Story = { - render: () => ( -
- - - -
- ), + args: { + example: 'button', + }, }; export const Image: Story = { - render: () => ( -
- -
- ), + args: { + example: 'image', + }, }; export const TextBlock: Story = { - render: () => ( -
- -
- ), + args: { + example: 'textBlock', + textLines: 4, + lastLineWidth: '70%', + textGap: 'sm', + }, }; export const Card: Story = { - render: () => ( -
- -
- ), + args: { + example: 'card', + cardShowImage: true, + cardShowAvatar: true, + cardTextLines: 2, + }, }; export const CardWithoutImage: Story = { - render: () => ( -
- -
- ), + args: { + example: 'cardWithoutImage', + cardShowAvatar: true, + cardTextLines: 3, + }, +}; + +export const CardMinimal: Story = { + args: { + example: 'cardMinimal', + cardTextLines: 2, + }, }; export const Table: Story = { - render: () => ( -
- -
- ), + args: { + example: 'table', + tableRows: 5, + tableColumns: 4, + }, }; export const ProfileCard: Story = { + args: { + example: 'profileCard', + }, +}; + +export const ListItems: Story = { + args: { + example: 'listItems', + listItemCount: 4, + }, +}; + +// ============================================================================= +// Showcase Stories (Controls Disabled) +// ============================================================================= + +export const AvatarSizes: Story = { + args: { + example: 'avatar', + }, + parameters: { + controls: { disable: true }, + }, render: () => ( -
-
- -
- - -
-
- -
- - -
+
+ + + + +
), }; -export const ListItems: Story = { +export const ButtonVariations: Story = { + args: { + example: 'button', + }, + parameters: { + controls: { disable: true }, + }, render: () => ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- -
- - -
-
- ))} +
+ + + +
), }; export const Grid: Story = { + args: { + example: 'card', + }, + parameters: { + controls: { disable: true }, + }, render: () => (
{Array.from({ length: 6 }).map((_, i) => ( @@ -182,3 +507,44 @@ export const Grid: Story = {
), }; + +export const AllVariants: Story = { + args: { + example: 'default', + }, + parameters: { + controls: { disable: true }, + }, + render: () => ( +
+
+

Default

+ +
+
+

Text

+ +
+
+

Title

+ +
+
+

Avatar

+ +
+
+

Button

+ +
+
+

Card

+ +
+
+

Image

+ +
+
+ ), +}; diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx index 6ba1e1b..f9377dd 100644 --- a/src/components/Table/Table.stories.tsx +++ b/src/components/Table/Table.stories.tsx @@ -12,6 +12,21 @@ import { } from './Table'; import { Badge } from '../Badge'; import { Checkbox } from '../Checkbox'; +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownSeparator, + DropdownLabel, +} from '../Dropdown'; +import { + FilterIcon, + ChevronDownIcon, + ChevronUpIcon, + EyeOffIcon, + LockIcon, + XIcon, +} from '../Icons'; const meta: Meta = { title: 'Components/Table', @@ -39,6 +54,552 @@ const meta: Meta = { export default meta; type Story = StoryObj; +// ============================================================================ +// Playground Story with Controls +// ============================================================================ + +interface PlaygroundArgs { + columnCount: number; + showFooter: boolean; + pinnedColumns: number; + selectable: boolean; + showColumnMenus: boolean; +} + +const allColumns = [ + { key: 'name', label: 'Name', footerValue: 'Total', filterable: true }, + { key: 'email', label: 'Email', footerValue: '', filterable: true }, + { key: 'role', label: 'Role', footerValue: '', filterable: true }, + { key: 'status', label: 'Status', footerValue: '', filterable: true }, + { key: 'department', label: 'Department', footerValue: '', filterable: true }, + { key: 'location', label: 'Location', footerValue: '', filterable: true }, + { key: 'phone', label: 'Phone', footerValue: '', filterable: false }, + { key: 'startDate', label: 'Start Date', footerValue: '', filterable: true }, + { key: 'salary', label: 'Salary', footerValue: '$485,000', filterable: true }, + { key: 'manager', label: 'Manager', footerValue: '', filterable: true }, + { key: 'team', label: 'Team', footerValue: '', filterable: true }, + { key: 'projects', label: 'Projects', footerValue: '23', filterable: false }, +]; + +const playgroundData = [ + { + id: 1, + name: 'John Doe', + email: 'john@example.com', + role: 'Admin', + status: 'Active', + department: 'Engineering', + location: 'New York', + phone: '(555) 123-4567', + startDate: '2021-03-15', + salary: '$120,000', + manager: 'Sarah Wilson', + team: 'Platform', + projects: '5', + }, + { + id: 2, + name: 'Jane Smith', + email: 'jane@example.com', + role: 'User', + status: 'Active', + department: 'Design', + location: 'San Francisco', + phone: '(555) 234-5678', + startDate: '2020-07-22', + salary: '$95,000', + manager: 'Mike Chen', + team: 'Product', + projects: '3', + }, + { + id: 3, + name: 'Bob Johnson', + email: 'bob@example.com', + role: 'User', + status: 'Inactive', + department: 'Marketing', + location: 'Chicago', + phone: '(555) 345-6789', + startDate: '2019-11-08', + salary: '$85,000', + manager: 'Lisa Park', + team: 'Growth', + projects: '2', + }, + { + id: 4, + name: 'Alice Brown', + email: 'alice@example.com', + role: 'Moderator', + status: 'Active', + department: 'Support', + location: 'Austin', + phone: '(555) 456-7890', + startDate: '2022-01-10', + salary: '$75,000', + manager: 'Tom Davis', + team: 'Customer Success', + projects: '8', + }, + { + id: 5, + name: 'Charlie Wilson', + email: 'charlie@example.com', + role: 'User', + status: 'Pending', + department: 'Sales', + location: 'Seattle', + phone: '(555) 567-8901', + startDate: '2023-06-01', + salary: '$110,000', + manager: 'Rachel Green', + team: 'Enterprise', + projects: '5', + }, +]; + +function PlaygroundTable({ + columnCount, + showFooter, + pinnedColumns, + selectable, + showColumnMenus, +}: PlaygroundArgs) { + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const [sortConfig, setSortConfig] = React.useState<{ + key: string; + direction: 'asc' | 'desc'; + } | null>(null); + const [hiddenColumns, setHiddenColumns] = React.useState>( + new Set() + ); + const [pinnedColumnKeys, setPinnedColumnKeys] = React.useState>( + new Set() + ); + const [filterValues, setFilterValues] = React.useState< + Record + >({}); + + const visibleColumns = allColumns + .slice(0, columnCount) + .filter((col) => !hiddenColumns.has(col.key)); + const allSelected = selectedIds.size === playgroundData.length; + const someSelected = selectedIds.size > 0 && !allSelected; + + // Sort and filter data + const filteredData = React.useMemo(() => { + let data = [...playgroundData]; + + // Apply filters + Object.entries(filterValues).forEach(([key, value]) => { + if (value) { + data = data.filter((row) => + String(row[key as keyof typeof row]) + .toLowerCase() + .includes(value.toLowerCase()) + ); + } + }); + + // Apply sorting + if (sortConfig) { + data.sort((a, b) => { + const aVal = String(a[sortConfig.key as keyof typeof a]); + const bVal = String(b[sortConfig.key as keyof typeof b]); + const direction = sortConfig.direction === 'asc' ? 1 : -1; + return aVal.localeCompare(bVal) * direction; + }); + } + + return data; + }, [filterValues, sortConfig]); + + const handleSelectAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(playgroundData.map((d) => d.id))); + } + }; + + const handleSelectRow = (id: number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const handleSort = (key: string, direction: 'asc' | 'desc') => { + setSortConfig({ key, direction }); + }; + + const handleHideColumn = (key: string) => { + setHiddenColumns((prev) => new Set([...prev, key])); + }; + + const handleTogglePinColumn = (key: string) => { + setPinnedColumnKeys((prev) => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + const handleClearFilter = (key: string) => { + setFilterValues((prev) => { + const newFilters = { ...prev }; + delete newFilters[key]; + return newFilters; + }); + }; + + const getPinnedStyle = ( + index: number, + colKey: string + ): React.CSSProperties => { + const isPinned = index < pinnedColumns || pinnedColumnKeys.has(colKey); + if (isPinned) { + const checkboxOffset = selectable ? 48 : 0; + // Calculate offset based on pinned columns before this one + let leftOffset = checkboxOffset; + for (let i = 0; i < index; i++) { + const prevCol = visibleColumns[i]; + if (i < pinnedColumns || pinnedColumnKeys.has(prevCol.key)) { + leftOffset += 140; + } + } + return { + position: 'sticky', + left: leftOffset, + zIndex: 10, + }; + } + return {}; + }; + + const getCheckboxPinnedStyle = (): React.CSSProperties => { + if (pinnedColumns > 0 || selectable) { + return { + position: 'sticky', + left: 0, + zIndex: 10, + }; + } + return {}; + }; + + // Column header with menu + const ColumnHeader = ({ + col, + index, + }: { + col: (typeof allColumns)[0]; + index: number; + }) => { + const isPinned = index < pinnedColumns || pinnedColumnKeys.has(col.key); + const isSorted = sortConfig?.key === col.key; + const hasFilter = filterValues[col.key]; + + if (!showColumnMenus) { + return ( + + {col.label} + + ); + } + + return ( + +
+ + {col.label} + {isSorted && ( + + {sortConfig.direction === 'asc' ? ( + + ) : ( + + )} + + )} + {hasFilter && } + + + + + } + > + + Sort + } + onClick={() => handleSort(col.key, 'asc')} + > + Sort Ascending + + } + onClick={() => handleSort(col.key, 'desc')} + > + Sort Descending + + + {col.filterable && ( + <> + + Filter +
+ + setFilterValues((prev) => ({ + ...prev, + [col.key]: e.target.value, + })) + } + className="border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-md border px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" + onClick={(e) => e.stopPropagation()} + /> +
+ {hasFilter && ( + } + onClick={() => handleClearFilter(col.key)} + > + Clear Filter + + )} + + )} + + + Column + } + onClick={() => handleTogglePinColumn(col.key)} + > + {isPinned ? 'Unpin Column' : 'Pin Column'} + + } + onClick={() => handleHideColumn(col.key)} + > + Hide Column + +
+
+
+
+ ); + }; + + return ( +
+ {/* Show hidden columns restore UI */} + {hiddenColumns.size > 0 && ( +
+ Hidden columns: + {Array.from(hiddenColumns).map((key) => { + const col = allColumns.find((c) => c.key === key); + return ( + + ); + })} +
+ )} + + + + {selectable && ( + + + + )} + {visibleColumns.map((col, index) => ( + + ))} + + + + {filteredData.length === 0 ? ( + + + No results found. + + + ) : ( + filteredData.map((row) => ( + + {selectable && ( + + handleSelectRow(row.id)} + aria-label={`Select ${row.name}`} + /> + + )} + {visibleColumns.map((col, index) => { + const isPinned = + index < pinnedColumns || pinnedColumnKeys.has(col.key); + return ( + + {col.key === 'status' ? ( + + {row.status} + + ) : ( + row[col.key as keyof typeof row] + )} + + ); + })} + + )) + )} + + {showFooter && ( + + + {selectable && ( + + )} + {visibleColumns.map((col, index) => { + const isPinned = + index < pinnedColumns || pinnedColumnKeys.has(col.key); + return ( + + {col.footerValue} + + ); + })} + + + )} +
+ {selectable && ( +

+ {selectedIds.size} of {filteredData.length} row(s) selected. +

+ )} +
+ ); +} + +export const Playground: StoryObj = { + args: { + columnCount: 6, + showFooter: false, + pinnedColumns: 0, + selectable: false, + showColumnMenus: true, + }, + argTypes: { + columnCount: { + control: { type: 'range', min: 1, max: 12, step: 1 }, + description: 'Number of columns to display (1-12)', + }, + showFooter: { + control: 'boolean', + description: 'Show or hide the table footer', + }, + pinnedColumns: { + control: { type: 'range', min: 0, max: 4, step: 1 }, + description: 'Number of columns to pin to the left (0-4)', + }, + selectable: { + control: 'boolean', + description: 'Enable row selection with checkboxes', + }, + showColumnMenus: { + control: 'boolean', + description: 'Show column header menus with sort, filter, and options', + }, + }, + render: (args) => , +}; + const users = [ { id: 1,