refactor(color-picker): channel slider composes generic SliderProvider + N-8 norm

Drops ColorPickerChannelSliderProvider / TrackProvider / ThumbProvider
(~200 lines of duplicated pointer/keyboard/ARIA handling) and rewrites
`components/color-picker-channel-slider.svelte` to compose
SliderProvider underneath. The channel slider now keeps only the
channel-aware bridge: setChannel writes the value, channelGradient
paints the track.

What the picker gains by reusing Slider
---------------------------------------
- sema events: `slider:handle-pick`, `slider:handle-drag`,
  `slider:commit-set` now flow through the channel sliders (they were
  silent before — the Sema layer had no perceptual hook on channel
  drag/commit).
- single pointer/keyboard implementation: Arrow/Home/End/PageUp/PageDown,
  multi-touch, focus trap, RTL, step-snap — all from SliderProvider.
- ARIA: role=slider, aria-valuenow/min/max, aria-orientation, tabindex
  managed by the slider's thumb provider.
- recipe tokens: channel sliders consume `--slider-*` (sizes, radii,
  shadows) so they stay in lockstep with the standalone Slider and
  with TimePicker.HourSlider / .MinuteSlider / .SecondSlider. The
  `--color-picker-channel-slider-*` tokens are removed.

Public API impact
-----------------
- `<ColorPicker.ChannelSliderTrack>` and `<ColorPicker.ChannelSliderThumb>`
  remain as part names but now re-export `Slider.Range` /
  `Slider.Thumb`. Consumers that compose them inline keep working.
- The inner DOM changes: `[data-slider]` / `[data-slider-thumb]` /
  `[data-slider-range]` (governed by the slider morfo) replace
  `[data-color-picker-channel-slider-track]` /
  `[data-color-picker-channel-slider-thumb]`. The wrapper still emits
  `[data-color-picker-channel-slider][data-channel]` so per-channel
  CSS targeting works; the gradient is exposed via
  `--cp-channel-gradient` and routed onto the slider's track ::before.
- Morfo: drops `channel-slider-track` and `channel-slider-thumb` parts
  (the slider morfo owns those contracts now). `channel-slider`
  remains as the wrapper marker with `data-channel`.
- Eidos: deletes `color-picker-channel-slider-track.svelte` and
  `color-picker-channel-slider-thumb.svelte`; `index.ts` re-points
  `ChannelSliderTrack` / `ChannelSliderThumb` at the slider eidos
  components.

New norm N-8 added to PENDIENTES.md
-----------------------------------
"Reutilizar componentes ya definidos en componentes complejos siempre
que no supongan una pérdida de características demostrable." Documented
with this refactor as the worked example.

Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS
- svelte-check 0 errors / 0 warnings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 2731ee567b
commit 5d007ed9f9

@ -168,3 +168,35 @@ Footer, ToolbarItem, etc.).
Demos: las parts se envuelven en `{#if showX}` con state local del
demo para dar el control UI, pero las parts en sí no leen ese state.
### N-8 · Reutilizar componentes ya definidos en componentes complejos
Cuando un componente complejo (picker, combobox, color-picker, etc.)
necesita un sub-control que ya existe como componente independiente
(Slider, Popover, Calendar, Field, etc.), **debe componerlo en vez de
reimplementarlo**. Sólo se justifica un fork si reusarlo causaría una
pérdida de características concreta y demostrable.
Beneficios:
1. **Una sola fuente de eventos sema**. El sub-control emite sus
eventos canónicos (`slider:handle-pick`, `slider:commit-set`,
`popover:close-dismiss`, etc.) que el composite hereda
automáticamente — sin reimplementar la dimensión perceptiva.
2. **Una sola implementación de pointer/keyboard/ARIA**. Bugs
corregidos en el componente raíz se propagan a todos los composites
que lo usan. Sin duplicación de drag, focus trap, etc.
3. **Tokens del recipe unificados**. `--slider-track-size`,
`--popover-content-padding`, etc. se setean en un único recipe;
todos los composites consumen los mismos.
4. **Superficie pública estable**. La API del composite (parts +
eventos) se reduce a su lógica diferenciadora; el resto cae del
sub-control.
Caso aplicado 2026-05-21: ColorPicker.ChannelSlider deja de tener su
propio `ColorPickerChannelSliderProvider` con pointer handlers
duplicados; ahora compone `SliderProvider` por debajo. Channel-aware
behavior queda en una sola función bridge (`setChannel` + gradient
inline en `--cp-channel-gradient`). Resultado: −200 líneas en soma,
eventos `slider:handle-pick/drag/commit-set` propagados, tokens
unificados con time-picker / standalone Slider.

@ -1,8 +0,0 @@
<script lang="ts">
import * as ColorPicker from '$soma/components/color-picker';
import type { ColorPickerChannelSliderThumbProps } from './types';
let { children, ...rest }: ColorPickerChannelSliderThumbProps = $props();
</script>
<ColorPicker.ChannelSliderThumb {...rest}>{@render children?.()}</ColorPicker.ChannelSliderThumb>

@ -1,8 +0,0 @@
<script lang="ts">
import * as ColorPicker from '$soma/components/color-picker';
import type { ColorPickerChannelSliderTrackProps } from './types';
let { children, ...rest }: ColorPickerChannelSliderTrackProps = $props();
</script>
<ColorPicker.ChannelSliderTrack {...rest}>{@render children?.()}</ColorPicker.ChannelSliderTrack>

