From b249515496a63bb435bee6f4f5e8a62fbbbb3415 Mon Sep 17 00:00:00 2001 From: Giovambattista Fazioli Date: Tue, 16 Jun 2026 09:37:21 +0200 Subject: [PATCH 1/2] fix(window): keep native focus on interactive content (fixes #33) A searchable Select rendered inside Window could not focus or type into its search input. The drag handlers called e.preventDefault() on every mousedown bubbling up from the content, which stops the browser from moving focus to the clicked element. handleMouseDownDrag / handleTouchStartDrag now bail out early (like the existing [data-resize-handle] guard) when the pointer starts on an interactive / focusable element (input, textarea, select, button, a, label, contenteditable, audio/video controls, ARIA option/menuitem/ listbox/menu) or on a [data-no-window-drag] opt-out region. In those cases the window neither starts a drag nor preventDefaults, so native focus behavior is preserved. - 3 regression tests in Window.test.tsx - Storybook story 'SearchableSelectInside' - docs 'Form Controls' section + Window.demo.formControls demo --- docs/demos/Window.demo.formControls.tsx | 71 ++++++++++++++++++++++ docs/demos/index.ts | 1 + docs/docs.mdx | 10 ++++ package/src/Window.story.tsx | 21 ++++++- package/src/Window.test.tsx | 79 +++++++++++++++++++++++++ package/src/hooks/use-window-drag.ts | 47 +++++++++++++++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 docs/demos/Window.demo.formControls.tsx 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..c8db065 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', + '[contenteditable=""]', + '[contenteditable="true"]', + '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; From e59de591c1d3b76400b8476bceb616d399db8578 Mon Sep 17 00:00:00 2001 From: Giovambattista Fazioli Date: Tue, 16 Jun 2026 09:48:28 +0200 Subject: [PATCH 2/2] fix(window): match all editable contenteditable variants in drag guard Replace the enumerated [contenteditable=""] / [contenteditable="true"] selectors with [contenteditable]:not([contenteditable="false"]) so the plaintext-only and bare-attribute variants are also treated as interactive (CodeRabbit review #34). --- package/src/hooks/use-window-drag.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/hooks/use-window-drag.ts b/package/src/hooks/use-window-drag.ts index c8db065..bf7fbd5 100644 --- a/package/src/hooks/use-window-drag.ts +++ b/package/src/hooks/use-window-drag.ts @@ -17,8 +17,8 @@ const INTERACTIVE_TARGET_SELECTOR = [ 'button', 'a[href]', 'label', - '[contenteditable=""]', - '[contenteditable="true"]', + // 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