Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 3 additions & 8 deletions src/components/rule-builder/LibraryItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Check } from 'lucide-react';
import type { KeyboardEvent } from 'react';
import React from 'react';
import { Library } from '../../data/dictionaries';
import { getLibraryTranslation } from '../../i18n/translations';
import type { LayerType } from '../../styles/theme';
import { getLayerClasses } from '../../styles/theme';
import { useAccordionContentOpen } from '../ui/Accordion';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

interface LibraryItemProps {
library: Library;
Expand All @@ -19,20 +19,15 @@ export const LibraryItem: React.FC<LibraryItemProps> = React.memo(
const isParentAccordionOpen = useAccordionContentOpen();
const itemClasses = getLayerClasses.libraryItem(layerType, isSelected);

const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle(library);
}
};
const createKeyboardActivationHandler = useKeyboardActivation<HTMLButtonElement>();

return (
<button
className={`flex gap-2 justify-between items-center px-3 py-2 w-full text-sm rounded-md transition-all duration-150 cursor-pointer focus-visible:ring-2 focus-visible:ring-blue-500 ${getLayerClasses.focusRing(
layerType,
)} ${itemClasses}`}
onClick={() => onToggle(library)}
onKeyDown={handleKeyDown}
onKeyDown={createKeyboardActivationHandler(() => onToggle(library))}
role="checkbox"
aria-checked={isSelected}
tabIndex={isParentAccordionOpen ? 0 : -1}
Expand Down
17 changes: 7 additions & 10 deletions src/components/rule-builder/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Search, X } from 'lucide-react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import type { ChangeEvent } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

interface SearchInputProps {
searchQuery: string;
Expand Down Expand Up @@ -32,14 +33,10 @@ export const SearchInput: React.FC<SearchInputProps> = ({
inputRef.current?.focus();
}, [setSearchQuery]);

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClear();
}
},
[handleClear],
const createKeyboardActivationHandler = useKeyboardActivation<HTMLButtonElement>();
const handleKeyDown = useMemo(
() => createKeyboardActivationHandler(handleClear),
[createKeyboardActivationHandler, handleClear],
);

return (
Expand Down
11 changes: 3 additions & 8 deletions src/components/rule-builder/SelectedRules.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { X } from 'lucide-react';
import React from 'react';
import type { KeyboardEvent } from 'react';
import { Library } from '../../data/dictionaries';
import type { LayerType } from '../../styles/theme';
import { getLayerClasses } from '../../styles/theme';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

interface SelectedRulesProps {
selectedLibraries: Library[];
Expand All @@ -13,12 +13,7 @@ interface SelectedRulesProps {

export const SelectedRules: React.FC<SelectedRulesProps> = React.memo(
({ selectedLibraries, unselectLibrary, getLibraryLayerType }) => {
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, library: Library) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
unselectLibrary(library);
}
};
const createKeyboardActivationHandler = useKeyboardActivation<HTMLButtonElement>();

if (selectedLibraries.length === 0) {
return null;
Expand All @@ -43,7 +38,7 @@ export const SelectedRules: React.FC<SelectedRulesProps> = React.memo(
<span>{library}</span>
<button
onClick={() => unselectLibrary(library)}
onKeyDown={(e) => handleKeyDown(e, library)}
onKeyDown={createKeyboardActivationHandler(() => unselectLibrary(library))}
className={`text-white opacity-70 cursor-pointer hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-1 ${getLayerClasses.focusRing(layerType)}`}
aria-label={`Remove ${library} rule`}
tabIndex={0}
Expand Down
41 changes: 26 additions & 15 deletions src/components/rule-collections/CollectionListEntry.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Book, Trash2, Pencil, Save } from 'lucide-react';
import type { Collection } from '../../store/collectionsStore';
import { useCollectionsStore } from '../../store/collectionsStore';
import DeletionDialog from './DeletionDialog';
import SaveCollectionDialog from './SaveCollectionDialog';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

interface CollectionListEntryProps {
collection: Collection;
Expand All @@ -29,9 +30,17 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
onClick?.(collection);
};

const openDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(true);
}, []);

const openEditDialog = useCallback(() => {
setIsEditDialogOpen(true);
}, []);

const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
openDeleteDialog();
};

