diff --git a/docs/demos/Window.demo.formControls.tsx b/docs/demos/Window.demo.formControls.tsx new file mode 100644 index 0000000..fd626ff --- /dev/null +++ b/docs/demos/Window.demo.formControls.tsx @@ -0,0 +1,71 @@ +import { Window } from '@gfazioli/mantine-window'; +import { Box, NumberInput, Select, Stack, TextInput } from '@mantine/core'; +import { MantineDemo } from '@mantinex/demo'; + +const code = `import { Window } from '@gfazioli/mantine-window'; +import { Box, NumberInput, Select, Stack, TextInput } from '@mantine/core'; + +function Demo() { + return ( + + + + + {/* Searchable Select: focus, typing and filtering work while inside the Window */} + + + + + + ); +} + +export const formControls: MantineDemo = { + type: 'code', + component: Demo, + code, + defaultExpanded: false, +}; diff --git a/docs/demos/index.ts b/docs/demos/index.ts index e4f3123..550ea61 100644 --- a/docs/demos/index.ts +++ b/docs/demos/index.ts @@ -7,6 +7,7 @@ export { controlled } from './Window.demo.controlled'; export { controlledPosition } from './Window.demo.controlledPosition'; export { dragBounds } from './Window.demo.dragBounds'; export { dynamicWindows } from './Window.demo.dynamicWindows'; +export { formControls } from './Window.demo.formControls'; export { fullSizeHandles } from './Window.demo.fullSizeHandles'; export { group } from './Window.demo.group'; export { groupLayout } from './Window.demo.groupLayout'; diff --git a/docs/docs.mdx b/docs/docs.mdx index d2d947f..a3afe3c 100644 --- a/docs/docs.mdx +++ b/docs/docs.mdx @@ -184,6 +184,16 @@ status bar, while the View menu toggles the canvas grid and changes the layout d +## Form Controls + +A Window is a regular content surface: any interactive Mantine input works inside it and +keeps its native behavior. In particular a searchable `Select` can be focused, typed into +and filtered — the window only starts dragging when you grab a non-interactive area (or the +header), so form fields never steal the drag and the drag never steals their focus. You can +opt any custom region out of dragging with the `data-no-window-drag` attribute. + + + ## Window.Group Wrap multiple windows in a `Window.Group` to enable coordinated window management. The group provides: diff --git a/package/src/Window.story.tsx b/package/src/Window.story.tsx index ae53c46..82fd55e 100644 --- a/package/src/Window.story.tsx +++ b/package/src/Window.story.tsx @@ -1,4 +1,4 @@ -import { Button, Stack, Text, Title } from '@mantine/core'; +import { Box, Button, Select, Stack, Text, Title } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import React from 'react'; import { Window } from './Window'; @@ -9,6 +9,25 @@ export default { argTypes: {}, }; +// Interactive form controls (e.g. a searchable Select) must keep their native +// focus behavior while rendered inside a draggable Window — see issue #33. +export function SearchableSelectInside() { + return ( + + {}}> + + + + ); + const input = container.querySelector('[data-testid="inner-input"]') as HTMLElement; + + // mousedown on the input must NOT be prevented, otherwise the browser + // never moves focus to it (this broke searchable Select inside Window). + const notPrevented = fireEvent.mouseDown(input, { clientX: 120, clientY: 120 }); + fireEvent.mouseMove(document, { clientX: 220, clientY: 240 }); + fireEvent.mouseUp(document); + + expect(notPrevented).toBe(true); + expect(onPositionChange).not.toHaveBeenCalled(); + }); + + it('does not start a drag when mousedown originates on a data-no-window-drag region', () => { + const onPositionChange = jest.fn(); + const { container } = renderWithMantine( + +
+ custom interactive region +
+
+ ); + const region = container.querySelector('[data-testid="no-drag"]') as HTMLElement; + + fireEvent.mouseDown(region, { clientX: 120, clientY: 120 }); + fireEvent.mouseMove(document, { clientX: 220, clientY: 240 }); + fireEvent.mouseUp(document); + + expect(onPositionChange).not.toHaveBeenCalled(); + }); + + it('still starts a drag when mousedown originates on non-interactive content', () => { + const onPositionChange = jest.fn(); + const { container } = renderWithMantine( + +
plain content
+
+ ); + const plain = container.querySelector('[data-testid="plain"]') as HTMLElement; + + fireEvent.mouseDown(plain, { clientX: 120, clientY: 120 }); + fireEvent.mouseMove(document, { clientX: 220, clientY: 240 }); + fireEvent.mouseUp(document); + + expect(onPositionChange).toHaveBeenCalled(); + }); + // ─── localStorage write on interaction ────────────────────────────── it('writes collapsed state to localStorage when persistState is true', () => { diff --git a/package/src/hooks/use-window-drag.ts b/package/src/hooks/use-window-drag.ts index 29c7fbf..bf7fbd5 100644 --- a/package/src/hooks/use-window-drag.ts +++ b/package/src/hooks/use-window-drag.ts @@ -2,6 +2,41 @@ import { useCallback, useRef } from 'react'; import { applyDragBounds, type DragConstraints } from '../lib/window-constraints'; import type { WindowPosition } from '../Window'; +/** + * Selector matching interactive / focusable elements that must keep their native + * pointer behavior (focus, text selection, value editing). When a drag starts on + * one of these, the window must NOT initiate a drag nor call `preventDefault()` — + * otherwise the browser never moves focus to the element (e.g. the search input of + * a `searchable` Select rendered inside the window). Consumers can also opt a custom + * region out of dragging with `data-no-window-drag`. + */ +const INTERACTIVE_TARGET_SELECTOR = [ + 'input', + 'textarea', + 'select', + 'button', + 'a[href]', + 'label', + // Any editable variant ("" / "true" / "plaintext-only" / bare attribute), but not "false". + '[contenteditable]:not([contenteditable="false"])', + 'audio[controls]', + 'video[controls]', + // ARIA interactive roles (e.g. combobox/menu options rendered inside the window + // when their dropdown uses withinPortal={false}). + '[role="option"]', + '[role="menuitem"]', + '[role="listbox"]', + '[role="menu"]', + // Consumer opt-out for custom interactive regions. + '[data-no-window-drag]', +].join(', '); + +/** Whether a pointer/touch event started on an element that should keep native focus behavior. */ +function isInteractiveTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + return !!el?.closest?.(INTERACTIVE_TARGET_SELECTOR); +} + export interface UseWindowDragOptions { positionPx: { x: number; y: number }; sizePx: { width: number; height: number }; @@ -80,6 +115,12 @@ export function useWindowDrag(options: UseWindowDragOptions) { return; } + // Don't hijack interactive elements (inputs, buttons, links, …): calling + // preventDefault() here would stop the browser from focusing them. + if (isInteractiveTarget(e.target)) { + return; + } + bringToFront(); isDragging.current = true; dragStart.current = { @@ -98,6 +139,12 @@ export function useWindowDrag(options: UseWindowDragOptions) { return; } + // Don't hijack interactive elements (inputs, buttons, links, …): calling + // preventDefault() here would stop the browser from focusing them. + if (isInteractiveTarget(e.target)) { + return; + } + const touch = e.touches[0]; bringToFront(); isDragging.current = true;