From b09d26d20631126b2a4444eacc02538c8cce8ee1 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Tue, 28 Jul 2026 00:49:41 +0200 Subject: [PATCH 1/4] feat: add passive interaction policy --- .changeset/add-interaction-policy.md | 5 + src/components/action-sheet/ActionSheet.tsx | 6 +- src/components/action-sheet/types.ts | 2 + src/components/drawer/Drawer.tsx | 6 +- src/components/drawer/types.ts | 3 + src/components/menu/Menu.tsx | 18 +++- src/components/menu/types.ts | 3 + src/components/modal/Modal.tsx | 6 +- src/components/modal/types.ts | 3 + src/components/tabs/Tab.tsx | 23 +++-- src/components/tabs/types.ts | 3 + src/components/text-input/TextInput.tsx | 12 ++- src/components/text-input/types.ts | 2 + src/components/toast/Toast.tsx | 12 ++- src/components/toast/types.ts | 2 + src/components/tooltip/Tooltip.tsx | 10 +- src/components/tooltip/types.ts | 3 + src/index.test.ts | 1 + src/index.ts | 1 + src/interactionPolicy.test.ts | 101 ++++++++++++++++++++ src/primitives/button-base/ButtonBase.tsx | 6 +- src/primitives/button-base/index.ts | 2 +- src/primitives/button-base/types.ts | 7 ++ 23 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 .changeset/add-interaction-policy.md create mode 100644 src/interactionPolicy.test.ts diff --git a/.changeset/add-interaction-policy.md b/.changeset/add-interaction-policy.md new file mode 100644 index 0000000..72985db --- /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..6f786d7 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({ }} > 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..74ee83c 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); @@ -123,7 +131,7 @@ export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID } return ( {trigger} @@ -140,7 +148,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/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..9b43a3c 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 '../../primitives/button-base'; + 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..9684535 100644 --- a/src/components/text-input/types.ts +++ b/src/components/text-input/types.ts @@ -6,6 +6,7 @@ import type { } from 'react-native'; import type { ControlSize } from '../../internal/resolvers/resolveControlSize'; +import type { InteractionPolicy } from '../../primitives/button-base'; export interface TextInputProps extends Omit< ReactNativeTextInputProps, @@ -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..80f42b9 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 '../../primitives/button-base'; 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..c667af6 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 '../../primitives/button-base'; + 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..e8fcea6 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 './primitives/button-base';", "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..2110511 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,7 @@ export type { I18nInstance, TranslationRuntime, Translator } from './context/Tra export { TranslationProvider, useTranslationContext } from './context/TranslationContext'; export * from './core/responsive'; export * from './layout'; +export type { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base'; export type { ButtonBaseProps } from './primitives/button-base'; export { ButtonBase } from './primitives/button-base'; export type { HeadingLevel, HeadingProps } from './primitives/heading'; diff --git a/src/interactionPolicy.test.ts b/src/interactionPolicy.test.ts new file mode 100644 index 0000000..b07c8d5 --- /dev/null +++ b/src/interactionPolicy.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test'; + +import type { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base'; + +describe('InteractionPolicy type', () => { + it('exports the InteractionPolicy type', () => { + const policy: InteractionPolicy = 'enabled'; + expect(policy).toBe('enabled'); + }); + + it('allows passive value', () => { + const policy: InteractionPolicy = 'passive'; + expect(policy).toBe('passive'); + }); +}); + +describe('InteractionPolicyProps', () => { + it('exports the InteractionPolicyProps interface', () => { + const props: InteractionPolicyProps = { interactionPolicy: 'passive' }; + expect(props.interactionPolicy).toBe('passive'); + }); + + it('allows omitted interactionPolicy', () => { + const props: InteractionPolicyProps = {}; + expect(props.interactionPolicy).toBeUndefined(); + }); +}); + +describe('ButtonBase interactionPolicy', () => { + it('accepts interactionPolicy="enabled"', () => { + const props = { interactionPolicy: 'enabled' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('enabled'); + }); + + it('accepts interactionPolicy="passive"', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); + + it('omitted interactionPolicy defaults to enabled', () => { + const props: InteractionPolicyProps = {}; + expect(props.interactionPolicy ?? 'enabled').toBe('enabled'); + }); +}); + +describe('Button interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); +}); + +describe('IconButton interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); +}); + +describe('Checkbox interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); +}); + +describe('TextInput interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); + + it('omitted interactionPolicy defaults to enabled', () => { + const props: InteractionPolicyProps = {}; + expect(props.interactionPolicy ?? 'enabled').toBe('enabled'); + }); +}); + +describe('Modal interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); +}); + +describe('Drawer interactionPolicy', () => { + it('accepts interactionPolicy prop', () => { + const props = { interactionPolicy: 'passive' as InteractionPolicy }; + expect(props.interactionPolicy).toBe('passive'); + }); +}); + +describe('Policy does not leak to native hosts', () => { + it('InteractionPolicy is not a valid native prop', () => { + const nativeProps: Record = { + accessible: true, + accessibilityLabel: 'test', + }; + expect(nativeProps.interactionPolicy).toBeUndefined(); + }); +}); 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/index.ts b/src/primitives/button-base/index.ts index 2aa285b..40a1109 100644 --- a/src/primitives/button-base/index.ts +++ b/src/primitives/button-base/index.ts @@ -1,2 +1,2 @@ export { ButtonBase } from './ButtonBase'; -export type { ButtonBaseProps } from './types'; +export type { ButtonBaseProps, InteractionPolicy, InteractionPolicyProps } from './types'; diff --git a/src/primitives/button-base/types.ts b/src/primitives/button-base/types.ts index 3a8546c..7cba330 100644 --- a/src/primitives/button-base/types.ts +++ b/src/primitives/button-base/types.ts @@ -4,9 +4,16 @@ import type { AccessibilityRole, AccessibilityState, GestureResponderEvent } fro import type { InteractionState } from '../../internal/resolvers/resolveInteractiveState'; import type { BoxProps } from '../../layout'; +export type InteractionPolicy = 'enabled' | 'passive'; + +export interface InteractionPolicyProps { + interactionPolicy?: InteractionPolicy; +} + export interface ButtonBaseProps extends Omit { children?: React.ReactNode | ((state: InteractionState) => React.ReactNode); disabled?: boolean; + interactionPolicy?: InteractionPolicy; onPress?: ((event: GestureResponderEvent) => void) | undefined; onLongPress?: ((event: GestureResponderEvent) => void) | undefined; accessibilityLabel?: string; From a6d76e7dda367d44d9f851159968bf9c1524e0e6 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Tue, 28 Jul 2026 01:45:07 +0200 Subject: [PATCH 2/4] fix: complete passive interaction coverage --- src/components/action-sheet/ActionSheet.tsx | 1 + .../action-sheet/ActionSheetItem.tsx | 2 + src/components/action-sheet/types.ts | 3 +- src/components/drawer/types.ts | 2 +- src/components/menu/Menu.tsx | 6 +- src/components/menu/types.ts | 2 +- src/components/modal/types.ts | 2 +- src/components/tabs/types.ts | 2 +- src/components/text-input/types.ts | 2 +- src/components/toast/types.ts | 2 +- src/components/tooltip/types.ts | 2 +- src/index.test.ts | 2 +- src/index.ts | 2 +- src/interactionPolicy.test.ts | 123 ++++++++---------- src/interactionPolicy.ts | 5 + src/primitives/button-base/index.ts | 2 +- src/primitives/button-base/types.ts | 11 +- 17 files changed, 82 insertions(+), 89 deletions(-) create mode 100644 src/interactionPolicy.ts diff --git a/src/components/action-sheet/ActionSheet.tsx b/src/components/action-sheet/ActionSheet.tsx index 6f786d7..c57a545 100644 --- a/src/components/action-sheet/ActionSheet.tsx +++ b/src/components/action-sheet/ActionSheet.tsx @@ -108,6 +108,7 @@ export function ActionSheet({ {onDismiss ? ( 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 f0a5ba6..b866651 100644 --- a/src/components/action-sheet/types.ts +++ b/src/components/action-sheet/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; import type { SurfaceColor } from '../../surfaceColor'; export interface ActionSheetProps { @@ -23,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/types.ts b/src/components/drawer/types.ts index 00c30de..59bec23 100644 --- a/src/components/drawer/types.ts +++ b/src/components/drawer/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; export interface DrawerProps { visible: boolean; diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index 74ee83c..1c4bc60 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -107,6 +107,10 @@ export function Menu({ } return bindKeydown((event) => { + if (passive) { + return; + } + const { key } = event; if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') { event.preventDefault(); @@ -126,7 +130,7 @@ export function Menu({ closeMenu(); } }); - }, [activateAction, actions, activeIndex, bindKeydown, closeMenu, open]); + }, [activateAction, actions, activeIndex, bindKeydown, closeMenu, open, passive]); return ( diff --git a/src/components/menu/types.ts b/src/components/menu/types.ts index b801a13..0fe4d02 100644 --- a/src/components/menu/types.ts +++ b/src/components/menu/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; export type MenuActionIntent = 'default' | 'danger'; diff --git a/src/components/modal/types.ts b/src/components/modal/types.ts index 579be0e..99307db 100644 --- a/src/components/modal/types.ts +++ b/src/components/modal/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; export interface ModalProps { visible: boolean; diff --git a/src/components/tabs/types.ts b/src/components/tabs/types.ts index 9b43a3c..afeeade 100644 --- a/src/components/tabs/types.ts +++ b/src/components/tabs/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; export interface TabsProps { children?: React.ReactNode; diff --git a/src/components/text-input/types.ts b/src/components/text-input/types.ts index 9684535..c6b2e0f 100644 --- a/src/components/text-input/types.ts +++ b/src/components/text-input/types.ts @@ -5,8 +5,8 @@ import type { TextStyle, } from 'react-native'; +import type { InteractionPolicy } from '../../interactionPolicy'; import type { ControlSize } from '../../internal/resolvers/resolveControlSize'; -import type { InteractionPolicy } from '../../primitives/button-base'; export interface TextInputProps extends Omit< ReactNativeTextInputProps, diff --git a/src/components/toast/types.ts b/src/components/toast/types.ts index 80f42b9..e5504d2 100644 --- a/src/components/toast/types.ts +++ b/src/components/toast/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; import type { SurfaceStatusColor } from '../../surfaceColor'; export type ToastStatus = 'default' | SurfaceStatusColor; diff --git a/src/components/tooltip/types.ts b/src/components/tooltip/types.ts index c667af6..bd3f327 100644 --- a/src/components/tooltip/types.ts +++ b/src/components/tooltip/types.ts @@ -1,6 +1,6 @@ import type React from 'react'; -import type { InteractionPolicy } from '../../primitives/button-base'; +import type { InteractionPolicy } from '../../interactionPolicy'; export interface TooltipProps { children?: React.ReactNode; diff --git a/src/index.test.ts b/src/index.test.ts index e8fcea6..012cb82 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,7 +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 './primitives/button-base';", + "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 2110511..ba58e06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,8 +61,8 @@ 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 { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base'; export type { ButtonBaseProps } from './primitives/button-base'; export { ButtonBase } from './primitives/button-base'; export type { HeadingLevel, HeadingProps } from './primitives/heading'; diff --git a/src/interactionPolicy.test.ts b/src/interactionPolicy.test.ts index b07c8d5..58fb622 100644 --- a/src/interactionPolicy.test.ts +++ b/src/interactionPolicy.test.ts @@ -1,101 +1,86 @@ -import { describe, expect, it } from 'bun:test'; - -import type { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base'; - -describe('InteractionPolicy type', () => { - it('exports the InteractionPolicy type', () => { - const policy: InteractionPolicy = 'enabled'; - expect(policy).toBe('enabled'); - }); +import { readFileSync } from 'node:fs'; - it('allows passive value', () => { - const policy: InteractionPolicy = 'passive'; - expect(policy).toBe('passive'); - }); -}); - -describe('InteractionPolicyProps', () => { - it('exports the InteractionPolicyProps interface', () => { - const props: InteractionPolicyProps = { interactionPolicy: 'passive' }; - expect(props.interactionPolicy).toBe('passive'); - }); - - it('allows omitted interactionPolicy', () => { - const props: InteractionPolicyProps = {}; - expect(props.interactionPolicy).toBeUndefined(); - }); -}); +import { describe, expect, it } from 'bun:test'; -describe('ButtonBase interactionPolicy', () => { - it('accepts interactionPolicy="enabled"', () => { - const props = { interactionPolicy: 'enabled' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('enabled'); - }); +const indexSource = readFileSync(new URL('./index.ts', import.meta.url), 'utf8'); - it('accepts interactionPolicy="passive"', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('passive'); +describe('InteractionPolicy public export', () => { + it('exports InteractionPolicy from the public root', () => { + expect(indexSource).toContain( + "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';", + ); }); - it('omitted interactionPolicy defaults to enabled', () => { - const props: InteractionPolicyProps = {}; - expect(props.interactionPolicy ?? 'enabled').toBe('enabled'); + it('exports InteractionPolicyProps from the public root', () => { + expect(indexSource).toContain( + "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';", + ); }); -}); -describe('Button interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('passive'); + it('does not export InteractionPolicy from button-base', () => { + expect(indexSource).not.toContain( + "export type { InteractionPolicy, InteractionPolicyProps } from './primitives/button-base';", + ); }); }); -describe('IconButton interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('passive'); +describe('InteractionPolicy type', () => { + it('only allows enabled', () => { + const policy = 'enabled' as const; + expect(policy).toBe('enabled'); }); -}); -describe('Checkbox interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('passive'); + it('only allows passive', () => { + const policy = 'passive' as const; + expect(policy).toBe('passive'); }); }); -describe('TextInput interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - expect(props.interactionPolicy).toBe('passive'); +describe('InteractionPolicyProps', () => { + it('allows omitted interactionPolicy', () => { + const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = {}; + expect(props.interactionPolicy).toBeUndefined(); }); - it('omitted interactionPolicy defaults to enabled', () => { - const props: InteractionPolicyProps = {}; - expect(props.interactionPolicy ?? 'enabled').toBe('enabled'); + it('allows explicit enabled', () => { + const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = { + interactionPolicy: 'enabled', + }; + expect(props.interactionPolicy).toBe('enabled'); }); -}); -describe('Modal interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; + it('allows explicit passive', () => { + const props: { readonly interactionPolicy?: 'enabled' | 'passive' } = { + interactionPolicy: 'passive', + }; expect(props.interactionPolicy).toBe('passive'); }); }); -describe('Drawer interactionPolicy', () => { - it('accepts interactionPolicy prop', () => { - const props = { interactionPolicy: 'passive' as InteractionPolicy }; - 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 valid native prop', () => { - const nativeProps: Record = { + it('InteractionPolicy is not a native View prop', () => { + const viewProps: Record = { accessible: true, accessibilityLabel: 'test', }; - expect(nativeProps.interactionPolicy).toBeUndefined(); + 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/index.ts b/src/primitives/button-base/index.ts index 40a1109..2aa285b 100644 --- a/src/primitives/button-base/index.ts +++ b/src/primitives/button-base/index.ts @@ -1,2 +1,2 @@ export { ButtonBase } from './ButtonBase'; -export type { ButtonBaseProps, InteractionPolicy, InteractionPolicyProps } from './types'; +export type { ButtonBaseProps } from './types'; diff --git a/src/primitives/button-base/types.ts b/src/primitives/button-base/types.ts index 7cba330..8a0662e 100644 --- a/src/primitives/button-base/types.ts +++ b/src/primitives/button-base/types.ts @@ -1,19 +1,14 @@ 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 type InteractionPolicy = 'enabled' | 'passive'; - -export interface InteractionPolicyProps { - interactionPolicy?: InteractionPolicy; -} - -export interface ButtonBaseProps extends Omit { +export interface ButtonBaseProps + extends Omit, InteractionPolicyProps { children?: React.ReactNode | ((state: InteractionState) => React.ReactNode); disabled?: boolean; - interactionPolicy?: InteractionPolicy; onPress?: ((event: GestureResponderEvent) => void) | undefined; onLongPress?: ((event: GestureResponderEvent) => void) | undefined; accessibilityLabel?: string; From 9d7e8b6d9bf769a14836b483b57e2884dc19e442 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Tue, 28 Jul 2026 03:26:42 +0200 Subject: [PATCH 3/4] fix: support passive navigation interactions --- .../navigation/DrawerNavigation.tsx | 9 +- .../navigation/DrawerNavigationItem.tsx | 8 +- src/components/navigation/NavigationItem.tsx | 8 +- src/components/navigation/NavigationList.tsx | 9 +- src/components/navigation/TabBar.tsx | 10 +- src/components/navigation/TabBarItem.tsx | 3 +- src/components/navigation/policy.test.ts | 96 +++++++++++++++++++ src/components/navigation/types.ts | 7 ++ 8 files changed, 143 insertions(+), 7 deletions(-) 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; } From 92addeae4621a3bce7c82d801e0a355e2b18d661 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Wed, 29 Jul 2026 00:25:56 +0200 Subject: [PATCH 4/4] fix: format changeset file --- .changeset/add-interaction-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-interaction-policy.md b/.changeset/add-interaction-policy.md index 72985db..d262f06 100644 --- a/.changeset/add-interaction-policy.md +++ b/.changeset/add-interaction-policy.md @@ -1,5 +1,5 @@ --- -"@ankhorage/surface": minor +'@ankhorage/surface': minor --- Add the uniform interactionPolicy="enabled" | "passive" contract to Surface-owned interactive primitives.