From 803fdf5c97fe999354471a43a5668c36691d5883 Mon Sep 17 00:00:00 2001 From: Sergey Kurdin Date: Sun, 15 Jun 2025 23:52:11 -0400 Subject: [PATCH] feat: Enhance keyboard navigation and accessibility in the dashboard - Added a `disabled` prop to `SplitPaneSecondaryProps` for better control over split view behavior. - Implemented a context for managing keyboard navigation settings in the `Tabs` component, allowing for disabling keyboard navigation. - Updated `TabsTrigger` to conditionally handle keyboard events based on the context. - Introduced navigation order building and item navigation functions to streamline board and clip navigation. - Enhanced clipboard history page with keyboard navigation support, allowing users to navigate between clips and boards using arrow keys and tab. - Refactored signal store to include navigation context and selected item signals for improved state management. - Updated UI components to reflect keyboard selection states and improve accessibility. - Added new translations for layout options in the dashboard. --- CLAUDE.md | 5 +- .../src/components/libs/split-view/types.ts | 1 + .../src/components/ui/tabs.tsx | 71 +++- packages/pastebar-app-ui/src/lib/utils.ts | 264 ++++++++++++- .../src/locales/lang/en/dashboard.yaml | 2 + .../ClipboardHistory/ClipboardHistoryRow.tsx | 3 +- .../pages/components/Dashboard/Dashboard.tsx | 39 ++ .../components/Dashboard/components/Board.tsx | 19 + .../Dashboard/components/BoardTabs.tsx | 12 +- .../Dashboard/components/ClipCard.tsx | 25 +- .../src/pages/main/ClipboardHistoryPage.tsx | 361 +++++++++++++++--- .../pastebar-app-ui/src/store/signalStore.ts | 7 + 12 files changed, 724 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0bea0c35..2fefc17d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,9 @@ npm run format # Version management npm run version:sync + +# Translation audit +npm run translation-audit ``` ### Frontend Development (packages/pastebar-app-ui/) @@ -203,4 +206,4 @@ src-tauri/src/ - Use Diesel migrations for schema changes - Place migration files in `migrations/` directory -- Run migrations with `npm run diesel:migration:run` +- Run migrations with `npm run diesel:migration:run` \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/components/libs/split-view/types.ts b/packages/pastebar-app-ui/src/components/libs/split-view/types.ts index 8a8d3313..82a81e0c 100644 --- a/packages/pastebar-app-ui/src/components/libs/split-view/types.ts +++ b/packages/pastebar-app-ui/src/components/libs/split-view/types.ts @@ -54,4 +54,5 @@ export type SplitPanePrimaryProps = { children: ReactNode } & DOMProps & { export type SplitPaneSecondaryProps = { children: ReactNode } & DOMProps & { isSplitPanelView?: boolean isFullWidth?: boolean + disabled?: boolean // Added disabled prop } diff --git a/packages/pastebar-app-ui/src/components/ui/tabs.tsx b/packages/pastebar-app-ui/src/components/ui/tabs.tsx index dde44255..28674190 100644 --- a/packages/pastebar-app-ui/src/components/ui/tabs.tsx +++ b/packages/pastebar-app-ui/src/components/ui/tabs.tsx @@ -3,36 +3,67 @@ import * as TabsPrimitive from '@radix-ui/react-tabs' import { cn } from '~/lib/utils' +// Create a context to share the setting from TabsList to TabsTrigger +const TabsContext = React.createContext({ + disableKeyboardNavigation: false, +}) + const Tabs = TabsPrimitive.Root const TabsList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + disableKeyboardNavigation?: boolean + } +>(({ className, children, disableKeyboardNavigation = false, ...props }, ref) => ( + // The provider makes the 'disableKeyboardNavigation' value available to all child components + + + {children} + + )) TabsList.displayName = TabsPrimitive.List.displayName const TabsTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) +>(({ className, onKeyDown, ...props }, ref) => { + // Consume the context to get the setting from the parent TabsList + const { disableKeyboardNavigation } = React.useContext(TabsContext) + + // Create a new keydown handler + const handleKeyDown = (e: React.KeyboardEvent) => { + // If the feature is enabled, prevent arrow key navigation (with or without modifiers) + if (disableKeyboardNavigation && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + e.preventDefault() + e.stopPropagation() // Also stop propagation to prevent other handlers from running + } + + // IMPORTANT: Still call any original onKeyDown function that was passed in props + onKeyDown?.(e) + } + + return ( + + ) +}) TabsTrigger.displayName = TabsPrimitive.Trigger.displayName const TabsContent = React.forwardRef< diff --git a/packages/pastebar-app-ui/src/lib/utils.ts b/packages/pastebar-app-ui/src/lib/utils.ts index 596f1618..bb0fb7eb 100644 --- a/packages/pastebar-app-ui/src/lib/utils.ts +++ b/packages/pastebar-app-ui/src/lib/utils.ts @@ -1,9 +1,25 @@ import { UniqueIdentifier } from '@dnd-kit/core' +import createBoardTree from '~/libs/create-board-tree' import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' +import { + currentBoardIndex, + currentNavigationContext, + keyboardSelectedBoardId, + keyboardSelectedClipId, +} from '~/store/signalStore' + import { MenuItem } from '~/types/menu' +// Navigation types and helper functions +interface NavigationItem { + id: UniqueIdentifier + type: 'history' | 'board' + parentId?: UniqueIdentifier | null + depth: number +} + const EMOJIREGEX = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/gm @@ -54,6 +70,231 @@ export function absoluteUrl(path: string) { return `${process.env.NEXT_PUBLIC_APP_URL}${path}` } +// Build a flattened navigation order that includes boards and sub-boards +// Only boards are included in Left/Right navigation - clips are navigated with Up/Down within boards +export function buildNavigationOrder( + clipItems: any[], + currentTab: string +): NavigationItem[] { + const navigationOrder: NavigationItem[] = [] + + // Add history placeholder - actual history navigation is handled by ClipboardHistoryPage + navigationOrder.push({ id: 'history', type: 'history', depth: 0 }) + + // Get all boards and clips in the current tab + const allItems = clipItems.filter(item => item.tabId === currentTab) + + // Build board tree for proper nesting + const boardTree = createBoardTree(clipItems, currentTab, null) + + // Recursively add boards (but not clips - clips are navigated with Up/Down within boards) + function addBoardAndContents(boardId: UniqueIdentifier, depth: number) { + const board = allItems.find(item => item.itemId === boardId && item.isBoard) + if (!board) return + + // Add the board itself to navigation order + navigationOrder.push({ + id: boardId, + type: 'board', + parentId: board.parentId, + depth, + }) + + // Get direct child boards (not clips) sorted by order + const childBoards = allItems + .filter(item => item.parentId === boardId && item.isBoard) + .sort((a, b) => a.orderNumber - b.orderNumber) + + // Add child boards recursively + childBoards.forEach(child => { + addBoardAndContents(child.itemId, depth + 1) + }) + } + + // Start with top-level boards + const topLevelBoards = allItems + .filter(item => item.isBoard && item.parentId === null) + .sort((a, b) => a.orderNumber - b.orderNumber) + + topLevelBoards.forEach(board => { + addBoardAndContents(board.itemId, 1) + }) + + return navigationOrder +} + +// Find current position in navigation order +export function findCurrentNavigationIndex(navigationOrder: NavigationItem[]): number { + if (currentNavigationContext.value === 'history') { + return 0 // History is always at index 0 + } else if (currentNavigationContext.value === 'board') { + if (keyboardSelectedBoardId.value) { + // Find board position - since clips are not in navigation order, + // we find the board that contains the selected clip + return navigationOrder.findIndex( + item => item.type === 'board' && item.id === keyboardSelectedBoardId.value + ) + } + } + return 0 +} + +// Find the next non-empty board in the navigation order (skipping empty boards) +function findNextNonEmptyBoard( + navigationOrder: NavigationItem[], + startIndex: number, + direction: 'forward' | 'backward', + clipItems: any[], + currentTab: string +): NavigationItem | null { + const maxAttempts = navigationOrder.length // Prevent infinite loops + let attempts = 0 + let currentIndex = startIndex + + while (attempts < maxAttempts) { + // Move in the specified direction + if (direction === 'forward') { + currentIndex = currentIndex + 1 + // If we've gone past the end, wrap to history (index 0) or return null for no more boards + if (currentIndex >= navigationOrder.length) { + return null // Let caller handle going back to history + } + } else { + currentIndex = currentIndex - 1 + // If we've gone before the beginning, return null for going to history + if (currentIndex < 1) { + // Index 0 is history, so < 1 means we should go to history + return null + } + } + + const candidateItem = navigationOrder[currentIndex] + + // Skip history item (only boards should be checked) + if (candidateItem.type === 'history') { + attempts++ + continue + } + + // Check if this board has clips + const clipsInBoard = clipItems.filter( + clipItem => + clipItem.isClip && + clipItem.parentId === candidateItem.id && + clipItem.tabId === currentTab + ) + + // If board has clips, return it + if (clipsInBoard.length > 0) { + return candidateItem + } + + attempts++ + } + + // If no non-empty board found, return null + return null +} + +// Navigate to specific item in the navigation order +export function navigateToItem( + item: NavigationItem, + clipItems: any[], + currentTab: string +) { + if (item.type === 'history') { + currentNavigationContext.value = 'history' + keyboardSelectedBoardId.value = null + keyboardSelectedClipId.value = null + currentBoardIndex.value = 0 + // Let ClipboardHistoryPage handle history item selection + } else if (item.type === 'board') { + // Check if the target board has clips + const clipsInBoard = clipItems + .filter( + clipItem => + clipItem.isClip && + clipItem.parentId === item.id && + clipItem.tabId === currentTab + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + // If the board is empty, try to find a non-empty board + if (clipsInBoard.length === 0) { + // Build navigation order to find alternatives + const navigationOrder = buildNavigationOrder(clipItems, currentTab) + const currentIndex = navigationOrder.findIndex(navItem => navItem.id === item.id) + + // Try to find next non-empty board in forward direction first + let alternativeBoard = findNextNonEmptyBoard( + navigationOrder, + currentIndex, + 'forward', + clipItems, + currentTab + ) + + // If no forward board found, try backward direction + if (!alternativeBoard) { + alternativeBoard = findNextNonEmptyBoard( + navigationOrder, + currentIndex, + 'backward', + clipItems, + currentTab + ) + } + + // If we found an alternative non-empty board, navigate to it instead + if (alternativeBoard) { + navigateToItem(alternativeBoard, clipItems, currentTab) + return + } + + // If no non-empty boards found anywhere, go to history + currentNavigationContext.value = 'history' + keyboardSelectedBoardId.value = null + keyboardSelectedClipId.value = null + currentBoardIndex.value = 0 + return + } + + // Board has clips, proceed with normal navigation + currentNavigationContext.value = 'board' + keyboardSelectedBoardId.value = item.id + keyboardSelectedClipId.value = clipsInBoard[0].itemId // Select first clip + + // Update board index - for subboards, find the root parent + const topLevelBoards = clipItems + .filter( + boardItem => + boardItem.isBoard && + boardItem.parentId === null && + boardItem.tabId === currentTab + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + // Find the root parent board for subboards + const currentBoard = clipItems.find(boardItem => boardItem.itemId === item.id) + if (currentBoard) { + let rootParent = currentBoard + while (rootParent.parentId !== null) { + rootParent = clipItems.find(boardItem => boardItem.itemId === rootParent.parentId) + if (!rootParent) break + } + if (rootParent) { + currentBoardIndex.value = topLevelBoards.findIndex( + board => board.itemId === rootParent.itemId + ) + } else { + currentBoardIndex.value = topLevelBoards.findIndex( + board => board.itemId === item.id + ) + } + } + } +} + export const findNewChildrenOrderByParentIdAndDragId = ( items: MenuItem[], dragId: string, @@ -305,19 +546,16 @@ export function bgColor( ) { const colorNameToUse = colorName || 'slate' - const darkColorCode = darkCode - ? darkCode - : colorCode === '200' && colorNameToUse === 'slate' - ? '700' - : colorNameToUse !== 'slate' - ? '900' - : '300' - ? '600' - : '400' - ? '500' - : '600' - ? '700' - : '300' + let darkColorCode: string + if (darkCode) { + darkColorCode = darkCode + } else if (colorCode === '200' && colorNameToUse === 'slate') { + darkColorCode = '700' + } else if (colorNameToUse !== 'slate') { + darkColorCode = '900' + } else { + darkColorCode = '300' + } const type = isBorder ? 'border' : 'bg' diff --git a/packages/pastebar-app-ui/src/locales/lang/en/dashboard.yaml b/packages/pastebar-app-ui/src/locales/lang/en/dashboard.yaml index c9399fe7..ac80bbec 100644 --- a/packages/pastebar-app-ui/src/locales/lang/en/dashboard.yaml +++ b/packages/pastebar-app-ui/src/locales/lang/en/dashboard.yaml @@ -147,8 +147,10 @@ Filter's Value: Filter's Value Find in Clip: Find in Clip Find in clip: Find in clip Find in history: Find in history +Flex Layout: Flex Layout Form Fields: Form Fields General Fields: General Fields +Grid Layout: Grid Layout HTML: HTML Headers: Headers Hide Label: Hide Label diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index e42c1cdc..7dffd332 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -234,6 +234,7 @@ export function ClipboardHistoryRowComponent({ rowKeyboardRef.current.scrollIntoView({ block: 'center', }) + // rowKeyboardRef.current.focus() } }, [isKeyboardSelected, isScrolling]) @@ -403,7 +404,7 @@ export function ClipboardHistoryRowComponent({ !isSelected ? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950' : isKeyboardSelected - ? `bg-blue-50 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:border-blue-900/80 hover:border-blue-300/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ${ + ? `bg-blue-50 ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ring-offset-white dark:ring-offset-gray-800 ${ isPinnedTop ? ' dark:!bg-amber-950' : '' }` : isDeleting && !isDragPreview diff --git a/packages/pastebar-app-ui/src/pages/components/Dashboard/Dashboard.tsx b/packages/pastebar-app-ui/src/pages/components/Dashboard/Dashboard.tsx index 17c3c59c..2b74a251 100644 --- a/packages/pastebar-app-ui/src/pages/components/Dashboard/Dashboard.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Dashboard/Dashboard.tsx @@ -31,8 +31,13 @@ import { activeOverTabId, collectionsStoreAtom, createFirstBoard, + currentBoardIndex, + currentNavigationContext, isFullyExpandViewBoard, isKeyAltPressed, + keyboardSelectedBoardId, + keyboardSelectedClipId, + keyboardSelectedItemId, playerStoreAtom, settingsStoreAtom, showClipsMoveOnBoardId, @@ -57,6 +62,7 @@ import { Plus, X, } from 'lucide-react' +import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import { bgColor } from '~/lib/utils' @@ -103,6 +109,7 @@ import { import { useUpdateTabs } from '~/hooks/queries/use-tabs' import { useCopyClipItem, usePasteClipItem } from '~/hooks/use-copypaste-clip-item' import { useLocalStorage } from '~/hooks/use-localstorage' +// import { useNavigation } from '~/hooks/use-navigation' import { useSignal } from '~/hooks/use-signal' import { Item } from '~/types/menu' @@ -255,6 +262,31 @@ function DashboardComponent({ [clipItems] ) + // // Prepare board contexts for navigation + // const boardContexts = useMemo(() => { + // return clipItems + // .filter( + // ({ parentId, isBoard, tabId }) => + // parentId === null && isBoard && tabId === currentTab + // ) + // .map(board => ({ + // boardId: board.itemId.toString(), + // boardName: board.name, + // clips: clipItems + // .filter( + // ({ parentId, isClip, tabId }) => + // parentId === board.itemId && isClip && tabId === currentTab + // ) + // .map(clip => ({ id: clip.itemId, itemId: clip.itemId })) + // })) + // }, [clipItems, currentTab]) + + // // Use unified navigation hook (empty history items for Dashboard only) + // const { selectedClipId } = useNavigation({ + // historyItems: [], + // boardContexts, + // }) + useEffect(() => { if (pinnedClips.length === 0) { isPinnedPanelHovering.value = false @@ -907,6 +939,7 @@ function DashboardComponent({ pinnedItemIds={pinnedClips.map(clip => clip.id)} currentTab={currentTab} setCurrentTab={setCurrentTab} + isKeyboardNavigationDisabled={currentNavigationContext.value !== null} /> )} @@ -975,6 +1008,9 @@ function DashboardComponent({ setShowDetailsItem={setShowDetailsItem} selectedItemIds={selectedItemIds} setSelectedItemId={setSelectedItemId} + keyboardSelectedClipId={keyboardSelectedClipId} + currentSelectedBoardId={keyboardSelectedBoardId} + keyboardNavigationMode={currentNavigationContext} /> ))} @@ -1007,6 +1043,9 @@ function DashboardComponent({ setShowDetailsItem={setShowDetailsItem} selectedItemIds={selectedItemIds} setSelectedItemId={setSelectedItemId} + keyboardSelectedClipId={keyboardSelectedClipId} + currentSelectedBoardId={keyboardSelectedBoardId} + keyboardNavigationMode={currentNavigationContext} />