Skip to content

Commit 46351f4

Browse files
committed
fix(ios): orientation handling so landscape devices work
1 parent 09696b8 commit 46351f4

6 files changed

Lines changed: 367 additions & 37 deletions

File tree

packages/client/src/features/accessibility/AccessibilityOverlay.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,45 @@ describe("AccessibilityOverlay", () => {
129129
expect(markup).toContain("accessibility-rect skeleton");
130130
expect(markup).not.toContain("<span>Continue</span>");
131131
});
132+
133+
it("converts display-space frames into natural space for a rotated landscape display", () => {
134+
// Landscape display (1210x834). The child is the right half of the display
135+
// in display space; after the shell's 270deg rotation it must land in the
136+
// bottom half of the natural (portrait) presentation box.
137+
const roots = [
138+
{
139+
frame: { height: 834, width: 1210, x: 0, y: 0 },
140+
role: "application",
141+
children: [
142+
{
143+
AXLabel: "RightHalf",
144+
frame: { height: 834, width: 605, x: 605, y: 0 },
145+
type: "Button",
146+
},
147+
],
148+
},
149+
];
150+
151+
const rotated = renderToStaticMarkup(
152+
createElement(AccessibilityOverlay, {
153+
hoveredId: null,
154+
roots,
155+
rotationQuarterTurns: 3,
156+
selectedId: "",
157+
}),
158+
);
159+
// display right-half -> natural bottom-half.
160+
expect(rotated).toContain("height:50%;left:0%;top:50%;width:100%");
161+
162+
const unrotated = renderToStaticMarkup(
163+
createElement(AccessibilityOverlay, {
164+
hoveredId: null,
165+
roots,
166+
rotationQuarterTurns: 0,
167+
selectedId: "",
168+
}),
169+
);
170+
// With no rotation the frame keeps its display-space placement (right half).
171+
expect(unrotated).toContain("height:100%;left:50%;top:0%;width:50%");
172+
});
132173
});

packages/client/src/features/accessibility/AccessibilityOverlay.tsx

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createElement, type AriaRole, type CSSProperties } from "react";
22