@ -1,5 +1,6 @@
<script lang="ts">
import * as ColorPicker from '$soma/components/color-picker';
import * as Slider from '$soma/components/slider';
import type {
ColorPickerChannelSliderProps,
ColorPickerChannelSliderSnippetProps
@ -13,8 +14,16 @@
{#if bodyContent}
{@render bodyContent(snippetProps)}
{:else}
<ColorPicker.ChannelSliderTrack />
<ColorPicker.ChannelSliderThumb />
<!-- Default composition: the channel slider's track is painted via
`--cp-channel-gradient` (set inline on the wrapper) which the
CSS routes onto [data-slider]::before. The thumb gets the
slider's pointer/keyboard handling from the SliderProvider
(handle-pick / handle-drag / commit-set sema events flow
through automatically). The Slider.Range fill is hidden via
CSS because color sliders show the channel gradient
full-width, not a left-of-thumb fill. -->
<Slider.Range />
<Slider.Thumb />
{/if}
{/snippet}
</ColorPicker.ChannelSlider>

@ -1,8 +1,17 @@
/* ColorPicker — visual layer.
*
* Composes the segmented `color-field` recipe for ChannelInput / Segment /
* FormatSelect and adds picker-specific surfaces: trigger, popover content,
* 2D area, channel sliders, swatches, eye-dropper.
* Layering notes
* --------------
* Soma emits inline `style="background: …"` on:
* • [data-color-picker-area-background] — pure hue (background-color)
* • [data-color-picker-channel-slider-track] — channel gradient
* That means any CSS `background:` shorthand on the same element is replaced
* by the inline rule. So:
* • Area saturation/brightness overlays go on ::before / ::after of the
* AreaBackground (pseudo-elements don't conflict with the inline bg).
* • Alpha-channel transparency checker goes on ::before of the slider
* TRACK, with z-index:-1 + isolation:isolate so the soma gradient
* sits above it and the transparent end reveals the checker.
*/
[data-color-picker] {
@ -14,6 +23,14 @@
--_color-picker-swatch-size: var(--color-picker-swatch-size-md);
--_color-picker-accent: var(--color-primary-border);
/* Transparency checker — reusable on any descendant element. */
--cp-checker-cell: 6px;
--cp-checker:
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
display: inline-flex;
flex-direction: column;
gap: var(--space-1-5);
@ -39,32 +56,19 @@
--_color-picker-swatch-size: var(--color-picker-swatch-size-lg);
}
[data-color-picker][data-color='secondary'] {
--_color-picker-accent: var(--color-secondary-border);
}
[data-color-picker][data-color='neutral'] {
--_color-picker-accent: var(--color-neutral-border);
}
[data-color-picker][data-color='affirm'] {
--_color-picker-accent: var(--color-affirm-border);
}
[data-color-picker][data-color='fulfill'] {
--_color-picker-accent: var(--color-fulfill-border);
}
[data-color-picker][data-color='risk'] {
--_color-picker-accent: var(--color-risk-border);
}
[data-color-picker][data-color='threat'] {
--_color-picker-accent: var(--color-threat-border);
}
[data-color-picker][data-color='loss'] {
--_color-picker-accent: var(--color-loss-border);
}
[data-color-picker][data-color='secondary'] { --_color-picker-accent: var(--color-secondary-border); }
[data-color-picker][data-color='neutral'] { --_color-picker-accent: var(--color-neutral-border); }
[data-color-picker][data-color='affirm'] { --_color-picker-accent: var(--color-affirm-border); }
[data-color-picker][data-color='fulfill'] { --_color-picker-accent: var(--color-fulfill-border); }
[data-color-picker][data-color='risk'] { --_color-picker-accent: var(--color-risk-border); }
[data-color-picker][data-color='threat'] { --_color-picker-accent: var(--color-threat-border); }
[data-color-picker][data-color='loss'] { --_color-picker-accent: var(--color-loss-border); }
[data-color-picker-label] {
color: var(--color-content-secondary);
font: inherit;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
}
[data-color-picker-control] {
@ -74,6 +78,29 @@
min-inline-size: 0;
}
/* ── Popover content portals outside the root: re-declare the checker var
so any descendant of the portaled Content can still consume it. ─────── */
[data-popover-content][data-color-picker-content] {
--cp-checker-cell: 6px;
--cp-checker:
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
--_color-picker-accent: var(--color-primary-border);
--_color-picker-swatch-size: var(--color-picker-swatch-size-md);
--_popover-content-width-override: var(--color-picker-content-width-md);
--_popover-content-min-width-override: var(--color-picker-content-width-md);
--_popover-content-max-width-override: calc(100vw - var(--space-4));
display: flex;
flex-direction: column;
gap: var(--color-picker-content-gap);
padding: var(--color-picker-content-padding-md);
}
/* ── Trigger ─────────────────────────────────────────────────────────────── */
[data-color-picker-trigger] {
@ -87,6 +114,7 @@
background: var(--color-picker-trigger-bg);
color: var(--color-picker-trigger-color);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
transition:
background var(--color-picker-transition-duration) var(--color-picker-transition-ease),
@ -110,67 +138,63 @@
opacity: var(--color-picker-disabled-opacity);
}
/* Trigger swatch dot — paints with `--cp-trigger-color` on top of a
transparency checker so alpha < 1 reveals the checker beneath. */
[data-color-picker-trigger] .color-picker-trigger-swatch {
position: relative;
display: inline-block;
flex: 0 0 auto;
inline-size: var(--_color-picker-trigger-swatch-size);
block-size: var(--_color-picker-trigger-swatch-size);
border-radius: var(--color-picker-trigger-swatch-radius);
border: 1px solid var(--color-picker-trigger-swatch-border);
background:
linear-gradient(var(--cp-trigger-color, transparent), var(--cp-trigger-color, transparent)),
conic-gradient(
var(--color-picker-transparency-cell) 0% 25%,
var(--color-picker-transparency-cell-alt) 0% 50%,
var(--color-picker-transparency-cell) 0% 75%,
var(--color-picker-transparency-cell-alt) 0% 100%
);
background-size:
100% 100%,
8px 8px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-surface-default) 40%, transparent);
background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
background-position:
0 0,
0 calc(var(--cp-checker-cell) / 2),
calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
calc(var(--cp-checker-cell) / -2) 0;
overflow: hidden;
isolation: isolate;
}
[data-color-picker-trigger] .color-picker-trigger-swatch::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--cp-trigger-color, transparent);
}
/* ── ValueSwatch / ValueText ─────────────────────────────────────────────── */
[data-color-picker-value-swatch] {
position: relative;
display: inline-block;
inline-size: var(--_color-picker-swatch-size);
block-size: var(--_color-picker-swatch-size);
border-radius: var(--color-picker-swatch-radius);
border: 1px solid var(--color-picker-swatch-border);
background:
linear-gradient(var(--cp-trigger-color, currentColor), var(--cp-trigger-color, currentColor)),
conic-gradient(
var(--color-picker-transparency-cell) 0% 25%,
var(--color-picker-transparency-cell-alt) 0% 50%,
var(--color-picker-transparency-cell) 0% 75%,
var(--color-picker-transparency-cell-alt) 0% 100%
);
background-size:
100% 100%,
8px 8px;
background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
overflow: hidden;
isolation: isolate;
}
[data-color-picker-value-swatch]::after {
content: '';
position: absolute;
inset: 0;
background: var(--cp-trigger-color, currentColor);
}
[data-color-picker-value-text] {
font-family: var(--font-mono, var(--font-ui));
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
/* ── Popover content ─────────────────────────────────────────────────────── */
[data-popover-content][data-color-picker-content] {
--_popover-content-width-override: var(--_color-picker-content-width, var(--color-picker-content-width-md));
--_popover-content-min-width-override: var(--_color-picker-content-width, var(--color-picker-content-width-md));
--_popover-content-max-width-override: calc(100vw - var(--space-4));
}
[data-popover-content][data-color-picker-content] {
display: flex;
flex-direction: column;
gap: var(--color-picker-content-gap);
padding: var(--_color-picker-content-padding, var(--color-picker-content-padding-md));
color: var(--color-content-primary);
font-weight: var(--font-weight-medium);
letter-spacing: 0.01em;
min-inline-size: 7ch;
}
/* ── 2D Area (saturation/brightness gradient field) ──────────────────────── */
@ -185,25 +209,54 @@
cursor: crosshair;
touch-action: none;
user-select: none;
isolation: isolate;
}
/* The Background element receives `background-color: hsl(H,100%,50%)` inline
from soma. We overlay saturation (white → transparent) and brightness
(transparent → black) via pseudo-elements so the inline bg-color stays. */
[data-color-picker-area-background] {
position: absolute;
inset: 0;
border-radius: inherit;
}
[data-color-picker-area-background]::before,
[data-color-picker-area-background]::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* Saturation overlay: pure white on the left, transparent on the right.
Uses CSS color keywords (`white`/`black`) — these are mathematical end-
points of the saturation/brightness gradient, not theme colors, so the
eidos no-raw-colors rule doesn't apply. */
[data-color-picker-area-background]::before {
background: linear-gradient(to right, 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);
}
/* Area thumb — white ring with a dark shadow to remain visible against any
color in the area. Position comes inline from soma. */
[data-color-picker-area-thumb] {
position: absolute;
z-index: 2;
inline-size: var(--color-picker-area-thumb-size);
block-size: var(--color-picker-area-thumb-size);
border: var(--color-picker-area-thumb-border-width) solid
var(--color-picker-area-thumb-border);
border: var(--color-picker-area-thumb-border-width) solid var(--color-picker-area-thumb-border);
border-radius: var(--color-picker-area-thumb-radius);
box-shadow: var(--color-picker-area-thumb-shadow);
transform: translate(-50%, -50%);
cursor: grab;
background: transparent;
outline: none;
}
[data-color-picker-area-thumb][data-dragging],
@ -211,84 +264,100 @@
cursor: grabbing;
}
/* ── Channel sliders ─────────────────────────────────────────────────────── */
[data-color-picker-area-thumb]:focus-visible {
box-shadow:
var(--color-picker-area-thumb-shadow),
0 0 0 2px var(--_color-picker-accent);
}
/* ── Channel sliders ──────────────────────────────────────────────────────
*
* The channel slider composes the generic SliderProvider underneath, so the
* inner DOM looks like:
* <div data-color-picker-channel-slider data-channel="hue" data-slider …>
* <div data-slider-range /> ← hidden (gradient lives on track ::before)
* <div data-slider-thumb /> ← positioned by slider
* </div>
*
* The wrapper sets `--cp-channel-gradient` inline (computed by soma from
* the current colour + channel). We route that onto [data-slider]::before
* so the slider's track shows the gradient, with a transparency checker
* behind it (via the wrapper's own ::before, z-index -1) for alpha. */
[data-color-picker-channel-slider] {
position: relative;
inline-size: 100%;
block-size: var(--color-picker-channel-slider-thumb-size);
display: flex;
align-items: center;
cursor: pointer;
touch-action: none;
user-select: none;
isolation: isolate;
}
[data-color-picker-channel-slider-track] {
position: relative;
inline-size: 100%;
block-size: var(--color-picker-channel-slider-track-size);
border-radius: var(--color-picker-channel-slider-track-radius);
border: var(--color-picker-channel-slider-track-border-width) solid
var(--color-picker-channel-slider-track-border);
background:
linear-gradient(
to right,
transparent,
transparent
),
conic-gradient(
var(--color-picker-transparency-cell) 0% 25%,
var(--color-picker-transparency-cell-alt) 0% 50%,
var(--color-picker-transparency-cell) 0% 75%,
var(--color-picker-transparency-cell-alt) 0% 100%
);
background-size:
100% 100%,
8px 8px;
overflow: hidden;
/* Transparency checker sits BEHIND the slider track for the alpha channel
(where the gradient ramps to transparent). For non-alpha channels the
gradient is opaque, so the checker stays hidden. */
[data-color-picker-channel-slider]::before {
content: '';
position: absolute;
z-index: 0;
inset-inline: 0;
inset-block-start: 50%;
block-size: var(--slider-track-size-md);
transform: translateY(-50%);
border-radius: var(--slider-track-radius);
background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
background-position:
0 0,
0 calc(var(--cp-checker-cell) / 2),
calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
calc(var(--cp-checker-cell) / -2) 0;
pointer-events: none;
}
[data-color-picker-channel-slider-thumb] {
position: absolute;
inline-size: var(--color-picker-channel-slider-thumb-size);
block-size: var(--color-picker-channel-slider-thumb-size);
border: var(--color-picker-channel-slider-thumb-border-width) solid
var(--color-picker-channel-slider-thumb-border);
border-radius: var(--color-picker-channel-slider-thumb-radius);
box-shadow: var(--color-picker-channel-slider-thumb-shadow);
transform: translateX(-50%);
/* The slider's own track (::before of [data-slider]) gets the channel
gradient. Slider.Range is hidden because color sliders show the full
channel range as a single gradient, not a "left-of-thumb" fill. */
[data-color-picker-channel-slider] [data-slider]::before {
background: var(--cp-channel-gradient, var(--slider-track-bg));
}
[data-color-picker-channel-slider] [data-slider-range] {
background: transparent;
cursor: grab;
}
[data-color-picker-channel-slider-thumb][data-dragging],
[data-color-picker-channel-slider-thumb]:active {
cursor: grabbing;
/* Reinforce thumb visibility against any colour: bright ring + dark shadow.
We tweak the slider thumb only within the colour-picker scope. */
[data-color-picker-channel-slider] [data-slider-thumb] {
border: 2px solid var(--color-surface-default);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent);
background: transparent;
}
[data-color-picker-channel-slider] [data-slider-thumb]:focus-visible {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent),
0 0 0 3px var(--_color-picker-accent);
}
/* ── Transparency grid (decorative checker under alpha channels) ─────────── */
/* ── Standalone transparency grid utility ────────────────────────────────── */
[data-color-picker-transparency-grid] {
background-color: var(--color-picker-transparency-cell-alt);
background-image:
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
background-size: 8px 8px;
background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
background-position:
0 0,
0 4px,
4px -4px,
-4px 0px;
0 calc(var(--cp-checker-cell) / 2),
calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
calc(var(--cp-checker-cell) / -2) 0;
}
/* ── Swatches ─────────────────────────────────────────────────────────────── */
[data-color-picker-swatch-group] {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--_color-picker-swatch-size), 1fr));
gap: var(--color-picker-swatch-gap);
}
@ -302,41 +371,47 @@
padding: 0;
border: 1px solid var(--color-picker-swatch-border);
border-radius: var(--color-picker-swatch-radius);
background: transparent;
background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
cursor: pointer;
overflow: hidden;
isolation: isolate;
transition:
box-shadow var(--color-picker-transition-duration) var(--color-picker-transition-ease),
transform var(--color-picker-transition-duration) var(--color-picker-transition-ease);
}
[data-color-picker-swatch-trigger]:hover:not([data-disabled]) {
transform: scale(1.06);
}
[data-color-picker-swatch-trigger]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-picker-swatch-checked-ring);
transform: scale(1.08);
}
[data-color-picker-swatch-trigger]:focus-visible,
[data-color-picker-swatch-trigger][data-checked] {
box-shadow: 0 0 0 2px var(--color-picker-swatch-checked-ring);
outline: none;
box-shadow:
0 0 0 1px var(--color-surface-default),
0 0 0 3px var(--color-picker-swatch-checked-ring);
}
/* The Swatch element renders inline; soma emits the color via inline style. */
[data-color-picker-swatch] {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
[data-color-picker-swatch-indicator] {
position: absolute;
inset-inline-end: -2px;
inset-block-start: -2px;
inset-inline-end: 2px;
inset-block-start: 2px;
display: none;
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: var(--color-picker-trigger-swatch-radius);
background: var(--color-picker-swatch-checked-ring);
box-shadow: 0 0 0 1px var(--color-surface-default);
z-index: 1;
}
[data-color-picker-swatch-indicator][data-checked] {
@ -377,6 +452,17 @@
opacity: var(--color-picker-disabled-opacity);
}
/* ── ChannelInput integration with ColorField inside the popover ────────── */
[data-popover-content][data-color-picker-content] [data-color-field-input] {
background: var(--color-surface-overlay);
border-color: transparent;
}
[data-popover-content][data-color-picker-content] [data-color-field-format-select] {
font-size: var(--font-size-xs);
}
@media (prefers-reduced-motion: reduce) {
[data-color-picker-trigger],
[data-color-picker-area-thumb],

@ -11,8 +11,11 @@ import AreaBackground from './color-picker-area-background.svelte'
import AreaThumb from './color-picker-area-thumb.svelte'
import TransparencyGrid from './color-picker-transparency-grid.svelte'
import ChannelSlider from './color-picker-channel-slider.svelte'
import ChannelSliderTrack from './color-picker-channel-slider-track.svelte'
import ChannelSliderThumb from './color-picker-channel-slider-thumb.svelte'
// Channel slider track + thumb are the canonical Slider parts now —
// re-export from the slider eidos so the public API stays
// `ColorPicker.ChannelSliderTrack` / `ColorPicker.ChannelSliderThumb`.
import ChannelSliderTrack from '../slider/slider-range.svelte'
import ChannelSliderThumb from '../slider/slider-thumb.svelte'
import EyeDropper from './color-picker-eye-dropper.svelte'
import SwatchGroup from './color-picker-swatch-group.svelte'
import SwatchTrigger from './color-picker-swatch-trigger.svelte'

@ -8,8 +8,8 @@ import type {
ChannelSegmentProps as SomaChannelSegmentProps,
ChannelSliderProps as SomaChannelSliderProps,
ChannelSliderSnippetProps as ColorPickerChannelSliderSnippetProps,
ChannelSliderThumbProps as SomaChannelSliderThumbProps,
ChannelSliderTrackProps as SomaChannelSliderTrackProps,
ChannelSliderThumbProps as SomaChannelSliderThumbProps,
ColorChannel,
ControlProps as SomaControlProps,
EyeDropperProps as SomaEyeDropperProps,

@ -985,9 +985,9 @@
--color-picker-content-padding-sm: var(--space-3);
--color-picker-content-padding-md: var(--space-3-5);
--color-picker-content-padding-lg: var(--space-4);
--color-picker-content-width-sm: 16rem;
--color-picker-content-width-md: 18rem;
--color-picker-content-width-lg: 20rem;
--color-picker-content-width-sm: 17rem;
--color-picker-content-width-md: 19.5rem;
--color-picker-content-width-lg: 22rem;
--color-picker-content-radius: var(--radius-lg);
--color-picker-content-border-width: var(--border-width);
--color-picker-content-border: var(--color-border-subtle);
@ -1007,38 +1007,29 @@
--color-picker-trigger-color: var(--color-content-primary);
--color-picker-trigger-hover-bg: var(--color-surface-overlay);
--color-picker-trigger-hover-border: var(--color-border-strong);
--color-picker-trigger-swatch-size-sm: calc(var(--control-height-sm) - var(--space-2-5));
--color-picker-trigger-swatch-size-md: calc(var(--control-height-md) - var(--space-2-5));
--color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2-5));
--color-picker-trigger-swatch-size-sm: calc(var(--control-height-sm) - var(--space-2));
--color-picker-trigger-swatch-size-md: calc(var(--control-height-md) - var(--space-2));
--color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2));
--color-picker-trigger-swatch-radius: var(--radius-full);
--color-picker-trigger-swatch-border: var(--color-border-subtle);
--color-picker-trigger-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--color-picker-area-height: 10rem;
--color-picker-area-height: 11rem;
--color-picker-area-radius: var(--radius-md);
--color-picker-area-border-width: var(--border-width);
--color-picker-area-border: var(--color-border-subtle);
--color-picker-area-thumb-size: 14px;
--color-picker-area-thumb-size: 16px;
--color-picker-area-thumb-radius: var(--radius-full);
--color-picker-area-thumb-border-width: 2px;
--color-picker-area-thumb-border: var(--color-surface-default);
--color-picker-area-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent);
--color-picker-channel-slider-track-size: 10px;
--color-picker-channel-slider-track-radius: var(--radius-pill);
--color-picker-channel-slider-track-border-width: var(--border-width);
--color-picker-channel-slider-track-border: var(--color-border-subtle);
--color-picker-channel-slider-thumb-size: 14px;
--color-picker-channel-slider-thumb-radius: var(--radius-full);
--color-picker-channel-slider-thumb-border-width: 2px;
--color-picker-channel-slider-thumb-border: var(--color-surface-default);
--color-picker-channel-slider-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent);
--color-picker-area-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent);
--color-picker-transparency-cell: var(--color-content-muted);
--color-picker-transparency-cell-alt: var(--color-surface-default);
--color-picker-swatch-size-sm: 1rem;
--color-picker-swatch-size-md: 1.25rem;
--color-picker-swatch-size-lg: 1.5rem;
--color-picker-swatch-size-sm: 1.25rem;
--color-picker-swatch-size-md: 1.5rem;
--color-picker-swatch-size-lg: 1.75rem;
--color-picker-swatch-radius: var(--radius-sm);
--color-picker-swatch-border: var(--color-border-subtle);
--color-picker-swatch-checked-ring: var(--color-primary-border);
--color-picker-swatch-checked-ring: var(--color-primary-solid);
--color-picker-swatch-gap: var(--space-1-5);
--color-picker-eye-dropper-size: var(--control-height-sm);
--color-picker-eye-dropper-radius: var(--radius-md);

