From 0f457edcad8716dc91885b14a6b92ddad6529f57 Mon Sep 17 00:00:00 2001 From: Darktan242 Date: Mon, 3 Aug 2026 22:04:26 +0000 Subject: [PATCH] design: RTL mirror pass for wizard stepper (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RTL (Right-to-Left) mirror pass for the wizard progress indicator: [ADD] src/utils/rtl.ts — RTL utility helpers - isRtl(), direction(), inlineStart(), inlineEnd() detection - mirrorGradient() for CSS gradient direction mirroring - formatStepCount() with Unicode bidi isolation markers [ADD] src/styles/rtl.css — consolidated RTL styles - Gradient mirroring helper classes - Transform origin mirroring - Numeric content isolation (dir=ltr + unicode-bidi: isolate) - AppShell sidebar RTL adjustments - Stepper/progress indicator RTL overrides - Reduced motion support The WizardStepper already uses CSS logical properties for automatic RTL mirroring. This pass adds the utility layer and consolidated styles for gradient directions, transform origins, and other physical CSS properties that cannot mirror via logical properties. --- src/styles/rtl.css | 100 +++++++++++++++++++++++++++++++++++++++++++ src/utils/rtl.ts | 104 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/styles/rtl.css create mode 100644 src/utils/rtl.ts diff --git a/src/styles/rtl.css b/src/styles/rtl.css new file mode 100644 index 0000000..275c099 --- /dev/null +++ b/src/styles/rtl.css @@ -0,0 +1,100 @@ +/* ───────────────────────────────────────────────────────────────────────── + * RTL (Right-to-Left) consolidated styles + * + * This file centralizes RTL overrides that cannot be expressed through CSS + * logical properties alone — primarily gradient directions, transform origins, + * and physical-positioned elements that need explicit mirroring. + * + * ## What CSS logical properties handle automatically + * + * The following properties mirror under `dir="rtl"` without any extra CSS: + * - margin-inline-start / margin-inline-end + * - padding-inline-start / padding-inline-end + * - inset-inline-start / inset-inline-end + * - border-inline-start / border-inline-end + * - text-align: start / end + * - float: inline-start / inline-end + * + * ## What this file covers + * + * 1. Gradient angles (are physical, not logical) + * 2. Transform origins (default to physical center) + * 3. Box-shadow offsets (horizontal offset is physical) + * 4. Physical-positioned pseudo-elements + * 5. Explicit direction overrides for numeric content + * ───────────────────────────────────────────────────────────────────────── */ + +/* ── Gradient helper classes ─────────────────────────────────────────── */ + +[dir="rtl"] .rtl-mirror-gradient-to-right { + background-image: linear-gradient( + to left, + var(--rtl-grad-start, #10b981), + var(--rtl-grad-end, #3b82f6) + ); +} + +[dir="rtl"] .rtl-mirror-gradient-to-left { + background-image: linear-gradient( + to right, + var(--rtl-grad-start, #10b981), + var(--rtl-grad-end, #3b82f6) + ); +} + +/* ── Transform origin mirroring ──────────────────────────────────────── */ + +[dir="rtl"] .rtl-transform-origin-start { + transform-origin: right center; +} + +[dir="rtl"] .rtl-transform-origin-end { + transform-origin: left center; +} + +/* ── Numeric content: always LTR ─────────────────────────────────────── + Unicode bidi isolation prevents numeric sequences from being reordered + in RTL contexts. Per TR-9 and TR-53, numbers retain their natural order. ── */ + +.rtl-num { + direction: ltr; + unicode-bidi: isolate; + font-variant-numeric: tabular-nums; +} + +/* ── AppShell RTL adjustments ────────────────────────────────────────── */ + +[dir="rtl"] .app-shell__sidebar { + /* Sidebar slides from the right in RTL */ + transform-origin: right center; +} + +[dir="rtl"] .app-shell__main { + /* Main content area padding flips */ + padding-inline-start: var(--sidebar-width, 260px); + padding-inline-end: 0; +} + +/* ── Stepper / progress indicator RTL (shared across wizards) ────────── */ + +[dir="rtl"] .progress-indicator__fill { + transform-origin: right center; +} + +[dir="rtl"] .progress-indicator__connector--active { + background: linear-gradient( + to left, + var(--ws-completed, #10b981), + var(--ws-active, #3b82f6) + ); +} + +/* ── Reduced motion (global) ─────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + [dir="rtl"] .rtl-animate, + [dir="rtl"] .wizard-stepper__fill, + [dir="rtl"] .progress-indicator__fill { + transition: none; + } +} diff --git a/src/utils/rtl.ts b/src/utils/rtl.ts new file mode 100644 index 0000000..908bc94 --- /dev/null +++ b/src/utils/rtl.ts @@ -0,0 +1,104 @@ +/** + * RTL (Right-to-Left) utility helpers. + * + * These utilities provide a consistent API for detecting and responding to + * RTL language contexts across the application. They are used by the + * WizardStepper, AppShell, and other direction-sensitive components. + * + * ## Design principles + * + * 1. **CSS logical properties are the primary mechanism** — most RTL mirroring + * happens automatically through `margin-inline-start`, `inset-inline-end`, + * etc. These utilities handle the cases CSS can't. + * + * 2. **Numeric labels stay LTR** — per Unicode TR-9 and TR-53, numeric + * sequences (step numbers, percentages, counts) must retain their LTR + * order even in RTL contexts. Use `dir="ltr"` + `unicode-bidi: isolate` + * on every numeric element. + * + * 3. **Document order is NEVER reversed** — RTL mirroring is purely visual. + * The DOM order of wizard steps, tab lists, and other sequential elements + * matches the logical reading order for all languages. CSS (`flex-direction`, + * `direction`) handles the visual reversal. + * + * 4. **Gradients need explicit RTL overrides** — CSS gradients use `to right` + * or `to left` which are physical, not logical. Always provide a matching + * `[dir="rtl"]` rule that mirrors the gradient angle. + */ + +/** + * Returns `true` if the document's `` element has `dir="rtl"`. + * Defaults to `false` for SSR environments where `document` is unavailable. + */ +export function isRtl(): boolean { + if (typeof document === "undefined") return false; + return document.documentElement.getAttribute("dir") === "rtl"; +} + +/** + * Returns the CSS `direction` value for consumption in inline styles + * or CSS-in-JS: `"rtl"` or `"ltr"`. + */ +export function direction(): "rtl" | "ltr" { + return isRtl() ? "rtl" : "ltr"; +} + +/** + * Returns the inline-start value for a directional CSS property. + * In LTR this is `"left"`; in RTL this is `"right"`. + * + * Prefer CSS logical properties (`inset-inline-start`, `margin-inline-start`) + * over this function in production code. Use this only when computing + * imperative styles (e.g., positioning tooltips via `getBoundingClientRect`). + */ +export function inlineStart(): "left" | "right" { + return isRtl() ? "right" : "left"; +} + +/** + * Returns the inline-end value for a directional CSS property. + * In LTR this is `"right"`; in RTL this is `"left"`. + * + * @see {@link inlineStart} for usage guidance. + */ +export function inlineEnd(): "left" | "right" { + return isRtl() ? "left" : "right"; +} + +/** + * Mirrors a gradient angle for RTL contexts. + * + * Given a CSS gradient direction like `"to right"`, returns the mirrored + * equivalent `"to left"`. This is useful for progress bars, connector + * lines, and other gradient-based visual flows that must mirror under RTL. + * + * @param gradientDir - A CSS gradient direction keyword (e.g., `"to right"`) + * @returns The mirrored direction for RTL, or the original for LTR + */ +export function mirrorGradient(gradientDir: string): string { + const mirrors: Record = { + "to right": "to left", + "to left": "to right", + "to top right": "to top left", + "to top left": "to top right", + "to bottom right": "to bottom left", + "to bottom left": "to bottom right", + }; + if (!isRtl()) return gradientDir; + return mirrors[gradientDir] ?? gradientDir; +} + +/** + * Formats a step count for screen-reader announcement. + * + * The format is always LTR-numeric: "Step 2 of 5". + * This function isolates the numeric parts so they are never reordered + * by bidirectional text algorithms, even in RTL contexts. + * + * @param current - 1-based current step number + * @param total - Total number of steps + */ +export function formatStepCount(current: number, total: number): string { + // Use Unicode bidi isolation markers (LRI / PDI) + return `Step \u2066${current}\u2069 of \u2066${total}\u2069`; +}