diff --git a/src/__tests__/native/container-activity.test.tsx b/src/__tests__/native/container-activity.test.tsx new file mode 100644 index 00000000..6e8a9cab --- /dev/null +++ b/src/__tests__/native/container-activity.test.tsx @@ -0,0 +1,225 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const containerID = "container"; +const childID = "child"; + +const RED = { color: "#f00" }; + +/** A touch event as React Native delivers it: every touch still on screen, and the touches this event changed. */ +function touchEvent(onScreen: number[], changed: number[]) { + return { + nativeEvent: { + touches: onScreen.map((identifier) => ({ identifier })), + changedTouches: changed.map((identifier) => ({ identifier })), + }, + }; +} + +test("a size container takes no part in responder negotiation", () => { + registerCSS(` + .container { container-name: my-container; container-type: inline-size; } + @container my-container (width > 100px) { + .child { color: red; } + } + `); + + render( + + + , + ); + + expect( + screen.getByTestId(containerID).props.onStartShouldSetResponder, + ).toBeUndefined(); +}); + +test("a group its descendants ask only about an attribute takes no part in responder negotiation", () => { + registerCSS(` + .group\\/item[data-state="on"] .child { color: red; } + `); + + render( + + + , + ); + + expect(screen.getByTestId(childID)).toHaveStyle(RED); + expect( + screen.getByTestId(containerID).props.onStartShouldSetResponder, + ).toBeUndefined(); +}); + +test("a group answers :active from the touches its subtree receives, and stays out of responder negotiation", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + const child = screen.getByTestId(childID); + + expect(container.props.onStartShouldSetResponder).toBeUndefined(); + expect(child).not.toHaveStyle(RED); + + fireEvent(container, "touchStart", touchEvent([1], [1])); + expect(child).toHaveStyle(RED); + + fireEvent(container, "touchEnd", touchEvent([], [1])); + expect(child).not.toHaveStyle(RED); + + fireEvent(container, "touchStart", touchEvent([2], [2])); + expect(child).toHaveStyle(RED); + + fireEvent(container, "touchCancel", touchEvent([], [2])); + expect(child).not.toHaveStyle(RED); +}); + +test("a group stays :active until the last touch that went down inside it ends", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + const child = screen.getByTestId(childID); + + fireEvent(container, "touchStart", touchEvent([1], [1])); + fireEvent(container, "touchStart", touchEvent([1, 2], [2])); + fireEvent(container, "touchEnd", touchEvent([2], [1])); + expect(child).toHaveStyle(RED); + + fireEvent(container, "touchEnd", touchEvent([], [2])); + expect(child).not.toHaveStyle(RED); +}); + +test("a touch that went down outside the group does not keep it :active", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + + fireEvent(container, "touchStart", touchEvent([7, 1], [1])); + fireEvent(container, "touchEnd", touchEvent([7], [1])); + expect(screen.getByTestId(childID)).not.toHaveStyle(RED); +}); + +test("a touch whose end never reached the group is dropped at the next touch event", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + const child = screen.getByTestId(childID); + + fireEvent(container, "touchStart", touchEvent([1], [1])); + fireEvent(container, "touchStart", touchEvent([2], [2])); + fireEvent(container, "touchEnd", touchEvent([], [2])); + expect(child).not.toHaveStyle(RED); +}); + +test("a touch on a descendant activates the group, as every ancestor of the pressed element is :active", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + render( + + + , + ); + + const child = screen.getByTestId(childID); + + fireEvent(child, "touchStart", touchEvent([1], [1])); + expect(child).toHaveStyle(RED); + + fireEvent(child, "touchEnd", touchEvent([], [1])); + expect(child).not.toHaveStyle(RED); +}); + +test("a group the touches reach keeps the touch handlers it was given", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + const onTouchStart = jest.fn(); + const onTouchEnd = jest.fn(); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + + fireEvent(container, "touchStart", touchEvent([1], [1])); + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(screen.getByTestId(childID)).toHaveStyle(RED); + + fireEvent(container, "touchEnd", touchEvent([], [1])); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + expect(screen.getByTestId(childID)).not.toHaveStyle(RED); +}); + +test("a group pressed for its own reasons answers :active through its press", () => { + registerCSS(` + .group\\/item:active .child { color: red; } + `); + + const onPress = jest.fn(); + + render( + + + , + ); + + const container = screen.getByTestId(containerID); + const child = screen.getByTestId(childID); + + fireEvent(container, "pressIn"); + expect(child).toHaveStyle(RED); + + fireEvent(container, "pressOut"); + expect(child).not.toHaveStyle(RED); + + fireEvent.press(container); + expect(onPress).toHaveBeenCalledTimes(1); +}); diff --git a/src/__tests__/native/grouping.test.tsx b/src/__tests__/native/grouping.test.tsx index 1484e4b8..91d2892b 100644 --- a/src/__tests__/native/grouping.test.tsx +++ b/src/__tests__/native/grouping.test.tsx @@ -56,7 +56,12 @@ test("group - active", () => { expect(child.props.style).toStrictEqual(undefined); - fireEvent(parent, "pressIn"); + fireEvent(parent, "touchStart", { + nativeEvent: { + touches: [{ identifier: 1 }], + changedTouches: [{ identifier: 1 }], + }, + }); expect(child.props.style).toStrictEqual({ backgroundColor: "#f00" }); }); @@ -77,9 +82,16 @@ test("group - active (animated) supplies changed and restored transition targets }); }; expectStyle("#000"); - fireEvent(screen.getByTestId(parentID), "pressIn"); + fireEvent(screen.getByTestId(parentID), "touchStart", { + nativeEvent: { + touches: [{ identifier: 1 }], + changedTouches: [{ identifier: 1 }], + }, + }); expectStyle("#f00"); - fireEvent(screen.getByTestId(parentID), "pressOut"); + fireEvent(screen.getByTestId(parentID), "touchEnd", { + nativeEvent: { touches: [], changedTouches: [{ identifier: 1 }] }, + }); expectStyle("#000"); }); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index e25df564..78e78b3a 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -8,7 +8,7 @@ import type { } from "react-native-css/compiler"; import { - activeFamily, + containerActiveFamily, containerHeightFamily, containerWidthFamily, focusFamily, @@ -72,7 +72,7 @@ function testContainerPseudoCondition( if (query.h && !get(hoverFamily(containerKey))) { return false; } - if (query.a && !get(activeFamily(containerKey))) { + if (query.a && !get(containerActiveFamily(containerKey))) { return false; } if (query.f && !get(focusFamily(containerKey))) { diff --git a/src/native/react/interaction.ts b/src/native/react/interaction.ts index d412b15e..86799aba 100644 --- a/src/native/react/interaction.ts +++ b/src/native/react/interaction.ts @@ -1,8 +1,9 @@ /* eslint-disable */ -import type { LayoutChangeEvent } from "react-native"; +import type { GestureResponderEvent, LayoutChangeEvent } from "react-native"; import { activeFamily, + containerActiveFamily, containerLayoutFamily, focusFamily, hoverFamily, @@ -14,6 +15,38 @@ const mainCache = new WeakMap< Map void>> >(); +/** The touches each container follows: those that went down inside it and are still on screen. */ +const containerTouches = new WeakMap>(); + +/** + * A container is `:active` until the last touch that went down inside it ends. A touch whose + * end never reached it — its target left the screen first — is dropped at the next touch event, + * because it is no longer among the touches on screen. + */ +function followContainerTouches( + weakKey: WeakKey, + event: GestureResponderEvent, + phase: "down" | "up", +) { + const onScreen = new Set( + event.nativeEvent.touches.map((touch) => touch.identifier), + ); + const following = new Set( + [...(containerTouches.get(weakKey) ?? [])].filter((identifier) => + onScreen.has(identifier), + ), + ); + for (const touch of event.nativeEvent.changedTouches) { + if (phase === "down") { + following.add(touch.identifier); + } else { + following.delete(touch.identifier); + } + } + containerTouches.set(weakKey, following); + containerActiveFamily(weakKey).set(following.size > 0); +} + type Handler = (event: unknown) => void; export type InteractionType = @@ -23,6 +56,9 @@ export type InteractionType = | "onPress" | "onPressIn" | "onPressOut" + | "onTouchStart" + | "onTouchEnd" + | "onTouchCancel" | "onFocus" | "onBlur"; @@ -33,6 +69,9 @@ const defaultHandlers: Record = { onPress: () => {}, onPressIn: () => {}, onPressOut: () => {}, + onTouchStart: () => {}, + onTouchEnd: () => {}, + onTouchCancel: () => {}, onFocus: () => {}, onBlur: () => {}, }; @@ -76,9 +115,22 @@ export function getInteractionHandler( break; case "onPressIn": activeFamily(weakKey).set(true); + containerActiveFamily(weakKey).set(true); break; case "onPressOut": activeFamily(weakKey).set(false); + containerActiveFamily(weakKey).set(false); + break; + case "onTouchStart": + followContainerTouches( + weakKey, + event as GestureResponderEvent, + "down", + ); + break; + case "onTouchEnd": + case "onTouchCancel": + followContainerTouches(weakKey, event as GestureResponderEvent, "up"); break; case "onFocus": focusFamily(weakKey).set(true); diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..afc9ca6c 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -9,6 +9,7 @@ import { getDeepPath } from "../objects"; import { activeFamily, cleanupEffect, + containerActiveFamily, containerLayoutFamily, focusFamily, hoverFamily, @@ -115,7 +116,7 @@ export function updateRules( if (rule.c) { containers ??= inheritedContainers; - activeFamily(state.ruleEffectGetter); + observeContainerActivity(state, currentProps); } if ( @@ -163,7 +164,7 @@ export function updateRules( // Enable hover/active/focus/layout handlers hoverFamily(state.ruleEffectGetter); - activeFamily(state.ruleEffectGetter); + observeContainerActivity(state, currentProps); focusFamily(state.ruleEffectGetter); containerLayoutFamily(state.ruleEffectGetter); } @@ -265,6 +266,22 @@ export function updateRules( }; } +/** + * A container answers `:active` for its queries through the press it already + * handles, or else through the touches its subtree receives, so registering a + * container never makes it a touch target. + */ +function observeContainerActivity( + state: ComponentState, + props: ComponentState["currentProps"], +) { + if (props?.onPress) { + activeFamily(state.ruleEffectGetter); + } else { + containerActiveFamily(state.ruleEffectGetter); + } +} + /** * Create variations of a style rule based on the config. * Cache for reference equality. diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index a32ac64d..fc09b7a6 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -283,3 +283,8 @@ export const containerHeightFamily = weakFamily((key) => { return read(containerLayoutFamily(key))?.height || 0; }); }); + +/** A container's `:active` as its queries read it, apart from `activeFamily`, whose presence makes an element pressable. */ +export const containerActiveFamily = weakFamily(() => + observable(false), +); diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..e3094d6d 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -6,6 +6,7 @@ import { getInteractionHandler } from "../react/interaction"; import type { ComponentState, Config } from "../react/useNativeCss"; import { activeFamily, + containerActiveFamily, containerLayoutFamily, family, focusFamily, @@ -265,6 +266,24 @@ export function getStyledProps( "onPressOut", inline?.onPressOut, ); + } else if (containerActiveFamily.has(state.ruleEffectGetter)) { + // Touch events bubble to every ancestor without a responder claim, so observing them leaves the subtree's gestures alone. + result ??= {}; + result.onTouchStart = getInteractionHandler( + state.ruleEffectGetter, + "onTouchStart", + inline?.onTouchStart, + ); + result.onTouchEnd = getInteractionHandler( + state.ruleEffectGetter, + "onTouchEnd", + inline?.onTouchEnd, + ); + result.onTouchCancel = getInteractionHandler( + state.ruleEffectGetter, + "onTouchCancel", + inline?.onTouchCancel, + ); } if (focusFamily.has(state.ruleEffectGetter)) {