const handleSaveClick = async (e: React.MouseEvent) => {
Expand Down Expand Up @@ -62,7 +71,7 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({

const handleEditClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsEditDialogOpen(true);
openEditDialog();
};

const handleEditSave = async (name: string, description: string) => {
Expand All @@ -74,6 +83,18 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
}
};

const createIconKeyboardHandler = useKeyboardActivation<HTMLDivElement>({
stopPropagation: true,
});
const handleEditKeyDown = useMemo(
() => createIconKeyboardHandler(openEditDialog),
[createIconKeyboardHandler, openEditDialog],
);
const handleDeleteKeyDown = useMemo(
() => createIconKeyboardHandler(openDeleteDialog),
[createIconKeyboardHandler, openDeleteDialog],
);

return (
<>
<button
Expand Down Expand Up @@ -101,12 +122,7 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
tabIndex={0}
className="p-1.5 rounded-md text-gray-400 hover:text-blue-400 hover:bg-gray-700/50 opacity-0 group-hover:opacity-100 transition-colors cursor-pointer"
aria-label={`Edit ${collection.name}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleEditClick(e as unknown as React.MouseEvent);
}
}}
onKeyDown={handleEditKeyDown}
>
<Pencil className="size-4" />
</div>
Expand All @@ -117,12 +133,7 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
tabIndex={0}
className="p-1.5 rounded-md text-gray-400 hover:text-red-400 hover:bg-gray-700/50 opacity-0 group-hover:opacity-100 cursor-pointer transition-colors"
aria-label={`Delete ${collection.name}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDeleteClick(e as unknown as React.MouseEvent);
}
}}
onKeyDown={handleDeleteKeyDown}
>
<Trash2 className="size-4" />
</div>
Expand Down
15 changes: 7 additions & 8 deletions src/components/ui/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChevronDown } from 'lucide-react';
import type { KeyboardEvent } from 'react';
import React, { createContext, useContext } from 'react';
import React, { createContext, useContext, useMemo } from 'react';
import { transitions } from '../../styles/theme';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

// Create a context to track accordion open state
const AccordionContentContext = createContext<boolean>(false);
Expand Down Expand Up @@ -67,12 +67,11 @@ export const AccordionTrigger: React.FC<AccordionTriggerProps> = React.memo(
// Only nested triggers should check parent state
const shouldBeFocusable = isRoot || isParentAccordionOpen;

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
};
const createKeyboardActivationHandler = useKeyboardActivation<HTMLDivElement>();
const handleKeyDown = useMemo(
() => createKeyboardActivationHandler(() => onClick?.()),
[createKeyboardActivationHandler, onClick],
);

return (
<div
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/useKeyboardActivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useCallback, useMemo } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';

const DEFAULT_KEYS = ['Enter', ' '] as const;

type KeyboardActivationEvent<T extends HTMLElement> = ReactKeyboardEvent<T>;
type KeyboardActivationHandler<T extends HTMLElement> = (event: KeyboardActivationEvent<T>) => void;

type KeyboardActivationFactory<T extends HTMLElement> = (
handler: KeyboardActivationHandler<T>,
) => KeyboardActivationHandler<T>;

interface UseKeyboardActivationOptions {
keys?: readonly string[];
preventDefault?: boolean;
stopPropagation?: boolean;
}

/**
* Shared helper to support activating interactive elements via keyboard input.
* Handles common Enter/Space detection and default prevention so components only
* need to supply their activation logic.
*/
export function useKeyboardActivation<T extends HTMLElement>(
options: UseKeyboardActivationOptions = {},
): KeyboardActivationFactory<T> {
const { keys = DEFAULT_KEYS, preventDefault = true, stopPropagation = false } = options;

const keysSignature = useMemo(() => keys.join(','), [keys]);
const keySet = useMemo(() => new Set(keys), [keysSignature]);

return useCallback<KeyboardActivationFactory<T>>(
(handler) => {
return (event) => {
if (keySet.has(event.key)) {
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
handler(event);
}
};
},
[keySet, preventDefault, stopPropagation],
);
}

