diff --git a/src/components/DateRangePicker/DateRangePicker.stories.tsx b/src/components/DateRangePicker/DateRangePicker.stories.tsx
index 06f0a8a8d..48a69bd29 100644
--- a/src/components/DateRangePicker/DateRangePicker.stories.tsx
+++ b/src/components/DateRangePicker/DateRangePicker.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
-import { DateRangePicker, DateRangeFilter, DateRange } from './DateRangePicker';
+
+import { DateRange, DateRangeFilter, DateRangePicker } from './DateRangePicker';
// ============================================================================
// DateRangePicker Stories
@@ -364,7 +365,7 @@ export const FilterDropdown: StoryObj
= {
activePreset={preset}
/>
{preset && (
-
+
Selected: {preset}
{range.start &&
` (${range.start.toLocaleDateString()} - ${range.end?.toLocaleDateString()})`}
diff --git a/src/components/DocumentScanner/WebcamModal.tsx b/src/components/DocumentScanner/WebcamModal.tsx
index d00f592fc..dc0e96b0b 100644
--- a/src/components/DocumentScanner/WebcamModal.tsx
+++ b/src/components/DocumentScanner/WebcamModal.tsx
@@ -268,6 +268,8 @@ export function WebcamModal({
}
}, [isReady, videoRef]);
+ const { startDetection, stopDetection } = detection;
+
// Start camera and detection when modal opens
React.useEffect(() => {
if (open && permission !== 'denied' && permission !== 'unavailable') {
@@ -278,7 +280,7 @@ export function WebcamModal({
} else if (!open) {
hasStartedRef.current = false;
stopCamera();
- detection.stopDetection();
+ stopDetection();
// Reset captured state
setCapturedFile(null);
setPreviewUrl((prev) => {
@@ -288,19 +290,17 @@ export function WebcamModal({
return null;
});
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, permission]);
+ }, [open, permission, startCamera, stopCamera, stopDetection]);
// Start detection when camera is ready
React.useEffect(() => {
if (isReady && autoDetectEnabled && !capturedFile) {
- detection.startDetection();
+ startDetection();
}
return () => {
- detection.stopDetection();
+ stopDetection();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isReady, autoDetectEnabled, capturedFile]);
+ }, [isReady, autoDetectEnabled, capturedFile, startDetection, stopDetection]);
const handleCapture = React.useCallback(() => {
const file = capturePhoto();
diff --git a/src/components/DocumentScanner/useDocumentDetection.ts b/src/components/DocumentScanner/useDocumentDetection.ts
index f62edd684..25a7a80a2 100644
--- a/src/components/DocumentScanner/useDocumentDetection.ts
+++ b/src/components/DocumentScanner/useDocumentDetection.ts
@@ -258,22 +258,18 @@ export function useDocumentDetection(
stopDetection: () => void;
resetDetection: () => void;
} {
- const mergedConfig = useMemo(
- () => ({ ...DEFAULT_CONFIG, ...config }),
- // Intentionally depend on individual properties to avoid unnecessary re-renders
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- config.minFocusScore,
- config.minBrightness,
- config.maxBrightness,
- config.minDocumentCoverage,
- config.maxDocumentCoverage,
- config.stabilityDuration,
- config.captureCountdown,
- config.detectionFps,
- config.enableAutoCapture,
- ]
- );
+ const { minFocusScore, minBrightness, maxBrightness, minDocumentCoverage, maxDocumentCoverage, stabilityDuration, captureCountdown, detectionFps, enableAutoCapture } = config;
+ const mergedConfig = useMemo(() => ({
+ minFocusScore: minFocusScore ?? DEFAULT_CONFIG.minFocusScore,
+ minBrightness: minBrightness ?? DEFAULT_CONFIG.minBrightness,
+ maxBrightness: maxBrightness ?? DEFAULT_CONFIG.maxBrightness,
+ minDocumentCoverage: minDocumentCoverage ?? DEFAULT_CONFIG.minDocumentCoverage,
+ maxDocumentCoverage: maxDocumentCoverage ?? DEFAULT_CONFIG.maxDocumentCoverage,
+ stabilityDuration: stabilityDuration ?? DEFAULT_CONFIG.stabilityDuration,
+ captureCountdown: captureCountdown ?? DEFAULT_CONFIG.captureCountdown,
+ detectionFps: detectionFps ?? DEFAULT_CONFIG.detectionFps,
+ enableAutoCapture: enableAutoCapture ?? DEFAULT_CONFIG.enableAutoCapture,
+ }), [minFocusScore, minBrightness, maxBrightness, minDocumentCoverage, maxDocumentCoverage, stabilityDuration, captureCountdown, detectionFps, enableAutoCapture]);
const [state, setState] = useState(INITIAL_STATE);
diff --git a/src/components/DocumentScanner/useFileUpload.ts b/src/components/DocumentScanner/useFileUpload.ts
index 40a26dcb3..ede2d33d9 100644
--- a/src/components/DocumentScanner/useFileUpload.ts
+++ b/src/components/DocumentScanner/useFileUpload.ts
@@ -107,13 +107,14 @@ export function useFileUpload({
}: UseFileUploadOptions = {}): UseFileUploadReturn {
const [files, setFiles] = React.useState([]);
- // Cleanup preview URLs on unmount
+ const filesRef = React.useRef(files);
+ React.useEffect(() => { filesRef.current = files; }, [files]);
+
+ // Cleanup the latest previews, including files added after mount.
React.useEffect(() => {
return () => {
- files.forEach((f) => URL.revokeObjectURL(f.previewUrl));
+ filesRef.current.forEach((f) => URL.revokeObjectURL(f.previewUrl));
};
- // Only run on unmount
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addFiles = React.useCallback(
@@ -144,6 +145,7 @@ export function useFileUpload({
// If not multiple, revoke old URLs and replace
if (!multiple) {
prev.forEach((f) => URL.revokeObjectURL(f.previewUrl));
+ newFiles.slice(1).forEach((f) => URL.revokeObjectURL(f.previewUrl));
return newFiles.slice(0, 1);
}
return [...prev, ...newFiles];
diff --git a/src/components/Dropdown/Dropdown.test.tsx b/src/components/Dropdown/Dropdown.test.tsx
index a5cbd0d06..4c9904479 100644
--- a/src/components/Dropdown/Dropdown.test.tsx
+++ b/src/components/Dropdown/Dropdown.test.tsx
@@ -13,6 +13,16 @@ import {
} from './Dropdown';
describe('Dropdown', () => {
+ it('closes an action menu when an item opens another interface', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ renderWithTheme(Actions}>Edit role);
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Edit role' }));
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ });
+
it('renders a search input when searchable is enabled', async () => {
const user = userEvent.setup();
diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx
index 5147d295b..8a410b4b4 100644
--- a/src/components/Dropdown/Dropdown.tsx
+++ b/src/components/Dropdown/Dropdown.tsx
@@ -60,6 +60,7 @@ export interface DropdownProps {
const placementOffset = 8; // matches the previous mt-2/mb-2 gap
interface DropdownContextValue {
+ close: () => void;
multiSelect: boolean;
selectedValues: string[];
toggleSelectedValue: (value: string) => void;
@@ -431,8 +432,9 @@ function Dropdown({
selectedValues,
toggleSelectedValue,
registerOutsideRef,
+ close: handleClose,
}),
- [multiSelect, selectedValues, toggleSelectedValue, registerOutsideRef]
+ [multiSelect, selectedValues, toggleSelectedValue, registerOutsideRef, handleClose]
);
useEscapeKey(handleClose, isOpen);
@@ -819,6 +821,7 @@ const DropdownItem = React.forwardRef(
onCheckedChange?.(!isChecked);
}
+ if (!disabled && !isMultiSelectItem) dropdownContext?.close();
onClick?.(event);
},
[
diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts
index eb8a1994a..b0201e74c 100644
--- a/src/components/Dropdown/index.ts
+++ b/src/components/Dropdown/index.ts
@@ -1,15 +1,15 @@
export {
Dropdown,
- DropdownHeader,
DropdownContent,
- DropdownItem,
- DropdownSubmenu,
- DropdownSeparator,
- DropdownLabel,
- type DropdownProps,
- type DropdownHeaderProps,
type DropdownContentProps,
+ DropdownHeader,
+ type DropdownHeaderProps,
+ DropdownItem,
type DropdownItemProps,
+ DropdownLabel,
+ DropdownSubmenu,
type DropdownSubmenuProps,
type DropdownPlacement,
+ type DropdownProps,
+ DropdownSeparator,
} from './Dropdown';
diff --git a/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx b/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx
index 3f271f00c..12b97436b 100644
--- a/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx
+++ b/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx
@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
-import { DropzoneOverlay, useDropzone } from './DropzoneOverlay';
+
import { Button } from '../Button';
import { Card } from '../Card';
+import { DropzoneOverlay, useDropzone } from './DropzoneOverlay';
const meta: Meta = {
id: 'files-dropzoneoverlay',
@@ -116,7 +117,7 @@ export const Default: Story = {
(Story) => (
-
+
Drag files here or click to upload
@@ -134,7 +135,7 @@ export const Hidden: Story = {
(Story) => (
-
+
Overlay is hidden - set isVisible to true to see it
@@ -202,7 +203,7 @@ function InteractiveDemo() {
isVisible={isDragging}
message="Drop files to upload"
/>
-
+
Drag files here or click to upload
Accepts: Images, PDF, Word documents
@@ -225,7 +226,7 @@ function InteractiveDemo() {
📄
{file.name}
-
+
({(file.size / 1024).toFixed(1)} KB)
@@ -282,7 +283,7 @@ function DocumentUploadDemo() {
📄
{file.name}
-
+
{(file.size / 1024).toFixed(1)} KB