fix(scroll-area): el eje horizontal no se espejaba en RTL — el thumb salia del carril y aria-valuenow era negativo

En RTL el navegador DESCANSA scrollLeft en 0 con el contenido pegado a la
derecha y lo lleva NEGATIVO hacia el final de lectura (modelo negativo del
CSSOM). Nadie lo traducia, asi que resolvedDir era codigo muerto y toda la
matematica del eje inline estaba rota:

  aria-valuenow  -50 / -99 con aria-valuemin="0"   ->  0 / 50 / 100
  thumb          left: -167px, FUERA del carril    ->  dentro y espejado
  data-at-left   siempre puesto                    ->  solo en el borde izq.
  data-at-right  nunca                             ->  en reposo
  click canalon  al 10% desde la izquierda -> 0    ->  -232, el 90% logico

Dos traducciones privadas en el provider y cada consumidor toma la suya: lo
que sigue el eje de LECTURA (offset del thumb, aria-valuenow, click en el
canalon) y lo que se queda FISICO (data-at-left / data-at-right, cuyos
nombres lo son y cuyo uso documentado —sombras de scroll— tambien).

El ancla del thumb pasa a inset-inline-start porque el offset es progreso de
lectura: es la decision opuesta a tabs / sliding-indicator, donde el JS media
una coordenada fisica. La regla no cambia: mira de que lado esta la medida.

El arrastre no necesito nada, y no por suerte: subir scrollLeft mueve el
viewport a la derecha en los dos marcos. Verificado, no supuesto.

Ambos extremos llevan ahora la misma holgura de 1px, porque solo uno cae en
un cero exacto y cual de los dos depende de la direccion. De paso LTR gana el
data-at-right del extremo, que tampoco se encendia.

Medido en Chrome en las dos direcciones, con el toggle del topbar. El test
nuevo sale ROJO con el provider anterior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 40db0b9816
commit ae756ee578

