From 23ad9351eaf745320f610a74573552bf960b1031 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 21:56:53 +0200 Subject: [PATCH] feat(carousel): wire dir + locale (was hardcoded 'ltr'); expose i18n in demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit caught Carousel ignored direction entirely: `getDirectionalKeys` received a hardcoded `'ltr'` so ArrowLeft / ArrowRight had the same semantics under RTL as LTR, and the horizontal translate had no RTL flip so slides advanced the wrong way visually. Drag direction had the same bug. ## Soma - New `dir` + `locale` props on `Carousel.Provider` (fall back to `soma.prefs.getDir()` then `'ltr'`). - `onkeydown` now reads `this.opts.dir.current` for `getDirectionalKeys`. - `itemGroupTransform` flips the translate sign on horizontal RTL so index N→N+1 moves the rail in the reading-direction-appropriate way. - `finishDrag` swaps the offset sign on horizontal RTL so drag-right advances under RTL (drag-left under LTR). - Root stamps `dir="..."`, `lang="..."`, and `data-dir="..."` for consumers and the recipe. - Test mocks updated. ## Demos - Carousel demo: new `soma · i18n` controls section — `dir` chips (`auto` / `ltr` / `rtl`) + `locale` chips covering en/es/ar/he/ja so the user can flip orientation semantics live. - Range-calendar demo: same `dir` + `locale` chip rows added next to `weekStartsOn`; previously only `locale` was state-bound but the control wasn't surfaced and `dir` wasn't forwarded at all. - The eidos code snippet preview emits `dir="..."` and `locale="..."` attrs when they differ from defaults. ## Verified Browser walk: with `dir="rtl"`, the carousel root reports `data-dir="rtl"`, prev/next chevrons swap sides, indicator order mirrors, and `ArrowRight` correctly advances to the *previous* slide (index 1 → 0). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../carousel/carousel-provider.svelte.test.ts | 2 + .../carousel/carousel-provider.svelte.ts | 42 +++++++++++++++---- .../carousel/components/carousel.svelte | 6 +++ src/uix/soma/components/carousel/types.ts | 11 ++++- .../uix/components/carousel/+page.svelte | 39 +++++++++++++++++ .../components/range-calendar/+page.svelte | 23 ++++++++++ 6 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.test.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.test.ts index 1dbd03133..a886f471c 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.test.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.test.ts @@ -93,6 +93,8 @@ function carouselOpts(root = document.createElement('section')) { stopOnMouseEnter: state(false), stopOnLastSnap: state(false), disabled: state(false), + dir: state<'ltr' | 'rtl'>('ltr'), + locale: state(undefined), ariaLabel: state(undefined), onValueChange: state<((value: number) => void) | undefined>(undefined) }; diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts index 25183a902..ee6da174b 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts @@ -2,7 +2,13 @@ import { context, type WithRefOpts } from '../../provider'; import { createAttrs } from '$uix/morfo'; import { boolToEmptyStrOrUndef } from '$adom'; import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive'; -import type { OnChangeFn, Orientation, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; +import type { + Direction, + OnChangeFn, + Orientation, + SomaKeyboardEvent, + SomaMouseEvent +} from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; import { Gesture } from '../../layers/gesture'; @@ -36,6 +42,8 @@ interface CarouselOpts stopOnMouseEnter: boolean; stopOnLastSnap: boolean; disabled: boolean; + dir: Direction; + locale: string | undefined; ariaLabel: string | undefined; onValueChange: OnChangeFn | undefined; }> {} @@ -222,7 +230,13 @@ export class CarouselProvider { return 0; }); - /** Translate value (px) for the item-group. */ + /** + * Translate value (px) for the item-group. The base offset is computed + * along the primary axis; the sign for horizontal RTL is flipped at the + * `itemGroupTransform` boundary so that index N→N+1 moves the rail in + * the reading-direction-appropriate way (slides advance leftward in LTR, + * rightward in RTL). + */ readonly translate = $derived.by(() => { const base = -this.opts.value.current * this.slideSize + this.alignOffset; return base + this.dragOffset; @@ -230,9 +244,10 @@ export class CarouselProvider { readonly itemGroupTransform = $derived.by(() => { const v = this.translate; - return this.opts.orientation.current === 'horizontal' - ? `translate3d(${v}px, 0, 0)` - : `translate3d(0, ${v}px, 0)`; + const horizontal = this.opts.orientation.current === 'horizontal'; + if (!horizontal) return `translate3d(0, ${v}px, 0)`; + const flip = this.opts.dir.current === 'rtl' ? -1 : 1; + return `translate3d(${v * flip}px, 0, 0)`; }); // ── Navigation ─────────────────────────────────────────────────────────── @@ -314,7 +329,10 @@ export class CarouselProvider { readonly onkeydown = (e: SomaKeyboardEvent) => { if (this.isDisabled) return; - const { nextKey, prevKey } = getDirectionalKeys('ltr', this.opts.orientation.current); + const { nextKey, prevKey } = getDirectionalKeys( + this.opts.dir.current, + this.opts.orientation.current + ); if (e.key === nextKey) { e.preventDefault(); this.scrollNext(); @@ -367,8 +385,13 @@ export class CarouselProvider { if (!shouldAdvance) return; this.noteInteraction(); - // Negative offset = dragged left/up = advance to next slide. - if (offset < 0) this.next(); + // Direction-aware advance: + // - Vertical: drag up (negative Y) = next, drag down = prev. + // - Horizontal LTR: drag left (negative X) = next, drag right = prev. + // - Horizontal RTL: mirrored — drag right = next, drag left = prev. + const isRtlHorizontal = horizontal && this.opts.dir.current === 'rtl'; + const direction = isRtlHorizontal ? -offset : offset; + if (direction < 0) this.next(); else this.prev(); } @@ -402,7 +425,10 @@ export class CarouselProvider { 'aria-roledescription': 'carousel', 'aria-label': this.resolvedAriaLabel.current, tabindex: this.isDisabled ? -1 : 0, + dir: this.opts.dir.current, + lang: this.opts.locale.current || undefined, 'data-orientation': this.opts.orientation.current, + 'data-dir': this.opts.dir.current, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), 'data-dragging': boolToEmptyStrOrUndef(this.gesture.isDragging), 'data-autoplay': boolToEmptyStrOrUndef(this.opts.autoplay.current && !this.paused), diff --git a/src/uix/soma/components/carousel/components/carousel.svelte b/src/uix/soma/components/carousel/components/carousel.svelte index 47ab190a2..4522738bd 100644 --- a/src/uix/soma/components/carousel/components/carousel.svelte +++ b/src/uix/soma/components/carousel/components/carousel.svelte @@ -7,8 +7,10 @@ import { createId } from '../../../id'; import { CarouselProvider } from '../carousel-provider.svelte'; import type { CarouselProps } from '../types'; + import { Soma } from '../../../core/soma.svelte'; const uid = $props.id(); + const soma = Soma.get(); let { ref = $bindable(null), @@ -33,6 +35,8 @@ stopOnMouseEnter = false, stopOnLastSnap = false, disabled = false, + dir, + locale, 'aria-label': ariaLabel, children, child, @@ -74,6 +78,8 @@ stopOnMouseEnter: readableActive(() => stopOnMouseEnter), stopOnLastSnap: readableActive(() => stopOnLastSnap), disabled: readableActive(() => disabled), + dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'), + locale: readableActive(() => locale), ariaLabel: readableActive(() => ariaLabel) }); diff --git a/src/uix/soma/components/carousel/types.ts b/src/uix/soma/components/carousel/types.ts index 03299edc6..6845f1c9a 100644 --- a/src/uix/soma/components/carousel/types.ts +++ b/src/uix/soma/components/carousel/types.ts @@ -1,5 +1,5 @@ import type { Snippet } from 'svelte'; -import type { WithChild, Without, OnChangeFn, Orientation } from '../../types'; +import type { Direction, WithChild, Without, OnChangeFn, Orientation } from '../../types'; import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types'; /** Snippet props exposed by `Carousel.Provider`. */ @@ -155,6 +155,15 @@ export type CarouselProps = WithChild< disabled?: boolean; // i18n + /** + * Reading direction. Affects horizontal keyboard nav (ArrowLeft / + * ArrowRight semantics flip under RTL) and the recipe's slide + * translation direction via `data-dir`. Falls back to the active + * UIX prefs direction, then `'ltr'`. + */ + dir?: Direction; + /** BCP-47 locale tag. Forwarded for future locale-aware behavior. */ + locale?: string; /** Override the root's accessible label. */ 'aria-label'?: string; diff --git a/web/routes/uix/components/carousel/+page.svelte b/web/routes/uix/components/carousel/+page.svelte index 3e3fe3af8..5563e5e02 100644 --- a/web/routes/uix/components/carousel/+page.svelte +++ b/web/routes/uix/components/carousel/+page.svelte @@ -43,6 +43,16 @@ let disabled = $state(false); + // i18n — `dir` controls the keyboard arrow semantics (RTL swaps + // next/prev) AND flips the translation sign so horizontal advance + // follows reading direction. `locale` is forwarded for label + // formatting hooks (consumers using lang-aware aria-labels). + type DirCtl = 'auto' | 'ltr' | 'rtl'; + let dirCtl = $state('auto'); + let locale = $state('en-US'); + + const dir = $derived(dirCtl === 'auto' ? undefined : dirCtl); + let size = $state('md'); let variant = $state('surface'); let color = $state('primary'); @@ -126,6 +136,8 @@ autoplay && stopOnMouseEnter && ' stopOnMouseEnter', autoplay && stopOnLastSnap && ' stopOnLastSnap', disabled && ' disabled', + dir && ` dir="${dir}"`, + locale !== 'en-US' && ` locale="${locale}"`, '>', ' ', ' ', @@ -202,6 +214,8 @@ {stopOnMouseEnter} {stopOnLastSnap} {disabled} + {dir} + {locale} {size} {variant} {color} @@ -444,6 +458,31 @@ isPlaying = {isPlaying} +
+ soma i18n + + dir flips arrow-key + drag semantics for horizontal RTL; locale is forwarded for downstream label formatting. + +
+
+ + +
+
eidos visual props
diff --git a/web/routes/uix/components/range-calendar/+page.svelte b/web/routes/uix/components/range-calendar/+page.svelte index c751e2321..1dd0c4995 100644 --- a/web/routes/uix/components/range-calendar/+page.svelte +++ b/web/routes/uix/components/range-calendar/+page.svelte @@ -55,6 +55,12 @@ let readonly = $state(false); let weekStartsOn = $state('1'); let locale = $state('en-US'); + // `dir` reflows the calendar header + weekday strip + day grid. `auto` + // inherits the app preference set in the topbar; explicit `ltr`/`rtl` + // overrides it per-instance. + type DirCtl = 'auto' | 'ltr' | 'rtl'; + let dirCtl = $state('auto'); + const dir = $derived(dirCtl === 'auto' ? undefined : dirCtl); let size = $state('md'); let variant = $state('surface'); let color = $state('primary'); @@ -262,6 +268,7 @@ maxValue={selectionMaxValue} weekStartsOn={resolvedWeekStartsOn} {locale} + {dir} {size} {variant} {color} @@ -456,6 +463,22 @@ {/each} + +