diff --git a/.changeset/add-interaction-policy.md b/.changeset/add-interaction-policy.md
new file mode 100644
index 0000000..d262f06
--- /dev/null
+++ b/.changeset/add-interaction-policy.md
@@ -0,0 +1,5 @@
+---
+'@ankhorage/surface': minor
+---
+
+Add the uniform interactionPolicy="enabled" | "passive" contract to Surface-owned interactive primitives.
diff --git a/src/components/action-sheet/ActionSheet.tsx b/src/components/action-sheet/ActionSheet.tsx
index c107b5b..c57a545 100644
--- a/src/components/action-sheet/ActionSheet.tsx
+++ b/src/components/action-sheet/ActionSheet.tsx
@@ -18,10 +18,12 @@ export function ActionSheet({
children,
cancelLabel = 'Cancel',
closeOnBackdrop = true,
+ interactionPolicy = 'enabled',
testID,
}: ActionSheetProps) {
const { theme } = useTheme();
const animation = resolveOverlayAnimation('modal');
+ const passive = interactionPolicy === 'passive';
if (!visible) {
return null;
@@ -40,7 +42,7 @@ export function ActionSheet({
}}
>
diff --git a/src/components/action-sheet/ActionSheetItem.tsx b/src/components/action-sheet/ActionSheetItem.tsx
index 0ea4603..013cef5 100644
--- a/src/components/action-sheet/ActionSheetItem.tsx
+++ b/src/components/action-sheet/ActionSheetItem.tsx
@@ -13,6 +13,7 @@ export function ActionSheetItem({
color,
disabled = false,
selected = false,
+ interactionPolicy = 'enabled',
onPress,
testID,
}: ActionSheetItemProps) {
@@ -21,6 +22,7 @@ export function ActionSheetItem({
accessibilityRole="button"
accessibilityState={{ disabled, selected }}
disabled={disabled}
+ interactionPolicy={interactionPolicy}
onPress={onPress}
radius="m"
testID={testID}
diff --git a/src/components/action-sheet/types.ts b/src/components/action-sheet/types.ts
index f5cf18c..b866651 100644
--- a/src/components/action-sheet/types.ts
+++ b/src/components/action-sheet/types.ts
@@ -1,5 +1,6 @@
import type React from 'react';
+import type { InteractionPolicy } from '../../interactionPolicy';
import type { SurfaceColor } from '../../surfaceColor';
export interface ActionSheetProps {
@@ -10,6 +11,7 @@ export interface ActionSheetProps {
children?: React.ReactNode;
cancelLabel?: React.ReactNode;
closeOnBackdrop?: boolean;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
@@ -21,6 +23,7 @@ export interface ActionSheetItemProps {
color?: SurfaceColor;
disabled?: boolean;
selected?: boolean;
+ interactionPolicy?: InteractionPolicy;
onPress?: (() => void) | undefined;
testID?: string;
}
diff --git a/src/components/drawer/Drawer.tsx b/src/components/drawer/Drawer.tsx
index 6b8061f..a79d481 100644
--- a/src/components/drawer/Drawer.tsx
+++ b/src/components/drawer/Drawer.tsx
@@ -14,10 +14,12 @@ export function Drawer({
position = 'right',
children,
closeOnBackdrop = true,
+ interactionPolicy = 'enabled',
testID,
}: DrawerProps) {
const { theme } = useTheme();
const animation = resolveOverlayAnimation('drawer');
+ const passive = interactionPolicy === 'passive';
if (!visible) {
return null;
@@ -36,7 +38,7 @@ export function Drawer({
}}
>
void) | undefined;
position?: 'left' | 'right';
children?: React.ReactNode;
closeOnBackdrop?: boolean;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx
index e0800f6..1c4bc60 100644
--- a/src/components/menu/Menu.tsx
+++ b/src/components/menu/Menu.tsx
@@ -53,7 +53,14 @@ function renderActionContent(action: MenuAction, active: boolean) {
);
}
-export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }: MenuProps) {
+export function Menu({
+ trigger,
+ actions,
+ dismiss,
+ closeOnSelect = true,
+ interactionPolicy = 'enabled',
+ testID,
+}: MenuProps) {
const { theme } = useTheme();
const { bindKeydown } = useFocusManager();
const animation = resolveOverlayAnimation('menu');
@@ -61,6 +68,7 @@ export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }
const [open, setOpen] = React.useState(false);
const [layout, setLayout] = React.useState(null);
const [activeIndex, setActiveIndex] = React.useState(0);
+ const passive = interactionPolicy === 'passive';
const closeMenu = React.useCallback(() => {
setOpen(false);
@@ -99,6 +107,10 @@ export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }
}
return bindKeydown((event) => {
+ if (passive) {
+ return;
+ }
+
const { key } = event;
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
event.preventDefault();
@@ -118,12 +130,12 @@ export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }
closeMenu();
}
});
- }, [activateAction, actions, activeIndex, bindKeydown, closeMenu, open]);
+ }, [activateAction, actions, activeIndex, bindKeydown, closeMenu, open, passive]);
return (
{trigger}
@@ -140,7 +152,7 @@ export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }
}}
>
-
+
activateAction(action)}
+ onPress={passive ? undefined : () => activateAction(action)}
>
void;
closeOnSelect?: boolean;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx
index 8e29ccc..e051dbf 100644
--- a/src/components/modal/Modal.tsx
+++ b/src/components/modal/Modal.tsx
@@ -13,10 +13,12 @@ export function Modal({
onDismiss,
children,
closeOnBackdrop = true,
+ interactionPolicy = 'enabled',
testID,
}: ModalProps) {
const { theme } = useTheme();
const animation = resolveOverlayAnimation('modal');
+ const passive = interactionPolicy === 'passive';
if (!visible) {
return null;
@@ -35,7 +37,7 @@ export function Modal({
}}
>
void) | undefined;
children?: React.ReactNode;
closeOnBackdrop?: boolean;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
diff --git a/src/components/navigation/DrawerNavigation.tsx b/src/components/navigation/DrawerNavigation.tsx
index 3351685..43e7bfe 100644
--- a/src/components/navigation/DrawerNavigation.tsx
+++ b/src/components/navigation/DrawerNavigation.tsx
@@ -10,13 +10,20 @@ export function DrawerNavigation({
footer,
compact = false,
testID,
+ interactionPolicy,
}: DrawerNavigationProps) {
return (
{header}
{items.map((item) => (
-
+
))}
{footer}
diff --git a/src/components/navigation/DrawerNavigationItem.tsx b/src/components/navigation/DrawerNavigationItem.tsx
index 47a283b..48600b8 100644
--- a/src/components/navigation/DrawerNavigationItem.tsx
+++ b/src/components/navigation/DrawerNavigationItem.tsx
@@ -3,10 +3,16 @@ import React from 'react';
import { NavigationItem } from './NavigationItem';
import type { DrawerNavigationItemProps } from './types';
-export function DrawerNavigationItem({ item, compact = false, testID }: DrawerNavigationItemProps) {
+export function DrawerNavigationItem({
+ item,
+ compact = false,
+ testID,
+ interactionPolicy,
+}: DrawerNavigationItemProps) {
return (
diff --git a/src/components/navigation/NavigationList.tsx b/src/components/navigation/NavigationList.tsx
index 93c2747..3516787 100644
--- a/src/components/navigation/NavigationList.tsx
+++ b/src/components/navigation/NavigationList.tsx
@@ -9,6 +9,7 @@ export function NavigationList({
orientation = 'vertical',
compact = false,
testID,
+ interactionPolicy,
}: NavigationListProps) {
return (
{items.map((item) => (
-
+
))}
);
diff --git a/src/components/navigation/TabBar.tsx b/src/components/navigation/TabBar.tsx
index 6e0d95c..d844190 100644
--- a/src/components/navigation/TabBar.tsx
+++ b/src/components/navigation/TabBar.tsx
@@ -5,7 +5,7 @@ import { useTheme } from '../../theme/ThemeContext';
import { TabBarItem } from './TabBarItem';
import type { TabBarProps } from './types';
-export function TabBar({ items, compact = false, testID }: TabBarProps) {
+export function TabBar({ items, compact = false, testID, interactionPolicy }: TabBarProps) {
const { theme } = useTheme();
return (
@@ -18,7 +18,13 @@ export function TabBar({ items, compact = false, testID }: TabBarProps) {
testID={testID}
>
{items.map((item) => (
-
+
))}
);
diff --git a/src/components/navigation/TabBarItem.tsx b/src/components/navigation/TabBarItem.tsx
index 068dcfc..8789e7b 100644
--- a/src/components/navigation/TabBarItem.tsx
+++ b/src/components/navigation/TabBarItem.tsx
@@ -9,7 +9,7 @@ import { useTheme } from '../../theme/ThemeContext';
import { resolveNavigationAccessibilityState, resolveNavigationItemPresentation } from './helpers';
import type { TabBarItemProps } from './types';
-export function TabBarItem({ item, compact = false, testID }: TabBarItemProps) {
+export function TabBarItem({ item, compact = false, testID, interactionPolicy }: TabBarItemProps) {
const { theme } = useTheme();
const active = Boolean(item.active);
const disabled = Boolean(item.disabled);
@@ -26,6 +26,7 @@ export function TabBarItem({ item, compact = false, testID }: TabBarItemProps) {
accessibilityRole={item.accessibilityRole ?? 'tab'}
accessibilityState={resolvedAccessibilityState}
disabled={disabled}
+ interactionPolicy={interactionPolicy}
onPress={item.onPress}
style={{ flex: 1 }}
testID={resolvedTestID}
diff --git a/src/components/navigation/policy.test.ts b/src/components/navigation/policy.test.ts
index 6efd87c..a36c618 100644
--- a/src/components/navigation/policy.test.ts
+++ b/src/components/navigation/policy.test.ts
@@ -42,3 +42,99 @@ describe('navigation module boundaries', () => {
});
});
});
+
+describe('navigation interaction policy', () => {
+ it('host component prop types include interactionPolicy', () => {
+ const sources = listNavigationSources().map((path) => ({
+ path,
+ content: readFileSync(path, 'utf8'),
+ }));
+
+ const hostComponents = [{ path: 'types.ts', prop: 'interactionPolicy?: InteractionPolicy' }];
+
+ sources.forEach(({ content, path }) => {
+ const component = hostComponents.find((host) => path.endsWith(host.path));
+ if (component) {
+ expect(content, path).toContain(component.prop);
+ }
+ });
+ });
+
+ it('navigation components accept interactionPolicy prop', () => {
+ const sources = listNavigationSources().map((path) => ({
+ path,
+ content: readFileSync(path, 'utf8'),
+ }));
+
+ const components = [
+ 'NavigationItem.tsx',
+ 'NavigationList.tsx',
+ 'TabBar.tsx',
+ 'TabBarItem.tsx',
+ 'DrawerNavigation.tsx',
+ 'DrawerNavigationItem.tsx',
+ ];
+
+ sources.forEach(({ content, path }) => {
+ const component = components.find((c) => path.endsWith(c));
+ if (component) {
+ expect(content, path).toContain('interactionPolicy');
+ }
+ });
+ });
+
+ it('direct ButtonBase owners forward interactionPolicy unchanged', () => {
+ const sources = listNavigationSources().map((path) => ({
+ path,
+ content: readFileSync(path, 'utf8'),
+ }));
+
+ const directButtonBaseOwners = ['NavigationItem.tsx', 'TabBarItem.tsx'];
+
+ sources.forEach(({ content, path }) => {
+ const component = directButtonBaseOwners.find((owner) => path.endsWith(owner));
+ if (component) {
+ expect(content, path).toContain('interactionPolicy={interactionPolicy}');
+ }
+ });
+ });
+
+ it('container components forward interactionPolicy to children', () => {
+ const sources = listNavigationSources().map((path) => ({
+ path,
+ content: readFileSync(path, 'utf8'),
+ }));
+
+ const forwardingMap: Record = {
+ 'NavigationList.tsx': 'NavigationItem',
+ 'TabBar.tsx': 'TabBarItem',
+ 'DrawerNavigation.tsx': 'DrawerNavigationItem',
+ 'DrawerNavigationItem.tsx': 'NavigationItem',
+ };
+
+ sources.forEach(({ content, path }) => {
+ const filename = path.split('/').pop();
+ if (filename && forwardingMap[filename]) {
+ expect(content, path).toContain(`interactionPolicy={interactionPolicy}`);
+ }
+ });
+ });
+
+ it('does not map passive interactionPolicy to disabled in navigation components', () => {
+ const sources = listNavigationSources().map((path) => ({
+ path,
+ content: readFileSync(path, 'utf8'),
+ }));
+
+ const passiveToDisabledPatterns = [
+ /disabled=\{interactionPolicy === 'passive'\}/,
+ /disabled=\{passive/,
+ ];
+
+ sources.forEach(({ content, path }) => {
+ passiveToDisabledPatterns.forEach((pattern) => {
+ expect(content, path).not.toMatch(pattern);
+ });
+ });
+ });
+});
diff --git a/src/components/navigation/types.ts b/src/components/navigation/types.ts
index 61c312c..1dfe939 100644
--- a/src/components/navigation/types.ts
+++ b/src/components/navigation/types.ts
@@ -1,6 +1,7 @@
import type React from 'react';
import type { AccessibilityRole, AccessibilityState } from 'react-native';
+import type { InteractionPolicy } from '../../interactionPolicy';
import type { IconProps } from '../../primitives/icon';
export type NavigationItemIcon = Pick;
@@ -23,6 +24,7 @@ export interface NavigationItemProps {
item: NavigationItemSpec;
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
export interface NavigationListProps {
@@ -30,18 +32,21 @@ export interface NavigationListProps {
orientation?: 'vertical' | 'horizontal';
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
export interface TabBarProps {
items: readonly NavigationItemSpec[];
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
export interface TabBarItemProps {
item: NavigationItemSpec;
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
export interface DrawerNavigationProps {
@@ -50,10 +55,12 @@ export interface DrawerNavigationProps {
footer?: React.ReactNode;
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
export interface DrawerNavigationItemProps {
item: NavigationItemSpec;
compact?: boolean;
testID?: string;
+ interactionPolicy?: InteractionPolicy;
}
diff --git a/src/components/tabs/Tab.tsx b/src/components/tabs/Tab.tsx
index 13d1286..114d78e 100644
--- a/src/components/tabs/Tab.tsx
+++ b/src/components/tabs/Tab.tsx
@@ -7,7 +7,13 @@ import { useTheme } from '../../theme/ThemeContext';
import { useTabsContext } from './context';
import type { TabProps } from './types';
-export function Tab({ value, children, disabled = false, testID }: TabProps) {
+export function Tab({
+ value,
+ children,
+ disabled = false,
+ interactionPolicy = 'enabled',
+ testID,
+}: TabProps) {
const { theme } = useTheme();
const {
activeValue,
@@ -22,6 +28,7 @@ export function Tab({ value, children, disabled = false, testID }: TabProps) {
const selected = activeValue === value;
const tabId = getTabId(value);
const panelId = getPanelId(value);
+ const passive = interactionPolicy === 'passive';
React.useEffect(() => {
registerTab({
@@ -50,11 +57,15 @@ export function Tab({ value, children, disabled = false, testID }: TabProps) {
disabled={disabled}
onFocus={() => setFocusedValue(value)}
onBlur={() => setFocusedValue(undefined)}
- onPress={() => {
- if (!disabled) {
- setActiveValue(value);
- }
- }}
+ onPress={
+ passive
+ ? undefined
+ : () => {
+ if (!disabled) {
+ setActiveValue(value);
+ }
+ }
+ }
ref={pressableRef}
testID={testID}
>
diff --git a/src/components/tabs/types.ts b/src/components/tabs/types.ts
index b1466b5..afeeade 100644
--- a/src/components/tabs/types.ts
+++ b/src/components/tabs/types.ts
@@ -1,5 +1,7 @@
import type React from 'react';
+import type { InteractionPolicy } from '../../interactionPolicy';
+
export interface TabsProps {
children?: React.ReactNode;
value?: string;
@@ -17,6 +19,7 @@ export interface TabProps {
value: string;
children?: React.ReactNode;
disabled?: boolean;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
diff --git a/src/components/text-input/TextInput.tsx b/src/components/text-input/TextInput.tsx
index b52a0d5..15d52a6 100644
--- a/src/components/text-input/TextInput.tsx
+++ b/src/components/text-input/TextInput.tsx
@@ -22,6 +22,7 @@ export function TextInput({
invalid = false,
leadingAccessory,
trailingAccessory,
+ interactionPolicy = 'enabled',
style,
testID,
onFocus,
@@ -52,6 +53,7 @@ export function TextInput({
const containerMinHeight = props.multiline
? inputMinHeight + controlSize.paddingVertical * 2
: controlSize.minHeight;
+ const passive = interactionPolicy === 'passive';
const handleFocus: NonNullable = (event) => {
setFocused(true);
@@ -87,10 +89,16 @@ export function TextInput({
{
+ if (passive) {
+ return;
+ }
+
+ onChangeText?.(nextValue);
+ }}
onFocus={handleFocus}
placeholder={placeholder}
placeholderTextColor={colors.placeholderColor}
diff --git a/src/components/text-input/types.ts b/src/components/text-input/types.ts
index db44299..c6b2e0f 100644
--- a/src/components/text-input/types.ts
+++ b/src/components/text-input/types.ts
@@ -5,6 +5,7 @@ import type {
TextStyle,
} from 'react-native';
+import type { InteractionPolicy } from '../../interactionPolicy';
import type { ControlSize } from '../../internal/resolvers/resolveControlSize';
export interface TextInputProps extends Omit<
@@ -27,6 +28,7 @@ export interface TextInputProps extends Omit<
invalid?: boolean;
leadingAccessory?: React.ReactNode;
trailingAccessory?: React.ReactNode;
+ interactionPolicy?: InteractionPolicy;
style?: StyleProp;
testID?: string;
}
diff --git a/src/components/toast/Toast.tsx b/src/components/toast/Toast.tsx
index 54a1072..fdd75b6 100644
--- a/src/components/toast/Toast.tsx
+++ b/src/components/toast/Toast.tsx
@@ -38,10 +38,18 @@ function resolveToastStatusTextColor(status: ToastProps['status']): SurfaceColor
}
}
-export function Toast({ title, description, status = 'default', onDismiss, testID }: ToastProps) {
+export function Toast({
+ title,
+ description,
+ status = 'default',
+ onDismiss,
+ interactionPolicy = 'enabled',
+ testID,
+}: ToastProps) {
const { theme } = useTheme();
const statusColor = resolveToastStatusColor(theme, status);
const statusTextColor = resolveToastStatusTextColor(status);
+ const passive = interactionPolicy === 'passive';
return (
×
diff --git a/src/components/toast/types.ts b/src/components/toast/types.ts
index a612299..e5504d2 100644
--- a/src/components/toast/types.ts
+++ b/src/components/toast/types.ts
@@ -1,5 +1,6 @@
import type React from 'react';
+import type { InteractionPolicy } from '../../interactionPolicy';
import type { SurfaceStatusColor } from '../../surfaceColor';
export type ToastStatus = 'default' | SurfaceStatusColor;
@@ -9,6 +10,7 @@ export interface ToastProps {
description?: React.ReactNode;
status?: ToastStatus;
onDismiss?: (() => void) | undefined;
+ interactionPolicy?: InteractionPolicy;
testID?: string;
}
diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx
index 5dce7c4..437ed68 100644
--- a/src/components/tooltip/Tooltip.tsx
+++ b/src/components/tooltip/Tooltip.tsx
@@ -25,6 +25,7 @@ export function Tooltip({
children,
content,
delay = 150,
+ interactionPolicy = 'enabled',
placement = 'top',
testID,
}: TooltipProps) {
@@ -34,6 +35,7 @@ export function Tooltip({
const timeoutRef = React.useRef | null>(null);
const [visible, setVisible] = React.useState(false);
const [layout, setLayout] = React.useState(null);
+ const passive = interactionPolicy === 'passive';
const show = React.useCallback(() => {
if (timeoutRef.current) {
@@ -74,10 +76,10 @@ export function Tooltip({
return (
{children}
diff --git a/src/components/tooltip/types.ts b/src/components/tooltip/types.ts
index bef8684..bd3f327 100644
--- a/src/components/tooltip/types.ts
+++ b/src/components/tooltip/types.ts
@@ -1,9 +1,12 @@
import type React from 'react';
+import type { InteractionPolicy } from '../../interactionPolicy';
+
export interface TooltipProps {
children?: React.ReactNode;
content?: React.ReactNode;
delay?: number;
+ interactionPolicy?: InteractionPolicy;
placement?: 'top' | 'bottom';
testID?: string;
}
diff --git a/src/index.test.ts b/src/index.test.ts
index e345b3b..012cb82 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -19,6 +19,7 @@ const expectedRootExports = [
"export { Field } from './components/field';",
"export { HelperText } from './components/helper-text';",
"export { IconButton } from './components/icon-button';",
+ "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';",
"export { Label } from './components/label';",
"export { ListItem } from './components/list-item';",
"export {\n DrawerNavigation,\n DrawerNavigationItem,\n NavigationItem,\n NavigationList,\n TabBar,\n TabBarItem,\n} from './components/navigation';",
diff --git a/src/index.ts b/src/index.ts
index d7e0fe0..ba58e06 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -61,6 +61,7 @@ export { FontProvider, useFontContext } from './context/FontContext';
export type { I18nInstance, TranslationRuntime, Translator } from './context/TranslationContext';
export { TranslationProvider, useTranslationContext } from './context/TranslationContext';
export * from './core/responsive';
+export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';
export * from './layout';
export type { ButtonBaseProps } from './primitives/button-base';
export { ButtonBase } from './primitives/button-base';
diff --git a/src/interactionPolicy.test.ts b/src/interactionPolicy.test.ts
new file mode 100644
index 0000000..58fb622
--- /dev/null
+++ b/src/interactionPolicy.test.ts
@@ -0,0 +1,86 @@
+import { readFileSync } from 'node:fs';
+
+import { describe, expect, it } from 'bun:test';
+
+const indexSource = readFileSync(new URL('./index.ts', import.meta.url), 'utf8');
+
+describe('InteractionPolicy public export', () => {
+ it('exports InteractionPolicy from the public root', () => {
+ expect(indexSource).toContain(
+ "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';",
+ );
+ });
+
+ it('exports InteractionPolicyProps from the public root', () => {
+ expect(indexSource).toContain(
+ "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';",
+ );
+ });
+
+ it('does not export InteractionPolicy from button-base', () => {
+ expect(indexSource).not.toContain(
+ "export type { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base';",
+ );
+ });
+});
+
+describe('InteractionPolicy type', () => {
+ it('only allows enabled', () => {
+ const policy = 'enabled' as const;
+ expect(policy).toBe('enabled');
+ });
+
+ it('only allows passive', () => {
+ const policy = 'passive' as const;
+ expect(policy).toBe('passive');
+ });
+});
+
+describe('InteractionPolicyProps', () => {
+ it('allows omitted interactionPolicy', () => {
+ const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = {};
+ expect(props.interactionPolicy).toBeUndefined();
+ });
+
+ it('allows explicit enabled', () => {
+ const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = {
+ interactionPolicy: 'enabled',
+ };
+ expect(props.interactionPolicy).toBe('enabled');
+ });
+
+ it('allows explicit passive', () => {
+ const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = {
+ interactionPolicy: 'passive',
+ };
+ expect(props.interactionPolicy).toBe('passive');
+ });
+});
+
+describe('ButtonBaseProps includes interactionPolicy', () => {
+ it('has interactionPolicy in ButtonBaseProps', () => {
+ expect(indexSource).toContain(
+ "export type { ButtonBaseProps } from './primitives/button-base';",
+ );
+ });
+});
+
+describe('Policy does not leak to native hosts', () => {
+ it('InteractionPolicy is not a native View prop', () => {
+ const viewProps: Record = {
+ accessible: true,
+ accessibilityLabel: 'test',
+ };
+ expect(viewProps.interactionPolicy).toBeUndefined();
+ });
+
+ it('InteractionPolicy is not a native TextInput prop', () => {
+ const textInputProps: Record = {
+ editable: true,
+ onChangeText: (_text: string) => {
+ void _text;
+ },
+ };
+ expect(textInputProps.interactionPolicy).toBeUndefined();
+ });
+});
diff --git a/src/interactionPolicy.ts b/src/interactionPolicy.ts
new file mode 100644
index 0000000..8ea940c
--- /dev/null
+++ b/src/interactionPolicy.ts
@@ -0,0 +1,5 @@
+export type InteractionPolicy = 'enabled' | 'passive';
+
+export interface InteractionPolicyProps {
+ interactionPolicy?: InteractionPolicy;
+}
diff --git a/src/primitives/button-base/ButtonBase.tsx b/src/primitives/button-base/ButtonBase.tsx
index 06d1771..f845165 100644
--- a/src/primitives/button-base/ButtonBase.tsx
+++ b/src/primitives/button-base/ButtonBase.tsx
@@ -28,6 +28,7 @@ function getInteractionState(
export function ButtonBase({
children,
disabled = false,
+ interactionPolicy = 'enabled',
onPress,
onLongPress,
accessibilityLabel,
@@ -43,6 +44,7 @@ export function ButtonBase({
const [focused, setFocused] = React.useState(false);
const isWeb = Platform.OS === 'web';
const resolvedBoxStyles = resolveBoxStyles(theme, breakpoint, props);
+ const passive = interactionPolicy === 'passive';
return (
setHovered(true) : undefined}
onHoverOut={isWeb ? () => setHovered(false) : undefined}
- onLongPress={onLongPress}
- onPress={onPress}
+ onLongPress={passive ? undefined : onLongPress}
+ onPress={passive ? undefined : onPress}
style={(pressableState) => {
const state = getInteractionState(pressableState, hovered, focused, disabled);
diff --git a/src/primitives/button-base/types.ts b/src/primitives/button-base/types.ts
index 3a8546c..8a0662e 100644
--- a/src/primitives/button-base/types.ts
+++ b/src/primitives/button-base/types.ts
@@ -1,10 +1,12 @@
import type React from 'react';
import type { AccessibilityRole, AccessibilityState, GestureResponderEvent } from 'react-native';
+import type { InteractionPolicyProps } from '../../interactionPolicy';
import type { InteractionState } from '../../internal/resolvers/resolveInteractiveState';
import type { BoxProps } from '../../layout';
-export interface ButtonBaseProps extends Omit {
+export interface ButtonBaseProps
+ extends Omit, InteractionPolicyProps {
children?: React.ReactNode | ((state: InteractionState) => React.ReactNode);
disabled?: boolean;
onPress?: ((event: GestureResponderEvent) => void) | undefined;