@ -55,8 +55,8 @@ Native scrollbars are hidden via CSS. Custom scrollbars are positioned absolutel
| Viewport | `data-scroll-area-viewport` | Always present |
| Viewport | `data-at-top` | Present when scrolled to top |
| Viewport | `data-at-bottom` | Present when scrolled to bottom |
| Viewport | `data-at-left` | Present when scrolled to left edge |
| Viewport | `data-at-right` | Present when scrolled to right edge |
| Viewport | `data-at-left` | Present at the content's physical left edge |
| Viewport | `data-at-right` | Present at the content's physical right edge |
| Viewport | `data-overflow-x` | Present when content overflows horizontally |
| Viewport | `data-overflow-y` | Present when content overflows vertically |
| Scrollbar | `data-scroll-area-scrollbar` | Always present |
@ -115,6 +115,32 @@ Clicking on the scrollbar track (not the thumb) scrolls to that position proport
The viewport exposes `data-at-top`, `data-at-bottom`, `data-at-left`, `data-at-right` for styling scroll shadows or sticky headers.
### RTL
Only the horizontal axis is affected, and the browser hands it over in a
direction-dependent frame: in RTL `scrollLeft` **rests at 0** with the content
pinned to the right and runs **negative** towards the reading end, down to
`-(scrollWidth - clientWidth)`. That is the CSSOM negative model — Chrome 85+,
Firefox, Safari 14.1+.
Soma normalizes it once, from `dir` (prop → `soma.prefs.getDir()` → `'ltr'`),
never by reading the DOM. Two idioms come out of it, and each consumer takes
the one that matches its own vocabulary:
| Follows the **reading** axis | Stays **physical** |
| --- | --- |
| Thumb offset (anchored with `inset-inline-start`, so it rests on the right in RTL like a native scrollbar) | `data-at-left` / `data-at-right` |
| `aria-valuenow` (0 at the start of the content, 100 at the end) | |
| Track clicks (the fraction is read from the track's inline-start end) | |
`data-at-left` / `data-at-right` keep physical names because their documented
use — scroll shadows — is physical: the shadow belongs on whichever edge still
has content behind it, regardless of how the text reads. They mirror their math
so they mean the same thing in both directions.
Thumb drag needs no direction handling: raising `scrollLeft` moves the viewport
right in both frames, so a physical finger delta maps onto it unchanged.
## Usage
### Basic (vertical only)

@ -66,13 +66,13 @@ function installSomaHarness() {
return { dom, schedule, tasks, handles };
}
function scrollAreaOpts(root = document.createElement('div')) {
function scrollAreaOpts(root = document.createElement('div'), dir: 'ltr' | 'rtl' = 'ltr') {
return {
id: state('scroll-area-root'),
ref: state<HTMLElement | null>(root),
type: state<'hover' | 'scroll' | 'auto' | 'always'>('scroll'),
scrollHideDelay: state(600),
dir: state<'ltr' | 'rtl'>('ltr')
dir: state<'ltr' | 'rtl'>(dir)
};
}
@ -370,4 +370,91 @@ describe('ScrollAreaProvider', () => {
cleanup();
harness.dom.dispose();
});
// Anchors the RTL horizontal axis. In RTL the browser rests `scrollLeft` at
// 0 with the content pinned right and runs it NEGATIVE towards the reading
// end — before this was handled, `aria-valuenow` went negative, the thumb's
// physical `left` pushed it clean out of its track, `data-at-left` was stuck
// on and `data-at-right` never fired.
it('mirrors the horizontal axis in RTL, where scrollLeft runs negative', async () => {
const harness = installSomaHarness();
const root = document.createElement('div');
const viewportEl = document.createElement('div');
const scrollbarEl = document.createElement('div');
const thumbEl = document.createElement('div');
document.body.append(root, viewportEl, scrollbarEl, thumbEl);
defineBox(viewportEl, {
clientWidth: 100,
clientHeight: 100,
scrollWidth: 300,
scrollHeight: 100,
scrollTop: 0,
scrollLeft: 0
});
defineBox(scrollbarEl, { clientWidth: 100, clientHeight: 8 });
scrollbarEl.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 100, height: 8 }) as DOMRect;
const opts = scrollAreaOpts(root, 'rtl');
const { result, cleanup } = withEffectRoot(() => {
const provider = ScrollAreaProvider.create(opts);
vi.spyOn(ScrollAreaProvider, 'require').mockReturnValue(provider);
provider.setupViewport(viewportEl);
const viewport = ScrollAreaViewportProvider.create({
id: state('scroll-area-viewport'),
ref: state<HTMLElement | null>(viewportEl)
});
const scrollbar = ScrollAreaScrollbarProvider.create({
id: state('scroll-area-scrollbar-x'),
ref: state<HTMLElement | null>(scrollbarEl),
orientation: state<'horizontal'>('horizontal')
});
vi.spyOn(ScrollAreaScrollbarProvider, 'require').mockReturnValue(scrollbar);
scrollbar.show();
const thumb = ScrollAreaThumbProvider.create({
id: state('scroll-area-thumb-x'),
ref: state<HTMLElement | null>(thumbEl)
});
return { provider, viewport, scrollbar, thumb };
});
// Deferred viewport measurement, then the track's ResizeObserver entry.
await nextFrame();
await nextFrame();
// At rest the content sits against the RIGHT edge: reading progress is 0
// and the thumb parks at the track's inline-start (right, in RTL).
// Compared loosely because negating a resting 0 yields -0, which reads as
// "0" the moment it hits the attribute but is not `Object.is`-equal.
expect(result.provider.getScrollProgressX()).toBeCloseTo(0);
expect(result.scrollbar.props).toMatchObject({ 'aria-valuenow': expect.closeTo(0) });
expect(result.viewport.props['data-at-right']).toBe('');
expect(result.viewport.props['data-at-left']).toBeUndefined();
expect(result.thumb.props.style).toMatchObject({ 'inset-inline-start': '0px' });
expect(result.thumb.props.style).not.toHaveProperty('left');
// Halfway along the reading axis — negative scrollLeft, positive progress.
viewportEl.scrollLeft = -100;
result.viewport.onscroll({ currentTarget: viewportEl } as never);
expect(result.provider.getScrollProgressX()).toBeCloseTo(0.5);
expect(result.scrollbar.props).toMatchObject({ 'aria-valuenow': 50 });
expect(result.viewport.props['data-at-left']).toBeUndefined();
expect(result.viewport.props['data-at-right']).toBeUndefined();
// Reading end — now the content's PHYSICAL left edge is what's exposed.
viewportEl.scrollLeft = -200;
result.viewport.onscroll({ currentTarget: viewportEl } as never);
expect(result.provider.getScrollProgressX()).toBeCloseTo(1);
expect(result.scrollbar.props).toMatchObject({ 'aria-valuenow': 100 });
expect(result.viewport.props['data-at-left']).toBe('');
expect(result.viewport.props['data-at-right']).toBeUndefined();
// A track click is read from the inline-start end, and the offset it
// produces carries the sign the browser expects.
result.scrollbar.onclick({ clientX: 10 } as MouseEvent);
expect(viewportEl.scrollLeft).toBeCloseTo(-180);
cleanup();
harness.dom.dispose();
});
});

