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.
This commit is contained in:
Sergey Kurdin 2025-06-15 23:52:11 -04:00
parent 2144265b9c
commit 803fdf5c97
12 changed files with 724 additions and 85 deletions

View File

@ -49,6 +49,9 @@ npm run format
# Version management # Version management
npm run version:sync npm run version:sync
# Translation audit
npm run translation-audit
``` ```
### Frontend Development (packages/pastebar-app-ui/) ### Frontend Development (packages/pastebar-app-ui/)

View File

@ -54,4 +54,5 @@ export type SplitPanePrimaryProps = { children: ReactNode } & DOMProps & {
export type SplitPaneSecondaryProps = { children: ReactNode } & DOMProps & { export type SplitPaneSecondaryProps = { children: ReactNode } & DOMProps & {
isSplitPanelView?: boolean isSplitPanelView?: boolean
isFullWidth?: boolean isFullWidth?: boolean
disabled?: boolean // Added disabled prop
} }

View File

@ -3,12 +3,21 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '~/lib/utils' 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 Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
>(({ className, ...props }, ref) => ( disableKeyboardNavigation?: boolean
}
>(({ className, children, disableKeyboardNavigation = false, ...props }, ref) => (
// The provider makes the 'disableKeyboardNavigation' value available to all child components
<TabsContext.Provider value={{ disableKeyboardNavigation }}>
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
@ -16,23 +25,45 @@ const TabsList = React.forwardRef<
className className
)} )}
{...props} {...props}
/> >
{children}
</TabsPrimitive.List>
</TabsContext.Provider>
)) ))
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ 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<HTMLButtonElement>) => {
// 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 (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background dark:data-[state=active]:bg-gray-600 data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset disabled:pointer-events-none disabled:opacity-50', 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background dark:data-[state=active]:bg-gray-600 data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset disabled:pointer-events-none disabled:opacity-50',
className className
)} )}
// Conditionally apply our new handler ONLY if the prop is set
onKeyDown={disableKeyboardNavigation ? handleKeyDown : onKeyDown}
{...props} {...props}
/> />
)) )
})
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<

View File