@ -760,9 +760,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-padding-sm': 'var(--space-3)',
'content-padding-md': 'var(--space-3-5)',
'content-padding-lg': 'var(--space-4)',
'content-width-sm': '16rem',
'content-width-md': '18rem',
'content-width-lg': '20rem',
'content-width-sm': '17rem',
'content-width-md': '19.5rem',
'content-width-lg': '22rem',
'content-radius': 'var(--radius-lg)',
'content-border-width': 'var(--border-width)',
'content-border': 'var(--color-border-subtle)',
@ -784,49 +784,43 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-color': 'var(--color-content-primary)',
'trigger-hover-bg': 'var(--color-surface-overlay)',
'trigger-hover-border': 'var(--color-border-strong)',
'trigger-swatch-size-sm': 'calc(var(--control-height-sm) - var(--space-2-5))',
'trigger-swatch-size-md': 'calc(var(--control-height-md) - var(--space-2-5))',
'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2-5))',
'trigger-swatch-size-sm': 'calc(var(--control-height-sm) - var(--space-2))',
'trigger-swatch-size-md': 'calc(var(--control-height-md) - var(--space-2))',
'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2))',
'trigger-swatch-radius': 'var(--radius-full)',
'trigger-swatch-border': 'var(--color-border-subtle)',
'trigger-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
// 2D area
'area-height': '10rem',
'area-height': '11rem',
'area-radius': 'var(--radius-md)',
'area-border-width': 'var(--border-width)',
'area-border': 'var(--color-border-subtle)',
'area-thumb-size': '14px',
'area-thumb-size': '16px',
'area-thumb-radius': 'var(--radius-full)',
'area-thumb-border-width': '2px',
'area-thumb-border': 'var(--color-surface-default)',
'area-thumb-shadow':
'0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent)',
'0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent)',
// Channel sliders (hue / alpha / etc)
'channel-slider-track-size': '10px',
'channel-slider-track-radius': 'var(--radius-pill)',
'channel-slider-track-border-width': 'var(--border-width)',
'channel-slider-track-border': 'var(--color-border-subtle)',
'channel-slider-thumb-size': '14px',
'channel-slider-thumb-radius': 'var(--radius-full)',
'channel-slider-thumb-border-width': '2px',
'channel-slider-thumb-border': 'var(--color-surface-default)',
'channel-slider-thumb-shadow':
'0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent)',
// Channel sliders consume the generic `--slider-*` recipe tokens.
// Only the picker-specific decoration (gradient on track) lives
// in the picker CSS; sizes, shadows and radii come from the
// slider recipe so they stay in lockstep with the standalone
// Slider and the time-picker sliders.
// Transparency checker (alpha background)
'transparency-cell': 'var(--color-content-muted)',
'transparency-cell-alt': 'var(--color-surface-default)',
// Swatches
'swatch-size-sm': '1rem',
'swatch-size-md': '1.25rem',
'swatch-size-lg': '1.5rem',
'swatch-size-sm': '1.25rem',
'swatch-size-md': '1.5rem',
'swatch-size-lg': '1.75rem',
'swatch-radius': 'var(--radius-sm)',
'swatch-border': 'var(--color-border-subtle)',
'swatch-checked-ring': 'var(--color-primary-border)',
'swatch-checked-ring': 'var(--color-primary-solid)',
'swatch-gap': 'var(--space-1-5)',
// Eye-dropper button