@ -80,16 +80,47 @@ export class ScrollAreaProvider {
readonly hasOverflowX = $derived.by(() => this.contentWidth > this.viewportWidth);
readonly hasOverflowY = $derived.by(() => this.contentHeight > this.viewportHeight);
// ── Horizontal scroll, in both idioms ────────────────────────────────────
/**
* The inline axis is the only one `dir` touches, and the browser hands it
* to us in a direction-dependent frame: in RTL `scrollLeft` RESTS at 0
* with the content pinned to the right and runs NEGATIVE towards the
* reading end, down to `-maxScrollX` (the CSSOM negative model — Chrome
* 85+, Firefox, Safari 14.1+). So every consumer has to say which frame
* it wants, and the two below are the only translations needed.
*/
private get maxScrollX(): number {
return this.contentWidth - this.viewportWidth;
}
/** Distance travelled along the READING axis — 0 at the start, growing. */
private get logicalScrollX(): number {
return this.resolvedDir === 'rtl' ? -this.scrollLeft : this.scrollLeft;
}
/** Distance from the PHYSICAL left edge of the content. */
private get physicalScrollX(): number {
return this.resolvedDir === 'rtl' ? this.maxScrollX + this.scrollLeft : this.scrollLeft;
}
// ── Position tracking ────────────────────────────────────────────────────
readonly isAtTop = $derived.by(() => this.scrollTop <= 0);
readonly isAtBottom = $derived.by(
() => this.scrollTop >= this.contentHeight - this.viewportHeight - 1
);
readonly isAtLeft = $derived.by(() => this.scrollLeft <= 0);
readonly isAtRight = $derived.by(
() => this.scrollLeft >= this.contentWidth - this.viewportWidth - 1
);
// `left` / `right` are PHYSICAL names (same family as at-top / at-bottom),
// and their documented use — scroll shadows — is physical too: the shadow
// belongs on the edge that still has content behind it, whichever way the
// text reads. So they keep their names and mirror their math.
//
// Both ends carry the same 1px slack, because only ONE of them lands on an
// exact zero and which one depends on the direction: `scrollLeft` rests at
// 0 against the inline-start edge, so the far end is always the browser's
// fractional maximum, and in RTL that far end is the LEFT one.
readonly isAtLeft = $derived.by(() => this.physicalScrollX <= 1);
readonly isAtRight = $derived.by(() => this.physicalScrollX >= this.maxScrollX - 1);
// ── Scroll progress (0..1) ───────────────────────────────────────────────
@ -98,9 +129,10 @@ export class ScrollAreaProvider {
return max <= 0 ? 0 : this.scrollTop / max;
}
/** Progress along the reading axis — feeds the thumb and `aria-valuenow`. */
getScrollProgressX(): number {
const max = this.contentWidth - this.viewportWidth;
return max <= 0 ? 0 : this.scrollLeft / max;
const max = this.maxScrollX;
return max <= 0 ? 0 : this.logicalScrollX / max;
}
// ── Viewport setup ───────────────────────────────────────────────────────
@ -429,9 +461,14 @@ export class ScrollAreaScrollbarProvider {
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = clickRatio * maxScroll;
} else {
const clickRatio = (e.clientX - rect.left) / rect.width;
// The horizontal track runs along the READING axis (the thumb rests at
// its inline-start end), so the click fraction is measured from that
// end, and the offset it produces carries the sign the browser expects.
const rtl = this.provider.resolvedDir === 'rtl';
const physicalRatio = (e.clientX - rect.left) / rect.width;
const clickRatio = rtl ? 1 - physicalRatio : physicalRatio;
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = clickRatio * maxScroll;
viewport.scrollLeft = (rtl ? -clickRatio : clickRatio) * maxScroll;
}
};
@ -593,7 +630,12 @@ export class ScrollAreaThumbProvider {
'data-dragging': boolToEmptyStrOrUndef(this.scrollbar.dragging),
style: {
position: 'absolute',
[this.isVertical ? 'top' : 'left']: `${this.thumbOffset}px`,
// The offset is READING progress (0 = start of the content), so the
// anchor has to be logical too — `inset-inline-start` puts the resting
// thumb on the right in RTL, like a native horizontal scrollbar. This
// is the opposite call from the sliding-indicator / tabs, where the JS
// measured a physical coordinate and the anchor had to match it.
[this.isVertical ? 'top' : 'inset-inline-start']: `${this.thumbOffset}px`,
[this.isVertical ? 'height' : 'width']: `${this.thumbSize}px`,
[this.isVertical ? 'width' : 'height']: '100%',
// Border-radius is owned by the recipe via the

@ -690,8 +690,10 @@
<tr>
<td class="name">RTL</td>
<td>
Soma's <code>dir</code> prop is honoured: in RTL, the vertical scrollbar moves
to the inline-start side, matching native scrollbar positioning.
Soma's <code>dir</code> prop is honoured: the vertical scrollbar moves to the
inline-start side, and the horizontal one mirrors — the thumb rests on the
right and <code>aria-valuenow</code> still runs 0→100 along the reading axis,
even though the browser reports <code>scrollLeft</code> as negative there.
</td>
</tr>
</tbody>

Loading…
Cancel
Save

Powered by TurnKey Linux.