mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(client): three more places follow the reading direction in Arabic
On a phone in Arabic every system notice, the release notes included, opened as an empty sheet: its three-slot strip is shifted to show the middle slot, and in a right-to-left row that shift moved all three out of view. The shifts point the other way in a right-to-left page now, which the swipe handling already assumed. The budget's total amount kept its parts in order but sat on the opposite edge from its label, because the whole row was made left to right. Only the amount itself is now. The phone chat's bubble tails were still physical corners while the desktop ones follow the row; both use logical corners now. lint:rtl takes comments from the TypeScript parser instead of scanning lines, so a "/*" inside a string (accept="image/*") no longer hides the lines after it.
This commit is contained in:
+36
-28
@@ -24,6 +24,7 @@
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url))
|
||||
const BASELINE = fileURLToPath(new URL('./rtl-baseline.json', import.meta.url))
|
||||
@@ -61,40 +62,47 @@ function walk(dir, files = []) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The code of each line, without its comments: prose like "top right" or
|
||||
* "left out" is no side. Good enough for this source, which keeps `//` and
|
||||
* `/*` out of string literals except in URLs (`https://`).
|
||||
* The source with its comments blanked out, so prose like "top right" or
|
||||
* "left out" is no side. TypeScript finds the comments in a .ts/.tsx file,
|
||||
* which keeps `/*` and `//` inside strings and JSX text where they belong
|
||||
* (accept="image/*", a URL); a stylesheet only has block comments.
|
||||
*/
|
||||
function codeLines(source) {
|
||||
let inBlock = false
|
||||
return source.split('\n').map((line) => {
|
||||
let code = ''
|
||||
let rest = line
|
||||
while (rest) {
|
||||
if (inBlock) {
|
||||
const end = rest.indexOf('*/')
|
||||
if (end < 0) return code
|
||||
rest = rest.slice(end + 2)
|
||||
inBlock = false
|
||||
continue
|
||||
}
|
||||
const block = rest.indexOf('/*')
|
||||
const lineComment = rest.search(/(?<!:)\/\//)
|
||||
if (lineComment >= 0 && (block < 0 || lineComment < block)) return code + rest.slice(0, lineComment)
|
||||
if (block < 0) return code + rest
|
||||
code += rest.slice(0, block)
|
||||
rest = rest.slice(block + 2)
|
||||
inBlock = true
|
||||
function withoutComments(source, file) {
|
||||
const chars = source.split('')
|
||||
const blank = (pos, end) => {
|
||||
for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' '
|
||||
}
|
||||
if (file.endsWith('.css')) {
|
||||
for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length)
|
||||
return chars.join('')
|
||||
}
|
||||
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
|
||||
const seen = new Set()
|
||||
const take = (ranges) => {
|
||||
for (const r of ranges ?? []) {
|
||||
if (seen.has(r.pos)) continue
|
||||
seen.add(r.pos)
|
||||
blank(r.pos, r.end)
|
||||
}
|
||||
return code
|
||||
})
|
||||
}
|
||||
const visit = (node) => {
|
||||
take(ts.getLeadingCommentRanges(source, node.pos))
|
||||
take(ts.getTrailingCommentRanges(source, node.end))
|
||||
// JSX text holds no comments, and asking for them there would read `//` in a URL as one.
|
||||
if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child)
|
||||
}
|
||||
visit(sf)
|
||||
take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos))
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
/** Every physical use in one file, as `line: match`. */
|
||||
export function physicalUses(source, isCss) {
|
||||
export function physicalUses(source, file) {
|
||||
const isCss = file.endsWith('.css')
|
||||
const uses = []
|
||||
const lines = source.split('\n')
|
||||
codeLines(source).forEach((code, i) => {
|
||||
withoutComments(source, file).split('\n').forEach((code, i) => {
|
||||
if (lines[i].includes(DISABLE)) return
|
||||
const patterns = isCss ? [CSS] : [CLASS, STYLE]
|
||||
for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`)
|
||||
@@ -106,7 +114,7 @@ function scan() {
|
||||
const counts = {}
|
||||
const listed = {}
|
||||
for (const file of walk(SRC)) {
|
||||
const uses = physicalUses(readFileSync(file, 'utf8'), file.endsWith('.css'))
|
||||
const uses = physicalUses(readFileSync(file, 'utf8'), file)
|
||||
if (!uses.length) continue
|
||||
const key = relative(SRC, file).replace(/\\/g, '/')
|
||||
counts[key] = uses.length
|
||||
|
||||
@@ -57,11 +57,14 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||
return (
|
||||
// dir=ltr: the amount is split into spans, which a right-to-left flex row would reorder.
|
||||
<div dir="ltr" style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginInlineStart: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
<div style={{ display: 'flex', letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
{/* One left-to-right box at the row's start: the amount is split into
|
||||
spans, which a right-to-left flex row would reorder. */}
|
||||
<span dir="ltr" style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginInlineStart: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1199,10 +1199,13 @@ describe('ModalRenderer', () => {
|
||||
await flushGraceDelay();
|
||||
|
||||
const { sheet, strip } = sheetParts();
|
||||
// The slots run right to left, so the strip sits shifted the other way to
|
||||
// show the current one; the left-to-right shift left the sheet empty.
|
||||
expect(strip.style.transform).toBe('translateX(33.333%)');
|
||||
await act(async () => {
|
||||
swipeHorizontally(sheet, 120, 250);
|
||||
});
|
||||
expect(strip.style.transform).toBe('translateX(-66.666%)');
|
||||
expect(strip.style.transform).toBe('translateX(66.666%)');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.transitionEnd(strip);
|
||||
|
||||
@@ -668,6 +668,11 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
|
||||
const prevNotice = notices[idx - 1] ?? null;
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
// Where the 300% strip has to sit to show slot 0 (prev), 1 (current) or 2
|
||||
// (next). In a right-to-left page the slots run the other way and the strip
|
||||
// overflows to the left, so every shift points the other way; with the
|
||||
// left-to-right shifts the sheet showed nothing at all in Arabic.
|
||||
const stripShift = (slot: 0 | 1 | 2): number => (isRtlLanguage(language) ? 1 : -1) * slot * 33.333;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[var(--z-notice)]" role="presentation">
|
||||
@@ -718,7 +723,7 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
if (!strip) return;
|
||||
strip.style.transition = 'none';
|
||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||
strip.style.transform = `translateX(calc(${stripShift(1)}% + ${dx}px))`;
|
||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
|
||||
// If scrolled into content, let native pan-y scroll it back up.
|
||||
@@ -751,7 +756,7 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
|
||||
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
|
||||
strip.style.transition = 'transform 200ms ease-out';
|
||||
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
|
||||
strip.style.transform = `translateX(${stripShift(goNext ? 2 : 0)}%)`;
|
||||
strip.addEventListener('transitionend', function onDone() {
|
||||
strip.removeEventListener('transitionend', onDone);
|
||||
strip.style.transition = 'none';
|
||||
@@ -767,16 +772,16 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.style.transform = `translateX(${stripShift(1)}%)`;
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Spring back to center
|
||||
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.style.transform = `translateX(${stripShift(1)}%)`;
|
||||
strip.addEventListener('transitionend', function onSnap() {
|
||||
strip.removeEventListener('transitionend', onSnap);
|
||||
strip.style.transition = '';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.style.transform = `translateX(${stripShift(1)}%)`;
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
@@ -809,7 +814,7 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||
<div
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: `translateX(${stripShift(1)}%)` }}
|
||||
>
|
||||
{/* The side slots are drag previews only: their pager and CTA are a
|
||||
second copy of the centre ones, so they stay out of the a11y tree
|
||||
|
||||
@@ -471,7 +471,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
className={`px-[13px] py-[9px] text-[0.8125rem] leading-[1.45] ${
|
||||
own ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
|
||||
}`}
|
||||
style={{ borderRadius: own ? OWN_BUBBLE_RADIUS : OTHER_BUBBLE_RADIUS }}
|
||||
style={own ? OWN_BUBBLE_RADIUS : OTHER_BUBBLE_RADIUS}
|
||||
>
|
||||
{hasReply && (
|
||||
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
|
||||
|
||||
@@ -54,9 +54,10 @@ export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥'
|
||||
|
||||
/** Bubble corner radii, spec 03 §6.1 — constant per sender, not per group
|
||||
* position (grouping is expressed through spacing + a single trailing
|
||||
* timestamp instead, see MCollabChat). */
|
||||
export const OWN_BUBBLE_RADIUS = '16px 16px 4px 16px'
|
||||
export const OTHER_BUBBLE_RADIUS = '4px 16px 16px 16px'
|
||||
* timestamp instead, see MCollabChat). Logical corners, so the tail follows
|
||||
* the row to the other side in a right-to-left language. */
|
||||
export const OWN_BUBBLE_RADIUS = { borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 } as const
|
||||
export const OTHER_BUBBLE_RADIUS = { borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 } as const
|
||||
|
||||
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
|
||||
// rather than local time (same fix the desktop helpers apply).
|
||||
|
||||
@@ -105,8 +105,10 @@ describe('collabModel — constants', () => {
|
||||
expect(QUICK_REACTIONS).toHaveLength(8);
|
||||
expect(QUICK_REACTIONS).toContain('❤️');
|
||||
expect(new Set(QUICK_REACTIONS).size).toBe(QUICK_REACTIONS.length);
|
||||
expect(OWN_BUBBLE_RADIUS).toBe('16px 16px 4px 16px');
|
||||
expect(OTHER_BUBBLE_RADIUS).toBe('4px 16px 16px 16px');
|
||||
// Logical corners: in a left-to-right language the same as '16px 16px 4px 16px'
|
||||
// and '4px 16px 16px 16px', and the tail follows the row in right to left.
|
||||
expect(OWN_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 });
|
||||
expect(OTHER_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 });
|
||||
expect(NOTE_COLORS).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user