feat(uix): ColorPicker — ColorSwatch primitive, presets/saved colors, readonly rollout

- New <ColorSwatch> eidos primitive: transparency-checker base + colour
  overlay, opt-in inset ring, inherits the container corner-shape.
- ColorPicker presets tab + saved colours: `presets` prop, bindable
  `savedColors`, ColorPicker.Presets / SavedSwatches / SaveAction parts
  (canonical hsvToHex comparison; save/delete component-owned).
- Readonly: the trigger no longer opens the popover when readonly — gated
  via a PopoverTrigger `disabled` opt, rolled out across the color / date /
  date-range / time / time-range pickers.
- ColorField swatch respects its container (no bg/border, inherits shape via
  corner-shape); picker trigger swatch matches the button shape.
- Recipe: popover content-width tokens.
active-uix
dev 3 months ago
parent 54ecc84e2d
commit 72e8fb57ea

@ -1,5 +1,6 @@
<script lang="ts">
import * as ColorField from '$soma/components/color-field';
import ColorSwatch from '../color-swatch';
import type { ColorFieldInputProps, ColorFieldInputSnippetProps } from './types';
let { children: bodyContent, swatch = false, ...rest }: ColorFieldInputProps = $props();
@ -8,7 +9,11 @@
<ColorField.Input {...rest}>
{#snippet children(args: ColorFieldInputSnippetProps)}
{#if swatch}
<span data-color-field-swatch aria-hidden="true"></span>
<!-- Leading value swatch composes the shared <ColorSwatch> primitive: a
clean colour chip (checker + colour overlay, no border). The colour
comes from the inherited --cf-swatch-color the field root publishes;
size + corner radius/shape are matched to the field via CSS. -->
<ColorSwatch data-color-field-swatch="" aria-hidden="true" color="var(--cf-swatch-color, transparent)" />
{/if}
{@render bodyContent?.(args)}
{/snippet}

@ -222,20 +222,18 @@
min-inline-size: 6ch;
}
/* Leading value swatch (opt-in via <ColorField.Input swatch>). Sized to the
control icon for this field size via the `--_color-field-swatch-size` token
(= `--icon-size-{size}`) — a CONCRETE length, not `1em`, so it can never
recompute/jitter on re-render. The colour comes from --cf-swatch-color, set
on the root from the field value. Inset border keeps light colours visible. */
[data-color-field-swatch] {
flex: 0 0 auto;
inline-size: var(--_color-field-swatch-size);
block-size: var(--_color-field-swatch-size);
/* Shares the field's own corner radius token (no hand-rolled calc) so it
tracks the field and reads as part of the same control. */
/* Leading value swatch (opt-in via <ColorField.Input swatch>) — composes the
shared <ColorSwatch> primitive, which paints the checker + colour overlay
(from the inherited --cf-swatch-color) with NO border. The field owns only
placement: it drives the swatch SIZE off its own token (a CONCRETE length,
never `1em`, so it can't jitter on re-render), matches its corner radius and
inherits the field's corner-shape so the chip tracks the control. */
[data-color-field-swatch][data-color-swatch] {
--_color-swatch-size: var(--_color-field-swatch-size);
border-radius: var(--color-field-radius);
background: var(--cf-swatch-color, transparent);
box-shadow: inset 0 0 0 var(--border-width) var(--color-border-subtle);
corner-shape: inherit;
}
[data-color-field-swatch] {
margin-inline-end: var(--_color-field-segment-px);
}

@ -0,0 +1,28 @@
<script lang="ts">
/**
* Eidos `<ColorPicker.Presets>` — renders the `presets` palette as a swatch
* group. Each preset is painted with the canonical `<ColorSwatch>` (size-
* scaled), with the selection indicator on top. A consumer `children` snippet
* overrides the per-swatch visual.
*/
import * as ColorPicker from '$soma/components/color-picker';
import ColorSwatch from '../color-swatch';
import { getColorPickerVisualContext } from './context.svelte';
import type { ColorPickerPresetsProps } from './types';
let { children, ...rest }: ColorPickerPresetsProps = $props();
const visual = getColorPickerVisualContext();
const size = $derived(visual?.getSize() ?? 'md');
</script>
<ColorPicker.Presets {...rest}>
{#snippet children(snip)}
{#if children}
{@render children(snip)}
{:else}
<ColorSwatch color={snip.color} {size} />
<ColorPicker.SwatchIndicator />
{/if}
{/snippet}
</ColorPicker.Presets>

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Eidos `<ColorPicker.SaveAction>` — the Save / Delete colour button. Reads
* the headless action state from soma and renders a `<Button>`: "save"
* (neutral, disabled when the colour can't be saved) or "delete" (threat,
* when a saved swatch is selected). Labels default to the localized strings;
* `saveLabel` / `deleteLabel` override them.
*/
import * as ColorPicker from '$soma/components/color-picker';
import Button from '../button';
import { ActiveEidos } from '$uix/eidos';
import { getColorPickerVisualContext } from './context.svelte';
import type { ColorPickerSaveActionProps } from './types';
let { saveLabel, deleteLabel }: ColorPickerSaveActionProps = $props();
const eidos = ActiveEidos.require();
const visual = getColorPickerVisualContext();
const size = $derived(visual?.getSize() ?? 'md');
const save = $derived(saveLabel ?? eidos.langs.ts('#?components.color-picker.save-color|Save color'));
const del = $derived(deleteLabel ?? eidos.langs.ts('#?components.color-picker.delete-color|Delete color'));
</script>
<ColorPicker.SaveAction>
{#snippet children({ showDelete, disabled, onClick })}
<Button
variant="ghost"
color={showDelete ? 'threat' : 'neutral'}
{size}
{disabled}
onclick={onClick}
>
{showDelete ? del : save}
</Button>
{/snippet}
</ColorPicker.SaveAction>

@ -0,0 +1,28 @@
<script lang="ts">
/**
* Eidos `<ColorPicker.SavedSwatches>` — renders the editable `savedColors`
* as a swatch group (painted with `<ColorSwatch>`). Selecting one flips
* `<ColorPicker.SaveAction>` to "delete". A consumer `children` snippet
* overrides the per-swatch visual.
*/
import * as ColorPicker from '$soma/components/color-picker';
import ColorSwatch from '../color-swatch';
import { getColorPickerVisualContext } from './context.svelte';
import type { ColorPickerSavedSwatchesProps } from './types';
let { children, ...rest }: ColorPickerSavedSwatchesProps = $props();
const visual = getColorPickerVisualContext();
const size = $derived(visual?.getSize() ?? 'md');
</script>
<ColorPicker.SavedSwatches {...rest}>
{#snippet children(snip)}
{#if children}
{@render children(snip)}
{:else}
<ColorSwatch color={snip.color} {size} />
<ColorPicker.SwatchIndicator />
{/if}
{/snippet}
</ColorPicker.SavedSwatches>

@ -7,6 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as ColorPicker from '$soma/components/color-picker';
import ColorSwatch from '../color-swatch';
import { getColorPickerVisualContext } from './context.svelte';
import type { ColorPickerTriggerProps } from './types';
@ -14,6 +15,7 @@
size,
variant,
color,
shape = 'continuous',
showSwatch = true,
children,
style,
@ -32,11 +34,19 @@
data-size={resolvedSize}
data-variant={resolvedVariant}
data-color={resolvedColor}
data-shape={shape}
data-show-swatch={showSwatch ? '' : undefined}
{style}
>
{#if showSwatch}
<span class="color-picker-trigger-swatch" aria-hidden="true"></span>
<!-- Composes the shared <ColorSwatch> primitive: checker + colour overlay,
colour from the inherited --cp-current-color. Size + radius + corner-shape
are matched to the button via CSS so the chip echoes the trigger. -->
<ColorSwatch
class="color-picker-trigger-swatch"
aria-hidden="true"
color="var(--cp-current-color, transparent)"
/>
{/if}
{#if children}
{@render children()}

@ -177,34 +177,15 @@
opacity: var(--color-picker-disabled-opacity);
}
/* Trigger swatch dot — paints with `--cp-current-color` on top of a
transparency checker so alpha < 1 reveals the checker beneath. Soma's
ColorPickerTriggerProvider stamps `--cp-current-color` inline. */
[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);
/* Trigger swatch — composes the shared <ColorSwatch> primitive (transparency
checker + colour overlay, no border). Colour comes from the inherited
--cp-current-color the trigger provider stamps. The trigger owns only
placement: size from its token, and the button's radius + corner-shape so the
chip echoes the button (squircle) instead of a hard circle. */
[data-color-picker-trigger] .color-picker-trigger-swatch[data-color-swatch] {
--_color-swatch-size: var(--_color-picker-trigger-swatch-size);
border-radius: var(--color-picker-trigger-swatch-radius);
border: var(--border-width) solid var(--color-picker-trigger-swatch-border);
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-current-color, transparent);
corner-shape: inherit;
}
/* ── ValueSwatch / ValueText ─────────────────────────────────────────────── */
@ -529,10 +510,21 @@
transform var(--color-picker-transition-duration) var(--color-picker-transition-ease);
}
[data-color-picker-swatch-trigger]:hover:not([data-disabled]) {
[data-color-picker-swatch-trigger]:hover:not([data-disabled]):not([data-readonly]) {
transform: scale(1.08);
}
/* ── Readonly — value visible + controls focusable, editing locked, NOT dimmed.
Distinct from [data-disabled] (which dims + drops focus). Just removes the
edit-affordance cursor + the hover lift; the slider/segment locks live in
their own recipes. ─────────────────────────────────────────────────────── */
[data-color-picker-area][data-readonly],
[data-color-picker-area][data-readonly] [data-color-picker-area-thumb],
[data-color-picker-swatch-trigger][data-readonly],
[data-color-picker-eye-dropper][data-readonly] {
cursor: default;
}
[data-color-picker-swatch-trigger]:focus-visible,
[data-color-picker-swatch-trigger][data-checked] {
outline: none;

@ -19,6 +19,7 @@
placeholder = $bindable(),
format = $bindable('hex'),
open = $bindable(false),
savedColors = $bindable([]),
children,
...rest
}: ColorPickerProps = $props();
@ -41,6 +42,7 @@
bind:placeholder
bind:format
bind:open
bind:savedColors
data-size={resolvedSize}
data-variant={variant}
data-color={color}

@ -21,6 +21,9 @@ import SwatchGroup from './color-picker-swatch-group.svelte'
import SwatchTrigger from './color-picker-swatch-trigger.svelte'
import Swatch from './color-picker-swatch.svelte'
import SwatchIndicator from './color-picker-swatch-indicator.svelte'
import Presets from './color-picker-presets.svelte'
import SavedSwatches from './color-picker-saved-swatches.svelte'
import SaveAction from './color-picker-save-action.svelte'
import ChannelInput from './color-picker-channel-input.svelte'
import ChannelSegment from './color-picker-channel-segment.svelte'
import FormatSelect from './color-picker-format-select.svelte'
@ -61,6 +64,9 @@ type ColorPickerNamespace = typeof ColorPickerComponent & {
SwatchTrigger: typeof SwatchTrigger
Swatch: typeof Swatch
SwatchIndicator: typeof SwatchIndicator
Presets: typeof Presets
SavedSwatches: typeof SavedSwatches
SaveAction: typeof SaveAction
ChannelInput: typeof ChannelInput
ChannelSegment: typeof ChannelSegment
FormatSelect: typeof FormatSelect
@ -96,6 +102,9 @@ ColorPicker.SwatchGroup = SwatchGroup
ColorPicker.SwatchTrigger = SwatchTrigger
ColorPicker.Swatch = Swatch
ColorPicker.SwatchIndicator = SwatchIndicator
ColorPicker.Presets = Presets
ColorPicker.SavedSwatches = SavedSwatches
ColorPicker.SaveAction = SaveAction
ColorPicker.ChannelInput = ChannelInput
ColorPicker.ChannelSegment = ChannelSegment
ColorPicker.FormatSelect = FormatSelect
@ -129,6 +138,9 @@ export type {
ColorPickerSwatchTriggerProps as SwatchTriggerProps,
ColorPickerSwatchProps as SwatchProps,
ColorPickerSwatchIndicatorProps as SwatchIndicatorProps,
ColorPickerPresetsProps as PresetsProps,
ColorPickerSavedSwatchesProps as SavedSwatchesProps,
ColorPickerSaveActionProps as SaveActionProps,
ColorPickerChannelInputProps as ChannelInputProps,
ColorPickerChannelSegmentProps as ChannelSegmentProps,
ColorPickerFormatSelectProps as FormatSelectProps,

@ -21,13 +21,15 @@ import type {
SwatchIndicatorProps as SomaSwatchIndicatorProps,
SwatchProps as SomaSwatchProps,
SwatchTriggerProps as SomaSwatchTriggerProps,
PresetsProps as SomaPresetsProps,
SavedSwatchesProps as SomaSavedSwatchesProps,
TransparencyGridProps as SomaTransparencyGridProps,
TriggerProps as SomaTriggerProps,
ValueSwatchProps as SomaValueSwatchProps,
ValueTextProps as SomaValueTextProps
} from '$soma/components/color-picker'
import type { PopoverContentProps } from '../popover/types'
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
import type { ColorRole, ControlVariant, ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types'
export type ColorPickerSize = Extract<Size, 'sm' | 'md' | 'lg'>
export type ColorPickerVariant = ControlVariant
@ -46,6 +48,12 @@ export type ColorPickerTriggerProps = SomaTriggerProps & {
size?: ResponsiveProp<ColorPickerSize>
variant?: ColorPickerVariant
color?: ColorPickerColor
/**
* Corner shape family, wired the same way `<Button>` is (stamps `data-shape`).
* Defaults to `continuous` (squircle) so the trigger tracks the picker's
* surface shape instead of staying a plain arc. @default 'continuous'
*/
shape?: ShapeFamily
/** When true (default) the trigger shows a swatch dot before its content. */
showSwatch?: boolean
}
@ -76,6 +84,17 @@ export type ColorPickerSwatchTriggerProps = SomaSwatchTriggerProps
export type ColorPickerSwatchProps = SomaSwatchProps
export type ColorPickerSwatchIndicatorProps = SomaSwatchIndicatorProps
export type ColorPickerPresetsProps = SomaPresetsProps
export type ColorPickerSavedSwatchesProps = SomaSavedSwatchesProps
/** Props for the eidos `ColorPicker.SaveAction` — renders the Save / Delete button. */
export type ColorPickerSaveActionProps = {
/** Label for the "save" state. @default localized "Save colour" */
saveLabel?: string
/** Label for the "delete" state. @default localized "Delete colour" */
deleteLabel?: string
}
export type ColorPickerChannelInputProps = SomaChannelInputProps
export type ColorPickerChannelSegmentProps = SomaChannelSegmentProps
export type ColorPickerFormatSelectProps = SomaFormatSelectProps

@ -0,0 +1,57 @@
# Eidos ColorSwatch
A colour-sample **primitive** — a square (or circular) chip of a CSS colour
painted over a transparency checker, with a subtle inset ring. The canonical,
size-scaled way to show a colour, reused across `ColorPicker` / `ColorField`
(value preview, preset swatches, trigger dot) and anywhere a colour needs a
consistent sample.
```svelte
<ColorSwatch color="#1e90ff" />
<ColorSwatch color="rgb(234 88 12 / 0.6)" size="lg" />
<ColorSwatch color={value} rounded="full" /> <!-- a colour dot -->
```
## Why a primitive
Before this, every consumer drew its own swatch: the ColorField swatch used
`--icon-size-*`, the ColorPicker swatch used its own `1.25/1.5/1.75rem` scale,
and some preset swatches dropped the border + checker ad-hoc — so the same
"colour chip" rendered at different sizes, with a border in one place and a
hard white fill in another. `ColorSwatch` is the single source: one swatch
scale (16 / 20 / 24 / 28 / 32 px for xs..xl — a substantial chip, not an inline
glyph), one ring, one checker.
## Behaviour
- **Opaque colours fill solid** (the `::before` overlay covers the checker).
- **Alpha colours reveal the checker** beneath (mode-aware neutral grid, never
a hard white).
- **Clean flat colour by default** — no border. Pass `bordered` for an opt-in
subtle inset ring (an inset `box-shadow` painted ON TOP of the colour, so a
light / white swatch stays delineated and the box never grows past `size`).
- **Scales with `size`** via a dedicated swatch scale (16 / 20 / 24 / 28 / 32 px
for xs..xl) that follows `--scaling`.
## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `color` | `string` | — | any CSS colour (hex / rgb / hsl / oklch / named) |
| `size` | `ResponsiveProp<'xs'..'xl'>` | `md` | 16 / 20 / 24 / 28 / 32 px, follows `--scaling` |
| `rounded` | `ResponsiveProp<'sm'\|'md'\|'lg'\|'full'>` | `sm` | `full` = circular dot |
| `bordered` | `boolean` | `false` | opt-in subtle inset ring (for light / white colours) |
| `as` | `string` | `span` | render a different tag |
Decorative by default — pass an `aria-label` (or wrap it in an interactive
control) when the colour conveys meaning.
## Tokens
`--color-swatch-size-{xs..xl}` · `--color-swatch-radius` ·
`--color-swatch-border-{width,color}` · `--color-swatch-checker-{cell,bg,square}`.
## Morfo
`src/uix/morfo/components/color-swatch.ts` — single part, `scope: ['eidos']`,
passive (no events). Eidos-native, no soma (pure visual, like `Kbd` / `Icon`).

@ -0,0 +1,76 @@
/* ColorSwatch — a colour chip over a transparency checker.
*
* Layering (so it reads consistently on ANY colour or background):
* • the element's own background = the transparency CHECKER (neutral, mode-aware).
* • `::before` = the COLOUR overlay (inset:0, set inline via
* `--color-swatch-color`). Opaque colours cover the
* checker (solid); alpha colours reveal it.
* • `::after` = a subtle INSET ring ON TOP, so a light colour is
* still delineated and the box never grows (the ring
* is a box-shadow, not a border).
*
* Size scales via `--icon-size-*` (1:1 with the icon scale → follows the size of the
* control/text it sits beside, and `--scaling`).
*/
[data-color-swatch] {
--_color-swatch-size: var(--color-swatch-size-md);
position: relative;
display: inline-block;
flex: 0 0 auto;
inline-size: var(--_color-swatch-size);
block-size: var(--_color-swatch-size);
border-radius: var(--color-swatch-radius);
background-color: var(--color-swatch-checker-bg);
background-image:
linear-gradient(45deg, var(--color-swatch-checker-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-swatch-checker-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-swatch-checker-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-swatch-checker-square) 75%);
background-size: var(--color-swatch-checker-cell) var(--color-swatch-checker-cell);
background-position:
0 0,
0 calc(var(--color-swatch-checker-cell) / 2),
calc(var(--color-swatch-checker-cell) / 2) calc(var(--color-swatch-checker-cell) / -2),
calc(var(--color-swatch-checker-cell) / -2) 0;
overflow: hidden;
isolation: isolate;
}
[data-color-swatch][data-size='xs'] { --_color-swatch-size: var(--color-swatch-size-xs); }
[data-color-swatch][data-size='sm'] { --_color-swatch-size: var(--color-swatch-size-sm); }
[data-color-swatch][data-size='lg'] { --_color-swatch-size: var(--color-swatch-size-lg); }
[data-color-swatch][data-size='xl'] { --_color-swatch-size: var(--color-swatch-size-xl); }
[data-color-swatch][data-rounded='md'] { border-radius: var(--radius-md); }
[data-color-swatch][data-rounded='lg'] { border-radius: var(--radius-lg); }
[data-color-swatch][data-rounded='full'] { border-radius: var(--radius-full); }
/* Colour overlay — covers the checker for opaque colours; alpha reveals it. */
[data-color-swatch]::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
/* Match the element's corner-shape (e.g. a squircle inherited from a button) so
the colour overlay reaches the corners — otherwise the checker beneath peeks
out where a round overlay falls short of a non-round (squircle) element. */
corner-shape: inherit;
background: var(--color-swatch-fill, transparent);
pointer-events: none;
}
/* Opt-in subtle ring ON TOP of the colour so a light / white swatch stays
* delineated. OFF by default — a clean flat colour chip with no border. */
[data-color-swatch][data-bordered]::after {
content: '';
position: absolute;
inset: 0;
z-index: 2;
border-radius: inherit;
corner-shape: inherit;
box-shadow: inset 0 0 0 var(--color-swatch-border-width) var(--color-swatch-border-color);
pointer-events: none;
}

@ -0,0 +1,48 @@
<script lang="ts">
import './color-swatch.css';
/**
* Eidos `<ColorSwatch>` — a colour chip painted over a transparency checker.
*
* <ColorSwatch color="#1e90ff" />
* <ColorSwatch color="rgb(234 88 12 / 0.6)" size="lg" />
* <ColorSwatch color={value} rounded="full" /> <!-- a colour dot -->
*
* Opaque colours fill solid; colours with alpha reveal the checker beneath.
* A subtle inset ring delineates it on any background. Scales with `size`
* (via `--icon-size-*`). Decorative by default — pass an `aria-label` (or
* wrap it in an interactive control) when the colour conveys meaning.
*/
import { ActiveEidos } from '$uix/eidos';
import type { ColorSwatchProps } from './types';
let {
color,
size,
rounded,
bordered = false,
as = 'span',
tagStyle,
class: className,
...restProps
}: ColorSwatchProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size) ?? 'md');
const resolvedRounded = $derived(eidos.resolve(rounded));
const inlineStyle = $derived(
[`--color-swatch-fill: ${color};`, tagStyle].filter(Boolean).join(' ')
);
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-color-swatch=""
data-size={resolvedSize}
data-rounded={resolvedRounded}
data-bordered={bordered ? '' : undefined}
style={inlineStyle}
></svelte:element>

@ -0,0 +1,20 @@
// ColorSwatch — eidos single-component colour-sample primitive.
//
// import { ColorSwatch } from '$uix/eidos/components/color-swatch';
//
// <ColorSwatch color="#1e90ff" />
// <ColorSwatch color="rgb(234 88 12 / 0.6)" size="lg" />
// <ColorSwatch color={value} rounded="full" /> <!-- a colour dot -->
//
// Single-component primitive — `<ColorSwatch>` IS the root. Renders as
// `<span>` by default. Reused by ColorPicker / ColorField for a consistent,
// size-scaled colour sample (checker under alpha, inset ring, no white seams).
import ColorSwatchComponent from './color-swatch.svelte';
const ColorSwatch = ColorSwatchComponent;
export { ColorSwatch };
export default ColorSwatch;
export type { ColorSwatchProps, ColorSwatchSize, ColorSwatchRounded } from './types';

@ -0,0 +1,33 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/**
* Swatch size — a dedicated swatch scale (16 / 20 / 24 / 28 / 32 px for
* xs..xl; a colour chip is a substantial sample, bigger than an inline glyph).
* Follows `--scaling`.
*/
export type ColorSwatchSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/** Corner rounding. `'full'` makes the swatch a circle (a colour dot). */
export type ColorSwatchRounded = 'sm' | 'md' | 'lg' | 'full';
export type ColorSwatchProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** The colour to sample — any CSS colour string (hex / rgb / hsl / oklch / named). */
color: string;
/** Size scale (maps to `--icon-size-*`, scales with `--scaling`). @default 'md' */
size?: ResponsiveProp<ColorSwatchSize>;
/** Corner rounding; `'full'` = circular dot. @default 'sm' */
rounded?: ResponsiveProp<ColorSwatchRounded>;
/**
* Show a subtle inset ring to delineate the swatch — useful when the colour
* can be near the surface (light / white). Off by default: a clean flat
* colour chip with no border. @default false
*/
bordered?: boolean;
/** Render a different tag. @default 'span' */
as?: string;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
};

@ -1529,8 +1529,7 @@
--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-swatch-radius: var(--color-picker-trigger-radius);
--color-picker-area-height: 11rem;
--color-picker-area-radius: var(--radius-md);
--color-picker-area-border-width: var(--border-width);
@ -2960,6 +2959,18 @@
--switch-secondary-solid-hover: var(--color-secondary-solid-hover);
--switch-affirm-solid: var(--color-affirm-solid);
--switch-affirm-solid-hover: var(--color-affirm-solid-hover);
--color-swatch-size-xs: calc(16px * var(--scaling));
--color-swatch-size-sm: calc(20px * var(--scaling));
--color-swatch-size-md: calc(24px * var(--scaling));
--color-swatch-size-lg: calc(28px * var(--scaling));
--color-swatch-size-xl: calc(32px * var(--scaling));
--color-swatch-radius: var(--radius-sm);
--color-swatch-border-width: var(--border-width);
--color-swatch-border-color: var(--color-border-subtle);
--color-swatch-fill: transparent;
--color-swatch-checker-cell: 6px;
--color-swatch-checker-bg: var(--color-surface-default);
--color-swatch-checker-square: var(--color-content-muted);
--tabs-stack-gap: var(--space-4);
--tabs-line-stack-gap: var(--space-3);
--tabs-provider-radius: var(--radius-xl);
@ -2993,7 +3004,10 @@
--tabs-trigger-radius: var(--radius-md);
--tabs-trigger-border-width: var(--border-width);
--tabs-trigger-font-family: var(--font-ui);
--tabs-trigger-font-size: var(--font-size-sm);
--tabs-trigger-font-size-xs: var(--font-size-xs);
--tabs-trigger-font-size-sm: var(--font-size-sm);
--tabs-trigger-font-size-md: var(--font-size-md);
--tabs-trigger-font-size-lg: var(--font-size-lg);
--tabs-trigger-font-weight: var(--font-weight-medium);
--tabs-trigger-line-height: var(--leading-ui);
--tabs-trigger-letter-spacing: var(--tracking-ui);
@ -3014,6 +3028,12 @@
--tabs-pills-trigger-radius: var(--radius-full);
--tabs-pills-trigger-border-active: color-mix( in srgb, var(--tabs-trigger-border-active) 58%, transparent );
--tabs-pills-trigger-shadow-active: var(--shadow-subtle);
--tabs-segmented-list-bg: var(--color-surface-muted);
--tabs-segmented-list-radius: var(--radius-md);
--tabs-segmented-trigger-radius: max(0px, calc(var(--tabs-segmented-list-radius) - var(--tabs-list-padding)));
--tabs-segmented-trigger-bg-active: var(--color-primary-solid);
--tabs-segmented-trigger-color-active: var(--color-content-on-solid);
--tabs-segmented-trigger-shadow-active: var(--shadow-subtle);
--tabs-content-padding-xs: var(--space-3);
--tabs-content-padding-sm: var(--space-4);
--tabs-content-padding-md: var(--space-5);

@ -1411,8 +1411,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'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-swatch-radius': 'var(--color-picker-trigger-radius)',
// 2D area
'area-height': '11rem',
@ -3504,6 +3503,28 @@ export const THEME_BASE_RECIPE_TOKENS = {
]
}
},
'color-swatch': {
// Dedicated swatch scale — a colour chip is a SUBSTANTIAL, tappable sample,
// NOT an inline glyph, so it is bigger than the icon scale (which read
// ridiculously small). Values match the prior ColorPicker swatch (20/24/28
// for sm/md/lg) + ends; follows --scaling like the other metrics.
'size-xs': 'calc(16px * var(--scaling))',
'size-sm': 'calc(20px * var(--scaling))',
'size-md': 'calc(24px * var(--scaling))',
'size-lg': 'calc(28px * var(--scaling))',
'size-xl': 'calc(32px * var(--scaling))',
radius: 'var(--radius-sm)',
'border-width': 'var(--border-width)',
'border-color': 'var(--color-border-subtle)',
// The sampled colour — set per-instance inline by the wrapper; default
// transparent (an empty swatch shows the checker). Public knob.
fill: 'transparent',
// Transparency checker — mode-aware neutral base + a slightly darker square,
// so alpha colours read on a subtle grid (never a hard white).
'checker-cell': '6px',
'checker-bg': 'var(--color-surface-default)',
'checker-square': 'var(--color-content-muted)'
},
tabs: {
'stack-gap': 'var(--space-4)',
'line-stack-gap': 'var(--space-3)',
@ -3538,7 +3559,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-radius': 'var(--radius-md)',
'trigger-border-width': 'var(--border-width)',
'trigger-font-family': 'var(--font-ui)',
'trigger-font-size': 'var(--font-size-sm)',
'trigger-font-size-xs': 'var(--font-size-xs)',
'trigger-font-size-sm': 'var(--font-size-sm)',
'trigger-font-size-md': 'var(--font-size-md)',
'trigger-font-size-lg': 'var(--font-size-lg)',
'trigger-font-weight': 'var(--font-weight-medium)',
'trigger-line-height': 'var(--leading-ui)',
'trigger-letter-spacing': 'var(--tracking-ui)',
@ -3561,6 +3585,13 @@ export const THEME_BASE_RECIPE_TOKENS = {
'pills-trigger-border-active':
'color-mix( in srgb, var(--tabs-trigger-border-active) 58%, transparent )',
'pills-trigger-shadow-active': 'var(--shadow-subtle)',
'segmented-list-bg': 'var(--color-surface-muted)',
'segmented-list-radius': 'var(--radius-md)',
'segmented-trigger-radius':
'max(0px, calc(var(--tabs-segmented-list-radius) - var(--tabs-list-padding)))',
'segmented-trigger-bg-active': 'var(--color-primary-solid)',
'segmented-trigger-color-active': 'var(--color-content-on-solid)',
'segmented-trigger-shadow-active': 'var(--shadow-subtle)',
'content-padding-xs': 'var(--space-3)',
'content-padding-sm': 'var(--space-4)',
'content-padding-md': 'var(--space-5)',

@ -64,6 +64,18 @@ export const colorPickerLangs = {
es: "Muestra de color",
en: "Color swatch"
},
'saved-swatches': {
es: "Colores guardados",
en: "Saved colors"
},
'save-color': {
es: "Guardar color",
en: "Save color"
},
'delete-color': {
es: "Borrar color",
en: "Delete color"
},
'eye-dropper': {
es: "Capturar color de pantalla",
en: "Pick color from screen"

@ -192,6 +192,7 @@ export const colorPickerMorfo = {
optional: true,
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-x-channel' },
{ attr: 'data-y-channel' }
],
@ -328,7 +329,8 @@ export const colorPickerMorfo = {
optional: true,
data: [
{ attr: 'data-checked', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },

@ -694,7 +694,7 @@ abstract class BaseColorSegmentProvider {
}
protected handleKeydown = (e: SomaKeyboardEvent): void => {
if (e.ctrlKey || e.metaKey || this.provider.isDisabled) return;
if (e.ctrlKey || e.metaKey || this.provider.isDisabled || this.provider.isReadonly) return;
if (e.key !== KEYS.TAB) e.preventDefault();
if (this.provider.readonlySegmentsSet.has(this.part)) return;

@ -132,6 +132,7 @@ export interface ColorPickerOpts
placeholder: ColorValue;
format: ColorFormat;
open: boolean;
savedColors?: string[];
}>,
ActiveProps<{
allowedFormats: ColorFormat[];
@ -141,6 +142,8 @@ export interface ColorPickerOpts
mode: PickerShellMode;
areaChannels: { x: ColorChannel; y: ColorChannel };
readonlySegments: EditableColorSegmentPart[];
presets?: string[];
maxSavedColors?: number;
validate: ColorValidator | undefined;
onInvalid: ColorOnInvalid | undefined;
onValueChangeEnd: OnChangeFn<ColorValue | undefined> | undefined;
@ -252,6 +255,72 @@ export class ColorPickerProvider {
() => this.opts.value.current ?? this.opts.placeholder.current
);
// ── Presets + saved colours ─────────────────────────────────────────────────
// `presets` is a read-only palette; `savedColors` is an editable, bindable list
// the Save/Delete action mutates. A saved colour is "selected" (→ Delete) only
// when the user CLICKS a saved swatch, not merely when the value matches one.
readonly presets = $derived.by(() => this.opts.presets?.current ?? []);
readonly savedColors = $derived.by(() => this.opts.savedColors?.current ?? []);
private get maxSavedColors(): number {
return this.opts.maxSavedColors?.current ?? 10;
}
/** The saved colour the user explicitly selected (drives Save↔Delete). */
selectedSavedColor = $state<string | undefined>(undefined);
/** Canonicalise a colour through the picker's own HSV pipeline so two strings
* that differ only by the lossy HSV round-trip compare equal. */
private canonicalHex(c: string): string | undefined {
const hsv = parseColor(c);
return hsv ? hsvToHex(hsv) : undefined;
}
colorsEqual(a: string, b: string): boolean {
const ca = this.canonicalHex(a);
return ca !== undefined && ca === this.canonicalHex(b);
}
/** Current colour can be saved: value set, under cap, not already saved. */
readonly canSaveColor = $derived.by(() => {
if (!this.opts.value.current) return false;
if (this.savedColors.length >= this.maxSavedColors) return false;
const hex = this.effectiveColor.hex;
return !this.savedColors.some((c) => this.colorsEqual(c, hex));
});
/** Show "delete" instead of "save": a saved swatch is selected AND is the value. */
readonly showDeleteSaved = $derived.by(() => {
const sel = this.selectedSavedColor;
return sel !== undefined && this.colorsEqual(sel, this.effectiveColor.hex);
});
/** Mark a saved swatch as explicitly selected (called by SavedSwatches). */
selectSaved(hex: string): void {
this.selectedSavedColor = hex;
}
clearSavedSelection(): void {
this.selectedSavedColor = undefined;
}
saveCurrentColor(): void {
if (this.isReadonly || this.isDisabled || !this.canSaveColor) return;
const slot = this.opts.savedColors;
if (!slot) return;
slot.current = [...this.savedColors, this.effectiveColor.hex];
}
deleteSavedColor(hex?: string): void {
if (this.isReadonly || this.isDisabled) return;
const slot = this.opts.savedColors;
if (!slot) return;
const target = hex ?? this.selectedSavedColor;
if (target === undefined) return;
slot.current = this.savedColors.filter((c) => !this.colorsEqual(c, target));
this.selectedSavedColor = undefined;
}
readonly validationStatus = $derived.by(() => {
const v = this.opts.value.current;
if (!v) return false as const;
@ -507,6 +576,7 @@ export class ColorPickerTriggerProvider {
'aria-label': this.resolvedLabel,
disabled: this.provider.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
style: this.inlineStyle
// `data-state` comes from the composed PopoverTriggerProvider via mergeProps
// in the wrapper — we intentionally do not duplicate it here.
@ -726,6 +796,9 @@ export class ColorPickerAreaProvider {
* pointerdown / pointermove listeners (it needs the DOMRect).
*/
handlePointerMove(clientX: number, clientY: number, rect: DOMRect): void {
// 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));
const yProgress = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const xRange = getChannelRange(this.xChannel);
@ -741,6 +814,7 @@ export class ColorPickerAreaProvider {
}
readonly onpointerdown = (e: PointerEvent): void => {
if (this.provider.isDisabled || this.provider.isReadonly) return;
if (!this.opts.ref.current) return;
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
@ -760,7 +834,7 @@ export class ColorPickerAreaProvider {
readonly onpointerup = (e: PointerEvent): void => {
if (this.pointerId !== e.pointerId) return;
this.pointerId = null;
// Sema: fire `commit-set` on release (canonical save signal).
// Sema: fire `commit-set` on release (the picker's declared release cue).
void this.provider.runtime.trigger('commit-set');
this.provider.commitChange();
};
@ -769,6 +843,7 @@ export class ColorPickerAreaProvider {
this.runtimePart.assert({
...this.runtimePart.props,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'data-x-channel': this.xChannel,
'data-y-channel': this.yChannel,
onpointerdown: this.onpointerdown,
@ -1116,6 +1191,7 @@ export class ColorPickerSwatchTriggerProvider {
'aria-label': this.opts.color.current,
'data-checked': boolToEmptyStrOrUndef(this.isChecked),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
disabled: this.provider.isDisabled || undefined,
onclick: this.onclick
} as const)

@ -74,7 +74,8 @@
max: readableActive(() => range.max),
step: readableActive(() => stepForChannel),
orientation: readableActive(() => orientation),
disabled: readableActive(() => cp.isDisabled || cp.isReadonly),
disabled: readableActive(() => cp.isDisabled),
readonly: readableActive(() => cp.isReadonly),
dir: cp.opts.dir,
name: readableActive(() => ''),
thumbLabels: readableActive(() => [channel])

@ -0,0 +1,23 @@
<script lang="ts">
/**
* ColorPicker.Presets — renders the read-only `presets` palette as a
* `SwatchGroup` of `SwatchTrigger`s (selecting one commits it as the value).
* Each preset colour is yielded to `children` so the visual layer paints it.
*/
import { ColorPickerProvider } from '../color-picker-provider.svelte';
import SwatchGroup from './color-picker-swatch-group.svelte';
import SwatchTrigger from './color-picker-swatch-trigger.svelte';
import type { ColorPickerPresetsProps } from '../types';
let { 'aria-label': ariaLabel, children, ...rest }: ColorPickerPresetsProps = $props();
const cp = ColorPickerProvider.require();
</script>
<SwatchGroup aria-label={ariaLabel} {...rest}>
{#each cp.presets as color (color)}
<SwatchTrigger {color}>
{#if children}{@render children({ color })}{/if}
</SwatchTrigger>
{/each}
</SwatchGroup>

@ -0,0 +1,25 @@
<script lang="ts">
/**
* ColorPicker.SaveAction — the Save / Delete colour action. Headless: it
* yields `{ showDelete, disabled, onClick }` to `children` so the visual
* layer renders the button. Shows "delete" when a saved swatch is selected
* (and is the current value); otherwise "save" (disabled when the colour is
* already saved / cap reached / no value). Blocked under readonly / disabled.
*/
import { ColorPickerProvider } from '../color-picker-provider.svelte';
import type { ColorPickerSaveActionProps } from '../types';
let { children }: ColorPickerSaveActionProps = $props();
const cp = ColorPickerProvider.require();
const showDelete = $derived(cp.showDeleteSaved);
const disabled = $derived(showDelete ? false : !cp.canSaveColor);
function onClick(): void {
if (cp.showDeleteSaved) cp.deleteSavedColor();
else cp.saveCurrentColor();
}
</script>
{@render children?.({ showDelete, disabled, onClick })}

@ -0,0 +1,28 @@
<script lang="ts">
/**
* ColorPicker.SavedSwatches — renders the editable `savedColors` list as a
* `SwatchGroup` of `SwatchTrigger`s. Clicking one selects it (so `SaveAction`
* flips to "delete") AND commits it as the value. Each colour is yielded to
* `children` so the visual layer paints it.
*/
import { ColorPickerProvider } from '../color-picker-provider.svelte';
import { COLOR_PICKER_LANGS } from '../langs';
import SwatchGroup from './color-picker-swatch-group.svelte';
import SwatchTrigger from './color-picker-swatch-trigger.svelte';
import type { ColorPickerSavedSwatchesProps } from '../types';
let { 'aria-label': ariaLabel, children, ...rest }: ColorPickerSavedSwatchesProps = $props();
const cp = ColorPickerProvider.require();
// Default the radiogroup's accessible name from the component's langs (the base
// SwatchGroup would otherwise label it "Preset colors"). Overridable.
const label = $derived(ariaLabel ?? cp.langs(COLOR_PICKER_LANGS.SAVED_SWATCHES));
</script>
<SwatchGroup aria-label={label} {...rest}>
{#each cp.savedColors as color (color)}
<SwatchTrigger {color} onclick={() => cp.selectSaved(color)}>
{#if children}{@render children({ color })}{/if}
</SwatchTrigger>
{/each}
</SwatchGroup>

@ -46,7 +46,13 @@
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
closeDelay: readableActive(() => 0),
// In readonly / disabled the trigger must neither open the picker NOR
// emit the `present` (emerge) signal. The popover trigger gates its own
// handlers on this — they stay present (no Svelte event-delegation null),
// just inert. The native `disabled` attr (trigger provider) still drops
// the tab stop for `disabled`; `readonly` stays focusable.
disabled: readableActive(() => provider.isReadonly || provider.isDisabled)
}).props;
const mergedProps = $derived(

@ -43,6 +43,10 @@
enableAlpha = true,
areaChannels,
readonlySegments = [],
presets = [],
savedColors = $bindable([]),
onSavedColorsChange = () => {},
maxSavedColors = 10,
locale,
dir: _dir,
errorMessageId,
@ -124,10 +128,21 @@
const sharedOpen = writableActive(
() => open,
(v) => {
// readonly / disabled lock the picker: the trigger can't OPEN it (the
// trigger stays focusable — readonly is not disabled). Closing is always
// allowed. This is the core "readonly doesn't fire the picker" behaviour.
if (v && (disabled || readonly)) return;
open = v;
onOpenChange(v);
}
);
const sharedSavedColors = writableActive(
() => savedColors,
(v) => {
savedColors = v;
onSavedColorsChange(v);
}
);
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
@ -153,6 +168,9 @@
() => areaChannels ?? { x: 'saturation' as const, y: 'brightness' as const }
),
readonlySegments: readableActive(() => readonlySegments),
presets: readableActive(() => presets),
savedColors: sharedSavedColors,
maxSavedColors: readableActive(() => maxSavedColors),
validate: readableActive(() => validate),
onInvalid: readableActive(() => onInvalid),
onValueChangeEnd: readableActive(() => onValueChangeEnd),

@ -34,6 +34,11 @@ export { default as SwatchTrigger } from './components/color-picker-swatch-trigg
export { default as Swatch } from './components/color-picker-swatch.svelte';
export { default as SwatchIndicator } from './components/color-picker-swatch-indicator.svelte';
// Presets + saved colours + the save / delete action (component-owned).
export { default as Presets } from './components/color-picker-presets.svelte';
export { default as SavedSwatches } from './components/color-picker-saved-swatches.svelte';
export { default as SaveAction } from './components/color-picker-save-action.svelte';
// Stage 6 — EyeDropper (browser native API, Chrome / Edge only)
export { default as EyeDropper } from './components/color-picker-eye-dropper.svelte';
@ -69,6 +74,11 @@ export type {
ColorPickerSwatchTriggerProps as SwatchTriggerProps,
ColorPickerSwatchProps as SwatchProps,
ColorPickerSwatchIndicatorProps as SwatchIndicatorProps,
ColorPickerPresetsProps as PresetsProps,
ColorPickerSavedSwatchesProps as SavedSwatchesProps,
ColorPickerSaveActionProps as SaveActionProps,
ColorPickerSaveActionSnippetProps as SaveActionSnippetProps,
ColorPickerSwatchListSnippetProps as SwatchListSnippetProps,
ColorPickerEyeDropperProps as EyeDropperProps,
ColorPickerContentProps as ContentProps,
ColorPickerArrowProps as ArrowProps,

@ -21,6 +21,11 @@ export const COLOR_PICKER_LANGS = {
// Swatches
SWATCH_GROUP: '#?components.color-picker.swatch-group|Preset colors',
SWATCH: '#?components.color-picker.swatch|Color swatch',
SAVED_SWATCHES: '#?components.color-picker.saved-swatches|Saved colors',
// Save / delete action
SAVE_COLOR: '#?components.color-picker.save-color|Save color',
DELETE_COLOR: '#?components.color-picker.delete-color|Delete color',
// Eye dropper
EYE_DROPPER: '#?components.color-picker.eye-dropper|Pick color from screen',

@ -133,6 +133,24 @@ export type ColorPickerProviderProps = WithChild<{
/** Which segments of the ChannelInput are read-only. @default [] */
readonlySegments?: EditableColorSegmentPart[];
// Presets + saved colours
/**
* Read-only palette the picker renders via `<ColorPicker.Presets>`. Flat
* list of CSS colour strings; selecting one commits it as the value.
* @default []
*/
presets?: string[];
/**
* Editable, two-way bindable list of saved colours, rendered via
* `<ColorPicker.SavedSwatches>`. `<ColorPicker.SaveAction>` adds the current
* colour / deletes the selected saved one. @default []
*/
savedColors?: string[];
/** Fired when `savedColors` changes (save / delete). */
onSavedColorsChange?: OnChangeFn<string[]>;
/** Cap on the number of saved colours. @default 10 */
maxSavedColors?: number;
// i18n
locale?: string;
dir?: Direction;
@ -262,6 +280,34 @@ export type ColorPickerSwatchGroupProps = WithChild<{
}> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string }>;
/** Snippet props yielded per colour by `ColorPicker.Presets` / `.SavedSwatches`. */
export type ColorPickerSwatchListSnippetProps = { color: string };
/** Props for `ColorPicker.Presets` — renders the read-only `presets` palette. */
export type ColorPickerPresetsProps = {
id?: string;
'aria-label'?: string;
children?: Snippet<[ColorPickerSwatchListSnippetProps]>;
} & Without<PrimitiveDivAttributes, { 'aria-label'?: string; children?: unknown }>;
/** Props for `ColorPicker.SavedSwatches` — renders the editable `savedColors`. */
export type ColorPickerSavedSwatchesProps = ColorPickerPresetsProps;
/** Snippet props yielded by `ColorPicker.SaveAction` to the visual button. */
export type ColorPickerSaveActionSnippetProps = {
/** Show the delete affordance (a saved swatch is selected + is the value). */
showDelete: boolean;
/** Disable the button (can't save: already saved / cap reached / no value). */
disabled: boolean;
/** Save the current colour, or delete the selected saved one, per `showDelete`. */
onClick: () => void;
};
/** Props for `ColorPicker.SaveAction` — headless save / delete action. */
export type ColorPickerSaveActionProps = {
children?: Snippet<[ColorPickerSaveActionSnippetProps]>;
};
/** Props for `ColorPicker.SwatchTrigger` — focusable radio-like swatch button. */
export type ColorPickerSwatchTriggerProps = WithChild<{
id?: string;

@ -32,7 +32,10 @@
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
closeDelay: readableActive(() => 0),
// A readonly / disabled field-overlay trigger must not open the picker nor
// emit the open signal; gated inside the popover trigger so it stays focusable.
disabled: readableActive(() => provider.opts.disabled.current || provider.opts.readonly.current)
});
const resolvedLabel = $derived(

@ -35,7 +35,10 @@
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
closeDelay: readableActive(() => 0),
// A readonly / disabled field-overlay trigger must not open the picker nor
// emit the open signal; gated inside the popover trigger so it stays focusable.
disabled: readableActive(() => provider.opts.disabled.current || provider.opts.readonly.current)
});
const resolvedLabel = $derived(

@ -330,6 +330,14 @@ interface PopoverTriggerOpts extends ActiveProps<{
openOnHover: boolean;
openDelay: number;
closeDelay: number;
/**
* When true the trigger is inert: click / keyboard / hover neither toggle the
* popover nor emit the `present` signal. Stays focusable — the consumer adds
* the native `disabled` attr separately if it wants to drop the tab stop.
* Optional; defaults to interactive. Used by field-overlay triggers (pickers)
* to honour `disabled` / `readonly` without losing focusability.
*/
disabled?: boolean;
}> {
id: Active<string>;
ref: State<HTMLElement | null>;
@ -369,6 +377,7 @@ export class PopoverTriggerProvider {
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.opts.disabled?.current) return;
// Toggle through the runtime so both directions emit a perceptual signal.
// `present`'s declared target (content) isn't in the DOM when opening —
// pass the trigger as fallbackTarget so the visual channel still has
@ -383,6 +392,7 @@ export class PopoverTriggerProvider {
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.opts.disabled?.current) return;
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
if (this.provider.opts.open.current) {
@ -396,6 +406,7 @@ export class PopoverTriggerProvider {
};
readonly onpointerenter = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.opts.disabled?.current) return;
if (!this.opts.openOnHover.current) return;
// Modal + already-open: ignore. The overlay covers the trigger so
// any pointerenter is the cursor passing over the trigger area
@ -412,6 +423,7 @@ export class PopoverTriggerProvider {
};
readonly onpointerleave = () => {
if (this.opts.disabled?.current) return;
if (!this.opts.openOnHover.current) return;
// Same reason: when modal is open, leaving the trigger means the
// cursor crossed onto the overlay — don't schedule a close. The

@ -32,7 +32,10 @@
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
closeDelay: readableActive(() => 0),
// A readonly / disabled field-overlay trigger must not open the picker nor
// emit the open signal; gated inside the popover trigger so it stays focusable.
disabled: readableActive(() => provider.opts.disabled.current || provider.opts.readonly.current)
});
const resolvedLabel = $derived(

@ -35,7 +35,10 @@
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
closeDelay: readableActive(() => 0),
// A readonly / disabled field-overlay trigger must not open the picker nor
// emit the open signal; gated inside the popover trigger so it stays focusable.
disabled: readableActive(() => provider.opts.disabled.current || provider.opts.readonly.current)
});
const resolvedLabel = $derived(

@ -8,7 +8,7 @@
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Tabs } from '$uix/eidos/components/tabs';
import { ColorField } from '$uix/eidos/components/color-field';
import { Button } from '$uix/eidos/components/button';
import { ColorSwatch } from '$uix/eidos/components/color-swatch';
import { PALETTE_SCALES } from '$uix/eidos/lib/types';
import { INTENTS } from '$uix/intent';
import { compileMorfo } from '$uix/morfo';
@ -78,17 +78,10 @@
let placeholder = $state<ColorValue>(brand);
let format = $state<ColorFormat>('hex');
// "Guardar color" is enabled only when there's a current color that isn't
// already saved and the cap (10) hasn't been reached.
const canSaveColor = $derived.by(() => {
if (!value || savedColors.length >= SAVED_COLORS_MAX) return false;
const hex = value.hex.toLowerCase();
return !savedColors.some((c) => c.toLowerCase() === hex);
});
function saveColor(): void {
if (!value || !canSaveColor) return;
savedColors = [...savedColors, value.hex];
}
// Save / delete of saved colours now lives in the COMPONENT: <ColorPicker>
// owns `savedColors` (bound below), the dedupe-by-canonical-equality, the
// selection tracking and the Save↔Delete toggle — surfaced via
// <ColorPicker.SavedSwatches> + <ColorPicker.SaveAction>. No hand-rolled state.
let autoFormat = $state(true);
let showValueText = $state(true);
// Composition toggles — pure {#if} branches in the live markup. Per the
@ -250,12 +243,13 @@
' let open = $state(false);',
'</' + 'script>',
'',
'<ColorPicker.Provider bind:value bind:format bind:open',
'<ColorPicker.Provider bind:value bind:format bind:open bind:savedColors',
mode !== 'inline' && ` mode="${mode}"`,
autoFormat && ' autoFormat',
inline && ' inline',
closeOnSelect && ' closeOnSelect',
!enableAlpha && ' enableAlpha={false}',
showSwatches && ' presets={presets}',
'>',
' <ColorPicker.Control>',
showValueText
@ -279,18 +273,16 @@
' <ColorPicker.ValueSwatch />',
showChannelInput && ' <ColorPicker.ChannelInput />',
showEyeDropper && ' <ColorPicker.EyeDropper />',
showSwatches && ' <ColorPicker.SwatchGroup>',
showSwatches && ' {#each presets as c}<ColorPicker.SwatchTrigger color={c}>',
showSwatches && ' <ColorPicker.Swatch color={c} />',
showSwatches && ' <ColorPicker.SwatchIndicator />',
showSwatches && ' </ColorPicker.SwatchTrigger>{/each}',
showSwatches && ' </ColorPicker.SwatchGroup>',
showSwatches && ' <ColorPicker.Presets />',
showSwatches && ' <ColorPicker.SavedSwatches />',
' </PickerShell.Body>',
showFooter && (showClear || showCancel || showClose) && ' <PickerShell.Footer>',
showFooter && ' <PickerShell.Footer>',
showFooter &&
' <ColorPicker.SaveAction />',
showFooter && showClear && ' <PickerShell.Clear />',
showFooter && showCancel && ' <PickerShell.Cancel />',
showFooter && showClose && ' <PickerShell.Close />',
showFooter && (showClear || showCancel || showClose) && ' </PickerShell.Footer>',
showFooter && ' </PickerShell.Footer>',
' </ColorPicker.Content>',
'</ColorPicker.Provider>'
]
@ -310,7 +302,7 @@
' let open = $state(false);',
'</' + 'script>',
'',
'<ColorPicker bind:value bind:format bind:open',
'<ColorPicker bind:value bind:format bind:open bind:savedColors',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
@ -319,6 +311,7 @@
inline && ' inline',
closeOnSelect && ' closeOnSelect',
!enableAlpha && ' enableAlpha={false}',
showSwatches && ' presets={presets}',
'>',
showLabel && ' <ColorPicker.Label>Brand color</ColorPicker.Label>',
' <ColorPicker.Control>',
@ -337,16 +330,16 @@
' <ColorPicker.ValueSwatch />',
showChannelInput && ' <ColorPicker.ChannelInput />',
showEyeDropper && ' <ColorPicker.EyeDropper />',
showSwatches && ' <ColorPicker.SwatchGroup>',
showSwatches &&
' {#each presets as c}<ColorPicker.SwatchTrigger color={c} />{/each}',
showSwatches && ' </ColorPicker.SwatchGroup>',
showSwatches && ' <ColorPicker.Presets />',
showSwatches && ' <ColorPicker.SavedSwatches />',
' </PickerShell.Body>',
showFooter && (showClear || showCancel || showClose) && ' <ColorPicker.Footer>',
showFooter && ' <ColorPicker.Footer>',
showFooter &&
' <ColorPicker.SaveAction />',
showFooter && showClear && ' <ColorPicker.Clear />',
showFooter && showCancel && ' <ColorPicker.Cancel />',
showFooter && showClose && ' <ColorPicker.Close />',
showFooter && (showClear || showCancel || showClose) && ' </ColorPicker.Footer>',
showFooter && ' </ColorPicker.Footer>',
' </ColorPicker.Content>',
' </ColorPicker.Portal>',
'</ColorPicker>'
@ -413,6 +406,9 @@
{size}
{variant}
{color}
{presets}
bind:savedColors
maxSavedColors={SAVED_COLORS_MAX}
>
{#if showLabel}<ColorPicker.Label>Brand color</ColorPicker.Label>{/if}
<ColorPicker.Control>
@ -426,13 +422,12 @@
<ColorPicker.Content>
<PickerShell.Body>
<Tabs
variant="pills"
variant="segmented"
fitted
{size}
bind:value={pickerTab}
style="--tabs-trigger-font-size: var(--font-size-{size})"
>
<Tabs.List>
<Tabs.List aria-label="Vistas del selector de color">
<Tabs.Trigger value="selection">Selección</Tabs.Trigger>
<Tabs.Trigger value="presets">Presets</Tabs.Trigger>
<Tabs.Indicator />
@ -476,7 +471,7 @@
color={resolvedTokens[role.token]}
title={role.label}
>
<ColorPicker.Swatch color={resolvedTokens[role.token]} />
<ColorSwatch color={resolvedTokens[role.token]} {size} />
<ColorPicker.SwatchIndicator />
</ColorPicker.SwatchTrigger>
{/if}
@ -488,14 +483,7 @@
<section class="cp-preset-group cp-preset-group--wide">
<span class="cp-preset-label">Guardados</span>
{#if savedColors.length}
<ColorPicker.SwatchGroup aria-label="Saved colors">
{#each savedColors as c (c)}
<ColorPicker.SwatchTrigger color={c}>
<ColorPicker.Swatch color={c} />
<ColorPicker.SwatchIndicator />
</ColorPicker.SwatchTrigger>
{/each}
</ColorPicker.SwatchGroup>
<ColorPicker.SavedSwatches />
{:else}
<p class="cp-empty">Sin colores guardados.</p>
{/if}
@ -533,15 +521,7 @@
{#if showFooter}
<ColorPicker.Footer>
<Button
variant="ghost"
color="neutral"
{size}
disabled={!canSaveColor}
onclick={saveColor}
>
Guardar color
</Button>
<ColorPicker.SaveAction />
{#if showClear}<ColorPicker.Clear />{/if}
{#if showCancel}<ColorPicker.Cancel />{/if}
{#if showClose}<ColorPicker.Close />{/if}
@ -1455,22 +1435,12 @@
</div>
<style>
/* Picker tabs flush: kill the recipe's content-panel padding so the area /
sliders / field align with the popover edges. The footer buttons stay
OUTSIDE the tabs (rendered after <Tabs>, shared across both panels). */
/* Picker tabs flush: kill the content-panel padding so the area / sliders /
field align with the popover edges. The segmented band is now a first-class
Tabs variant (`variant="segmented"`) — no token hacks needed. */
:global([data-color-picker-content] [data-tabs-content]) {
padding: 0;
}
/* Rail = flat segmented band: the active segment is a solid fill, NOT the
default raised pill — drop its border + shadow and use a minimal radius.
All via the tabs' own tokens (no ad-hoc values). */
:global([data-color-picker-content] [data-tabs]) {
--tabs-pills-trigger-border-active: transparent;
--tabs-pills-trigger-shadow-active: none;
--tabs-pills-trigger-radius: var(--radius-sm);
/* Rail radius concentric with the pill: pill radius + the list inset padding. */
--tabs-list-radius: calc(var(--radius-sm) + var(--tabs-list-padding));
}
.cp-selection {
display: grid;
gap: var(--uix-space-3, 0.75rem);
@ -1552,8 +1522,13 @@
flex-wrap: wrap;
}
.cp-presets :global([data-color-picker-swatch-trigger]) {
flex: 0 0 var(--_color-picker-swatch-size);
/* Presets are opaque — drop the recipe's transparency checker + border. */
/* The trigger is just the clickable wrapper now; the canonical swatch
visual (checker + inset ring, size-scaled) is the <ColorSwatch> inside. */
flex: 0 0 auto;
inline-size: auto;
block-size: auto;
aspect-ratio: auto;
max-inline-size: none;
background-image: none;
border: none;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.