33
import type { AccessibilityNode } from "../../api/types";
4+
import { mapDisplayedPointToNaturalOrientation } from "../viewport/viewportMath";
45
import {
56
accessibilityKind,
67
accessibilityIdentifier,
@@ -16,13 +17,15 @@ import {
1617
interface AccessibilityOverlayProps {
1718
hoveredId: string | null;
1819
roots: AccessibilityNode[];
20+
rotationQuarterTurns?: number;
1921
selectedId: string;
2022
skeletonVisible?: boolean;
2123
}
2224

2325
export function AccessibilityOverlay({
2426
hoveredId,
2527
roots,
28+
rotationQuarterTurns = 0,
2629
selectedId,
2730
skeletonVisible = false,
2831
}: AccessibilityOverlayProps) {
@@ -61,6 +64,7 @@ export function AccessibilityOverlay({
6164
key={item.id}
6265
node={item.node}
6366
rootFrame={rootFrame}
67+
rotationQuarterTurns={rotationQuarterTurns}
6468
/>
6569
))}
6670
</div>
@@ -71,15 +75,26 @@ export function AccessibilityOverlay({
7175
key={`skeleton-${item.id}`}
7276
node={item.node}
7377
rootFrame={rootFrame}
78+
rotationQuarterTurns={rotationQuarterTurns}
7479
variant="skeleton"
7580
/>
7681
))
7782
: null}
7883
{hovered ? (
79-
<NodeRect node={hovered} rootFrame={rootFrame} variant="hovered" />
84+
<NodeRect
85+
node={hovered}
86+
rootFrame={rootFrame}
87+
rotationQuarterTurns={rotationQuarterTurns}
88+
variant="hovered"
89+
/>
8090
) : null}
8191
{selected ? (
82-
<NodeRect node={selected} rootFrame={rootFrame} variant="selected" />
92+
<NodeRect
93+
node={selected}
94+
rootFrame={rootFrame}
95+
rotationQuarterTurns={rotationQuarterTurns}
96+
variant="selected"
97+
/>
8398
) : null}
8499
</div>
85100
</div>
@@ -107,20 +122,18 @@ function framedNode(
107122
function NodeRect({
108123
node,
109124
rootFrame,
125+
rotationQuarterTurns,
110126
variant,
111127
}: {
112128
node: AccessibilityNode;
113129
rootFrame: { height: number; width: number; x: number; y: number };
130+
rotationQuarterTurns: number;
114131
variant: "hovered" | "selected" | "skeleton";
115132
}) {
116133
if (!validFrame(node.frame)) {
117134
return null;
118135
}
119136

120-
const left = ((node.frame.x - rootFrame.x) / rootFrame.width) * 100;
121-
const top = ((node.frame.y - rootFrame.y) / rootFrame.height) * 100;
122-
const width = (node.frame.width / rootFrame.width) * 100;
123-
const height = (node.frame.height / rootFrame.height) * 100;
124137
const label =
125138
variant === "skeleton"
126139
? ""
@@ -129,12 +142,7 @@ function NodeRect({
129142
return (
130143
<div
131144
className={`accessibility-rect ${variant}`}
132-
style={{
133-
height: `${height}%`,
134-
left: `${left}%`,
135-
top: `${top}%`,
136-
width: `${width}%`,
137-
}}
145+
style={frameStyle(node.frame, rootFrame, rotationQuarterTurns)}
138146
>
139147
{label ? <span>{label}</span> : null}
140148
</div>
@@ -146,11 +154,13 @@ function AccessibilityDomNode({
146154
id,
147155
node,
148156
rootFrame,
157+
rotationQuarterTurns,
149158
}: {
150159
depth: number;
151160
id: string;
152161
node: AccessibilityNode;
153162
rootFrame: { height: number; width: number; x: number; y: number };
163+
rotationQuarterTurns: number;
154164
}) {
155165
if (!validFrame(node.frame)) {
156166
return null;
@@ -200,19 +210,40 @@ function AccessibilityDomNode({
200210
"data-simdeck-inspector-id": node.inspectorId || undefined,
201211
"data-simdeck-uikit-id": node.uikitId || undefined,
202212
role,
203-
style: frameStyle(node.frame, rootFrame),
213+
style: frameStyle(node.frame, rootFrame, rotationQuarterTurns),
204214
});
205215
}
206216

217+
// Native-ax frames arrive in DISPLAY (interface) space. The overlay lives
218+
// inside the shell container that CSS-rotates by `rotationQuarterTurns`, so the
219+
// frame is converted back into the device's natural (pre-rotation) space here;
220+
// the shared shell rotation then carries the overlay and the video to the
221+
// displayed orientation together. `rotationQuarterTurns === 0` is the identity,
222+
// so portrait devices keep their existing behavior.
207223
function frameStyle(
208224
frame: { height: number; width: number; x: number; y: number },
209225
rootFrame: { height: number; width: number; x: number; y: number },
226+
rotationQuarterTurns: number,
210227
): CSSProperties {
228+
const start = mapDisplayedPointToNaturalOrientation(
229+
{
230+
x: (frame.x - rootFrame.x) / rootFrame.width,
231+
y: (frame.y - rootFrame.y) / rootFrame.height,
232+
},
233+
rotationQuarterTurns,
234+
);
235+
const end = mapDisplayedPointToNaturalOrientation(
236+
{
237+
x: (frame.x + frame.width - rootFrame.x) / rootFrame.width,
238+
y: (frame.y + frame.height - rootFrame.y) / rootFrame.height,
239+
},
240+
rotationQuarterTurns,
241+
);
211242
return {
212-
height: `${(frame.height / rootFrame.height) * 100}%`,
213-
left: `${((frame.x - rootFrame.x) / rootFrame.width) * 100}%`,
214-
top: `${((frame.y - rootFrame.y) / rootFrame.height) * 100}%`,
215-
width: `${(frame.width / rootFrame.width) * 100}%`,
243+
height: `${Math.abs(end.y - start.y) * 100}%`,
244+
left: `${Math.min(start.x, end.x) * 100}%`,
245+
top: `${Math.min(start.y, end.y) * 100}%`,
246+
width: `${Math.abs(end.x - start.x) * 100}%`,
216247
};
217248
}
218249

packages/client/src/features/viewport/DeviceChrome.tsx

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
} from "../../api/types";
88
import { AccessibilityOverlay } from "../accessibility/AccessibilityOverlay";
99
import { findAccessibilityItemAtPoint } from "../accessibility/accessibilityTree";
10-
import { normalizedPointerCoordinatesForOrientation } from "../input/gestureMath";
10+
import { normalizedPointerCoordinates } from "../input/gestureMath";
1111
import type { TouchIndicator } from "./types";
1212

1313
interface DeviceChromeProps {
@@ -698,6 +698,7 @@ function ScreenLayer({
698698
<AccessibilityOverlay
699699
hoveredId={accessibilityHoveredId}
700700
roots={accessibilityRoots}
701+
rotationQuarterTurns={rotationQuarterTurns}
701702
selectedId={accessibilitySelectedId}
702703
skeletonVisible={accessibilitySkeletonVisible}
703704
/>
@@ -719,22 +720,12 @@ function ScreenLayer({
719720
onPointerMove={(event) => {
720721
event.preventDefault();
721722
event.stopPropagation();
722-
onPickerHover(
723-
hitTestAccessibilityId(
724-
event,
725-
accessibilityRoots,
726-
rotationQuarterTurns,
727-
),
728-
);
723+
onPickerHover(hitTestAccessibilityId(event, accessibilityRoots));
729724
}}
730725
onPointerUp={(event) => {
731726
event.preventDefault();
732727
event.stopPropagation();
733-
const id = hitTestAccessibilityId(
734-
event,
735-
accessibilityRoots,
736-
rotationQuarterTurns,
737-
);
728+
const id = hitTestAccessibilityId(event, accessibilityRoots);
738729
if (id) {
739730
onPickerSelect(id);
740731
}
@@ -788,15 +779,16 @@ function TouchInteractionOverlay({
788779
);
789780
}
790781

782+
// Native-ax frames are in DISPLAY (interface) space, so the picker compares the
783+
// pointer directly in displayed coordinates — no display→natural remap. (The
784+
// overlay does the inverse remap for rendering because it lives inside the
785+
// CSS-rotated shell; the pointer here is already measured against the displayed,
786+
// post-rotation bounding box.)
791787
function hitTestAccessibilityId(
792788
event: React.PointerEvent<HTMLElement>,
793789
roots: AccessibilityNode[],
794-
rotationQuarterTurns: number,
795790
): string | null {
796-
const point = normalizedPointerCoordinatesForOrientation(
797-
event,
798-
rotationQuarterTurns,
799-
);
791+
const point = normalizedPointerCoordinates(event);
800792
if (!point) {
801793
return null;
802794
}

0 commit comments

Comments
 (0)