diff --git a/src/uix/eidos/components/color-picker/color-picker.css b/src/uix/eidos/components/color-picker/color-picker.css index 580c1f3fa..3f8e7eda3 100644 --- a/src/uix/eidos/components/color-picker/color-picker.css +++ b/src/uix/eidos/components/color-picker/color-picker.css @@ -290,6 +290,15 @@ background: linear-gradient(to right, white, transparent); } +/* The x axis carries a colour CHANNEL across the inline dimension — a reading + axis — so the area mirrors in RTL and soma already mirrors the thumb and the + drag with it (see `physicalXProgress`). The ramp has to follow, or the + gradient stops matching the value under the thumb. `to right` has no logical + form, hence the `:dir()` pair. The block-axis overlays below never flip. */ +[data-color-picker-area-background]:dir(rtl)::before { + background: linear-gradient(to left, white, transparent); +} + /* Brightness overlay: transparent at top, pure black at the bottom. */ [data-color-picker-area-background]::after { background: linear-gradient(to top, black, transparent); @@ -322,6 +331,21 @@ ) !important; /* important: bypasses soma's inline base background */ } +/* Mirrored rainbow — same reason as the saturation ramp above. */ +[data-color-picker-area][data-x-channel='hue'] [data-color-picker-area-background]:dir(rtl) { + background: + linear-gradient( + to left, + var(--color-picker-hue-0) 0%, + var(--color-picker-hue-60) 17%, + var(--color-picker-hue-120) 33%, + var(--color-picker-hue-180) 50%, + var(--color-picker-hue-240) 67%, + var(--color-picker-hue-300) 83%, + var(--color-picker-hue-360) 100% + ) !important; /* important: bypasses soma's inline base background */ +} + /* y=saturation: top vivid, bottom gray (desaturated). The ::before slot is the only overlay; ::after (brightness) is suppressed. */ [data-color-picker-area][data-x-channel='hue'][data-y-channel='saturation'] diff --git a/src/uix/soma/components/color-picker/README.md b/src/uix/soma/components/color-picker/README.md index 861c239c4..6cf0136b6 100644 --- a/src/uix/soma/components/color-picker/README.md +++ b/src/uix/soma/components/color-picker/README.md @@ -185,6 +185,26 @@ The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via t - **EyeDropper**: Enter / Space invokes the browser picker. - **Escape** inside the Popover: closes and returns focus to the Trigger. +## RTL + +The x axis carries a colour **channel** across the inline dimension — a reading +axis — so **the Area mirrors in RTL**, together with the channel sliders. This +follows react-aria's `ColorArea` ("orientation of the gradient background, +positioning of the thumb, and dragging behavior is automatically mirrored"); a +radial wheel would not mirror, the same reading-vs-radial split the chart rule +uses. The Y axis is the block axis and never flips. + +| Piece | In RTL | +| --- | --- | +| Area pointer | the physical fraction is mirrored onto the channel: clicking 25% from the left edge yields channel 75 | +| Area thumb | anchor stays **physical** (`left`) — the recipe centres it with `translate(-50%,-50%)`, and a logical anchor paired with a physical translate is the trap the RTL contract forbids. Soma feeds it the already-mirrored fraction (`physicalXProgress`) | +| Area arrows | `ArrowRight` moves the thumb physically right, so it **decreases** the channel | +| Area gradients | the saturation ramp and the hue rainbow flip via `:dir(rtl)` in the recipe — `to right` has no logical form | +| Channel sliders | the generic `SliderProvider` underneath already mirrors thumb, pointer and keyboard; only `channelGradient` needed the direction, or the swatch under the thumb showed the wrong colour | + +`Home`/`End` stay **min/max of the channel** (a value, not a physical edge), the +same way a slider's do. + ## Data attributes All parts emit their canonical `data-color-picker-{part}` attribute. State-bearing attributes: diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts index 770f48aa9..8967021e6 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts @@ -26,6 +26,7 @@ import { ColorPickerTriggerProvider, ColorPickerValueSwatchProvider, ColorPickerValueTextProvider, + channelGradient, getChannelRange, getColorChannel, setColorChannel @@ -234,6 +235,64 @@ describe('ColorPickerProvider', () => { dom.dispose(); }); + // Anchors the RTL mirroring of the 2D area. The x axis carries a colour + // CHANNEL across the inline dimension — a reading axis — so it mirrors, the + // way react-aria's ColorArea does; a radial wheel would not. Before this, + // clicking the same physical spot produced the same channel value in both + // directions and the gradient never followed the thumb. + it('mirrors the 2D area in RTL — pointer, thumb, arrows and gradient', () => { + const { dom } = installSomaHarness(); + const opts = createColorPickerOpts(); + opts.dir.current = 'rtl'; + opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 }); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ColorPickerProvider.create(opts); + vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider); + const area = ColorPickerAreaProvider.create({ + id: state('color-picker-area'), + ref: state(document.createElement('div')), + xChannel: state(undefined), + yChannel: state(undefined) + }); + vi.spyOn(ColorPickerAreaProvider.ctx, 'get').mockReturnValue(area); + const thumb = ColorPickerAreaThumbProvider.create({ + id: state('color-picker-area-thumb'), + ref: state(null) + }); + return { provider, area, thumb }; + }); + + // Same physical point as the LTR test above (25% from the left edge), but + // the channel now grows leftwards: saturation reads 75, not 25. + result.area.handlePointerMove(25, 150, rect(0, 0, 100, 200)); + expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(75); + // Y is the block axis and is untouched by direction. + expect(Math.round(result.provider.effectiveColor.hsv.v)).toBe(25); + + // The thumb still lands under the cursor: the anchor stays PHYSICAL so it + // composes with the recipe's `translate(-50%, -50%)`. + expect(result.area.thumbStyle).toEqual({ left: '25%', top: '75%' }); + + // ArrowRight moves the thumb physically right ⇒ the channel DECREASES. + result.thumb.props.onkeydown(keyEvent('ArrowRight') as never); + expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(74); + + cleanup(); + dom.dispose(); + }); + + it('mirrors the channel gradient on the inline axis only', () => { + const red = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 }); + expect(channelGradient(red, 'hue', 'horizontal', 'ltr')).toContain('to right'); + expect(channelGradient(red, 'hue', 'horizontal', 'rtl')).toContain('to left'); + // Vertical is the block axis — same ramp in both directions. + expect(channelGradient(red, 'hue', 'vertical', 'ltr')).toContain('to top'); + expect(channelGradient(red, 'hue', 'vertical', 'rtl')).toContain('to top'); + // Default keeps the LTR ramp, so an un-updated caller is unaffected. + expect(channelGradient(red, 'alpha', 'horizontal')).toContain('to right'); + }); + it('lets each area override the root channel mapping', () => { const { dom } = installSomaHarness(); const opts = createColorPickerOpts(); diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts index d8e3f9e98..c45c0d114 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts @@ -816,8 +816,26 @@ export class ColorPickerAreaProvider { return `hsl(${hue}, 100%, 50%)`; }); + /** + * `xProgress` as a fraction of the area's PHYSICAL width, from its left + * edge. The x axis carries a colour CHANNEL mapped across the inline + * dimension — a reading axis — so an RTL area mirrors: the channel's + * minimum sits on the right. react-aria states it outright for `ColorArea` + * ("orientation of the gradient background, positioning of the thumb, and + * dragging behavior is automatically mirrored"); a radial `ColorWheel` does + * not, which is the same reading-vs-radial split the chart rule uses. + * + * Kept PHYSICAL rather than switching the anchor to `inset-inline-start` + * because the recipe centres the thumb with `translate(-50%, -50%)`, and a + * logical anchor paired with a physical translate is exactly the trap the + * RTL contract forbids. The Y axis is the block axis and never flips. + */ + readonly physicalXProgress: number = $derived.by(() => + this.provider.resolvedDir === 'rtl' ? 1 - this.xProgress : this.xProgress + ); + readonly thumbStyle: { left: string; top: string } = $derived.by(() => ({ - left: `${this.xProgress * 100}%`, + left: `${this.physicalXProgress * 100}%`, // Invert Y — top = max (top of area), bottom = min. top: `${(1 - this.yProgress) * 100}%` })); @@ -831,7 +849,10 @@ export class ColorPickerAreaProvider { // readonly / disabled lock the value: no pointer-driven edit (mirrors the // guarded keyboard path). readonly keeps focus/visibility; disabled also dims. if (this.provider.isDisabled || this.provider.isReadonly) return; - const xProgress = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + // The pointer arrives in PHYSICAL coordinates; mirror it back onto the + // channel's own axis so dragging tracks the gradient under the cursor. + const physicalX = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const xProgress = this.provider.resolvedDir === 'rtl' ? 1 - physicalX : physicalX; const yProgress = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height)); const xRange = getChannelRange(this.xChannel); const yRange = getChannelRange(this.yChannel); @@ -960,12 +981,16 @@ export class ColorPickerAreaThumbProvider { let nextX = x; let nextY = y; + // The area mirrors in RTL, so the arrow that moves the thumb physically + // right is the one that DECREASES the channel. react-aria's rule: the + // left arrow always moves to what is physically on the left. + const xStep = this.provider.resolvedDir === 'rtl' ? -step : step; switch (e.key) { case KEYS.ARROW_RIGHT: - nextX = Math.min(xRange.max, x + step); + nextX = Math.max(xRange.min, Math.min(xRange.max, x + xStep)); break; case KEYS.ARROW_LEFT: - nextX = Math.max(xRange.min, x - step); + nextX = Math.max(xRange.min, Math.min(xRange.max, x - xStep)); break; case KEYS.ARROW_UP: nextY = Math.min(yRange.max, y + step); @@ -1002,7 +1027,8 @@ export class ColorPickerAreaThumbProvider { }; readonly props = $derived.by(() => { - const x = this.area.xProgress * 100; + // Physical fraction: the area mirrors in RTL (see `physicalXProgress`). + const x = this.area.physicalXProgress * 100; const y = (1 - this.area.yProgress) * 100; const ariaText = this.ariaValueText; return this.runtimePart.assert({ @@ -1032,41 +1058,51 @@ export class ColorPickerAreaThumbProvider { type Orientation = 'horizontal' | 'vertical'; -/** Gradient string representing a channel's full range under the current color. */ +/** + * Gradient string representing a channel's full range under the current color. + * + * The horizontal ramp follows the READING axis: the generic `SliderProvider` + * underneath already mirrors its thumb in RTL, so a gradient pinned to `to + * right` would leave the swatch under the thumb showing the wrong colour — + * measured at hue 210: thumb 123px from the right, gradient still counting + * from the left. The vertical one is the block axis and never flips. + */ export function channelGradient( current: ColorValue, channel: ColorChannel, - orientation: Orientation + orientation: Orientation, + dir: Direction = 'ltr' ): string { - const dir = orientation === 'horizontal' ? 'to right' : 'to top'; + const inlineEnd = dir === 'rtl' ? 'to left' : 'to right'; + const gradientDir = orientation === 'horizontal' ? inlineEnd : 'to top'; switch (channel) { case 'hue': - return `linear-gradient(${dir}, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)`; + return `linear-gradient(${gradientDir}, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)`; case 'alpha': { const { r, g, b } = current.rgb; - return `linear-gradient(${dir}, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1))`; + return `linear-gradient(${gradientDir}, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1))`; } case 'saturation': { const h = current.hsv.h; const v = current.hsv.v; - return `linear-gradient(${dir}, hsl(${h},0%,${v / 2}%) 0%, hsl(${h},100%,${v / 2}%) 100%)`; + return `linear-gradient(${gradientDir}, hsl(${h},0%,${v / 2}%) 0%, hsl(${h},100%,${v / 2}%) 100%)`; } case 'brightness': { const h = current.hsv.h; const s = current.hsv.s; - return `linear-gradient(${dir}, #000 0%, hsl(${h},${s}%,50%) 100%)`; + return `linear-gradient(${gradientDir}, #000 0%, hsl(${h},${s}%,50%) 100%)`; } case 'red': { const { g, b } = current.rgb; - return `linear-gradient(${dir}, rgb(0,${g},${b}), rgb(255,${g},${b}))`; + return `linear-gradient(${gradientDir}, rgb(0,${g},${b}), rgb(255,${g},${b}))`; } case 'green': { const { r, b } = current.rgb; - return `linear-gradient(${dir}, rgb(${r},0,${b}), rgb(${r},255,${b}))`; + return `linear-gradient(${gradientDir}, rgb(${r},0,${b}), rgb(${r},255,${b}))`; } case 'blue': { const { r, g } = current.rgb; - return `linear-gradient(${dir}, rgb(${r},${g},0), rgb(${r},${g},255))`; + return `linear-gradient(${gradientDir}, rgb(${r},${g},0), rgb(${r},${g},255))`; } } } diff --git a/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte b/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte index ac9c79db8..401ec0edd 100644 --- a/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte +++ b/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte @@ -37,7 +37,11 @@ const range = $derived(getChannelRange(channel)); const channelValue = $derived(getColorChannel(cp.effectiveColor, channel)); - const gradient = $derived(channelGradient(cp.effectiveColor, channel, orientation)); + // The slider underneath already mirrors its thumb in RTL, so the ramp has to + // mirror with it or the swatch under the thumb shows the wrong colour. + const gradient = $derived( + channelGradient(cp.effectiveColor, channel, orientation, cp.resolvedDir) + ); // Continuous values (0..255 for RGB, 0..360 for hue, 0..100 for saturation/ // brightness, 0..1 for alpha). Step sizes chosen so keyboard arrows produce