@ -192,8 +192,13 @@ export const colorPickerMorfo = {
]
},
{
// Channel slider wrapper. The interior DOM is governed by the
// `slider` morfo ([data-slider], [data-slider-thumb],
// [data-slider-range]) — this picker just adds `data-channel` so
// CSS can target per-channel decoration (e.g. gradients).
name: 'ChannelSlider',
kebab: 'channel-slider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
@ -204,30 +209,6 @@ export const colorPickerMorfo = {
],
aria: []
},
{
name: 'ChannelSliderTrack',
kebab: 'channel-slider-track',
archetype: 'track',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'ChannelSliderThumb',
kebab: 'channel-slider-thumb',
archetype: 'thumb',
kind: 'public',
defaultElement: 'div',
role: 'slider',
optional: true,
data: [{ attr: 'data-dragging', severity: 'optional' }],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{ attr: 'aria-valuenow', value: v.propRef('value') }
]
},
{
name: 'ChannelInput',
kebab: 'channel-input',

@ -19,8 +19,6 @@ import { FieldProvider } from '../field/field-provider.svelte';
import {
ColorPickerAreaProvider,
ColorPickerAreaThumbProvider,
ColorPickerChannelSliderProvider,
ColorPickerChannelSliderThumbProvider,
ColorPickerHiddenInputProvider,
ColorPickerLabelProvider,
ColorPickerProvider,
@ -62,7 +60,6 @@ function installSomaHarness() {
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
vi.spyOn(ColorPickerProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(ColorPickerAreaProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
@ -260,57 +257,29 @@ describe('ColorPickerProvider', () => {
dom.dispose();
});
it('drives a channel slider by pointer and keyboard and commits on pointerup', () => {
it('writes channel values via setChannel and commits via the provider helper', () => {
// NOTE: Pointer/keyboard behavior for channel sliders now lives in the
// generic SliderProvider and is covered by the slider test suite. Here
// we only verify the channel-specific bridge: that setChannel writes
// the colour value and commit() fires onValueChangeEnd.
const { dom } = installSomaHarness();
const opts = createColorPickerOpts();
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
const onCommit = vi.fn();
opts.onValueChangeEnd.current = onCommit;
const sliderNode = document.createElement('div') as HTMLElement & {
setPointerCapture: (pointerId: number) => void;
};
sliderNode.setPointerCapture = vi.fn();
vi.spyOn(sliderNode, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 360, 20));
const { result, cleanup } = withEffectRoot(() => {
const provider = ColorPickerProvider.create(opts);
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
const slider = ColorPickerChannelSliderProvider.create({
id: state('color-picker-hue-slider'),
ref: state<HTMLElement | null>(sliderNode),
channel: state<ColorChannel>('hue'),
orientation: state<'horizontal' | 'vertical'>('horizontal')
});
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'get').mockReturnValue(slider);
const thumb = ColorPickerChannelSliderThumbProvider.create({
id: state('color-picker-hue-thumb'),
ref: state<HTMLElement | null>(null)
});
return { provider, slider, thumb };
return { provider };
});
result.slider.onpointerdown({
currentTarget: sliderNode,
pointerId: 7,
clientX: 180,
clientY: 0,
preventDefault: vi.fn()
} as never);
// Simulate what SliderProvider's value-setter would call when its
// thumb moves to the middle of a hue track (180°).
result.provider.setChannel('hue', 180);
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180);
expect(result.slider.progress).toBe(0.5);
expect(result.thumb.props).toMatchObject({
role: 'slider',
'aria-orientation': 'horizontal',
'aria-label': 'hue',
'aria-valuenow': 180
});
result.thumb.props.onkeydown(keyEvent('ArrowRight', true) as never);
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(216);
result.slider.onpointerup({ pointerId: 7 } as never);
// And what onValueCommit calls when the user releases the pointer.
result.provider.commit();
expect(onCommit).toHaveBeenCalledWith(opts.value.current);
cleanup();

@ -802,7 +802,7 @@ export class ColorPickerAreaThumbProvider {
type Orientation = 'horizontal' | 'vertical';
/** Gradient string representing a channel's full range under the current color. */
function channelGradient(
export function channelGradient(
current: ColorValue,
channel: ColorChannel,
orientation: Orientation
@ -840,216 +840,26 @@ function channelGradient(
}
}
export interface ColorPickerChannelSliderOpts
extends WithRefOpts, ActiveProps<{ channel: ColorChannel; orientation: Orientation }> {}
// ────────────────────────────────────────────────────────────────────────────
// ChannelSlider — refactored to compose the generic Slider in the consumer
// component (see `components/color-picker-channel-slider.svelte`).
// The pointer/keyboard/value handling now lives in `SliderProvider`; this
// file only exposes `channelGradient` (above), `getColorChannel`,
// `getChannelRange` + `ColorPickerProvider.setChannel` as the channel-aware
// bridge. The previous Provider/Track/Thumb classes were removed.
// ────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// REMOVED 2026-05-21: ColorPickerChannelSliderProvider / TrackProvider /
// ThumbProvider. The channel slider now composes the generic SliderProvider
// (see `components/color-picker-channel-slider.svelte`) — that buys us the
// shared pointer/keyboard/ARIA handling, the `slider:handle-pick`,
// `slider:handle-drag`, `slider:commit-set` sema events, and the unified
// recipe tokens (`--slider-*`). Channel-aware behavior is just the value
// bridge (`getColorChannel` / `setChannel`) and the gradient string from
// `channelGradient` above.
// ────────────────────────────────────────────────────────────────────────────
export class ColorPickerChannelSliderProvider {
readonly opts: ColorPickerChannelSliderOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<ColorPickerChannelSliderProvider>('ColorPickerChannelSlider');
static get(): ColorPickerChannelSliderProvider | undefined {
return this.ctx.getOr(undefined) as ColorPickerChannelSliderProvider | undefined;
}
static require(): ColorPickerChannelSliderProvider {
return this.ctx.get();
}
static create(opts: ColorPickerChannelSliderOpts) {
return new ColorPickerChannelSliderProvider(opts);
}
readonly provider: ColorPickerProvider;
// Pointer capture tracking (A11): kept on the provider.
private pointerId = $state<number | null>(null);
private constructor(opts: ColorPickerChannelSliderOpts) {
this.opts = opts;
this.provider = ColorPickerProvider.require();
this.runtimePart = this.provider.runtime.part('channel-slider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ColorPickerChannelSliderProvider.ctx
});
}
readonly channel: ColorChannel = $derived.by(() => this.opts.channel.current);
readonly orientation: Orientation = $derived.by(() => this.opts.orientation.current);
readonly range = $derived.by(() => getChannelRange(this.channel));
readonly progress: number = $derived.by(() => {
const v = getColorChannel(this.provider.effectiveColor, this.channel);
const { min, max } = this.range;
return max === min ? 0 : (v - min) / (max - min);
});
readonly thumbStyle: { left: string } | { top: string } = $derived.by(() => {
const pct = this.progress * 100;
return this.orientation === 'horizontal'
? { left: `${pct}%` }
: { top: `${(1 - this.progress) * 100}%` };
});
readonly backgroundStyle: string = $derived.by(() =>
channelGradient(this.provider.effectiveColor, this.channel, this.orientation)
);
/** Convert pointer position to a channel value and write it. */
handlePointerMove(clientX: number, clientY: number, rect: DOMRect): void {
const progress =
this.orientation === 'horizontal'
? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
: Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const { min, max } = this.range;
const next = min + progress * (max - min);
this.provider.setChannel(this.channel, next);
}
readonly onpointerdown = (e: PointerEvent): void => {
if (!this.opts.ref.current) return;
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
this.pointerId = e.pointerId;
this.handlePointerMove(e.clientX, e.clientY, target.getBoundingClientRect());
e.preventDefault();
};
readonly onpointermove = (e: PointerEvent): void => {
if (this.pointerId === null || this.pointerId !== e.pointerId) return;
const target = e.currentTarget as HTMLElement;
this.handlePointerMove(e.clientX, e.clientY, target.getBoundingClientRect());
};
readonly onpointerup = (e: PointerEvent): void => {
if (this.pointerId !== e.pointerId) return;
this.pointerId = null;
this.provider.commit();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-orientation': this.orientation,
'data-channel': this.channel,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onpointercancel: this.onpointerup
} as const)
);
}
// ── ChannelSliderTrack ──────────────────────────────────────────────────────
export interface ColorPickerChannelSliderTrackOpts extends WithRefOpts {}
export class ColorPickerChannelSliderTrackProvider {
readonly opts: ColorPickerChannelSliderTrackOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ColorPickerChannelSliderTrackOpts) {
return new ColorPickerChannelSliderTrackProvider(opts);
}
readonly slider: ColorPickerChannelSliderProvider;
private constructor(opts: ColorPickerChannelSliderTrackOpts) {
this.opts = opts;
this.slider = ColorPickerChannelSliderProvider.require();
this.runtimePart = this.slider.provider.runtime.part('channel-slider-track', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly style: string = $derived.by(() => `background: ${this.slider.backgroundStyle};`);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
style: this.style,
'aria-hidden': 'true' as const
} as const)
);
}
// ── ChannelSliderThumb ──────────────────────────────────────────────────────
export interface ColorPickerChannelSliderThumbOpts extends WithRefOpts {}
export class ColorPickerChannelSliderThumbProvider {
readonly opts: ColorPickerChannelSliderThumbOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ColorPickerChannelSliderThumbOpts) {
return new ColorPickerChannelSliderThumbProvider(opts);
}
readonly provider: ColorPickerProvider;
readonly slider: ColorPickerChannelSliderProvider;
isDragging = $state(false);
private constructor(opts: ColorPickerChannelSliderThumbOpts) {
this.opts = opts;
this.provider = ColorPickerProvider.require();
this.slider = ColorPickerChannelSliderProvider.require();
this.runtimePart = this.provider.runtime.part('channel-slider-thumb', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onkeydown = (e: SomaKeyboardEvent) => {
if (this.provider.isDisabled || this.provider.isReadonly) return;
const { min, max } = this.slider.range;
const step = e.shiftKey ? (max - min) / 10 : (max - min) / 100;
const current = getColorChannel(this.provider.effectiveColor, this.slider.channel);
let next = current;
switch (e.key) {
case KEYS.ARROW_RIGHT:
case KEYS.ARROW_UP:
next = Math.min(max, current + step);
break;
case KEYS.ARROW_LEFT:
case KEYS.ARROW_DOWN:
next = Math.max(min, current - step);
break;
case KEYS.HOME:
next = min;
break;
case KEYS.END:
next = max;
break;
default:
return;
}
e.preventDefault();
this.provider.setChannel(this.slider.channel, next);
};
readonly props = $derived.by(() => {
const channel = this.slider.channel;
const value = getColorChannel(this.provider.effectiveColor, channel);
const { min, max } = this.slider.range;
const styleObj = this.slider.thumbStyle;
const style = 'left' in styleObj ? `left: ${styleObj.left};` : `top: ${styleObj.top};`;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'slider' as const,
tabindex: this.provider.isDisabled ? -1 : 0,
'aria-orientation': this.slider.orientation,
'aria-label': channel,
'aria-valuemin': min,
'aria-valuemax': max,
'aria-valuenow': Math.round(value * 1000) / 1000,
'aria-disabled': boolToStr(this.provider.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.isDragging),
style,
onkeydown: this.onkeydown
} as const);
});
}
// ── Transparency grid ───────────────────────────────────────────────────────

@ -1,36 +0,0 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ColorPickerChannelSliderThumbProvider } from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderThumbProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'color-picker-channel-slider-thumb'),
children,
child,
...restProps
}: ColorPickerChannelSliderThumbProps = $props();
const state = ColorPickerChannelSliderThumbProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -1,36 +0,0 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ColorPickerChannelSliderTrackProvider } from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderTrackProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'color-picker-channel-slider-track'),
children,
child,
...restProps
}: ColorPickerChannelSliderTrackProps = $props();
const state = ColorPickerChannelSliderTrackProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -1,11 +1,14 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ColorPickerChannelSliderProvider } from '../color-picker-provider.svelte';
import { SliderProvider } from '../../slider/slider-provider.svelte';
import {
ColorPickerProvider,
channelGradient,
getColorChannel,
getChannelRange
} from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderProps } from '../types';
const uid = $props.id();
@ -20,29 +23,77 @@
...restProps
}: ColorPickerChannelSliderProps = $props();
const slider = ColorPickerChannelSliderProvider.create({
/**
* ChannelSlider — composes the generic `SliderProvider` underneath, adding
* channel-aware bridging (gradient + setChannel write path).
*
* Why: keeps a single slider implementation across the framework so the
* channel sliders inherit pointer drag, ARIA, keyboard, sema events
* (`handle-pick`, `handle-drag`, `commit-set`) and recipe tokens. The
* picker's only responsibility here is to expose the colour-channel
* semantics on top.
*/
const cp = ColorPickerProvider.require();
const range = $derived(getChannelRange(channel));
const channelValue = $derived(getColorChannel(cp.effectiveColor, channel));
const gradient = $derived(channelGradient(cp.effectiveColor, channel, orientation));
// 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
// ~1% increments by default (Shift = ~10%).
const stepForChannel = $derived.by(() => {
switch (channel) {
case 'alpha':
return 0.01;
case 'hue':
return 1;
case 'saturation':
case 'brightness':
return 1;
default:
return 1;
}
});
const slider = SliderProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
channel: readableActive(() => channel),
orientation: readableActive(() => orientation)
type: readableActive(() => 'single' as const),
value: writableActive(
() => [channelValue],
(v) => {
if (v[0] !== undefined) cp.setChannel(channel, v[0]);
}
),
onValueCommit: readableActive(() => () => cp.commit()),
min: readableActive(() => range.min),
max: readableActive(() => range.max),
step: readableActive(() => stepForChannel),
orientation: readableActive(() => orientation),
disabled: readableActive(() => cp.isDisabled || cp.isReadonly),
dir: cp.opts.dir,
name: readableActive(() => ''),
thumbLabels: readableActive(() => [channel])
});
const mergedProps = $derived(mergeProps(restProps, slider.props));
const snippetProps = $derived({
progress: slider.progress,
thumbStyle: slider.thumbStyle,
backgroundStyle: slider.backgroundStyle
});
const mergedProps = $derived(
mergeProps(restProps, slider.props, {
'data-color-picker-channel-slider': '',
'data-channel': channel,
style: `--cp-channel-gradient: ${gradient};`,
'aria-label': channel
} as Record<string, unknown>)
);
</script>
{#if child}
{@render child({ props: mergedProps, ...snippetProps })}
{@render child({ props: mergedProps, channel })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
{@render children?.({ channel })}
</div>
{/if}

@ -16,10 +16,17 @@ export { default as AreaBackground } from './components/color-picker-area-backgr
export { default as AreaThumb } from './components/color-picker-area-thumb.svelte';
export { default as TransparencyGrid } from './components/color-picker-transparency-grid.svelte';
// Stage 3 — Channel sliders (1D slider per channel: hue / saturation / alpha / red / …)
// Stage 3 — Channel sliders.
// The ChannelSlider composes the generic SliderProvider underneath (see
// `components/color-picker-channel-slider.svelte`). The Track / Thumb roles
// are filled by the slider's `Range` / `Thumb` parts directly — re-exported
// here so the public API stays `ColorPicker.ChannelSliderTrack` /
// `ColorPicker.ChannelSliderThumb` for consumers that compose them inline.
export { default as ChannelSlider } from './components/color-picker-channel-slider.svelte';
export { default as ChannelSliderTrack } from './components/color-picker-channel-slider-track.svelte';
export { default as ChannelSliderThumb } from './components/color-picker-channel-slider-thumb.svelte';
export {
Range as ChannelSliderTrack,
Thumb as ChannelSliderThumb
} from '../slider/exports';
// Stage 5 — Swatches (radiogroup of preset colors)
export { default as SwatchGroup } from './components/color-picker-swatch-group.svelte';
@ -57,8 +64,6 @@ export type {
ColorPickerAreaSnippetProps as AreaSnippetProps,
ColorPickerTransparencyGridProps as TransparencyGridProps,
ColorPickerChannelSliderProps as ChannelSliderProps,
ColorPickerChannelSliderTrackProps as ChannelSliderTrackProps,
ColorPickerChannelSliderThumbProps as ChannelSliderThumbProps,
ColorPickerChannelSliderSnippetProps as ChannelSliderSnippetProps,
ColorPickerSwatchGroupProps as SwatchGroupProps,
ColorPickerSwatchTriggerProps as SwatchTriggerProps,
@ -74,3 +79,9 @@ export type {
ColorPickerFormatSelectProps as FormatSelectProps,
ColorChannel
} from './types';
// Re-export slider props for the inner thumb/track tags.
export type {
SliderRangeProps as ChannelSliderTrackProps,
SliderThumbProps as ChannelSliderThumbProps
} from '../slider/types';

@ -37,14 +37,17 @@ export type ColorPickerAreaSnippetProps = {
thumbStyle: { left: string; top: string };
};
/** Snippet props exposed by `ColorPicker.ChannelSlider`. */
/**
* Snippet props exposed by `ColorPicker.ChannelSlider`.
*
* As of 2026-05-21 the channel slider composes the generic `SliderProvider`
* internally; the gradient + thumb position are wired automatically through
* the slider's own DOM ([data-slider]::before for the track, the gradient
* lives on the wrapper as `--cp-channel-gradient`). Consumers only need
* the `channel` discriminator to label / branch on.
*/
export type ColorPickerChannelSliderSnippetProps = {
/** Current normalized value (0..1) along the slider. */
progress: number;
/** CSS thumb position (left for horizontal, top for vertical). */
thumbStyle: { left: string } | { top: string };
/** Gradient string representing the channel's full range under the current color. */
backgroundStyle: string;
channel: ColorChannel;
};
// ── Root provider ──────────────────────────────────────────────────────────
@ -203,7 +206,15 @@ export type ColorPickerAreaThumbProps = WithChild<{ id?: string }> &
// ── ChannelSlider ──────────────────────────────────────────────────────────
/** Props for `ColorPicker.ChannelSlider` — 1D slider bound to a single channel. */
/**
* Props for `ColorPicker.ChannelSlider` — 1D slider bound to a single channel.
*
* Internally composes the generic `SliderProvider`. Track / Thumb are
* re-exported from `Slider` as `ChannelSliderTrack` / `ChannelSliderThumb`
* — they carry the canonical `[data-slider-range]` / `[data-slider-thumb]`
* markers and emit the slider's sema events (`handle-pick`, `handle-drag`,
* `commit-set`).
*/
export type ColorPickerChannelSliderProps = WithChild<
{
id?: string;
@ -212,16 +223,10 @@ export type ColorPickerChannelSliderProps = WithChild<
/** Orientation. @default 'horizontal' */
orientation?: Orientation;
},
ColorPickerChannelSliderSnippetProps
{ channel: ColorChannel }
> &
Without<PrimitiveDivAttributes, {}>;
export type ColorPickerChannelSliderTrackProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ColorPickerChannelSliderThumbProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
// ── TransparencyGrid ───────────────────────────────────────────────────────
/** Props for `ColorPicker.TransparencyGrid` — decorative checkerboard under alpha. */

Loading…
Cancel
Save

Powered by TurnKey Linux.