Skip to content
Merged
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
71 changes: 71 additions & 0 deletions docs/demos/Window.demo.formControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box pos="relative" style={{ width: '100%', height: 420 }}>
<Window
title="Profile settings"
opened
defaultX={30}
defaultY={30}
defaultWidth={360}
defaultHeight={340}
persistState={false}
withinPortal={false}
>
<Stack gap="sm">
<TextInput label="Display name" placeholder="Jane Doe" />
{/* Searchable Select: focus, typing and filtering work while inside the Window */}
<Select
label="Favorite library"
placeholder="Pick value"
data={['React', 'Angular', 'Vue', 'Svelte']}
searchable
/>
<NumberInput label="Years of experience" placeholder="0" min={0} />
</Stack>
</Window>
</Box>
);
}`;

function Demo() {
return (
<Box pos="relative" style={{ width: '100%', height: 420 }}>
<Window
title="Profile settings"
opened
defaultX={30}
defaultY={30}
defaultWidth={360}
defaultHeight={340}
persistState={false}
withinPortal={false}
>
<Stack gap="sm">
<TextInput label="Display name" placeholder="Jane Doe" />
{/* Searchable Select: focus, typing and filtering work while inside the Window */}
<Select
label="Favorite library"
placeholder="Pick value"
data={['React', 'Angular', 'Vue', 'Svelte']}
searchable
/>
<NumberInput label="Years of experience" placeholder="0" min={0} />
</Stack>
</Window>
</Box>
);
}

export const formControls: MantineDemo = {
type: 'code',
component: Demo,
code,
defaultExpanded: false,
};
1 change: 1 addition & 0 deletions docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions docs/docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ status bar, while the View menu toggles the canvas grid and changes the layout d

<Demo data={demos.menuBar} />

## 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.

<Demo data={demos.formControls} />

## Window.Group

Wrap multiple windows in a `Window.Group` to enable coordinated window management. The group provides:
Expand Down
21 changes: 20 additions & 1 deletion package/src/Window.story.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<Stack>
<Window title="Searchable Select inside" opened onClose={() => {}}>
<Box>
<Select
label="Your favorite library"
placeholder="Pick value"
data={['React', 'Angular', 'Vue', 'Svelte']}
searchable
/>
</Box>
</Window>
</Stack>
);
}

export function Usage() {
return (
<Stack>
Expand Down
79 changes: 79 additions & 0 deletions package/src/Window.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,85 @@ describe('Window', () => {
expect(onSizeChange).toHaveBeenCalled();
});

// ─── Drag does not hijack interactive content (issue #33) ────────────

it('does not start a drag nor preventDefault when mousedown originates on an interactive element', () => {
const onPositionChange = jest.fn();
const { container } = renderWithMantine(
<Window
opened
title="Interactive"
defaultX={100}
defaultY={100}
draggable="both"
withinPortal={false}
onPositionChange={onPositionChange}
>
<input aria-label="Inner input" data-testid="inner-input" />
</Window>
);
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(
<Window
opened
title="Opt out"
defaultX={100}
defaultY={100}
draggable="both"
withinPortal={false}
onPositionChange={onPositionChange}
>
<div data-no-window-drag data-testid="no-drag">
custom interactive region
</div>
</Window>
);
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(
<Window
opened
title="Drag Body"
defaultX={100}
defaultY={100}
draggable="both"
withinPortal={false}
onPositionChange={onPositionChange}
>
<div data-testid="plain">plain content</div>
</Window>
);
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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ─── localStorage write on interaction ──────────────────────────────

it('writes collapsed state to localStorage when persistState is true', () => {
Expand Down
47 changes: 47 additions & 0 deletions package/src/hooks/use-window-drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down
Loading