diff --git a/src/components/wordpress/dataviews.tsx b/src/components/wordpress/dataviews.tsx index d5af89b..333ce24 100644 --- a/src/components/wordpress/dataviews.tsx +++ b/src/components/wordpress/dataviews.tsx @@ -9,7 +9,7 @@ import { type View } from '@wordpress/dataviews/wp'; import { __ } from '@wordpress/i18n'; -import { FileSearch, Funnel, Plus, Search, X } from 'lucide-react'; +import { ChevronLeft, ChevronRight, FileSearch, Funnel, Plus, Search, X } from 'lucide-react'; import type React from 'react'; import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { @@ -32,7 +32,11 @@ import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; declare global { interface Window { - wp?: { hooks?: { applyFilters: (hookName: string, value: unknown, ...args: unknown[]) => unknown } }; + wp?: { + hooks?: { + applyFilters: (hookName: string, value: unknown, ...args: unknown[]) => unknown; + }; + }; } } @@ -53,57 +57,6 @@ function applyFiltersToTableElements( return window.wp.hooks.applyFilters(hookName, element, props) as unknown; } -/** - * Update the current URL's query parameters without causing a full page reload. - */ -function updateUrlQueryParams(params: Record): void { - if (typeof window === 'undefined') { - return; - } - - const url = new URL(window.location.href); - - Object.entries(params).forEach(([key, value]) => { - if (value === undefined || value === null || value === '') { - url.searchParams.delete(key); - } else { - url.searchParams.set(key, String(value)); - } - }); - - window.history.replaceState({}, '', url.toString()); -} - -/** - * Extract query-param friendly values from a DataViews `View`. - * - * We intentionally support both camelCase (`perPage`) and snake_case (`per_page`) - * so that whatever the upstream shape is, we can still sync it into the URL. - */ -function getQueryParamsFromView(view: View, tabViewKey: string): Record { - const v = view as View & { - [key: string]: unknown; - }; - - const perPage = v.perPage ?? v.per_page; - - const filters = - 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 = { - // 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, - per_page: (perPage as number) ?? null, - search: v.search ?? '', - [tabViewKey]: (v[tabViewKey] as string) ?? '', - filters - }; - - return params; -} - // Extended action type with automatic destructive confirmation support export type DestructiveActionConfig = { /** When true, shows an AlertDialog confirmation before executing the action callback. */ @@ -345,7 +298,9 @@ export type DataViewsProps = { filter?: DataViewFilterProps; tabs?: TabsProps; children?: React.ReactNode; -} & (Item extends ItemWithId ? { getItemId?: (item: Item) => string } : { getItemId: (item: Item) => string }); +} & (Item extends ItemWithId + ? { getItemId?: (item: Item) => string } + : { getItemId: (item: Item) => string }); interface ListEmptyProps { icon?: JSX.Element; @@ -390,7 +345,9 @@ function SkeletonTable({ return (
{Array.from({ length: rows }, (_, i) => ( -
+
@@ -459,7 +416,9 @@ function SkeletonTable({ return (
- +
); @@ -477,6 +436,9 @@ export function DataViews(props: DataViewsProps) { const [openSelectorSignal, setOpenSelectorSignal] = useState(0); const [buttonRef, setButtonRef] = useState(); const [activeFilterCount, setActiveFilterCount] = useState(0); + const tabsScrollRef = useRef(null); + const [canScrollTabsLeft, setCanScrollTabsLeft] = useState(false); + const [canScrollTabsRight, setCanScrollTabsRight] = useState(false); const { responsive = true, namespace, @@ -568,7 +530,11 @@ export function DataViews(props: DataViewsProps) { if (!actions) return actions; return actions.map((action) => { - if (!action.isDestructive || !('callback' in action) || typeof action.callback !== 'function') { + if ( + !action.isDestructive || + !('callback' in action) || + typeof action.callback !== 'function' + ) { return action as Action; } @@ -614,11 +580,9 @@ export function DataViews(props: DataViewsProps) { const handleViewChange = useCallback( (nextView: View) => { - // Sync key pieces of state into the URL so that the UI is shareable and bookmarkable. - updateUrlQueryParams(getQueryParamsFromView(nextView, tabViewKey)); onChangeView(nextView); }, - [tabViewKey, onChangeView] + [onChangeView] ); const baseProps = { @@ -628,13 +592,18 @@ export function DataViews(props: DataViewsProps) { view: normalizedView, fields: normalizedFields, defaultLayouts: props.defaultLayouts || defaultLayouts, - empty: empty || + empty: empty || ( + + ) }; // Run WordPress filter hooks on table elements (only applies when wp.hooks exists) - const filteredActions = applyFiltersToTableElements(namespace, 'actions', baseProps.actions, props) as - | DataViewAction[] - | undefined; + const filteredActions = applyFiltersToTableElements( + namespace, + 'actions', + baseProps.actions, + props + ) as DataViewAction[] | undefined; const viewPerPageValue = (view as View & { perPage?: number; per_page?: number }).perPage ?? @@ -643,9 +612,24 @@ export function DataViews(props: DataViewsProps) { const filteredProps = { ...baseProps, - data: applyFiltersToTableElements(namespace, 'data', baseProps.data, props) as typeof baseProps.data, - view: applyFiltersToTableElements(namespace, 'view', baseProps.view, props) as typeof baseProps.view, - fields: applyFiltersToTableElements(namespace, 'fields', baseProps.fields, props) as typeof baseProps.fields, + data: applyFiltersToTableElements( + namespace, + 'data', + baseProps.data, + props + ) as typeof baseProps.data, + view: applyFiltersToTableElements( + namespace, + 'view', + baseProps.view, + props + ) as typeof baseProps.view, + fields: applyFiltersToTableElements( + namespace, + 'fields', + baseProps.fields, + props + ) as typeof baseProps.fields, actions: wrapDestructiveActions(filteredActions), defaultLayouts: applyFiltersToTableElements( namespace, @@ -717,15 +701,73 @@ export function DataViews(props: DataViewsProps) { const defaultTabValue = resolvedTabsConfig?.defaultValue ?? tabItems[0]?.value; const headerContent = resolvedTabsConfig?.headerContent ?? resolvedTabsConfig?.headerSlot ?? []; + const updateTabsScrollState = useCallback(() => { + const tabsScroller = tabsScrollRef.current; + if (!tabsScroller) { + setCanScrollTabsLeft(false); + setCanScrollTabsRight(false); + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = tabsScroller; + setCanScrollTabsLeft(scrollLeft > 0); + setCanScrollTabsRight(scrollLeft + clientWidth < scrollWidth - 1); + }, []); + + const scrollTabsByArrow = useCallback((direction: 'left' | 'right') => { + const tabsScroller = tabsScrollRef.current; + if (!tabsScroller) { + return; + } + + const scrollAmount = Math.max(tabsScroller.clientWidth * 0.7, 140); + tabsScroller.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth' + }); + }, []); + + useEffect(() => { + const tabsScroller = tabsScrollRef.current; + if (!tabsScroller || tabItems.length === 0) { + setCanScrollTabsLeft(false); + setCanScrollTabsRight(false); + return; + } + + updateTabsScrollState(); + + const handleScroll = () => updateTabsScrollState(); + tabsScroller.addEventListener('scroll', handleScroll, { passive: true }); + + const resizeObserver = new ResizeObserver(() => { + updateTabsScrollState(); + }); + resizeObserver.observe(tabsScroller); + + const tabsListElement = tabsScroller.querySelector('[role="tablist"]'); + if (tabsListElement instanceof HTMLElement) { + resizeObserver.observe(tabsListElement); + } + + return () => { + tabsScroller.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + }; + }, [tabItems.length, updateTabsScrollState]); + const paginationDetails = filteredProps.paginationInfo; const explicitTotalPages = paginationDetails?.totalPages; const perPage = viewPerPageValue; const computedTotalPages = - typeof paginationDetails?.totalItems === 'number' && typeof perPage === 'number' && perPage > 0 + typeof paginationDetails?.totalItems === 'number' && + typeof perPage === 'number' && + perPage > 0 ? Math.ceil(paginationDetails.totalItems / perPage) : 0; const shouldShowPagination = - !isLoading && (typeof explicitTotalPages === 'number' ? explicitTotalPages : computedTotalPages) > 1; + !isLoading && + (typeof explicitTotalPages === 'number' ? explicitTotalPages : computedTotalPages) > 1; const showFullWidthHeader = !tabItems.length && (search || hasFilters); const tableNameSpace = kebabCase(namespace); @@ -804,43 +846,71 @@ export function DataViews(props: DataViewsProps) { 'border-b border-border p-4 md:px-4 md:py-0' )}> {tabItems.length > 0 && ( -
- { - // 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?.([]); - }}> - - {tabItems.map((tab) => ( - - {tab.icon && } - {tab.label}{' '} - {tab.count !== undefined && ( - ({tab.count}) - )} - - ))} - - +
+ {canScrollTabsLeft && ( + + )} +
+ { + // 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?.([]); + }}> + + {tabItems.map((tab) => ( + + {tab.icon && } + {tab.label}{' '} + {tab.count !== undefined && ( + + ({tab.count}) + + )} + + ))} + + +
+ {canScrollTabsRight && ( + + )}
)}
(props: DataViewsProps) { '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-5 min-h-13 justify-between border-border rounded-t-md w-full' )} style={ - theadHeight ? { marginBottom: -theadHeight, minHeight: theadHeight } : undefined + theadHeight + ? { marginBottom: -theadHeight, minHeight: theadHeight } + : undefined }>
@@ -921,13 +993,17 @@ export function DataViews(props: DataViewsProps) { {/* Destructive action confirmation AlertDialog */} {pendingDestructiveAction && ( - !open && !isConfirming && handleDestructiveCancel()}> + !open && !isConfirming && handleDestructiveCancel()}> {pendingDestructiveAction.action.confirmTitle || (typeof pendingDestructiveAction.action.label === 'function' - ? pendingDestructiveAction.action.label(pendingDestructiveAction.items) + ? pendingDestructiveAction.action.label( + pendingDestructiveAction.items + ) : pendingDestructiveAction.action.label)} @@ -937,7 +1013,8 @@ export function DataViews(props: DataViewsProps) { - {pendingDestructiveAction.action.cancelButtonLabel || __('Cancel', 'default')} + {pendingDestructiveAction.action.cancelButtonLabel || + __('Cancel', 'default')} (props: DataViewsProps) { {isConfirming && } {pendingDestructiveAction.action.confirmButtonLabel || (typeof pendingDestructiveAction.action.label === 'function' - ? pendingDestructiveAction.action.label(pendingDestructiveAction.items) + ? pendingDestructiveAction.action.label( + pendingDestructiveAction.items + ) : pendingDestructiveAction.action.label)}