| 1 | // Run: tsx src/__tests__/composer-autosize-scrollbar-css.test.ts |
| 2 | // |
| 3 | // Contract: in autosize mode the composer textarea hides its scrollbar while |
| 4 | // the content fits, but once the content exceeds the max height (the JS |
| 5 | // inline overflow-y flips to auto and the card gains |
| 6 | // `.composer-card--auto-overflow`) the thin scrollbar reappears so long |
| 7 | // drafts expose their scrollability (#8494/#8742/#9019). The hero (creation) |
| 8 | // input cap is min(30vh, 160px), not the old 96px hard clip. |
| 9 | |
| 10 | import { readFileSync } from "node:fs"; |
| 11 | import { dirname, resolve } from "node:path"; |
| 12 | import { fileURLToPath } from "node:url"; |
| 13 | |
| 14 | const testDir = dirname(fileURLToPath(import.meta.url)); |
| 15 | // Strip comments so declaration parsing never matches prose inside them. |
| 16 | const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); |
| 17 | const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8"); |
| 18 | |
| 19 | let passed = 0; |
| 20 | let failed = 0; |
| 21 | |
| 22 | function ok(value: boolean, label: string) { |
| 23 | if (value) { |
| 24 | process.stdout.write(` PASS ${label}\n`); |
| 25 | passed += 1; |
| 26 | } else { |
| 27 | process.stdout.write(` FAIL ${label}\n`); |
| 28 | failed += 1; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function eq(a: unknown, b: unknown, label: string) { |
| 33 | if (a === b) { |
| 34 | ok(true, label); |
| 35 | } else { |
| 36 | ok(false, `${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | function matchingBlocks(selector: string): string[] { |
| 41 | const blocks: string[] = []; |
| 42 | const rule = /([^{}]+)\{([^{}]*)\}/g; |
| 43 | let match: RegExpExecArray | null; |
| 44 | while ((match = rule.exec(styles)) !== null) { |
| 45 | const selectors = match[1].split(",").map((part) => part.trim()); |
| 46 | if (selectors.includes(selector)) blocks.push(match[2]); |
| 47 | } |
| 48 | return blocks; |
| 49 | } |
| 50 | |
| 51 | function finalDeclaration(selector: string, property: string): string | undefined { |
| 52 | let value: string | undefined; |
| 53 | for (const block of matchingBlocks(selector)) { |
| 54 | const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g"); |
| 55 | let match: RegExpExecArray | null; |
| 56 | while ((match = declaration.exec(block)) !== null) { |
| 57 | value = match[1].trim(); |
| 58 | } |
| 59 | } |
| 60 | return value; |
| 61 | } |
| 62 | |
| 63 | console.log("\ncomposer autosize scrollbar css"); |
| 64 | |
| 65 | eq(finalDeclaration(".composer__input", "scrollbar-width"), "none", "autosize textarea hides the scrollbar while content fits"); |
| 66 | eq(finalDeclaration(".composer__input::-webkit-scrollbar", "display"), "none", "autosize textarea hides the webkit scrollbar while content fits"); |
| 67 | eq(finalDeclaration(".composer-card--auto-overflow .composer__input", "scrollbar-width"), "thin", "overflowing autosize textarea restores the thin scrollbar"); |
| 68 | eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "display"), "block", "overflowing autosize textarea restores the webkit scrollbar"); |
| 69 | eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "width"), "6px", "overflowing autosize scrollbar matches the resized-card width"); |
| 70 | eq( |
| 71 | finalDeclaration(".app--creation .composer-wrap--hero .composer__input", "max-height"), |
| 72 | "min(30vh, 160px) !important", |
| 73 | "hero input cap is min(30vh, 160px) instead of the 96px hard clip", |
| 74 | ); |
| 75 | eq( |
| 76 | finalDeclaration(".composer__input.composer__input--measure", "position"), |
| 77 | "absolute", |
| 78 | "autosize mirror stays outside the composer layout flow", |
| 79 | ); |
| 80 | eq( |
| 81 | finalDeclaration(".composer__input.composer__input--measure", "visibility"), |
| 82 | "hidden", |
| 83 | "autosize mirror is never visible to the user", |
| 84 | ); |
| 85 | |
| 86 | ok( |
| 87 | /composerAutoOverflow = composerHeight === null && textareaAutoOverflow/.test(composerSource) |
| 88 | && /composerAutoOverflow \? " composer-card--auto-overflow" : ""/.test(composerSource), |
| 89 | "Composer wires the auto-overflow modifier to the autosize overflow state", |
| 90 | ); |
| 91 | ok( |
| 92 | /Math\.min\(Math\.floor\(window\.innerHeight \* 0\.3\), 160\)/.test(composerSource), |
| 93 | "hero input JS cap is min(30vh, 160px)", |
| 94 | ); |
| 95 | ok( |
| 96 | /const measureTaRef = useRef<HTMLTextAreaElement>\(null\)/.test(composerSource) |
| 97 | && /ref=\{measureTaRef\}[\s\S]*?value=\{text\}[\s\S]*?aria-hidden="true"/.test(composerSource), |
| 98 | "Composer renders a text-synchronized, accessibility-hidden measurement mirror", |
| 99 | ); |
| 100 | ok( |
| 101 | !/\.style\.height\s*=\s*"auto"/.test(composerSource), |
| 102 | "autosize measurement never collapses the live textarea", |
| 103 | ); |
| 104 | |
| 105 | console.log(`\n${passed} passed, ${failed} failed`); |
| 106 | if (failed > 0) process.exit(1); |
| 107 |