@multiplatform.one/config 7.41.0 → 7.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/design-lint.mjs CHANGED
@@ -67,7 +67,23 @@ export const designRules = [
67
67
  "styled-mpo",
68
68
  "token-file",
69
69
  ];
70
- export function inspectDesign(source, filename) {
70
+ export function isFreeformBoard(filename, projectRoot = process.cwd()) {
71
+ const root = path.resolve(projectRoot);
72
+ const absolute = path.resolve(root, filename);
73
+ const inArea = (name, base) =>
74
+ path
75
+ .relative(base, name)
76
+ .replaceAll("\\", "/")
77
+ .startsWith("apps/storybook-expo/boards/freeform/");
78
+ return (
79
+ inArea(absolute, root) &&
80
+ (!existsSync(absolute) ||
81
+ inArea(realpathSync(absolute), existsSync(root) ? realpathSync(root) : root))
82
+ );
83
+ }
84
+
85
+ export function inspectDesign(source, filename, projectRoot = process.cwd()) {
86
+ if (isFreeformBoard(filename, projectRoot)) return [];
71
87
  const file = ts.createSourceFile(
72
88
  filename,
73
89
  source,
@@ -365,10 +381,11 @@ const ignoredDirectories = new Set([
365
381
  "scripts",
366
382
  ]);
367
383
 
368
- export function designFiles(input) {
384
+ export function designFiles(input, projectRoot = process.cwd()) {
369
385
  const root = path.resolve(input);
370
386
  const info = statSync(root);
371
387
  const eligible = (filename) =>
388
+ !isFreeformBoard(path.resolve(root, filename), projectRoot) &&
372
389
  /\.[cm]?[jt]sx?$/.test(filename) &&
373
390
  !/\.(?:test|spec|stories|config|gen|generated|d)\.[cm]?[jt]sx?$/.test(filename);
374
391
  if (info.isFile()) return eligible(root) ? [root] : [];
@@ -427,7 +444,7 @@ export function countDesign(errors) {
427
444
  }
428
445
 
429
446
  export function scanDesign(paths) {
430
- const files = [...new Set(paths.flatMap(designFiles))];
447
+ const files = [...new Set(paths.flatMap((input) => designFiles(input)))];
431
448
  if (!files.length) throw new Error("No application source files found in the supplied paths.");
432
449
  const errors = files.flatMap((filename) =>
433
450
  inspectDesign(readFileSync(filename, "utf8"), filename),
package/lint.mjs CHANGED
@@ -79,6 +79,10 @@
79
79
  * Size, spacing and radius are the preset's token scale; the only
80
80
  * per-site control is Tamagui's own `size="$n"`. Specs are exempt: they
81
81
  * are where the retirement is asserted.
82
+ * 17. Fail on a NESTED PRESS CLAIMANT (FZ-7792 NATIVE-PRESS-OWNER) — a view
83
+ * with `pressStyle` / `focusVisibleStyle` and no press handler of its own
84
+ * inside a JSX pressable. On native it claims the responder and the
85
+ * pressable's onPress never runs. See press-lint.mjs.
82
86
  *
83
87
  * Usage:
84
88
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -94,6 +98,7 @@ import { dirname, join, relative } from "node:path";
94
98
  import { pathToFileURL } from "node:url";
95
99
  import { DRAWING_PRAGMA, isDrawingFile, readDrawingRegistry } from "./drawing-registry.mjs";
96
100
  import { findHexEscapes } from "./oxlint.mjs";
101
+ import { exportedPressClaimants, findNestedPressClaimants } from "./press-lint.mjs";
97
102
  import { readHexEscapeRegistry, readSizeRecipeEscapeRegistry } from "./spec-registry.mjs";
98
103
 
99
104
  /**
@@ -1531,7 +1536,7 @@ const STORY_HONESTY_MARKER_RE = /\bforeignContrastSpecimen\s*:\s*true\b/;
1531
1536
  * forms reaches frappe through its @multiplatform.one/storybook devDependency,
1532
1537
  * so the import would close a workspace cycle.
1533
1538
  */
1534
- const STORY_HONESTY_EXEMPT_PREFIXES = ["public/frappe/"];
1539
+ const STORY_HONESTY_EXEMPT_PREFIXES = ["public/frappe/", "apps/storybook-expo/boards/freeform/"];
1535
1540
 
1536
1541
  const TAMAGUI_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']tamagui["']/g;
1537
1542
 
@@ -1930,6 +1935,7 @@ function readLayoutBaselines() {
1930
1935
  /** Screen code: where a page composes chrome (not the catalog primitives). */
1931
1936
  function isScreenSource(rel) {
1932
1937
  const path = rel.replaceAll("\\", "/");
1938
+ if (path.startsWith("apps/storybook-expo/boards/freeform/")) return false;
1933
1939
  return (
1934
1940
  path.startsWith("public/backoffice/src/") ||
1935
1941
  path.startsWith("public/frappe-ui/src/") ||
@@ -1998,7 +2004,12 @@ function checkHugContent() {
1998
2004
  const seen = new Set();
1999
2005
  for (const { rel, src } of sourceFiles()) {
2000
2006
  const path = rel.replaceAll("\\", "/");
2001
- if (!/\.(tsx|ts)$/.test(path) || /\.stories\./.test(path)) continue;
2007
+ if (
2008
+ !/\.(tsx|ts)$/.test(path) ||
2009
+ /\.stories\./.test(path) ||
2010
+ path.startsWith("apps/storybook-expo/boards/freeform/")
2011
+ )
2012
+ continue;
2002
2013
  const live = nonCommentOffsets(src);
2003
2014
  const hits = [...src.matchAll(SPREAD_JUSTIFY_RE)].filter((m) => live[m.index ?? 0] === 1);
2004
2015
  const allowed = baselines.hugContent[path] ?? 0;
@@ -2093,6 +2104,35 @@ function checkAccentSolidOwners() {
2093
2104
  return violations;
2094
2105
  }
2095
2106
 
2107
+ /**
2108
+ * FZ-7792 NATIVE-PRESS-OWNER: see press-lint.mjs. Two passes, so a styled
2109
+ * claimant exported by one file is caught where another file nests it.
2110
+ */
2111
+ function checkNativePressOwner() {
2112
+ /** @type {{ rel: string, src: string, abs: string }[]} */
2113
+ const files = [];
2114
+ walkOwnedSource((filePath) => {
2115
+ if (!/\.(?:tsx|jsx)$/.test(filePath) || /\.(?:spec|test)\./.test(filePath)) return;
2116
+ files.push({
2117
+ abs: filePath,
2118
+ rel: relative(ROOT, filePath).replaceAll("\\", "/"),
2119
+ src: readFileSync(filePath, "utf8"),
2120
+ });
2121
+ });
2122
+ /** @type {Set<string>} */
2123
+ const imported = new Set();
2124
+ for (const { abs, src } of files)
2125
+ for (const name of exportedPressClaimants(src, abs)) imported.add(name);
2126
+ /** @type {string[]} */
2127
+ const violations = [];
2128
+ for (const { abs, rel, src } of files)
2129
+ for (const hit of findNestedPressClaimants(src, abs, imported))
2130
+ violations.push(
2131
+ `${rel}:${hit.line} — <${hit.tag}> claims the press inside <${hit.owner}>; move its press look to the owner's group ("$group-<name>-press")`,
2132
+ );
2133
+ return violations;
2134
+ }
2135
+
2096
2136
  /**
2097
2137
  * Run structural convention checks over a consumer tree.
2098
2138
  *
@@ -2125,6 +2165,7 @@ export function runConventionChecks(options = {}) {
2125
2165
  const oneSidebar = checkOneSidebar();
2126
2166
  const hugContent = checkHugContent();
2127
2167
  const responsiveParity = checkResponsiveParity();
2168
+ const nativePressOwner = checkNativePressOwner();
2128
2169
  let failed = false;
2129
2170
 
2130
2171
  for (const [rule, list] of /** @type {const} */ ([
@@ -2140,6 +2181,10 @@ export function runConventionChecks(options = {}) {
2140
2181
  "Convention (LC-103 RESPONSIVE-PARITY): a phone shows the same information. Content hidden on phones names its phone twin (⋯ menu, icon twin, sheet) with a `responsive-parity: <reason>` comment.",
2141
2182
  responsiveParity,
2142
2183
  ],
2184
+ [
2185
+ 'Convention (FZ-7792 NATIVE-PRESS-OWNER, check 17): one view owns a press. On native, a view with pressStyle or focusVisibleStyle and no handler claims the touch, and React Native gives it to the deepest claimant, so a pressable\'s onPress never runs when the tap lands on that child. Put `group="<name>"` on the pressable and move the child\'s press look to `"$group-<name>-press"`.',
2186
+ nativePressOwner,
2187
+ ],
2143
2188
  ])) {
2144
2189
  if (!list.length) continue;
2145
2190
  failed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.41.0",
3
+ "version": "7.43.0",
4
4
  "description": "Shared build and test configuration presets for multiplatform.one",
5
5
  "keywords": [
6
6
  "config",
@@ -46,7 +46,9 @@
46
46
  "!tsconfig/types",
47
47
  "types",
48
48
  "!types/.tsbuildinfo",
49
- "design-lint.mjs"
49
+ "design-lint.mjs",
50
+ "press-lint.mjs",
51
+ "press-lint.d.ts"
50
52
  ],
51
53
  "type": "module",
52
54
  "sideEffects": false,
@@ -104,7 +106,7 @@
104
106
  "access": "public"
105
107
  },
106
108
  "dependencies": {
107
- "@multiplatform.one/utils": "^7.41.0",
109
+ "@multiplatform.one/utils": "^7.43.0",
108
110
  "@tamagui/vite-plugin": "2.7.6",
109
111
  "@vitejs/plugin-react": "^6.1.2",
110
112
  "dotenv": "^17.4.2",
@@ -0,0 +1,13 @@
1
+ export interface NestedPressClaimant {
2
+ line: number;
3
+ tag: string;
4
+ owner: string;
5
+ }
6
+
7
+ export function exportedPressClaimants(source: string, filename: string): string[];
8
+
9
+ export function findNestedPressClaimants(
10
+ source: string,
11
+ filename: string,
12
+ imported?: Set<string>,
13
+ ): NestedPressClaimant[];
package/press-lint.mjs ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * NATIVE-PRESS-OWNER (FZ-7792): one view owns a press.
3
+ *
4
+ * On native without react-native-gesture-handler, tamagui synthesizes an
5
+ * onPress for any view carrying `pressStyle` or `focusVisibleStyle` and wires
6
+ * it through the responder system, whose onStartShouldSetResponder answers
7
+ * true. React Native grants a touch to the DEEPEST view that asks, so such a
8
+ * view inside a real pressable takes the tap: it paints its press colour and
9
+ * the pressable's onPress never runs. On web the DOM click bubbles to the
10
+ * pressable, so web specs, Storybook and jsdom all pass (Alert's action pill,
11
+ * FZ-7792).
12
+ *
13
+ * Flagged: a JSX element that claims the touch without handling it, nested
14
+ * among the JSX children of an element that handles it.
15
+ *
16
+ * - claims: a `pressStyle` or `focusVisibleStyle` prop, or a tag bound to
17
+ * `styled(...)` whose config carries either key at any depth (variants
18
+ * included), or to `styled()` of such a tag, in this file or imported by
19
+ * name from another scanned file that exports it;
20
+ * - handles: `onPress`, `onPressIn`, `onPressOut` or `onLongPress`, or being
21
+ * the child of an `asChild` element, which slots its handler in;
22
+ * - not flagged: a claimant that handles the press itself, renders `asChild`,
23
+ * is a text input (tamagui wires inputs' presses directly, never through
24
+ * the responder), carries `pointerEvents="box-none"`, or sits under (or
25
+ * carries) `pointerEvents="none"`.
26
+ *
27
+ * Fix: move the look onto the owner's group state (`group="name"` on the
28
+ * pressable, `"$group-name-press": {...}` on the child).
29
+ */
30
+ import ts from "typescript";
31
+
32
+ const claimKeys = new Set(["pressStyle", "focusVisibleStyle"]);
33
+ const pressHandlers = new Set(["onPress", "onPressIn", "onPressOut", "onLongPress"]);
34
+ const inputTag = /(?:Input|TextArea)$/;
35
+
36
+ /** @param {ts.Node} node */
37
+ function keyName(node) {
38
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node)) return node.text;
39
+ return undefined;
40
+ }
41
+
42
+ /** @param {ts.Expression} expr */
43
+ function unwrap(expr) {
44
+ let current = expr;
45
+ while (
46
+ ts.isAsExpression(current) ||
47
+ ts.isSatisfiesExpression(current) ||
48
+ ts.isParenthesizedExpression(current)
49
+ )
50
+ current = current.expression;
51
+ return current;
52
+ }
53
+
54
+ /**
55
+ * `styled(Base, {...})`, also under `.styleable(...)` and friends.
56
+ * @param {ts.Expression} expr
57
+ * @returns {ts.CallExpression | undefined}
58
+ */
59
+ function styledCall(expr) {
60
+ let current = unwrap(expr);
61
+ while (ts.isCallExpression(current)) {
62
+ const callee = current.expression;
63
+ if (ts.isIdentifier(callee) && callee.text === "styled") return current;
64
+ if (ts.isPropertyAccessExpression(callee) && ts.isCallExpression(callee.expression))
65
+ current = callee.expression;
66
+ else return undefined;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * @param {string} source
73
+ * @param {string} filename
74
+ */
75
+ function parse(source, filename) {
76
+ const file = ts.createSourceFile(
77
+ filename,
78
+ source,
79
+ ts.ScriptTarget.Latest,
80
+ true,
81
+ ts.ScriptKind.TSX,
82
+ );
83
+ /** @type {Map<string, ts.Expression>} */
84
+ const objects = new Map();
85
+ /** @type {Map<string, ts.CallExpression>} */
86
+ const styledCalls = new Map();
87
+ /** @type {Set<string>} */
88
+ const exported = new Set();
89
+ /** @type {Set<string>} */
90
+ const importedNames = new Set();
91
+ /** @param {ts.Node} node */
92
+ function collect(node) {
93
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
94
+ const init = unwrap(node.initializer);
95
+ if (ts.isObjectLiteralExpression(init)) objects.set(node.name.text, init);
96
+ const call = styledCall(init);
97
+ if (call) styledCalls.set(node.name.text, call);
98
+ const statement = node.parent?.parent;
99
+ if (
100
+ statement &&
101
+ ts.isVariableStatement(statement) &&
102
+ statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
103
+ )
104
+ exported.add(node.name.text);
105
+ }
106
+ if (ts.isExportSpecifier(node) && !node.parent.parent.moduleSpecifier)
107
+ exported.add((node.propertyName ?? node.name).text);
108
+ if (ts.isImportSpecifier(node) && !node.isTypeOnly) importedNames.add(node.name.text);
109
+ ts.forEachChild(node, collect);
110
+ }
111
+ collect(file);
112
+
113
+ /** @param {ts.Node | undefined} node @param {Set<ts.Node>} seen @returns {boolean} */
114
+ function carriesClaim(node, seen = new Set()) {
115
+ if (!node || seen.has(node)) return false;
116
+ seen.add(node);
117
+ if (ts.isIdentifier(node)) return carriesClaim(objects.get(node.text), seen);
118
+ if (ts.isObjectLiteralExpression(node))
119
+ return node.properties.some((prop) => {
120
+ if (ts.isSpreadAssignment(prop)) return carriesClaim(prop.expression, seen);
121
+ const key = prop.name && keyName(prop.name);
122
+ if (key && claimKeys.has(key)) return true;
123
+ return ts.isPropertyAssignment(prop) && carriesClaim(prop.initializer, seen);
124
+ });
125
+ if (
126
+ ts.isParenthesizedExpression(node) ||
127
+ ts.isAsExpression(node) ||
128
+ ts.isSatisfiesExpression(node)
129
+ )
130
+ return carriesClaim(node.expression, seen);
131
+ if (ts.isConditionalExpression(node))
132
+ return carriesClaim(node.whenTrue, seen) || carriesClaim(node.whenFalse, seen);
133
+ return false;
134
+ }
135
+
136
+ /** @type {Map<string, boolean>} */
137
+ const cache = new Map();
138
+ /**
139
+ * @param {string} name
140
+ * @param {Set<string>} imported claimant names other files export
141
+ * @returns {boolean}
142
+ */
143
+ function isClaimant(name, imported) {
144
+ if (cache.has(name)) return /** @type {boolean} */ (cache.get(name));
145
+ cache.set(name, false);
146
+ const call = styledCalls.get(name);
147
+ let result = false;
148
+ if (call) {
149
+ const [base, ...configs] = call.arguments;
150
+ result =
151
+ configs.some((config) => carriesClaim(config)) ||
152
+ (!!base && ts.isIdentifier(base) && isClaimant(base.text, imported));
153
+ } else if (importedNames.has(name) && imported.has(name)) {
154
+ result = true;
155
+ }
156
+ cache.set(name, result);
157
+ return result;
158
+ }
159
+ /** @param {string} name */
160
+ function isInput(name) {
161
+ for (let current = name, hops = 0; current && hops < 8; hops++) {
162
+ if (inputTag.test(current)) return true;
163
+ const base = styledCalls.get(current)?.arguments[0];
164
+ current = base && ts.isIdentifier(base) ? base.text : "";
165
+ }
166
+ return false;
167
+ }
168
+ return { file, exported, isClaimant, isInput };
169
+ }
170
+
171
+ /**
172
+ * Names this file exports that claim a press on native (a styled component
173
+ * whose config carries pressStyle or focusVisibleStyle).
174
+ *
175
+ * @param {string} source
176
+ * @param {string} filename
177
+ * @returns {string[]}
178
+ */
179
+ export function exportedPressClaimants(source, filename) {
180
+ if (!/pressStyle|focusVisibleStyle/.test(source)) return [];
181
+ const { exported, isClaimant, isInput } = parse(source, filename);
182
+ return [...exported].filter((name) => !isInput(name) && isClaimant(name, new Set()));
183
+ }
184
+
185
+ /**
186
+ * @param {string} source
187
+ * @param {string} filename
188
+ * @param {Set<string>} [imported] claimant names exported by other scanned files
189
+ * @returns {{ line: number, tag: string, owner: string }[]}
190
+ */
191
+ export function findNestedPressClaimants(source, filename, imported = new Set()) {
192
+ if (!/on(?:Press|LongPress)|asChild/.test(source)) return [];
193
+ const { file, isClaimant, isInput } = parse(source, filename);
194
+
195
+ /** @param {ts.JsxOpeningLikeElement} el */
196
+ function attrs(el) {
197
+ /** @type {Map<string, ts.JsxAttribute>} */
198
+ const map = new Map();
199
+ for (const attr of el.attributes.properties)
200
+ if (ts.isJsxAttribute(attr)) map.set(attr.name.getText(file), attr);
201
+ return map;
202
+ }
203
+ /** @param {ts.JsxAttribute | undefined} attr */
204
+ function literal(attr) {
205
+ if (!attr?.initializer) return attr ? true : undefined;
206
+ if (ts.isStringLiteral(attr.initializer)) return attr.initializer.text;
207
+ const expr = ts.isJsxExpression(attr.initializer) ? attr.initializer.expression : undefined;
208
+ if (expr && ts.isStringLiteral(expr)) return expr.text;
209
+ return undefined;
210
+ }
211
+
212
+ /** @type {{ line: number, tag: string, owner: string }[]} */
213
+ const found = [];
214
+ /**
215
+ * @param {ts.Node} node
216
+ * @param {string | undefined} owner the nearest pressable JSX ancestor
217
+ * @param {string | undefined} slot an `asChild` parent handing this element its handler
218
+ */
219
+ function visit(node, owner, slot) {
220
+ const el = ts.isJsxElement(node)
221
+ ? node.openingElement
222
+ : ts.isJsxSelfClosingElement(node)
223
+ ? node
224
+ : undefined;
225
+ if (!el) {
226
+ ts.forEachChild(node, (child) =>
227
+ visit(child, owner, ts.isJsxExpression(node) ? undefined : slot),
228
+ );
229
+ return;
230
+ }
231
+ const tag = el.tagName.getText(file);
232
+ const props = attrs(el);
233
+ const pointerEvents = literal(props.get("pointerEvents"));
234
+ if (pointerEvents === "none") return;
235
+ const handles = !!slot || [...pressHandlers].some((key) => props.has(key));
236
+ const named = ts.isIdentifier(el.tagName);
237
+ const claims =
238
+ !(named && isInput(tag)) &&
239
+ ([...claimKeys].some((key) => props.has(key)) || (named && isClaimant(tag, imported)));
240
+ if (owner && claims && !handles && !props.has("asChild") && pointerEvents !== "box-none") {
241
+ const { line } = file.getLineAndCharacterOfPosition(el.getStart(file));
242
+ found.push({ line: line + 1, tag, owner });
243
+ }
244
+ // Props render wherever the component puts them; only children are known
245
+ // to sit inside this element.
246
+ for (const attr of el.attributes.properties) visit(attr, owner, undefined);
247
+ if (ts.isJsxElement(node)) {
248
+ const childOwner = handles ? tag : owner;
249
+ const childSlot = props.has("asChild") ? tag : undefined;
250
+ for (const child of node.children) visit(child, childOwner, childSlot);
251
+ }
252
+ }
253
+ visit(file, undefined, undefined);
254
+ return found;
255
+ }
package/src/lint.spec.ts CHANGED
@@ -971,6 +971,22 @@ ${csfTail}`,
971
971
  expect(messages).toContain(`${file}:2 — \`Input as TamaguiInput\` from "tamagui"`);
972
972
  });
973
973
 
974
+ it("permits raw controls in freeform boards while preserving the catalog gate", () => {
975
+ const root = makeTree();
976
+ story(
977
+ root,
978
+ "apps/storybook-expo/boards/freeform/Demo.stories.tsx",
979
+ `import { Button } from "tamagui";${csfTail}`,
980
+ );
981
+ expect(run(root).code).toBe(0);
982
+ story(
983
+ root,
984
+ "apps/storybook-expo/stories/Demo.stories.tsx",
985
+ `import { Button } from "tamagui";${csfTail}`,
986
+ );
987
+ expect(run(root).messages).toContain("STORY-HONESTY");
988
+ });
989
+
974
990
  it("passes once the story carries parameters.foreignContrastSpecimen: true", () => {
975
991
  const root = makeTree();
976
992
  story(
@@ -1549,3 +1565,31 @@ const value = theme.accentBackground;`);
1549
1565
  expect(messages).toContain("public/components/src/Selected.tsx:2");
1550
1566
  });
1551
1567
  });
1568
+
1569
+ describe("freeform helper layout exploration", () => {
1570
+ it("exempts the board directory and retains layout checks on the same app helper", () => {
1571
+ const root = makeTree();
1572
+ mkdirSync(join(root, "docs"), { recursive: true });
1573
+ writeFileSync(
1574
+ join(root, "docs/layout-rule-baselines.json"),
1575
+ JSON.stringify({ secondSidebar: [], hugContent: {} }),
1576
+ );
1577
+ const board = join(root, "apps/storybook-expo/boards/freeform/Row.tsx");
1578
+ mkdirSync(join(board, ".."), { recursive: true });
1579
+ const source =
1580
+ 'const row = <XStack justifyContent="space-between" $max-sm={{display:"none"}}><FilterSidebar/></XStack>;';
1581
+ writeFileSync(board, source);
1582
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
1583
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1584
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1585
+ try {
1586
+ expect(runConventionChecks({ roots: { root } })).toBe(0);
1587
+ writeFileSync(join(root, "apps/demo/Row.tsx"), source);
1588
+ expect(runConventionChecks({ roots: { root } })).toBe(1);
1589
+ } finally {
1590
+ error.mockRestore();
1591
+ log.mockRestore();
1592
+ warn.mockRestore();
1593
+ }
1594
+ });
1595
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { exportedPressClaimants, findNestedPressClaimants } from "../press-lint.mjs";
3
+
4
+ const find = (source: string, imported?: Set<string>) =>
5
+ findNestedPressClaimants(source, "Fixture.tsx", imported).map((hit) => `${hit.tag}<${hit.owner}`);
6
+
7
+ describe("findNestedPressClaimants (FZ-7792 NATIVE-PRESS-OWNER)", () => {
8
+ it("flags a styled pressStyle pill inside its pressable target (Alert's action, before the fix)", () => {
9
+ const source = `
10
+ const Target = styled(View, { name: "T", pressStyle: { opacity: 0.9 } });
11
+ const Pill = styled(View, { name: "P", pressStyle: { backgroundColor: "$color6" } });
12
+ export const A = ({ onPress }) => (
13
+ <Target onPress={onPress}>
14
+ <Pill><Text>Save</Text></Pill>
15
+ </Target>
16
+ );`;
17
+ expect(find(source)).toEqual(["Pill<Target"]);
18
+ });
19
+
20
+ it("passes the fix: the pill reads the owner's group press state", () => {
21
+ const source = `
22
+ const Target = styled(View, { name: "T", pressStyle: { opacity: 0.9 } });
23
+ const Pill = styled(View, { name: "P", "$group-action-press": { backgroundColor: "$color6" } });
24
+ export const A = ({ onPress }) => (
25
+ <Target group="action" onPress={onPress}>
26
+ <Pill><Text>Save</Text></Pill>
27
+ </Target>
28
+ );`;
29
+ expect(find(source)).toEqual([]);
30
+ });
31
+
32
+ it("flags an inline pressStyle or focusVisibleStyle prop, at any depth below the owner", () => {
33
+ const source = `
34
+ export const A = () => (
35
+ <View onPress={go}>
36
+ <XStack>
37
+ <View pressStyle={{ opacity: 0.5 }} />
38
+ <View focusVisibleStyle={{ outlineWidth: 2 }} />
39
+ </XStack>
40
+ </View>
41
+ );`;
42
+ expect(find(source)).toEqual(["View<View", "View<View"]);
43
+ });
44
+
45
+ it("follows styled() of a claimant and a config object bound to a name", () => {
46
+ const source = `
47
+ const look = { variants: { on: { true: { pressStyle: { opacity: 0.5 } } } } };
48
+ const Base = styled(View, look);
49
+ const Ring = styled(Base, { name: "Ring" });
50
+ export const A = () => <Pressable onPress={go}><Ring /></Pressable>;`;
51
+ expect(find(source)).toEqual(["Ring<Pressable"]);
52
+ });
53
+
54
+ it("treats the child of an asChild element as the owner it slots a handler into", () => {
55
+ const source = `
56
+ const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
57
+ export const A = () => (
58
+ <Toast.Close asChild>
59
+ <CloseTarget><Ring /></CloseTarget>
60
+ </Toast.Close>
61
+ );`;
62
+ expect(find(source)).toEqual(["Ring<CloseTarget"]);
63
+ });
64
+
65
+ it("leaves alone a claimant that owns its press, renders asChild, is an input or cannot be touched", () => {
66
+ const source = `
67
+ const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
68
+ const Field = styled(Input, { focusVisibleStyle: { borderColor: "$accent" } });
69
+ export const A = () => (
70
+ <View onPress={go}>
71
+ <Ring onPress={other} />
72
+ <Ring asChild><View /></Ring>
73
+ <Field />
74
+ <Input focusVisibleStyle={{ borderColor: "$accent" }} />
75
+ <Ring pointerEvents="box-none" />
76
+ <View pointerEvents="none"><Ring /></View>
77
+ </View>
78
+ );`;
79
+ expect(find(source)).toEqual([]);
80
+ });
81
+
82
+ it("does not treat a prop's element as a child of the owner", () => {
83
+ const source = `
84
+ const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
85
+ export const A = () => <Row onPress={go} trailing={<Ring />} />;`;
86
+ expect(find(source)).toEqual([]);
87
+ });
88
+
89
+ it("ignores a claimant with no pressable around it", () => {
90
+ const source = `
91
+ const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
92
+ export const A = () => <View><Ring /></View>;`;
93
+ expect(find(source)).toEqual([]);
94
+ });
95
+
96
+ it("flags a claimant another file exports when it is imported by name", () => {
97
+ const source = `
98
+ import { Pill } from "./pill";
99
+ export const A = () => <View onPress={go}><Pill /></View>;`;
100
+ expect(find(source)).toEqual([]);
101
+ expect(find(source, new Set(["Pill"]))).toEqual(["Pill<View"]);
102
+ });
103
+ });
104
+
105
+ describe("exportedPressClaimants", () => {
106
+ it("lists exported styled claimants and skips inputs and plain views", () => {
107
+ const source = `
108
+ export const Pill = styled(View, { pressStyle: { opacity: 0.5 } });
109
+ const Ring = styled(View, { focusVisibleStyle: { outlineWidth: 2 } });
110
+ export const Plain = styled(View, { hoverStyle: { opacity: 0.5 } });
111
+ export const Field = styled(TextInput, { focusVisibleStyle: { outlineWidth: 2 } });
112
+ export { Ring };`;
113
+ expect(exportedPressClaimants(source, "Fixture.tsx").sort()).toEqual(["Pill", "Ring"]);
114
+ });
115
+ });