Skip to content
Closed
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: 4 additions & 7 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,12 +19,9 @@ 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 handleKeyDown = useKeyboardActivation<HTMLButtonElement>(() => {
onToggle(library);
});

return (
<button
Expand Down
15 changes: 5 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 type { ChangeEvent } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useKeyboardActivation } from '../../hooks/useKeyboardActivation';

interface SearchInputProps {
searchQuery: string;
Expand Down Expand Up @@ -32,15 +33,9 @@ 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 handleKeyDown = useKeyboardActivation<HTMLButtonElement>(() => {
handleClear();
});

return (
<div
Expand Down
18 changes: 11 additions & 7 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,15 @@ 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 handleRemoveKeyDown = useKeyboardActivation<HTMLButtonElement>((event) => {
const library = event.currentTarget.dataset.library as Library | undefined;

if (!library) {
return;
}
};

unselectLibrary(library);
});

if (selectedLibraries.length === 0) {
return null;
Expand All @@ -42,8 +45,9 @@ export const SelectedRules: React.FC<SelectedRulesProps> = React.memo(
>
<span>{library}</span>
<button
data-library={library}
onClick={() => unselectLibrary(library)}
onKeyDown={(e) => handleKeyDown(e, library)}
onKeyDown={handleRemoveKeyDown}
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
45 changes: 31 additions & 14 deletions src/components/rule-collections/CollectionListEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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,13 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
onClick?.(collection);
};

const openDeleteDialog = () => {
setIsDeleteDialogOpen(true);
};

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

const handleSaveClick = async (e: React.MouseEvent) => {
Expand Down Expand Up @@ -60,11 +65,33 @@ export const CollectionListEntry: React.FC<CollectionListEntryProps> = ({
}
};

const openEditDialog = () => {
setIsEditDialogOpen(true);
};

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

const handleEditKeyDown = useKeyboardActivation<HTMLDivElement>(
() => {
openEditDialog();
},
{
stopPropagation: true,
},
);

const handleDeleteKeyDown = useKeyboardActivation<HTMLDivElement>(
() => {
openDeleteDialog();
},
{
stopPropagation: true,
},
);

const handleEditSave = async (name: string, description: string) => {
try {
await updateCollection(collection.id, { ...collection, name, description });
Expand Down Expand Up @@ -101,12 +128,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 +139,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
11 changes: 4 additions & 7 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 { 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,9 @@ 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 handleKeyDown = useKeyboardActivation<HTMLDivElement>(() => {
onClick?.();
});

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

type ActivationKey = 'Enter' | ' ' | 'Space' | 'Spacebar';

interface UseKeyboardActivationOptions {
keys?: ReadonlyArray<ActivationKey>;
preventDefault?: boolean;
stopPropagation?: boolean;
}

const DEFAULT_ACTIVATION_KEYS: ReadonlyArray<ActivationKey> = ['Enter', ' ', 'Space', 'Spacebar'];

export const useKeyboardActivation = <T extends HTMLElement>(
onActivate: (event: KeyboardEvent<T>) => void,
options: UseKeyboardActivationOptions = {},
): KeyboardEventHandler<T> => {
const {
keys = DEFAULT_ACTIVATION_KEYS,
preventDefault = true,
stopPropagation = false,
} = options;

const keySet = useMemo(() => new Set(keys), [keys]);

return useCallback<KeyboardEventHandler<T>>(
(event) => {
if (!keySet.has(event.key as ActivationKey)) {
return;
}

if (preventDefault) {
event.preventDefault();
}

if (stopPropagation) {
event.stopPropagation();
}

onActivate(event);
},
[keySet, onActivate, preventDefault, stopPropagation],
);
};

export default useKeyboardActivation;
76 changes: 76 additions & 0 deletions tests/hooks/useKeyboardActivation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { renderHook } from '@testing-library/react';
import type { KeyboardEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';

import useKeyboardActivation from '@/hooks/useKeyboardActivation';

type EventFactoryResult = {
event: KeyboardEvent<HTMLElement>;
preventDefault: ReturnType<typeof vi.fn>;
stopPropagation: ReturnType<typeof vi.fn>;
};

const createKeyboardEvent = (key: string): EventFactoryResult => {
const preventDefault = vi.fn();
const stopPropagation = vi.fn();

const event = {
key,
preventDefault,
stopPropagation,
} as unknown as KeyboardEvent<HTMLElement>;

return { event, preventDefault, stopPropagation };
};

describe('useKeyboardActivation', () => {
it('invokes the provided callback when an activation key is pressed', () => {
const onActivate = vi.fn();
const { result } = renderHook(() => useKeyboardActivation(onActivate));
const { event, preventDefault, stopPropagation } = createKeyboardEvent('Enter');

result.current(event);

expect(onActivate).toHaveBeenCalledWith(event);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(stopPropagation).not.toHaveBeenCalled();
});

it('ignores keys that are not configured as activation keys', () => {
const onActivate = vi.fn();
const { result } = renderHook(() => useKeyboardActivation(onActivate));
const { event, preventDefault, stopPropagation } = createKeyboardEvent('Escape');

result.current(event);

expect(onActivate).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
expect(stopPropagation).not.toHaveBeenCalled();
});

it('respects the preventDefault option', () => {
const onActivate = vi.fn();
const { result } = renderHook(() =>
useKeyboardActivation(onActivate, { preventDefault: false }),
);
const { event, preventDefault } = createKeyboardEvent('Enter');

result.current(event);

expect(onActivate).toHaveBeenCalledWith(event);
expect(preventDefault).not.toHaveBeenCalled();
});

it('calls stopPropagation when configured', () => {
const onActivate = vi.fn();
const { result } = renderHook(() =>
useKeyboardActivation(onActivate, { stopPropagation: true }),
);
const { event, stopPropagation } = createKeyboardEvent('Enter');

result.current(event);

expect(onActivate).toHaveBeenCalledWith(event);
expect(stopPropagation).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion tests/setup/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/vitest';

declare module 'vitest' {
// interface Assertion<T = any> extends jest.Matchers<void, T> {}
Expand Down
2 changes: 1 addition & 1 deletion tests/setup/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { setupServer } from 'msw/node';
import { cleanup } from '@testing-library/react';
Expand Down