- 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
parent
54ecc84e2d
commit
72e8fb57ea
@ -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>
|
||||
@ -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;
|
||||
};
|
||||
@ -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>
|
||||
Loading…
Reference in new issue