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)) {