feat(carousel): wire dir + locale (was hardcoded 'ltr'); expose i18n in demos

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent f744583c12
commit 23ad9351ea

@ -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<string | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: number) => void) | undefined>(undefined)
};

@ -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<number> | 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<HTMLDivElement>) => {
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),

@ -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)
});

@ -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;

@ -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<DirCtl>('auto');
let locale = $state('en-US');
const dir = $derived(dirCtl === 'auto' ? undefined : dirCtl);
let size = $state<CarouselSize>('md');
let variant = $state<CarouselVariant>('surface');
let color = $state<CarouselColor>('primary');
@ -126,6 +136,8 @@
autoplay && stopOnMouseEnter && ' stopOnMouseEnter',
autoplay && stopOnLastSnap && ' stopOnLastSnap',
disabled && ' disabled',
dir && ` dir="${dir}"`,
locale !== 'en-US' && ` locale="${locale}"`,
'>',
' <Carousel.Viewport>',
' <Carousel.ItemGroup>',
@ -202,6 +214,8 @@
{stopOnMouseEnter}
{stopOnLastSnap}
{disabled}
{dir}
{locale}
{size}
{variant}
{color}
@ -444,6 +458,31 @@
<span data-uix-control-label-aux>isPlaying = {isPlaying}</span>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> i18n
<span style="margin-inline-start: auto; color: var(--color-content-secondary); font-size: var(--font-size-xs);">
<code>dir</code> flips arrow-key + drag semantics for horizontal RTL; <code>locale</code> is forwarded for downstream label formatting.
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>dir <span data-uix-control-hint>{dir ?? 'inherits app pref'}</span></span>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'ltr', 'rtl'] as d (d)}
<button data-uix-chip data-active={dirCtl === d} onclick={() => (dirCtl = d as DirCtl)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<span data-uix-chips role="radiogroup">
{#each ['en-US', 'es-ES', 'ar-EG', 'he-IL', 'ja-JP'] as l (l)}
<button data-uix-chip data-active={locale === l} onclick={() => (locale = l)}>{l}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual props
</div>

@ -55,6 +55,12 @@
let readonly = $state(false);
let weekStartsOn = $state<WeekStartControl>('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<DirCtl>('auto');
const dir = $derived(dirCtl === 'auto' ? undefined : dirCtl);
let size = $state<RangeCalendarSize>('md');
let variant = $state<RangeCalendarVariant>('surface');
let color = $state<RangeCalendarColor>('primary');
@ -262,6 +268,7 @@
maxValue={selectionMaxValue}
weekStartsOn={resolvedWeekStartsOn}
{locale}
{dir}
{size}
{variant}
{color}
@ -456,6 +463,22 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>dir <span data-uix-control-hint>{dir ?? 'inherits app pref'}</span></span>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'ltr', 'rtl'] as d (d)}
<button data-uix-chip data-active={dirCtl === d} onclick={() => (dirCtl = d as DirCtl)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<span data-uix-chips role="radiogroup">
{#each ['en-US', 'es-ES', 'ar-EG', 'he-IL', 'ja-JP'] as l (l)}
<button data-uix-chip data-active={locale === l} onclick={() => (locale = l)}>{l}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>

Loading…
Cancel
Save

Powered by TurnKey Linux.