export default useKeyboardActivation;
8 changes: 8 additions & 0 deletions tests/setup/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ReactElement } from 'react';
import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react';

const customRender = (ui: ReactElement, options?: RenderOptions) => render(ui, options);

export * from '@testing-library/react';
export { customRender as render };
101 changes: 101 additions & 0 deletions tests/unit/hooks/useKeyboardActivation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { fireEvent, render } from '../../setup/test-utils';
import { describe, expect, it, vi } from 'vitest';
import type { KeyboardEventHandler } from 'react';
import { useKeyboardActivation } from '@/hooks/useKeyboardActivation';

const TARGET_TEST_ID = 'keyboard-activation-target';
const PARENT_TEST_ID = 'keyboard-activation-parent';

type KeyboardActivationOptions = Parameters<typeof useKeyboardActivation<HTMLDivElement>>[0];
type KeyboardActivationHandler = Parameters<ReturnType<typeof useKeyboardActivation<HTMLDivElement>>>[0];

interface TestComponentProps {
onActivate: KeyboardActivationHandler;
options?: KeyboardActivationOptions;
parentHandler?: KeyboardEventHandler<HTMLDivElement>;
}

function TestComponent({ onActivate, options, parentHandler }: TestComponentProps) {
const createActivationHandler = useKeyboardActivation<HTMLDivElement>(options);
const handleKeyDown = createActivationHandler(onActivate);

return (
<div data-testid={PARENT_TEST_ID} onKeyDown={parentHandler}>
<div data-testid={TARGET_TEST_ID} onKeyDown={handleKeyDown} tabIndex={0}>
Target
</div>
</div>
);
}

describe('useKeyboardActivation', () => {
it('invokes the handler for default activation keys and prevents the default action', () => {
const handler = vi.fn();
const { getByTestId } = render(<TestComponent onActivate={handler} />);
const target = getByTestId(TARGET_TEST_ID);

fireEvent.keyDown(target, { key: 'Enter' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].key).toBe('Enter');
expect(handler.mock.calls[0][0].defaultPrevented).toBe(true);

fireEvent.keyDown(target, { key: 'Escape' });
expect(handler).toHaveBeenCalledTimes(1);

fireEvent.keyDown(target, { key: ' ' });
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[1][0].key).toBe(' ');
expect(handler.mock.calls[1][0].defaultPrevented).toBe(true);
});

it('supports custom activation keys', () => {
const handler = vi.fn();
const { getByTestId } = render(
<TestComponent onActivate={handler} options={{ keys: ['Escape'] }} />,
);
const target = getByTestId(TARGET_TEST_ID);

fireEvent.keyDown(target, { key: 'Enter' });
expect(handler).not.toHaveBeenCalled();

fireEvent.keyDown(target, { key: 'Escape' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].defaultPrevented).toBe(true);
});

it('respects the preventDefault option', () => {
const handler = vi.fn();
const { getByTestId } = render(
<TestComponent onActivate={handler} options={{ preventDefault: false }} />,
);
const target = getByTestId(TARGET_TEST_ID);

fireEvent.keyDown(target, { key: 'Enter' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].defaultPrevented).toBe(false);
});

it('controls event propagation based on configuration', () => {
const handler = vi.fn();
const parentHandler = vi.fn();
const { getByTestId, rerender } = render(
<TestComponent onActivate={handler} parentHandler={parentHandler} />,
);
const target = getByTestId(TARGET_TEST_ID);

fireEvent.keyDown(target, { key: 'Enter' });
expect(parentHandler).toHaveBeenCalledTimes(1);

rerender(
<TestComponent
onActivate={handler}
options={{ stopPropagation: true }}
parentHandler={parentHandler}
/>,
);
const updatedTarget = getByTestId(TARGET_TEST_ID);

fireEvent.keyDown(updatedTarget, { key: 'Enter' });
expect(parentHandler).toHaveBeenCalledTimes(1);
});
});