Skip to content

Commit 227b360

Browse files
authored
[drawer] Make data-base-ui-swipe-ignore explicit for touch interactions (#4295)
1 parent 85d3d12 commit 227b360

File tree

14 files changed

+483
-22
lines changed

14 files changed

+483
-22
lines changed

docs/src/app/(docs)/react/components/drawer/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { DrawerPreview as Drawer } from '@base-ui/react/drawer';
4646
</Drawer.Provider>;
4747
```
4848

49-
Drawer supports swipe gestures to dismiss. Set `swipeDirection` to control which direction dismisses the drawer. `<Drawer.Content>` allows text selection of its children without swipe interference when using a mouse pointer.
49+
Drawer supports swipe gestures to dismiss. Set `swipeDirection` to control which direction dismisses the drawer. `<Drawer.Content>` allows text selection of its children without swipe interference when using a mouse pointer. Add `data-base-ui-swipe-ignore` to a descendant when you need to opt that element out of swipe dismissal for all input types.
5050

5151
## Examples
5252

docs/src/app/(docs)/react/components/toast/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { Toast } from '@base-ui/react/toast';
5353
- `<Toast.Provider>` can be wrapped around your entire app, ensuring all toasts are rendered in the same viewport.
5454
- <kbd>F6</kbd> lets users jump into the toast viewport landmark region to navigate toasts with
5555
keyboard focus.
56-
- The `data-swipe-ignore` attribute can be manually added to elements inside of a toast to prevent swipe-to-dismiss gestures on them. Interactive elements are automatically prevented.
56+
- The `data-base-ui-swipe-ignore` attribute can be manually added to elements inside of a toast to prevent swipe-to-dismiss gestures on them. Interactive elements are automatically prevented.
5757

5858
## Global manager
5959

docs/src/app/(docs)/react/handbook/forms/demos/components/toast.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function Toasts() {
1616
<Toast.Description className="text-[0.925rem] leading-5 text-gray-700" />
1717
<div
1818
className="text-xs mt-2 p-3 py-2 bg-gray-100 text-gray-900 font-medium rounded-md select-text"
19-
data-swipe-ignore
19+
data-base-ui-swipe-ignore
2020
>
2121
<pre className="whitespace-pre-wrap">{JSON.stringify(toast.data, null, 2)}</pre>
2222
</div>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
'use client';
2+
import * as React from 'react';
3+
import { DrawerPreview as Drawer } from '@base-ui/react/drawer';
4+
5+
type EventName = 'plain div click' | 'ignored div click' | 'native button click' | 'drawer closed';
6+
7+
export default function DrawerTouchIgnoreExperiment() {
8+
const [plainDivClicks, setPlainDivClicks] = React.useState(0);
9+
const [ignoredDivClicks, setIgnoredDivClicks] = React.useState(0);
10+
const [buttonClicks, setButtonClicks] = React.useState(0);
11+
const [events, setEvents] = React.useState<EventName[]>([]);
12+
13+
function recordEvent(eventName: EventName) {
14+
setEvents((previousEvents) => [eventName, ...previousEvents].slice(0, 8));
15+
}
16+
17+
return (
18+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10 text-slate-900">
19+
<div className="space-y-3">
20+
<h1 className="text-3xl font-semibold tracking-tight">Drawer touch ignore experiment</h1>
21+
<p className="max-w-2xl text-sm leading-6 text-slate-600">
22+
Use this to compare touch behavior inside <code>Drawer.Content</code>. The plain div
23+
should still participate in swipe-to-dismiss, while the explicit{' '}
24+
<code>data-base-ui-swipe-ignore</code> div should preserve taps.
25+
</p>
26+
</div>
27+
28+
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm md:grid-cols-[1.2fr_0.8fr]">
29+
<div className="space-y-3 text-sm text-slate-700">
30+
<h2 className="text-base font-semibold text-slate-900">What to test</h2>
31+
<ol className="list-inside list-decimal space-y-2">
32+
<li>Tap the plain div on a touch device. It should still be part of swipe handling.</li>
33+
<li>
34+
Tap the <code>data-base-ui-swipe-ignore</code> div. Its click counter should
35+
increment.
36+
</li>
37+
<li>Tap the native button. It should continue to work as before.</li>
38+
<li>Drag from the plain div area to confirm swipe-to-dismiss still starts there.</li>
39+
</ol>
40+
</div>
41+
42+
<div className="rounded-xl bg-slate-950 p-4 text-sm text-slate-100">
43+
<h2 className="mb-3 text-base font-semibold">Latest events</h2>
44+
<div className="space-y-2">
45+
<CounterRow label="Plain div clicks" value={plainDivClicks} />
46+
<CounterRow label="Ignored div clicks" value={ignoredDivClicks} />
47+
<CounterRow label="Native button clicks" value={buttonClicks} />
48+
</div>
49+
<div className="mt-4 border-t border-white/10 pt-4">
50+
<div className="mb-2 text-xs font-medium uppercase tracking-[0.2em] text-slate-400">
51+
Event log
52+
</div>
53+
<ul className="space-y-1 text-sm text-slate-300">
54+
{events.length === 0 ? <li>No events yet.</li> : null}
55+
{events.map((eventName, index) => (
56+
<li key={`${eventName}-${index}`}>{eventName}</li>
57+
))}
58+
</ul>
59+
</div>
60+
</div>
61+
</div>
62+
63+
<Drawer.Root
64+
onOpenChange={(open) => {
65+
if (!open) {
66+
recordEvent('drawer closed');
67+
}
68+
}}
69+
>
70+
<Drawer.Trigger className="inline-flex h-11 w-fit items-center justify-center rounded-xl bg-slate-900 px-4 text-sm font-medium text-white transition hover:bg-slate-700">
71+
Open touch test drawer
72+
</Drawer.Trigger>
73+
<Drawer.Portal>
74+
<Drawer.Backdrop className="fixed inset-0 bg-slate-950/30 transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0" />
75+
<Drawer.Viewport className="fixed inset-0 flex items-end justify-center">
76+
<Drawer.Popup className="flex w-full max-w-2xl max-h-[85vh] flex-col rounded-t-3xl bg-white px-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-4 text-slate-900 shadow-2xl outline outline-1 outline-slate-200 transition-transform data-swiping:select-none data-starting-style:translate-y-full data-ending-style:translate-y-full">
77+
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300" />
78+
<Drawer.Content className="space-y-4 overflow-y-auto overscroll-contain pb-2">
79+
<Drawer.Title className="text-lg font-semibold">Touch behavior test</Drawer.Title>
80+
<Drawer.Description className="text-sm leading-6 text-slate-600">
81+
The tiles below intentionally use different interaction models so you can verify
82+
the drawer bugfix on a real touch device or emulator.
83+
</Drawer.Description>
84+
85+
<div className="grid gap-3">
86+
{/* Intentional non-interactive div to reproduce the touch click behavior. */}
87+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
88+
<div
89+
className="rounded-2xl border border-amber-300 bg-amber-50 p-4 text-left"
90+
onClick={() => {
91+
setPlainDivClicks((value) => value + 1);
92+
recordEvent('plain div click');
93+
}}
94+
>
95+
<div className="text-sm font-semibold text-amber-950">
96+
Plain div inside Drawer.Content
97+
</div>
98+
<div className="mt-1 text-sm text-amber-800">
99+
On touch, this area should still participate in swipe-to-dismiss.
100+
</div>
101+
</div>
102+
103+
{/* Intentional non-interactive div to reproduce explicit swipe-ignore behavior. */}
104+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
105+
<div
106+
data-base-ui-swipe-ignore
107+
className="rounded-2xl border border-emerald-300 bg-emerald-50 p-4 text-left"
108+
onClick={() => {
109+
setIgnoredDivClicks((value) => value + 1);
110+
recordEvent('ignored div click');
111+
}}
112+
>
113+
<div className="text-sm font-semibold text-emerald-950">
114+
Div with data-base-ui-swipe-ignore
115+
</div>
116+
<div className="mt-1 text-sm text-emerald-800">
117+
Tapping here should preserve the click even on touch.
118+
</div>
119+
</div>
120+
121+
<button
122+
type="button"
123+
className="rounded-2xl border border-sky-300 bg-sky-50 p-4 text-left"
124+
onClick={() => {
125+
setButtonClicks((value) => value + 1);
126+
recordEvent('native button click');
127+
}}
128+
>
129+
<div className="text-sm font-semibold text-sky-950">Native button</div>
130+
<div className="mt-1 text-sm text-sky-800">
131+
Control case to compare against the custom div targets.
132+
</div>
133+
</button>
134+
</div>
135+
136+
<div className="flex gap-3 pt-2">
137+
<Drawer.Close className="inline-flex h-10 items-center justify-center rounded-lg border border-slate-200 px-3.5 text-sm font-medium text-slate-900 transition hover:bg-slate-50">
138+
Close
139+
</Drawer.Close>
140+
</div>
141+
</Drawer.Content>
142+
</Drawer.Popup>
143+
</Drawer.Viewport>
144+
</Drawer.Portal>
145+
</Drawer.Root>
146+
</div>
147+
);
148+
}
149+
150+
function CounterRow(props: { label: string; value: number }) {
151+
const { label, value } = props;
152+
153+
return (
154+
<div className="flex items-center justify-between gap-4 rounded-lg bg-white/5 px-3 py-2">
155+
<span>{label}</span>
156+
<span className="font-mono text-base text-white">{value}</span>
157+
</div>
158+
);
159+
}

packages/react/src/drawer/content/DrawerContent.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('<Drawer.Content />', () => {
2121
},
2222
}));
2323

24-
it('adds data-swipe-ignore', async () => {
24+
it('does not add public swipe-ignore attributes', async () => {
2525
await render(
2626
<Drawer.Root open>
2727
<Drawer.Portal>
@@ -34,6 +34,7 @@ describe('<Drawer.Content />', () => {
3434
</Drawer.Root>,
3535
);
3636

37-
expect(screen.getByTestId('content').getAttribute('data-swipe-ignore')).toBe('');
37+
expect(screen.getByTestId('content')).not.toHaveAttribute('data-swipe-ignore');
38+
expect(screen.getByTestId('content')).not.toHaveAttribute('data-base-ui-swipe-ignore');
3839
});
3940
});

packages/react/src/drawer/content/DrawerContent.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react';
33
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
44
import type { BaseUIComponentProps } from '../../utils/types';
55
import { useRenderElement } from '../../utils/useRenderElement';
6+
import { DRAWER_CONTENT_ATTRIBUTE } from './DrawerContentDataAttributes';
67

78
/**
89
* A container for the drawer contents.
@@ -20,7 +21,7 @@ export const DrawerContent = React.forwardRef(function DrawerContent(
2021

2122
return useRenderElement('div', componentProps, {
2223
ref: forwardedRef,
23-
props: [{ ['data-swipe-ignore' as string]: '' }, elementProps],
24+
props: [{ [DRAWER_CONTENT_ATTRIBUTE as string]: '' }, elementProps],
2425
});
2526
});
2627

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const DRAWER_CONTENT_ATTRIBUTE = 'data-drawer-content';

0 commit comments

Comments
 (0)