@ -1,9 +1,25 @@
import { UniqueIdentifier } from '@dnd-kit/core' import { UniqueIdentifier } from '@dnd-kit/core'
import createBoardTree from '~/libs/create-board-tree'
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import {
currentBoardIndex,
currentNavigationContext,
keyboardSelectedBoardId,
keyboardSelectedClipId,
} from '~/store/signalStore'
import { MenuItem } from '~/types/menu' import { MenuItem } from '~/types/menu'
// Navigation types and helper functions
interface NavigationItem {
id: UniqueIdentifier
type: 'history' | 'board'
parentId?: UniqueIdentifier | null
depth: number
}
const EMOJIREGEX = const EMOJIREGEX =
/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/gm /(\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}` 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 = ( export const findNewChildrenOrderByParentIdAndDragId = (
items: MenuItem[], items: MenuItem[],
dragId: string, dragId: string,
@ -305,19 +546,16 @@ export function bgColor(
) { ) {
const colorNameToUse = colorName || 'slate' const colorNameToUse = colorName || 'slate'
const darkColorCode = darkCode let darkColorCode: string
? darkCode if (darkCode) {
: colorCode === '200' && colorNameToUse === 'slate' darkColorCode = darkCode
? '700' } else if (colorCode === '200' && colorNameToUse === 'slate') {
: colorNameToUse !== 'slate' darkColorCode = '700'
? '900' } else if (colorNameToUse !== 'slate') {
: '300' darkColorCode = '900'
? '600' } else {
: '400' darkColorCode = '300'
? '500' }
: '600'
? '700'
: '300'
const type = isBorder ? 'border' : 'bg' const type = isBorder ? 'border' : 'bg'

View File

@ -147,8 +147,10 @@ Filter's Value: Filter's Value
Find in Clip: Find in Clip Find in Clip: Find in Clip
Find in clip: Find in clip Find in clip: Find in clip
Find in history: Find in history Find in history: Find in history
Flex Layout: Flex Layout
Form Fields: Form Fields Form Fields: Form Fields
General Fields: General Fields General Fields: General Fields
Grid Layout: Grid Layout
HTML: HTML HTML: HTML
Headers: Headers Headers: Headers
Hide Label: Hide Label Hide Label: Hide Label

View File

@ -234,6 +234,7 @@ export function ClipboardHistoryRowComponent({
rowKeyboardRef.current.scrollIntoView({ rowKeyboardRef.current.scrollIntoView({
block: 'center', block: 'center',
}) })
// rowKeyboardRef.current.focus()
} }
}, [isKeyboardSelected, isScrolling]) }, [isKeyboardSelected, isScrolling])
@ -403,7 +404,7 @@ export function ClipboardHistoryRowComponent({
!isSelected !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' ? '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 : 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' : '' isPinnedTop ? ' dark:!bg-amber-950' : ''
}` }`
: isDeleting && !isDragPreview : isDeleting && !isDragPreview

View File

@ -31,8 +31,13 @@ import {
activeOverTabId, activeOverTabId,
collectionsStoreAtom, collectionsStoreAtom,
createFirstBoard, createFirstBoard,
currentBoardIndex,
currentNavigationContext,
isFullyExpandViewBoard, isFullyExpandViewBoard,
isKeyAltPressed, isKeyAltPressed,
keyboardSelectedBoardId,
keyboardSelectedClipId,
keyboardSelectedItemId,
playerStoreAtom, playerStoreAtom,
settingsStoreAtom, settingsStoreAtom,
showClipsMoveOnBoardId, showClipsMoveOnBoardId,
@ -57,6 +62,7 @@ import {
Plus, Plus,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { bgColor } from '~/lib/utils' import { bgColor } from '~/lib/utils'
@ -103,6 +109,7 @@ import {
import { useUpdateTabs } from '~/hooks/queries/use-tabs' import { useUpdateTabs } from '~/hooks/queries/use-tabs'
import { useCopyClipItem, usePasteClipItem } from '~/hooks/use-copypaste-clip-item' import { useCopyClipItem, usePasteClipItem } from '~/hooks/use-copypaste-clip-item'
import { useLocalStorage } from '~/hooks/use-localstorage' import { useLocalStorage } from '~/hooks/use-localstorage'
// import { useNavigation } from '~/hooks/use-navigation'
import { useSignal } from '~/hooks/use-signal' import { useSignal } from '~/hooks/use-signal'
import { Item } from '~/types/menu' import { Item } from '~/types/menu'
@ -255,6 +262,31 @@ function DashboardComponent({
[clipItems] [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(() => { useEffect(() => {
if (pinnedClips.length === 0) { if (pinnedClips.length === 0) {
isPinnedPanelHovering.value = false isPinnedPanelHovering.value = false
@ -907,6 +939,7 @@ function DashboardComponent({
pinnedItemIds={pinnedClips.map(clip => clip.id)} pinnedItemIds={pinnedClips.map(clip => clip.id)}
currentTab={currentTab} currentTab={currentTab}
setCurrentTab={setCurrentTab} setCurrentTab={setCurrentTab}
isKeyboardNavigationDisabled={currentNavigationContext.value !== null}
/> />
</SortableContext> </SortableContext>
)} )}
@ -975,6 +1008,9 @@ function DashboardComponent({
setShowDetailsItem={setShowDetailsItem} setShowDetailsItem={setShowDetailsItem}
selectedItemIds={selectedItemIds} selectedItemIds={selectedItemIds}
setSelectedItemId={setSelectedItemId} setSelectedItemId={setSelectedItemId}
keyboardSelectedClipId={keyboardSelectedClipId}
currentSelectedBoardId={keyboardSelectedBoardId}
keyboardNavigationMode={currentNavigationContext}
/> />
))} ))}
</PanelGroup> </PanelGroup>
@ -1007,6 +1043,9 @@ function DashboardComponent({
setShowDetailsItem={setShowDetailsItem} setShowDetailsItem={setShowDetailsItem}
selectedItemIds={selectedItemIds} selectedItemIds={selectedItemIds}
setSelectedItemId={setSelectedItemId} setSelectedItemId={setSelectedItemId}
keyboardSelectedClipId={keyboardSelectedClipId}
currentSelectedBoardId={keyboardSelectedBoardId}
keyboardNavigationMode={currentNavigationContext}
/> />
<Flex className="absolute right-0 w-full bottom-[-13px] z-100"> <Flex className="absolute right-0 w-full bottom-[-13px] z-100">
<Button <Button

View File

@ -152,6 +152,9 @@ interface BoardProps {
showDetailsItem?: UniqueIdentifier | null showDetailsItem?: UniqueIdentifier | null
setShowDetailsItem?: (id: UniqueIdentifier | null) => void setShowDetailsItem?: (id: UniqueIdentifier | null) => void
isDragPreview?: boolean isDragPreview?: boolean
keyboardSelectedClipId?: { value: UniqueIdentifier | null }
currentSelectedBoardId?: { value: UniqueIdentifier | null }
keyboardNavigationMode?: { value: 'history' | 'board' | null }
} }
export function BoardComponent({ export function BoardComponent({
@ -173,6 +176,9 @@ export function BoardComponent({
setShowDetailsItem, setShowDetailsItem,
setCurrentTab, setCurrentTab,
setSelectedItemId, setSelectedItemId,
keyboardSelectedClipId,
currentSelectedBoardId,
keyboardNavigationMode,
}: BoardProps) { }: BoardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const childrenIds = useMemo(() => { const childrenIds = useMemo(() => {
@ -692,6 +698,9 @@ export function BoardComponent({
setSelectedItemId={setSelectedItemId} setSelectedItemId={setSelectedItemId}
showDetailsItem={showDetailsItem} showDetailsItem={showDetailsItem}
setShowDetailsItem={setShowDetailsItem} setShowDetailsItem={setShowDetailsItem}
keyboardSelectedClipId={keyboardSelectedClipId}
currentSelectedBoardId={currentSelectedBoardId}
keyboardNavigationMode={keyboardNavigationMode}
/> />
) : ( ) : (
item.type === CLIP && ( item.type === CLIP && (
@ -747,6 +756,10 @@ export function BoardComponent({
selectedOrder={ selectedOrder={
selectedItemIds.indexOf(item.id) + 1 selectedItemIds.indexOf(item.id) + 1
} }
isKeyboardSelected={
keyboardSelectedClipId?.value === item.id &&
keyboardNavigationMode?.value === 'board'
}
/> />
) )
)} )}
@ -980,6 +993,9 @@ export function BoardWithPanel({
order, order,
isLastBoard, isLastBoard,
setSelectedItemId, setSelectedItemId,
keyboardSelectedClipId,
currentSelectedBoardId,
keyboardNavigationMode,
}: BoardProps) { }: BoardProps) {
return ( return (
<ResizePanel <ResizePanel
@ -1012,6 +1028,9 @@ export function BoardWithPanel({
setShowDetailsItem={setShowDetailsItem} setShowDetailsItem={setShowDetailsItem}
isDark={isDark} isDark={isDark}
isDragPreview={isDragPreview} isDragPreview={isDragPreview}
keyboardSelectedClipId={keyboardSelectedClipId}
currentSelectedBoardId={currentSelectedBoardId}
keyboardNavigationMode={keyboardNavigationMode}
/> />
</ResizePanel> </ResizePanel>
) )

View File

@ -100,6 +100,7 @@ export default function BoardTabs({
pinnedItemIds, pinnedItemIds,
currentTab, currentTab,
setCurrentTab, setCurrentTab,
isKeyboardNavigationDisabled,
}: { }: {
tabs: TabsType[] tabs: TabsType[]
currentTab: string currentTab: string
@ -107,6 +108,7 @@ export default function BoardTabs({
pinnedItemIds: UniqueIdentifier[] pinnedItemIds: UniqueIdentifier[]
setSelectedItemIds: (ids: UniqueIdentifier[]) => void setSelectedItemIds: (ids: UniqueIdentifier[]) => void
setCurrentTab: (tab: string) => void setCurrentTab: (tab: string) => void
isKeyboardNavigationDisabled?: boolean
}) { }) {
const { clipboardHistory } = useAtomValue(clipboardHistoryStoreAtom) const { clipboardHistory } = useAtomValue(clipboardHistoryStoreAtom)
const { isSimplifiedLayout } = useAtomValue(settingsStoreAtom) const { isSimplifiedLayout } = useAtomValue(settingsStoreAtom)
@ -370,7 +372,10 @@ export default function BoardTabs({
> >
<SimpleBar style={{ width: '97%' }}> <SimpleBar style={{ width: '97%' }}>
{!showEditTabs.value ? ( {!showEditTabs.value ? (
<TabsList className="bg-transparent pr-0.5"> <TabsList
className="bg-transparent pr-0.5"
disableKeyboardNavigation={isKeyboardNavigationDisabled}
>
{tabs.map( {tabs.map(
({ tabId, tabName, tabIsHidden, tabOrderNumber }) => ({ tabId, tabName, tabIsHidden, tabOrderNumber }) =>
tabId && tabId &&
@ -396,7 +401,10 @@ export default function BoardTabs({
)} )}
</TabsList> </TabsList>
) : ( ) : (
<TabsList className="bg-transparent pr-0.5"> <TabsList
className="bg-transparent pr-0.5"
disableKeyboardNavigation={isKeyboardNavigationDisabled}
>
{tabs.map( {tabs.map(
({ tabId, tabName, tabIsHidden, tabColor, tabOrderNumber }) => ({ tabId, tabName, tabIsHidden, tabColor, tabOrderNumber }) =>
tabId && tabId &&

View File

@ -317,6 +317,7 @@ interface ClipCardProps {
setShowDetailsItem?: (id: UniqueIdentifier | null) => void setShowDetailsItem?: (id: UniqueIdentifier | null) => void
setSelectedItemId?: (id: UniqueIdentifier) => void setSelectedItemId?: (id: UniqueIdentifier) => void
isDragPreview?: boolean isDragPreview?: boolean
isKeyboardSelected?: boolean
} }
export interface ClipDragData { export interface ClipDragData {
@ -356,6 +357,7 @@ export function ClipCard({
onMovePinnedUpDown = ({}) => {}, onMovePinnedUpDown = ({}) => {},
setShowDetailsItem = () => {}, setShowDetailsItem = () => {},
setSelectedItemId = () => {}, setSelectedItemId = () => {},
isKeyboardSelected = false,
}: ClipCardProps) { }: ClipCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { isNoteIconsEnabled, defaultNoteIconType } = useAtomValue(settingsStoreAtom) const { isNoteIconsEnabled, defaultNoteIconType } = useAtomValue(settingsStoreAtom)
@ -386,6 +388,7 @@ export function ClipCard({
const contextMenuButtonRef = useRef<HTMLButtonElement>(null) const contextMenuButtonRef = useRef<HTMLButtonElement>(null)
const contextMenuTriggerRef = useRef<HTMLDivElement>(null) const contextMenuTriggerRef = useRef<HTMLDivElement>(null)
const clipCardRef = useRef<HTMLDivElement>(null)
const canReorangeClips = const canReorangeClips =
(isShowOrganizeLayoutValue || canReorangeItems) && !isPinnedBoard (isShowOrganizeLayoutValue || canReorangeItems) && !isPinnedBoard
@ -461,7 +464,13 @@ export function ClipCard({
: '' : ''
} ` + } ` +
`${isPinnedBoard && !isShowOrganizeLayoutValue ? 'animate-in fade-in' : ''} ` + `${isPinnedBoard && !isShowOrganizeLayoutValue ? 'animate-in fade-in' : ''} ` +
`${isShowLinkedClip ? 'pulse-clip' : ''} `, `${isShowLinkedClip ? 'pulse-clip' : ''} ` +
`${
// This was already present from a previous manual edit by the user, ensuring it's correct.
isKeyboardSelected
? 'ring-2 outline-none scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 dark:bg-blue-950/80 bg-blue-50'
: ''
} `,
{ {
variants: { variants: {
dragging: { dragging: {
@ -542,6 +551,14 @@ export function ClipCard({
} }
}, [showClipFindKeyPressed.value]) }, [showClipFindKeyPressed.value])
useEffect(() => {
if (isKeyboardSelected && clipCardRef.current) {
clipCardRef.current.focus()
} else {
clipCardRef.current?.blur()
}
}, [isKeyboardSelected])
const isEditing = isClipNameEditing || isClipEdit const isEditing = isClipNameEditing || isClipEdit
const copyDisabled = const copyDisabled =
@ -655,7 +672,11 @@ export function ClipCard({
) )
)} )}
<Card <Card
ref={mergeRefs(canReorangeClips || isClipEdit ? setNodeRef : null)} ref={mergeRefs(
canReorangeClips || isClipEdit ? setNodeRef : null,
clipCardRef
)}
tabIndex={isKeyboardSelected ? 0 : -1}
style={ style={
isDragPreview isDragPreview
? { ? {

View File

@ -18,11 +18,18 @@ import { listen } from '@tauri-apps/api/event'
import { MainContainer } from '~/layout/Layout' import { MainContainer } from '~/layout/Layout'
import { import {
clipboardHistoryStoreAtom, clipboardHistoryStoreAtom,
collectionsStoreAtom,
createClipBoardItemId, createClipBoardItemId,
createClipHistoryItemIds, createClipHistoryItemIds,
createMenuItemFromHistoryId, createMenuItemFromHistoryId,
currentBoardIndex,
currentNavigationContext,
keyboardSelectedItemId as globalKeyboardSelectedItemId,
hoveringHistoryRowId, hoveringHistoryRowId,
isKeyAltPressed, isKeyAltPressed,
keyboardSelectedBoardId,
keyboardSelectedClipId,
keyboardSelectedItemId,
settingsStoreAtom, settingsStoreAtom,
showClipsMoveOnBoardId, showClipsMoveOnBoardId,
showHistoryDeleteConfirmationId, showHistoryDeleteConfirmationId,
@ -54,6 +61,12 @@ import { VariableSizeList } from 'react-window'
import InfiniteLoader from 'react-window-infinite-loader' import InfiniteLoader from 'react-window-infinite-loader'
import useResizeObserver from 'use-resize-observer' import useResizeObserver from 'use-resize-observer'
import {
buildNavigationOrder,
findCurrentNavigationIndex,
navigateToItem,
} from '~/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '~/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '~/components/ui/tabs'
import mergeRefs from '~/components/atoms/merge-refs' import mergeRefs from '~/components/atoms/merge-refs'
import ToolTip from '~/components/atoms/tooltip' import ToolTip from '~/components/atoms/tooltip'
@ -97,6 +110,7 @@ import {
useUnpinAllClipboardHistory, useUnpinAllClipboardHistory,
} from '~/hooks/queries/use-history-items' } from '~/hooks/queries/use-history-items'
import { useUpdateItemValueByHistoryId } from '~/hooks/queries/use-items' import { useUpdateItemValueByHistoryId } from '~/hooks/queries/use-items'
import { useCopyClipItem } from '~/hooks/use-copypaste-clip-item' // Added for clip copying
import { import {
useCopyPasteHistoryItem, useCopyPasteHistoryItem,
usePasteHistoryItem, usePasteHistoryItem,
@ -166,6 +180,7 @@ const loadPrismComponents = async () => {
export default function ClipboardHistoryPage() { export default function ClipboardHistoryPage() {
const [copiedItem, setCopiedItem, runSequenceCopy] = useCopyPasteHistoryItem({}) const [copiedItem, setCopiedItem, runSequenceCopy] = useCopyPasteHistoryItem({})
const [, handleCopyClipItem] = useCopyClipItem({}) // Destructure to get handleCopyClipItem
const [pastedItem, pastingCountDown, setPastedItem, runSequencePaste] = const [pastedItem, pastingCountDown, setPastedItem, runSequencePaste] =
usePasteHistoryItem({}) usePasteHistoryItem({})
@ -194,8 +209,8 @@ export default function ClipboardHistoryPage() {
const [selectedHistoryItems, setSelectedHistoryItems] = useState<UniqueIdentifier[]>([]) const [selectedHistoryItems, setSelectedHistoryItems] = useState<UniqueIdentifier[]>([])
const [showSelectHistoryItems, setShowSelectHistoryItems] = useState(false) const [showSelectHistoryItems, setShowSelectHistoryItems] = useState(false)
const [isDragPinnedHistory, setIsDragPinnedHistory] = useState(false) const [isDragPinnedHistory, setIsDragPinnedHistory] = useState(false)
const keyboardSelectedItemId = useSignal<UniqueIdentifier | null>(null) // Use global signal for keyboardSelectedItemId, aliased to avoid conflict if needed locally
const navigatedWithCtrl = useSignal(false) // const keyboardSelectedItemId = useSignal<UniqueIdentifier | null>(null); // Removed local signal
const { const {
isScrolling, isScrolling,
setIsScrolling, setIsScrolling,
@ -228,12 +243,13 @@ export default function ClipboardHistoryPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { clipItems, currentTab } = useAtomValue(collectionsStoreAtom)
const { themeDark } = useAtomValue(themeStoreAtom) const { themeDark } = useAtomValue(themeStoreAtom)
const { ref: pinnedPanelRef, height: pinnedPanelHeight } = useResizeObserver() const { ref: pinnedPanelRef, height: pinnedPanelHeight } = useResizeObserver()
const isPinnedPanelHovering = useSignal(false) const isPinnedPanelHovering = useSignal(false)
const isPinnedPanelKeepOpen = useSignal(false) const isPinnedPanelKeepOpen = useSignal(false)
const isCtrlReleased = useSignal(false)
const { showConfirmation, hoveringHistoryIdDelete } = useDeleteConfirmationTimer({ const { showConfirmation, hoveringHistoryIdDelete } = useDeleteConfirmationTimer({
hoveringHistoryRowId, hoveringHistoryRowId,
@ -390,64 +406,292 @@ export default function ClipboardHistoryPage() {
) )
useHotkeys( useHotkeys(
['ctrl+enter', 'meta+enter'], ['enter'],
e => { async e => {
e.preventDefault() e.preventDefault()
const itemToCopy = keyboardSelectedItemId.value if (currentNavigationContext.value === 'board' && keyboardSelectedClipId.value) {
? keyboardSelectedItemId.value try {
: clipboardHistory[0]?.historyId currentNavigationContext.value = null
if (itemToCopy) { globalKeyboardSelectedItemId.value = null
setCopiedItem(itemToCopy) keyboardSelectedBoardId.value = null
await handleCopyClipItem(keyboardSelectedClipId.value)
keyboardSelectedClipId.value = null
} catch (error) {
console.error('Failed to copy clip item from hotkey', error)
} }
currentNavigationContext.value = null
globalKeyboardSelectedItemId.value = null
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
} else if (
(currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null) &&
globalKeyboardSelectedItemId.value
) {
setCopiedItem(globalKeyboardSelectedItemId.value)
} else if (
(currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null) &&
clipboardHistory.length > 0
) {
setCopiedItem(clipboardHistory[0]?.historyId)
}
currentNavigationContext.value = null
globalKeyboardSelectedItemId.value = null
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
}, },
{ enableOnFormTags: ['input'] } { enableOnFormTags: ['input'] }
) )
const currentNavigationContextValue = useMemo(
() => currentNavigationContext.value,
[currentNavigationContext.value, currentTab]
)
useHotkeys( useHotkeys(
['ctrl+arrowdown', 'meta+arrowdown'], ['arrowdown'],
e => {
e.preventDefault()
if (keyboardSelectedBoardId.value) {
const clipsOnBoard = clipItems
.filter(
item =>
item.isClip &&
item.parentId === keyboardSelectedBoardId.value &&
item.tabId === currentTab
)
.sort((a, b) => a.orderNumber - b.orderNumber)
if (clipsOnBoard.length === 0) return
let currentIndex = clipsOnBoard.findIndex(
clip => clip.itemId === keyboardSelectedClipId.value
)
if (currentIndex === -1 && clipsOnBoard.length > 0) {
keyboardSelectedClipId.value = clipsOnBoard[0].itemId
} else {
currentIndex = (currentIndex + 1) % clipsOnBoard.length
keyboardSelectedClipId.value = clipsOnBoard[currentIndex].itemId
}
}
},
{ enabled: currentNavigationContextValue === 'board', enableOnFormTags: ['input'] }
)
useHotkeys(
['arrowup'],
e => {
e.preventDefault()
if (keyboardSelectedBoardId.value) {
const clipsOnBoard = clipItems
.filter(
item =>
item.isClip &&
item.parentId === keyboardSelectedBoardId.value &&
item.tabId === currentTab
)
.sort((a, b) => a.orderNumber - b.orderNumber)
if (clipsOnBoard.length === 0) return
let currentIndex = clipsOnBoard.findIndex(
clip => clip.itemId === keyboardSelectedClipId.value
)
if (currentIndex === -1 && clipsOnBoard.length > 0) {
keyboardSelectedClipId.value = clipsOnBoard[clipsOnBoard.length - 1].itemId
} else {
currentIndex = (currentIndex - 1 + clipsOnBoard.length) % clipsOnBoard.length
keyboardSelectedClipId.value = clipsOnBoard[currentIndex].itemId
}
}
},
{ enabled: currentNavigationContextValue === 'board', enableOnFormTags: ['input'] }
)
useHotkeys(
['tab'],
e => {
e.preventDefault()
e.stopPropagation()
if (
currentNavigationContextValue === 'history' ||
currentNavigationContextValue === null
) {
currentNavigationContext.value = 'board'
keyboardSelectedItemId.value = null
const navigationOrder = buildNavigationOrder(clipItems, currentTab)
if (navigationOrder.length > 1) {
const firstBoardItem = navigationOrder[1]
navigateToItem(firstBoardItem, clipItems, currentTab)
}
return
}
if (currentNavigationContextValue === 'board') {
const navigationOrder = buildNavigationOrder(clipItems, currentTab)
if (navigationOrder.length <= 1) return
const currentIndex = findCurrentNavigationIndex(navigationOrder)
if (currentIndex === navigationOrder.length - 1) {
currentNavigationContext.value = 'history'
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
return
}
const nextIndex = currentIndex + 1
const nextItem = navigationOrder[nextIndex]
navigateToItem(nextItem, clipItems, currentTab)
}
},
{
enabled: true, // Always enabled, we check context inside the handler
enableOnFormTags: ['input'],
}
)
useHotkeys(
['shift+tab'],
e => {
e.preventDefault()
e.stopPropagation()
if (
currentNavigationContextValue === 'history' ||
currentNavigationContextValue === null
) {
currentNavigationContext.value = 'board'
keyboardSelectedItemId.value = null
const navigationOrder = buildNavigationOrder(clipItems, currentTab)
if (navigationOrder.length > 1) {
const firstBoardItem = navigationOrder[1]
navigateToItem(firstBoardItem, clipItems, currentTab)
}
return
}
if (currentNavigationContextValue === 'board') {
const navigationOrder = buildNavigationOrder(clipItems, currentTab)
if (navigationOrder.length <= 1) return
const currentIndex = findCurrentNavigationIndex(navigationOrder)
if (currentIndex === navigationOrder.length + 1) {
currentNavigationContext.value = 'history'
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
return
}
const nextIndex = currentIndex - 1
const nextItem = navigationOrder[nextIndex]
navigateToItem(nextItem, clipItems, currentTab)
}
},
{
enabled: true,
enableOnFormTags: ['input'],
}
)
useHotkeys(
'esc',
() => {
currentNavigationContext.value = null
globalKeyboardSelectedItemId.value = null
keyboardSelectedItemId.value = null
hoveringHistoryRowId.value = null
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
},
{
enableOnFormTags: ['input'],
}
)
useHotkeys(
['arrowdown'],
e => { e => {
e.preventDefault() e.preventDefault()
const currentItemIndex = clipboardHistory.findIndex( const currentItemIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value item => item.historyId === globalKeyboardSelectedItemId.value
) )
const nextItem = clipboardHistory[currentItemIndex + 1] const nextItem = clipboardHistory[currentItemIndex + 1]
if (nextItem) { if (nextItem) {
keyboardSelectedItemId.value = nextItem.historyId globalKeyboardSelectedItemId.value = nextItem.historyId
} }
}, },
{ enableOnFormTags: ['input'] } {
enableOnFormTags: ['input'],
enabled:
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null,
}
) )
useHotkeys( useHotkeys(
['ctrl+arrowup', 'meta+arrowup'], ['arrowup'],
e => { e => {
e.preventDefault() e.preventDefault()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
const currentItemIndex = clipboardHistory.findIndex( const currentItemIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value item => item.historyId === globalKeyboardSelectedItemId.value
) )
const prevItem = clipboardHistory[currentItemIndex - 1] const prevItem = clipboardHistory[currentItemIndex - 1]
if (prevItem) { if (prevItem) {
keyboardSelectedItemId.value = prevItem.historyId globalKeyboardSelectedItemId.value = prevItem.historyId
}
} }
}, },
{ enableOnFormTags: ['input'] } {
enableOnFormTags: ['input'],
enabled:
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null,
}
) )
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') { if (e.key === 'Control' || e.key === 'Meta') {
if (isCtrlReleased.value) { if (
keyboardSelectedItemId.value = clipboardHistory[0]?.historyId currentNavigationContext.value === null ||
isCtrlReleased.value = false currentNavigationContext.value === 'history'
) {
currentNavigationContext.value = 'history'
if (!globalKeyboardSelectedItemId.value && clipboardHistory.length > 0) {
globalKeyboardSelectedItemId.value = clipboardHistory[0]?.historyId
}
} else if (currentNavigationContext.value === 'board') {
globalKeyboardSelectedItemId.value = null // Ensure no history item is selected
} }
navigatedWithCtrl.value = true
} }
} }
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') { if (e.key === 'Control' || e.key === 'Meta') {
isCtrlReleased.value = true // Reset all navigation states
keyboardSelectedItemId.value = null currentNavigationContext.value = null
navigatedWithCtrl.value = false globalKeyboardSelectedItemId.value = null
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0
hoveringHistoryRowId.value = null
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
@ -456,13 +700,45 @@ export default function ClipboardHistoryPage() {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp) window.removeEventListener('keyup', handleKeyUp)
} }
}, [clipboardHistory]) }, [
clipboardHistory,
currentNavigationContext,
globalKeyboardSelectedItemId,
keyboardSelectedBoardId,
keyboardSelectedClipId,
currentBoardIndex,
]) // Added dependencies
useEffect(() => { useEffect(() => {
if (keyboardSelectedItemId.value) { if (
hoveringHistoryRowId.value = keyboardSelectedItemId.value currentNavigationContext.value === 'history' &&
!globalKeyboardSelectedItemId.value &&
clipboardHistory.length > 0
) {
globalKeyboardSelectedItemId.value = clipboardHistory[0]?.historyId
} }
}, [keyboardSelectedItemId.value]) }, [
currentNavigationContext.value,
globalKeyboardSelectedItemId.value,
clipboardHistory,
])
useEffect(() => {
if (
globalKeyboardSelectedItemId.value &&
(currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null)
) {
hoveringHistoryRowId.value = globalKeyboardSelectedItemId.value
} else if (!globalKeyboardSelectedItemId.value && !keyboardSelectedClipId.value) {
// Clear hover if no item is selected in any context
hoveringHistoryRowId.value = null
}
}, [
globalKeyboardSelectedItemId.value,
currentNavigationContext.value,
keyboardSelectedClipId.value,
])
useEffect(() => { useEffect(() => {
const listenToClipboardUnlisten = listen( const listenToClipboardUnlisten = listen(
@ -497,18 +773,6 @@ export default function ClipboardHistoryPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (isCtrlReleased.value) {
hoveringHistoryRowId.value = null
keyboardSelectedItemId.value = null
}
}, [isCtrlReleased.value])
useEffect(() => {
if (copiedItem || pastedItem) {
isCtrlReleased.value = true
keyboardSelectedItemId.value = null
navigatedWithCtrl.value = false
}
if (copiedItem && selectedHistoryItems.includes(copiedItem)) { if (copiedItem && selectedHistoryItems.includes(copiedItem)) {
setSelectedHistoryItems(prev => prev.filter(item => item !== copiedItem)) setSelectedHistoryItems(prev => prev.filter(item => item !== copiedItem))
} }
@ -1769,7 +2033,12 @@ export default function ClipboardHistoryPage() {
} }
isPasted={historyId === pastedItemValue} isPasted={historyId === pastedItemValue}
isKeyboardSelected={ isKeyboardSelected={
historyId === keyboardSelectedItemId.value (currentNavigationContext.value ===
'history' ||
currentNavigationContext.value ===
null) &&
historyId ===
globalKeyboardSelectedItemId.value
} }
isCopied={historyId === copiedItemValue} isCopied={historyId === copiedItemValue}
isSaved={historyId === savingItem} isSaved={historyId === savingItem}

View File

@ -98,6 +98,13 @@ export const creatingMenuItemCurrentMenuId = signal(false)
export const isNavBarHovering = signal(false) export const isNavBarHovering = signal(false)
// Keyboard Navigation Signals for Board/History Context
export const currentNavigationContext = signal<'history' | 'board' | null>(null)
export const currentBoardIndex = signal<number>(0)
export const keyboardSelectedItemId = signal<UniqueIdentifier | null>(null)
export const keyboardSelectedClipId = signal<UniqueIdentifier | null>(null)
export const keyboardSelectedBoardId = signal<UniqueIdentifier | null>(null)
export function closeEdit() { export function closeEdit() {
showDeleteClipConfirmationId.value = null showDeleteClipConfirmationId.value = null
editBoardItemId.value = null editBoardItemId.value = null