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;