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} + +