Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion src/components/wordpress/DataViews.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryFn } from "@storybook/react";
import { SlotFillProvider } from "@wordpress/components";
import { Archive, Ban, CheckCircle, Eye, Mail, Pencil, Trash2, UserCheck, Users, UserX } from "lucide-react";
import { Archive, Ban, CheckCircle, Clock, Eye, Mail, Pencil, ShieldCheck, Star, Trash2, UserCheck, UserCog, Users, UserX } from "lucide-react";
import React, { useState } from "react";
import { Badge, Input } from "../ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
Expand Down Expand Up @@ -1149,6 +1149,105 @@ All actions support bulk selection and have async callbacks with loading states.
},
};

/** Demonstrates many tabs that wrap responsively on smaller screens. Resize the browser to see tabs stack on mobile and stay inline on desktop. */
export const MultipleTabs: StoryFn = () => {
const [view, setView] = useState<DataViewState>(createDefaultView(["name", "email", "status", "role", "joinedAt"]));
const [selection, setSelection] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState("all");

// Extended status set for more tabs
const statusMap: Record<string, (user: User) => boolean> = {
all: () => true,
active: (u) => u.status === "active",
inactive: (u) => u.status === "inactive",
pending: (u) => u.status === "pending",
admin: (u) => u.role === "Admin",
editor: (u) => u.role === "Editor",
viewer: (u) => u.role === "Viewer",
manager: (u) => u.role === "Manager",
};

const filterFn = statusMap[activeTab] ?? statusMap.all;
let filteredUsers = allUsers.filter(filterFn);

// Apply search
const searchTerm = view.search ?? "";
if (searchTerm) {
filteredUsers = filteredUsers.filter(
(user) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
}

const paginatedData = paginateData(filteredUsers, view);

// Counts per tab
const tabCounts = {
all: allUsers.length,
active: allUsers.filter((u) => u.status === "active").length,
inactive: allUsers.filter((u) => u.status === "inactive").length,
pending: allUsers.filter((u) => u.status === "pending").length,
admin: allUsers.filter((u) => u.role === "Admin").length,
editor: allUsers.filter((u) => u.role === "Editor").length,
viewer: allUsers.filter((u) => u.role === "Viewer").length,
manager: allUsers.filter((u) => u.role === "Manager").length,
};

return (
<div className="p-4">
<DataViews<User>
namespace="dataviews-demo"
data={paginatedData}
fields={fields}
view={view}
onChangeView={setView}
search
searchPlaceholder="Search users..."
actions={actions}
selection={selection}
onChangeSelection={setSelection}
paginationInfo={{
totalItems: filteredUsers.length,
totalPages: getTotalPages(filteredUsers.length, view.perPage),
}}
getItemId={(item) => item.id}
tabs={{
items: [
{ label: "All", value: "all", icon: Users, count: tabCounts.all },
{ label: "Active", value: "active", icon: UserCheck, count: tabCounts.active },
{ label: "Inactive", value: "inactive", icon: UserX, count: tabCounts.inactive },
{ label: "Pending", value: "pending", icon: Clock, count: tabCounts.pending },
{ label: "Admins", value: "admin", icon: ShieldCheck, count: tabCounts.admin },
{ label: "Editors", value: "editor", icon: UserCog, count: tabCounts.editor },
{ label: "Viewers", value: "viewer", icon: Eye, count: tabCounts.viewer },
{ label: "Managers", value: "manager", icon: Star, count: tabCounts.manager },
],
defaultValue: "all",
onSelect: (value) => {
setActiveTab(value);
setSelection([]);
setView((prev) => ({ ...prev, page: 1 }));
},
}}
/>
</div>
);
};
MultipleTabs.storyName = "Multiple Tabs (Responsive)";
MultipleTabs.parameters = {
docs: {
description: {
story: `Demonstrates 8 tabs with icons and counts. On mobile viewports the tabs wrap into multiple rows; on desktop they stay in a single line.

- **Status tabs**: All, Active, Inactive, Pending
- **Role tabs**: Admins, Editors, Viewers, Managers

Each tab shows its item count as a badge. Resize the viewport or use the Storybook viewport addon to see the responsive wrapping behavior.`,
},
},
};

FixedWidthColumns.parameters = {
docs: {
description: {
Expand Down
95 changes: 51 additions & 44 deletions src/components/wordpress/dataviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
*/
function getQueryParamsFromView(view: View, tabViewKey: string): Record<string, string | number | null | undefined> {
const v = view as View & {
[key: string]: any;

Check warning on line 85 in src/components/wordpress/dataviews.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
};

const perPage = v.perPage ?? v.per_page;
Expand All @@ -91,7 +91,7 @@
typeof v.filters === 'object' && Object.keys(v.filters).length > 0 ? JSON.stringify(v.filters) : null;

// Start with the core pieces of state we always want in the URL.
const params: Record<string, any> = {

Check warning on line 94 in src/components/wordpress/dataviews.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
// Use `current_page` instead of `page` so we don't conflict with
// WordPress admin's own `page` query param (e.g. ?page=plugin-ui-test).
current_page: v.page ?? null,
Expand Down Expand Up @@ -519,9 +519,9 @@

// --- Destructive action confirmation via AlertDialog ---
const [pendingDestructiveAction, setPendingDestructiveAction] = useState<{
action: DataViewAction<Item> & { callback: (...args: any[]) => void };

Check warning on line 522 in src/components/wordpress/dataviews.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
items: Item[];
context: any;

Check warning on line 524 in src/components/wordpress/dataviews.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
} | null>(null);
const [isConfirming, setIsConfirming] = useState(false);

Expand Down Expand Up @@ -640,7 +640,7 @@
return;
}

const targetType = windowWidth <= 768 ? 'list' : 'table';
const targetType = windowWidth !== null && windowWidth <= 768 ? 'list' : 'table';

if (view.type !== targetType) {
onChangeView({
Expand Down Expand Up @@ -720,7 +720,7 @@

const searchTerm = (view as View & { search?: string }).search ?? '';
const [localSearch, setLocalSearch] = useState(searchTerm);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Sync local state when the external view search changes (e.g. tab reset)
useEffect(() => {
Expand Down Expand Up @@ -784,43 +784,50 @@
'border-b border-border p-4 md:px-4 md:py-0'
)}>
{tabItems.length > 0 && (
<Tabs
defaultValue={defaultTabValue}
onValueChange={(value) => {
// When a tab changes, reflect that in the view state
const nextView = {
...view,
[tabViewKey]: value,
page: 1
} as View & { [key: string]: string | number };

handleViewChange(nextView);

tabs?.onSelect?.(value);
filteredProps.onChangeSelection?.([]);
}}>
<TabsList variant="line" className="p-0 flex-wrap md:flex-nowrap">
{tabItems.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
disabled={tab.disabled}
className={cn(
'cursor-pointer! flex! py-2! px-2! text-xs! md:py-6! md:px-4! md:text-sm! text-muted-foreground! bg-transparent! rounded-none! hover:bg-transparent!',
'focus:outline-none! shadow-none!',
tab.className
)}>
{tab.icon && <tab.icon className="size-4" />}
{tab.label}{' '}
{tab.count !== undefined && (
<span className="text-muted-foreground">({tab.count})</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="min-w-0 overflow-x-auto no-scrollbar">
<Tabs
defaultValue={defaultTabValue}
className="w-max"
onValueChange={(value) => {
// When a tab changes, reflect that in the view state
const nextView = {
...view,
[tabViewKey]: value,
page: 1
} as View & { [key: string]: string | number };

handleViewChange(nextView);

tabs?.onSelect?.(value);
filteredProps.onChangeSelection?.([]);
}}>
<TabsList variant="line" className="p-0">
{tabItems.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
disabled={tab.disabled}
className={cn(
'cursor-pointer! flex! py-2! px-2! text-xs! md:py-6! md:px-4! md:text-sm! text-muted-foreground! bg-transparent! rounded-none! hover:bg-transparent!',
'focus:outline-none! shadow-none!',
tab.className
)}>
{tab.icon && <tab.icon className="size-4" />}
{tab.label}{' '}
{tab.count !== undefined && (
<span className="text-muted-foreground">({tab.count})</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
)}
<div className={cn('flex items-center gap-2', showFullWidthHeader && 'justify-end w-full py-2')}>
<div
className={cn(
'flex items-center gap-2 shrink-0',
showFullWidthHeader && 'justify-end w-full py-2'
)}>
{searchInput}
{headerContent.map((node, index) => (
<Fragment key={index}>{node}</Fragment>
Expand All @@ -835,6 +842,7 @@
}`}>
<FilterItems
{...filter}
fields={filter?.fields ?? []}
openSelectorSignal={openSelectorSignal}
onFirstFilterAdded={() => setShowFilters(true)}
onReset={() => {
Expand All @@ -855,7 +863,7 @@
children
) : (
<Fragment>
{view.type === 'table' && filteredProps?.selection?.length > 0 && (
{view.type === 'table' && (filteredProps?.selection?.length ?? 0) > 0 && (
<div
className={cn(
'animate-in py-1.5 fade-in-0 slide-in-from-top-1 duration-200 transition-all ease-in-out flex items-center bg-background z-1 border-b px-6 min-h-13 justify-between border-border w-full'
Expand Down Expand Up @@ -927,8 +935,7 @@
);
}

DataViews.Pagination = DataViewsTable.Pagination as React.ComponentType<any>;
DataViews.Layout = DataViewsTable.Layout as React.ComponentType<any>;
DataViews.Search = DataViewsTable.Search as React.ComponentType<any>;
DataViews.Filters = DataViewsTable.Filters as React.ComponentType<any>;
DataViews.BulkActionToolbar = DataViewsTable.BulkActionToolbar as React.ComponentType<any>;
DataViews.Pagination = DataViewsTable.Pagination;
DataViews.Layout = DataViewsTable.Layout;
DataViews.Filters = DataViewsTable.Filters;
DataViews.BulkActionToolbar = DataViewsTable.BulkActionToolbar;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
12 changes: 12 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@
--shadow-2xl: var(--shadow-2xl);
}

/* ============================================
Scrollbar Utilities
============================================ */

@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

/* ============================================
Scoped Base Styles (CSS Reset within .pui-root)
============================================ */
Expand Down
3 changes: 1 addition & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
"rootDir": "src",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "es2015",
"target": "es2017",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
Expand Down
Loading