mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(settings): keep note and paste fields at 16 px on a phone
On /settings/anamnesis the four note fields were raw textareas styled by three copies of a class constant with text-sm (14 px), and on /settings/export the three paste fields used text-xs (12 px) and the backup passphrase text-sm. iOS zooms the page into such a field on focus and leaves it zoomed. The notes use the Textarea primitive now and the export fields keep the small size only under a fine pointer. The 16 px guard read the five field primitives and could not see a call site override their size, or a raw field styled from a constant. It now parses every field call site; four that are still small sit on a list that only shrinks. The focus guard drops the three records files, whose fields come from the primitive now.
This commit is contained in:
1 parent
2a60f21a8b
commit
3a249bccba
9 files changed
+193
-28
No files matched your search
@@ -9,17 +9,21 @@
|
||||
* 5 px content box and pushed the logo across its bottom border).
|
||||
* 2. The stylesheet carries the rules the shell and the keyboard bridge
|
||||
* depend on.
|
||||
* 3. Every form-field primitive keeps 16 px type on a touch screen: iOS
|
||||
* Safari zooms the page into a focused field below 16 px, and a width
|
||||
* 3. Every form field keeps 16 px type on a touch screen: iOS Safari
|
||||
* zooms the page into a focused field below 16 px, and a width
|
||||
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
|
||||
* and to every iPad. The small size is allowed only behind
|
||||
* `pointer-fine:`.
|
||||
* `pointer-fine:`. That holds for the primitives and for every call
|
||||
* site, whose `text-sm` replaces the primitive's size, and for a raw
|
||||
* `<textarea>` styled from a class constant.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripComments } from "./helpers/source-files";
|
||||
import { parse, productionSources, readSrc } from "./helpers/source-metrics";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
|
||||
@@ -165,3 +169,175 @@ describe("form fields keep 16 px on a touch screen", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Elements a person types into. */
|
||||
const FIELD_TAGS = new Set([
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"Input",
|
||||
"Textarea",
|
||||
"PasswordInput",
|
||||
"NativeSelect",
|
||||
]);
|
||||
/** `type` values of an `<input>` nobody types into. */
|
||||
const NOT_TYPED = new Set([
|
||||
"checkbox",
|
||||
"radio",
|
||||
"range",
|
||||
"file",
|
||||
"hidden",
|
||||
"color",
|
||||
"button",
|
||||
"submit",
|
||||
"reset",
|
||||
"image",
|
||||
]);
|
||||
/** A type size under 16 px that is not behind `pointer-fine:`. */
|
||||
const SMALL_TYPE =
|
||||
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:2xs|xs|sm)(?![\w-])/g;
|
||||
|
||||
/** Every string a `className` expression can produce, constants resolved. */
|
||||
function classStrings(
|
||||
node: ts.Node,
|
||||
constants: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return [node.text];
|
||||
}
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.flatMap((span) => [
|
||||
...classStrings(span.expression, constants),
|
||||
span.literal.text,
|
||||
]),
|
||||
];
|
||||
}
|
||||
if (ts.isIdentifier(node)) {
|
||||
const value = constants.get(node.text);
|
||||
return value === undefined ? [] : [value];
|
||||
}
|
||||
const out: string[] = [];
|
||||
node.forEachChild((child) => {
|
||||
out.push(...classStrings(child, constants));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `rel:line <Tag> class` for each field whose classes go under 16 px. */
|
||||
function smallFields(
|
||||
rel: string,
|
||||
text: string,
|
||||
): {
|
||||
fields: number;
|
||||
small: string[];
|
||||
} {
|
||||
const sf = parse(rel, text);
|
||||
const constants = new Map<string, string>();
|
||||
sf.forEachChild((statement) => {
|
||||
if (!ts.isVariableStatement(statement)) return;
|
||||
for (const decl of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(decl.name) &&
|
||||
decl.initializer &&
|
||||
(ts.isStringLiteral(decl.initializer) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(decl.initializer))
|
||||
) {
|
||||
constants.set(decl.name.text, decl.initializer.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
let fields = 0;
|
||||
const small: string[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
|
||||
FIELD_TAGS.has(node.tagName.getText(sf))
|
||||
) {
|
||||
const attribute = (name: string) =>
|
||||
node.attributes.properties.find(
|
||||
(prop): prop is ts.JsxAttribute =>
|
||||
ts.isJsxAttribute(prop) && prop.name.getText(sf) === name,
|
||||
);
|
||||
const type = attribute("type")?.initializer;
|
||||
const typed = !(
|
||||
type &&
|
||||
ts.isStringLiteral(type) &&
|
||||
NOT_TYPED.has(type.text)
|
||||
);
|
||||
const className = attribute("className")?.initializer;
|
||||
if (typed && className) {
|
||||
fields += 1;
|
||||
const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
for (const cls of classStrings(className, constants)) {
|
||||
for (const match of cls.matchAll(SMALL_TYPE)) {
|
||||
small.push(
|
||||
`${rel}:${line} <${node.tagName.getText(sf)}> ${match[0]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
visit(sf);
|
||||
return { fields, small };
|
||||
}
|
||||
|
||||
describe("no field call site goes under 16 px on a touch screen", () => {
|
||||
/**
|
||||
* Call sites still under 16 px, fixed by a change of their own. An entry
|
||||
* that stops matching fails, so the list only shrinks.
|
||||
*/
|
||||
const OPEN: Readonly<Record<string, string>> = {
|
||||
"components/settings/coach-memory-section.tsx":
|
||||
"/settings/coach memory editor",
|
||||
"components/settings/access/managed-profile-handover.tsx":
|
||||
"read-only handover link field",
|
||||
"components/cycle/cycle-settings.tsx": "raw cycle-length number field",
|
||||
"components/command-palette/command-palette.tsx":
|
||||
"search field shrinks at `md:` without `pointer-fine:`",
|
||||
};
|
||||
|
||||
const scan = productionSources()
|
||||
.filter((rel) => rel.endsWith(".tsx"))
|
||||
.map((rel) => ({ rel, ...smallFields(rel, readSrc(rel)) }));
|
||||
|
||||
it("reads the field call sites (floor)", () => {
|
||||
const fields = scan.reduce((sum, file) => sum + file.fields, 0);
|
||||
expect(fields).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it("finds a class constant on a raw field, and spares fine-pointer sizes and checkboxes", () => {
|
||||
const { small } = smallFields(
|
||||
"probe.tsx",
|
||||
[
|
||||
'const FIELD_CLASSES = "border-input w-full text-sm";',
|
||||
"export const A = () => (",
|
||||
" <>",
|
||||
' <textarea className={cn(FIELD_CLASSES, "min-h-16")} />',
|
||||
' <Textarea className="font-mono sm:pointer-fine:text-xs" />',
|
||||
' <input type="checkbox" className="text-sm" />',
|
||||
' <Input className={`h-9 ${wide ? "md:text-sm" : ""}`} />',
|
||||
" </>",
|
||||
");",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(small).toEqual([
|
||||
"probe.tsx:4 <textarea> text-sm",
|
||||
"probe.tsx:7 <Input> md:text-sm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("no typed-into field sets a size under 16 px outside a fine pointer", () => {
|
||||
const offenders = scan
|
||||
.filter((file) => !(file.rel in OPEN))
|
||||
.flatMap((file) => file.small);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(Object.keys(OPEN))("%s is still open", (rel) => {
|
||||
expect(scan.find((file) => file.rel === rel)?.small ?? []).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -41,9 +41,6 @@ const TEXT_FIELD_FILES = [
|
||||
"src/components/ui/calendar.tsx",
|
||||
"src/components/insights/coach-panel/coach-input.tsx",
|
||||
"src/components/cycle/cycle-settings.tsx",
|
||||
"src/components/records/about-me-note-manager.tsx",
|
||||
"src/components/records/conditions-manager.tsx",
|
||||
"src/components/records/allergy-free-text-note.tsx",
|
||||
];
|
||||
|
||||
const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/;
|
||||
|
||||
Reference in new issue
Block a user