GradientBuilder (this session): - Stop color editor uses the canonical ColorField value row (swatch + styled Select), not a native select; rails at 90% width + xl track. - Composable footer: ColorPicker.Cancel / ColorPicker.Close + trash remove-stop, as a sibling of PickerShell.Body. - Surface-agnostic by default (variant='ghost'): no card, adapts to host. - '+' add-stop on the kind-switch row; inserts between the selected stop and the next; rail click-to-add re-enabled (insert at cursor). - GradientBuilder.StopList composable stops list (rendered by default). - presets prop (Gradient[]) rendered below the editor (flex, not tabs); save-to-presets with a maxPresets cap; the action button flips Save <-> Delete when a saved preset is selected (ColorPicker mechanic). Also lands the in-progress gradient tree so HEAD is self-consistent (langs/components/index.ts already imported ./gradient-picker, whose file was untracked). gradient-builder + gradient-picker stay on ACTIVE_DEV_TRACK; the GradientPicker 2-tab redesign is next (see gradient-builder/CONTINUE.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>menubar-v4-safe
parent
40284a3d98
commit
b3d0e29037
@ -0,0 +1,20 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { GradientBuilderSize, GradientBuilderVariant } from './types';
|
||||
|
||||
const GRADIENT_BUILDER_VISUAL_CONTEXT = Symbol('uix.eidos.gradient-builder.visual');
|
||||
|
||||
export interface GradientBuilderVisualContext {
|
||||
getSize(): GradientBuilderSize;
|
||||
getVariant(): GradientBuilderVariant;
|
||||
}
|
||||
|
||||
export function setGradientBuilderVisualContext(
|
||||
ctx: GradientBuilderVisualContext
|
||||
): GradientBuilderVisualContext {
|
||||
setContext(GRADIENT_BUILDER_VISUAL_CONTEXT, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function getGradientBuilderVisualContext(): GradientBuilderVisualContext | undefined {
|
||||
return getContext<GradientBuilderVisualContext | undefined>(GRADIENT_BUILDER_VISUAL_CONTEXT);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Plus } from '$uix/eidos/components/icon';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import type { GradientBuilderAddStopProps } from './types';
|
||||
|
||||
let { ...rest }: GradientBuilderAddStopProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-builder.add-stop|Add color stop'));
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
data-gradient-builder-add-stop
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
{size}
|
||||
iconOnly
|
||||
disabled={provider.isDisabled}
|
||||
aria-label={label}
|
||||
onclick={() => provider.addStop()}
|
||||
>
|
||||
{#snippet icon()}<Plus />{/snippet}
|
||||
{label}
|
||||
</Button>
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { Slider } from '$uix/eidos/components/slider';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import type { GradientBuilderAngleDialProps } from './types';
|
||||
|
||||
let { ...rest }: GradientBuilderAngleDialProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-builder.angle-dial|Gradient angle'));
|
||||
|
||||
// Linear `angle` / conic `from`; radial has no angle so the dial is hidden.
|
||||
const showDial = $derived(provider.kind === 'linear' || provider.kind === 'conic');
|
||||
</script>
|
||||
|
||||
{#if showDial}
|
||||
<div {...rest} data-gradient-builder-angle-dial>
|
||||
<div data-gradient-builder-angle-dial-head>
|
||||
<span data-gradient-builder-angle-dial-label>{label}</span>
|
||||
<span data-gradient-builder-angle-dial-value>{Math.round(provider.angle)}°</span>
|
||||
</div>
|
||||
<Slider
|
||||
{size}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
value={[provider.angle]}
|
||||
onValueChange={(v) => provider.setAngle(v[0] ?? 0)}
|
||||
onValueCommit={() => provider.commit()}
|
||||
aria-label={label}
|
||||
>
|
||||
<Slider.Range />
|
||||
<Slider.Thumb />
|
||||
</Slider>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import type { GradientBuilderKindSwitchProps } from './types';
|
||||
|
||||
let { ...rest }: GradientBuilderKindSwitchProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const groupLabel = $derived(eidos.langs.ts('#?components.gradient-builder.kind-switch|Gradient type'));
|
||||
|
||||
const kinds = $derived([
|
||||
{ value: 'linear', label: eidos.langs.ts('#?components.gradient-builder.kind.linear|Linear') },
|
||||
{ value: 'radial', label: eidos.langs.ts('#?components.gradient-builder.kind.radial|Radial') },
|
||||
{ value: 'conic', label: eidos.langs.ts('#?components.gradient-builder.kind.conic|Conic') }
|
||||
] as const);
|
||||
</script>
|
||||
|
||||
<div {...rest} data-gradient-builder-kind-switch>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
{size}
|
||||
attached
|
||||
block
|
||||
deselectable={false}
|
||||
value={[provider.kind]}
|
||||
onValueChange={(v) => {
|
||||
const next = v[0];
|
||||
if (next === 'linear' || next === 'radial' || next === 'conic') provider.setKind(next);
|
||||
}}
|
||||
aria-label={groupLabel}
|
||||
>
|
||||
{#each kinds as kind (kind.value)}
|
||||
<ToggleGroup.Item value={kind.value}>{kind.label}</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
// The presets panel — sits below the editor when a `presets` prop is defined.
|
||||
// A grid of swatches (provided + saved) and one action button that toggles
|
||||
// Save ↔ Delete: with a SAVED preset selected (and it IS the current value) the
|
||||
// button deletes it; otherwise it saves the current gradient (capped, no dupes).
|
||||
// Same mechanic as the ColorPicker's presets + saved swatches.
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Plus, Trash2 } from '$uix/eidos/components/icon';
|
||||
import { gradientToCss, type Gradient } from '$libs/gradient';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import type { GradientBuilderPresetsProps } from './types';
|
||||
|
||||
let { provided, savedPresets, canSave, onSave, onDelete }: GradientBuilderPresetsProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-builder.presets|Gradient presets'));
|
||||
const saveLabel = $derived(
|
||||
eidos.langs.ts('#?components.gradient-builder.save-preset|Save gradient')
|
||||
);
|
||||
const deleteLabel = $derived(
|
||||
eidos.langs.ts('#?components.gradient-builder.delete-preset|Delete gradient')
|
||||
);
|
||||
|
||||
// A saved preset the user explicitly selected — drives Save ↔ Delete on the
|
||||
// single action button, exactly like the ColorPicker's saved swatches.
|
||||
let selectedSaved = $state<number | null>(null);
|
||||
const showDelete = $derived(
|
||||
selectedSaved !== null &&
|
||||
savedPresets[selectedSaved] !== undefined &&
|
||||
gradientToCss(savedPresets[selectedSaved]) === gradientToCss(provider.value)
|
||||
);
|
||||
|
||||
function apply(g: Gradient, savedIndex: number | null): void {
|
||||
provider.reset(g);
|
||||
provider.commit();
|
||||
selectedSaved = savedIndex;
|
||||
}
|
||||
function buttonAction(): void {
|
||||
if (showDelete && selectedSaved !== null) {
|
||||
onDelete?.(selectedSaved);
|
||||
selectedSaved = null;
|
||||
} else {
|
||||
onSave?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-gradient-builder-presets role="group" aria-label={label}>
|
||||
<div class="gb-preset-grid">
|
||||
{#each provided as preset, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
data-gradient-builder-preset
|
||||
style="background: {gradientToCss(preset)};"
|
||||
aria-label="{label} {i + 1}"
|
||||
onclick={() => apply(preset, null)}
|
||||
></button>
|
||||
{/each}
|
||||
{#each savedPresets as preset, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
data-gradient-builder-preset
|
||||
data-selected={selectedSaved === i || undefined}
|
||||
style="background: {gradientToCss(preset)};"
|
||||
aria-label="{label} {provided.length + i + 1}"
|
||||
onclick={() => apply(preset, i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
color={showDelete ? 'threat' : 'neutral'}
|
||||
{size}
|
||||
disabled={!showDelete && !canSave}
|
||||
onclick={buttonAction}
|
||||
>
|
||||
{#snippet icon()}{#if showDelete}<Trash2 />{:else}<Plus />{/if}{/snippet}
|
||||
{showDelete ? deleteLabel : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { gradientToCss } from '$libs/gradient';
|
||||
import type { GradientBuilderPreviewProps } from './types';
|
||||
|
||||
let { ...rest }: GradientBuilderPreviewProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const css = $derived(gradientToCss(provider.value));
|
||||
</script>
|
||||
|
||||
<!-- Decorative live surface — the stops on the Track carry the semantics. -->
|
||||
<div
|
||||
{...rest}
|
||||
data-gradient-builder-preview
|
||||
data-kind={provider.kind}
|
||||
aria-hidden="true"
|
||||
style:background={css}
|
||||
></div>
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Trash2 } from '$uix/eidos/components/icon';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import type { GradientBuilderAddStopProps } from './types';
|
||||
|
||||
let { ...rest }: GradientBuilderAddStopProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop'));
|
||||
|
||||
// A gradient needs ≥ 2 stops; disable when there's nothing safe to remove.
|
||||
const canRemove = $derived(!provider.isDisabled && provider.stops.length > 2);
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
data-gradient-builder-remove-stop
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
{size}
|
||||
iconOnly
|
||||
disabled={!canRemove}
|
||||
aria-label={label}
|
||||
onclick={() => provider.removeStop(provider.selectedIndex)}
|
||||
>
|
||||
{#snippet icon()}<Trash2 />{/snippet}
|
||||
{label}
|
||||
</Button>
|
||||
@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { ColorPicker } from '$uix/eidos/components/color-picker';
|
||||
import { ColorField } from '$uix/eidos/components/color-field';
|
||||
import { PickerShell } from '$uix/eidos/components/picker-shell';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Trash2 } from '$uix/eidos/components/icon';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { DEFAULT_COLOR, type ColorValue } from '$libs/color';
|
||||
import type { StopColor } from '$libs/gradient';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import { stopColorToColorValue, colorValueToStopColor } from './stop-color';
|
||||
import type { GradientBuilderStopColorProps } from './types';
|
||||
|
||||
// `children` = the Track. We anchor the color popover to it; clicking a stop
|
||||
// (handled in soma) opens the popover for that stop.
|
||||
let {
|
||||
children,
|
||||
...rest
|
||||
}: GradientBuilderStopColorProps & { children?: import('svelte').Snippet } = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
const eidos = ActiveEidos.require();
|
||||
const removeLabel = $derived(
|
||||
eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop')
|
||||
);
|
||||
const acceptLabel = $derived(eidos.langs.ts('#?components.gradient-builder.accept|Accept'));
|
||||
const valueLabel = $derived(
|
||||
eidos.langs.ts('#?components.gradient-builder.value-label|Color value')
|
||||
);
|
||||
|
||||
// Open state mirrors the provider's colorEditOpen (set by clicking a stop).
|
||||
let open = $state(provider.colorEditOpen);
|
||||
$effect(() => {
|
||||
open = provider.colorEditOpen;
|
||||
});
|
||||
function onOpenChange(v: boolean): void {
|
||||
open = v;
|
||||
if (!v) provider.closeColorEdit();
|
||||
}
|
||||
|
||||
// Seed from the SELECTED stop up-front; re-seed on selection switch (track
|
||||
// only `selectedIndex`, untrack the color — the parent-write-loop trap).
|
||||
function seed(): ColorValue {
|
||||
const stop = provider.stops[provider.selectedIndex];
|
||||
return stop ? stopColorToColorValue(stop.color) : DEFAULT_COLOR;
|
||||
}
|
||||
let colorValue = $state<ColorValue>(seed());
|
||||
$effect(() => {
|
||||
provider.selectedIndex;
|
||||
colorValue = untrack(() => seed());
|
||||
});
|
||||
|
||||
// Live edit; commit once on release (no per-pixel commit ring on the card).
|
||||
function onPick(v: ColorValue | undefined): void {
|
||||
if (v) provider.setStopColor(provider.selectedIndex, colorValueToStopColor(v));
|
||||
}
|
||||
function onPickEnd(): void {
|
||||
provider.commit();
|
||||
}
|
||||
|
||||
const canRemove = $derived(provider.stops.length > 2);
|
||||
function removeThisStop(): void {
|
||||
provider.removeStop(provider.selectedIndex);
|
||||
provider.closeColorEdit();
|
||||
}
|
||||
|
||||
// Snapshot the stop's color when the editor opens, so Cancel restores the exact
|
||||
// original. The picker's own Cancel reverts its value; this reverts the stop too
|
||||
// (the editor commits live, so there is no other pending state to roll back).
|
||||
let stopColorOnOpen = $state<StopColor | undefined>(undefined);
|
||||
$effect(() => {
|
||||
if (open) stopColorOnOpen = untrack(() => provider.stops[provider.selectedIndex]?.color);
|
||||
});
|
||||
function cancelEdit(): void {
|
||||
if (stopColorOnOpen) provider.setStopColor(provider.selectedIndex, stopColorOnOpen);
|
||||
provider.commit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ColorPicker
|
||||
{...rest}
|
||||
{size}
|
||||
bind:value={colorValue}
|
||||
bind:open
|
||||
{onOpenChange}
|
||||
format="hex"
|
||||
onValueChange={onPick}
|
||||
onValueChangeEnd={onPickEnd}
|
||||
>
|
||||
<!-- The Track is the anchor: the popover floats by the stop rail. -->
|
||||
<ColorPicker.Anchor data-gradient-builder-stop-color-anchor>
|
||||
{@render children?.()}
|
||||
</ColorPicker.Anchor>
|
||||
|
||||
<ColorPicker.Portal>
|
||||
<ColorPicker.Content data-gradient-builder-stop-color>
|
||||
<PickerShell.Body>
|
||||
<div data-gradient-builder-stop-color-title>
|
||||
{eidos.langs.ts('#?components.gradient-builder.stop-label|Color stop')}
|
||||
{provider.selectedIndex + 1}
|
||||
</div>
|
||||
<ColorPicker.Area />
|
||||
<div data-gradient-builder-stop-color-sliders>
|
||||
<ColorPicker.ChannelSlider channel="hue" />
|
||||
<ColorPicker.ChannelSlider channel="alpha" />
|
||||
</div>
|
||||
<ColorField bind:value={colorValue} format="hex" {size} onValueChange={onPick}>
|
||||
<ColorField.Input aria-label={valueLabel} swatch>
|
||||
{#snippet children({ segments })}
|
||||
{#each segments as { part, value }, i (`${part}-${i}`)}
|
||||
<ColorField.Segment {part}>{value}</ColorField.Segment>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ColorField.Input>
|
||||
</ColorField>
|
||||
</PickerShell.Body>
|
||||
<ColorPicker.Footer>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="threat"
|
||||
{size}
|
||||
iconOnly
|
||||
disabled={!canRemove}
|
||||
aria-label={removeLabel}
|
||||
data-gradient-builder-stop-color-remove
|
||||
onclick={removeThisStop}
|
||||
>
|
||||
{#snippet icon()}<Trash2 />{/snippet}
|
||||
{removeLabel}
|
||||
</Button>
|
||||
<ColorPicker.Cancel onclick={cancelEdit} />
|
||||
<ColorPicker.Close>{acceptLabel}</ColorPicker.Close>
|
||||
</ColorPicker.Footer>
|
||||
</ColorPicker.Content>
|
||||
</ColorPicker.Portal>
|
||||
</ColorPicker>
|
||||
@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
// One StopList row: a colorpicker-value-looking button (swatch + hex + position)
|
||||
// that opens the SAME color editor for its stop, plus a delete icon. The two
|
||||
// endpoint stops (first / last by position) hide the delete — they anchor the
|
||||
// gradient at 0 / 1.
|
||||
import { ColorSwatch } from '$uix/eidos/components/color-swatch';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Trash2 } from '$uix/eidos/components/icon';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { stopColorToCss } from '$libs/gradient';
|
||||
import { getGradientBuilderVisualContext } from './context.svelte';
|
||||
import { stopColorToColorValue } from './stop-color';
|
||||
import type { GradientBuilderStopListRowProps } from './types';
|
||||
|
||||
let { index, isEndpoint = false }: GradientBuilderStopListRowProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const ctx = getGradientBuilderVisualContext();
|
||||
const size = $derived(ctx?.getSize() ?? 'md');
|
||||
const eidos = ActiveEidos.require();
|
||||
const removeLabel = $derived(
|
||||
eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop')
|
||||
);
|
||||
|
||||
const stop = $derived(provider.stops[index]);
|
||||
const swatchColor = $derived(stop ? stopColorToCss(stop.color) : 'transparent');
|
||||
const hex = $derived(stop ? stopColorToColorValue(stop.color).hex : '');
|
||||
const selected = $derived(provider.selectedIndex === index);
|
||||
const position = $derived(Math.round(provider.stopPosition(index) * 100));
|
||||
</script>
|
||||
|
||||
<div data-gradient-builder-stop-list-row role="listitem" data-selected={selected || undefined}>
|
||||
<button
|
||||
type="button"
|
||||
data-gradient-builder-stop-list-value
|
||||
data-selected={selected || undefined}
|
||||
disabled={provider.isDisabled}
|
||||
onclick={() => provider.openColorEdit(index)}
|
||||
>
|
||||
<ColorSwatch color={swatchColor} {size} />
|
||||
<span data-gradient-builder-stop-list-hex>{hex}</span>
|
||||
<span data-gradient-builder-stop-list-pos>{position}%</span>
|
||||
</button>
|
||||
{#if !isEndpoint}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="threat"
|
||||
{size}
|
||||
iconOnly
|
||||
disabled={provider.isDisabled}
|
||||
aria-label={removeLabel}
|
||||
onclick={() => provider.removeStop(index)}
|
||||
>
|
||||
{#snippet icon()}<Trash2 />{/snippet}
|
||||
{removeLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
// Opt-in stops list — the composable panel the user asked for (Untitled UI /
|
||||
// cssgradient.io ship the same). Default-renders one row per stop, ordered by
|
||||
// position so it mirrors the rail. Each row opens the SAME color editor and
|
||||
// (except the two endpoint stops) can be deleted. The rail stays the primary UI.
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import Row from './gradient-builder-stop-list-row.svelte';
|
||||
import type { GradientBuilderStopListProps } from './types';
|
||||
|
||||
let {
|
||||
children,
|
||||
...rest
|
||||
}: GradientBuilderStopListProps & { children?: import('svelte').Snippet } = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-builder.stop-list|Color stops'));
|
||||
|
||||
// Row order = position order (the leftmost / rightmost are the endpoints whose
|
||||
// delete is disabled). We keep the ORIGINAL index for each row so it maps back
|
||||
// to `provider.stops`.
|
||||
const ordered = $derived(
|
||||
provider.stops
|
||||
.map((_, i) => i)
|
||||
.sort((a, b) => provider.stopPosition(a) - provider.stopPosition(b))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div {...rest} data-gradient-builder-stop-list role="list" aria-label={label}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#each ordered as i, pos (i)}
|
||||
<Row index={i} isEndpoint={pos === 0 || pos === ordered.length - 1} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import * as GradientBuilder from '$soma/components/gradient-builder';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { stopColorToCss } from '$libs/gradient';
|
||||
import type { GradientBuilderStopProps } from './types';
|
||||
|
||||
let { index = 0, ...rest }: GradientBuilderStopProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
const pos = $derived(provider.stopPosition(index) * 100);
|
||||
const fill = $derived.by(() => {
|
||||
const stop = provider.stops[index];
|
||||
return stop ? stopColorToCss(stop.color, stop.alpha) : 'transparent';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- The handle IS the slider thumb (soma owns drag + keyboard). Eidos positions
|
||||
it along the rail and paints it with the stop's color via `--gb-stop-fill`. -->
|
||||
<GradientBuilder.Stop {...rest} {index} style="left: {pos}%; --gb-stop-fill: {fill};" />
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import * as GradientBuilder from '$soma/components/gradient-builder';
|
||||
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
|
||||
import { gradientToCss } from '$libs/gradient';
|
||||
import Stop from './gradient-builder-stop.svelte';
|
||||
import type { GradientBuilderTrackProps } from './types';
|
||||
|
||||
let { children, ...rest }: GradientBuilderTrackProps = $props();
|
||||
|
||||
const provider = GradientBuilderProvider.require();
|
||||
// The track paints the LIVE gradient as a horizontal ramp so the stops sit
|
||||
// over the actual colors they edit — independent of the gradient's `angle`
|
||||
// (the dial controls geometry; the rail is always left→right for editing).
|
||||
const rail = $derived(
|
||||
gradientToCss({ kind: 'linear', angle: 90, stops: provider.stops })
|
||||
);
|
||||
</script>
|
||||
|
||||
<GradientBuilder.Track {...rest} style="background: {rail};">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#each provider.stops as _stop, i (i)}
|
||||
<Stop index={i} />
|
||||
{/each}
|
||||
{/if}
|
||||
</GradientBuilder.Track>
|
||||
@ -0,0 +1,294 @@
|
||||
/* GradientBuilder — visual layer.
|
||||
*
|
||||
* Self-contained recipe (gradient-builder is on the active dev track, so it
|
||||
* does NOT register tokens in recipes/base.ts yet). Internal `--_gradient-builder-*`
|
||||
* tokens derive from global tokens; the child controls (Slider / ToggleGroup /
|
||||
* Button / ColorPicker) bring their own recipes.
|
||||
*
|
||||
* Scaling/density: every gap + padding uses `--space-*` (density- AND
|
||||
* scaling-aware); every fixed dimension is `calc(px * var(--scaling, 1))` so the
|
||||
* editor zooms with the System `scaling` axis. The color editor for the selected
|
||||
* stop is opened by clicking a stop (not always visible).
|
||||
*/
|
||||
|
||||
[data-gradient-builder] {
|
||||
--_gradient-builder-preview-height: calc(128px * var(--scaling, 1));
|
||||
--_gradient-builder-track-height: calc(24px * var(--scaling, 1));
|
||||
--_gradient-builder-stop-size: calc(20px * var(--scaling, 1));
|
||||
--_gradient-builder-radius: var(--radius-md);
|
||||
--_gradient-builder-border: var(--color-border-default);
|
||||
--_gradient-builder-stop-ring: var(--color-surface-default);
|
||||
--_gradient-builder-label: var(--color-content-secondary);
|
||||
|
||||
/* Transparency checker revealed under the preview where stops are
|
||||
translucent. Kept subtle so it doesn't fight the gradient in dark mode. */
|
||||
--gb-checker-cell: calc(7px * var(--scaling, 1));
|
||||
--gb-checker:
|
||||
linear-gradient(45deg, var(--color-border-subtle) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--color-border-subtle) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--color-border-subtle) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--color-border-subtle) 75%);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-4);
|
||||
/* `raised` + a defined border so the editor reads as a distinct panel in
|
||||
both light and dark (surface-default blended into the page background). */
|
||||
background: var(--color-surface-raised, var(--color-surface-default));
|
||||
border: var(--border-width) solid var(--color-border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
[data-gradient-builder][data-size='sm'] {
|
||||
--_gradient-builder-preview-height: calc(56px * var(--scaling, 1));
|
||||
--_gradient-builder-track-height: calc(20px * var(--scaling, 1));
|
||||
--_gradient-builder-stop-size: calc(18px * var(--scaling, 1));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
[data-gradient-builder][data-size='lg'] {
|
||||
--_gradient-builder-preview-height: calc(88px * var(--scaling, 1));
|
||||
--_gradient-builder-track-height: calc(28px * var(--scaling, 1));
|
||||
--_gradient-builder-stop-size: calc(22px * var(--scaling, 1));
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
/* Variant — the editor card framing. */
|
||||
[data-gradient-builder][data-variant='outline'] {
|
||||
background: transparent;
|
||||
}
|
||||
[data-gradient-builder][data-variant='ghost'] {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-gradient-builder][data-disabled] {
|
||||
opacity: var(--opacity-disabled);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The global `commit` firma pulses a primary box-shadow RING on whatever the
|
||||
commit event targets. Our `commit-set` targets the provider CARD, so every
|
||||
angle / kind / color commit flashed a ring around the whole editor. Kill the
|
||||
VISUAL ring on the card (the sound/haptic firma is unaffected — it runs in JS,
|
||||
not CSS). The child controls keep their own (small) commit rings. */
|
||||
[data-gradient-builder][data-event-family='commit'][data-event-phase='active'] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ── Preview — the live gradient bar (decorative) ────────────────────────── */
|
||||
[data-gradient-builder-preview] {
|
||||
position: relative;
|
||||
block-size: var(--_gradient-builder-preview-height);
|
||||
border-radius: var(--_gradient-builder-radius);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
background-color: var(--color-surface-default);
|
||||
background-image: var(--gb-checker);
|
||||
background-size: calc(var(--gb-checker-cell) * 2) calc(var(--gb-checker-cell) * 2);
|
||||
background-position:
|
||||
0 0,
|
||||
0 var(--gb-checker-cell),
|
||||
var(--gb-checker-cell) calc(-1 * var(--gb-checker-cell)),
|
||||
calc(-1 * var(--gb-checker-cell)) 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ── Track — the stop rail / drag surface ────────────────────────────────── */
|
||||
[data-gradient-builder-track] {
|
||||
position: relative;
|
||||
block-size: var(--_gradient-builder-track-height);
|
||||
/* Room so the edge handles (left:0 / 100%, centered by translate) clear the card. */
|
||||
margin-inline: calc(var(--_gradient-builder-stop-size) / 2);
|
||||
border-radius: var(--_gradient-builder-radius);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* ── Stop — the keyboard-accessible slider thumb ─────────────────────────── */
|
||||
[data-gradient-builder-stop] {
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
/* `left` is set inline. Center with `translate` (the property, not
|
||||
`transform`) so a sema press-squeeze can't fight the centering. */
|
||||
translate: -50% -50%;
|
||||
inline-size: var(--_gradient-builder-stop-size);
|
||||
block-size: var(--_gradient-builder-stop-size);
|
||||
border-radius: var(--radius-full);
|
||||
border: calc(2px * var(--scaling, 1)) solid var(--_gradient-builder-stop-ring);
|
||||
background: var(--gb-stop-fill, var(--color-surface-default));
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
|
||||
0 1px 3px color-mix(in srgb, var(--color-neutral-contrast) 30%, transparent);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-gradient-builder-stop][data-dragging] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
[data-gradient-builder-stop][data-selected] {
|
||||
box-shadow:
|
||||
0 0 0 calc(2px * var(--scaling, 1)) var(--color-primary-solid),
|
||||
0 1px 3px color-mix(in srgb, var(--color-neutral-contrast) 35%, transparent);
|
||||
}
|
||||
|
||||
[data-gradient-builder-stop]:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 calc(2px * var(--scaling, 1)) var(--color-surface-default),
|
||||
0 0 0 calc(4px * var(--scaling, 1)) var(--color-primary-solid);
|
||||
}
|
||||
|
||||
/* ── Stop actions — add / remove ─────────────────────────────────────────── */
|
||||
[data-gradient-builder-stop-actions] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── Kind switch — full-width segmented control ──────────────────────────── */
|
||||
[data-gradient-builder-kind-switch] {
|
||||
display: block;
|
||||
}
|
||||
/* Composed on the same row as the Add button — the switch fills the rest. */
|
||||
[data-gradient-builder-stop-actions] [data-gradient-builder-kind-switch] {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
/* ── Angle dial — [label … value] head + full-width slider ───────────────── */
|
||||
[data-gradient-builder-angle-dial] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
[data-gradient-builder-angle-dial-head] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
[data-gradient-builder-angle-dial-label] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--_gradient-builder-label);
|
||||
}
|
||||
[data-gradient-builder-angle-dial-value] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
/* ── Stop color editor — a popover anchored to the rail, opened by a stop click ── */
|
||||
/* The anchor wraps the Track; it must not change the rail layout. */
|
||||
[data-gradient-builder-stop-color-anchor] {
|
||||
display: block;
|
||||
}
|
||||
[data-gradient-builder-stop-color-title] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
[data-gradient-builder-stop-color-sliders] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
/* Rails span 90% of the panel, centred; taller track than the md default. */
|
||||
inline-size: 90%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
[data-gradient-builder-stop-color-sliders] [data-color-picker-channel-slider] {
|
||||
--_slider-track-size: var(--slider-track-size-xl);
|
||||
}
|
||||
/* The remove-stop button sits at the inline-start of the shell footer; the
|
||||
* canonical Cancel / Close stay end-aligned via the footer's own layout. */
|
||||
[data-gradient-builder-stop-color-remove] {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
/* ── Stops list (opt-in <GradientBuilder.StopList>) ─────────────────────────── */
|
||||
[data-gradient-builder-stop-list] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
[data-gradient-builder-stop-list-row] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
/* The value button reads like a ColorField value: swatch + hex + position. */
|
||||
[data-gradient-builder-stop-list-value] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-default);
|
||||
color: var(--color-content-primary);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-gradient-builder-stop-list-value]:hover:not(:disabled) {
|
||||
background-image: linear-gradient(var(--state-hover), var(--state-hover));
|
||||
}
|
||||
[data-gradient-builder-stop-list-value][data-selected] {
|
||||
border-color: var(--color-primary-border);
|
||||
}
|
||||
[data-gradient-builder-stop-list-value]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
[data-gradient-builder-stop-list-hex] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-gradient-builder-stop-list-pos] {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-content-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Presets panel (below the editor when a `presets` prop is defined) ──────── */
|
||||
[data-gradient-builder-presets] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
[data-gradient-builder-presets] .gb-preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
[data-gradient-builder-preset] {
|
||||
inline-size: 100%;
|
||||
block-size: calc(32px * var(--scaling, 1));
|
||||
padding: 0;
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-gradient-builder-preset]:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
[data-gradient-builder-preset]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
/* The selected saved preset — highlighted; it flips the action button to Delete. */
|
||||
[data-gradient-builder-preset][data-selected] {
|
||||
border-color: var(--color-primary-border);
|
||||
outline: var(--focus-ring-width) solid var(--color-primary-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
// Child controls compose Slider / ToggleGroup / Button / ColorPicker, so load
|
||||
// their recipes here first (self-import architecture, mirrors color-picker).
|
||||
import '$uix/eidos/components/slider/slider.css';
|
||||
import '$uix/eidos/components/toggle-group/toggle-group.css';
|
||||
import '$uix/eidos/components/button/button.css';
|
||||
import '$uix/eidos/components/color-picker/color-picker.css';
|
||||
import './gradient-builder.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as GradientBuilder from '$soma/components/gradient-builder';
|
||||
import { setGradientBuilderVisualContext } from './context.svelte';
|
||||
import Preview from './gradient-builder-preview.svelte';
|
||||
import Track from './gradient-builder-track.svelte';
|
||||
import KindSwitch from './gradient-builder-kind-switch.svelte';
|
||||
import AddStop from './gradient-builder-add-stop.svelte';
|
||||
import AngleDial from './gradient-builder-angle-dial.svelte';
|
||||
import StopColor from './gradient-builder-stop-color.svelte';
|
||||
import StopList from './gradient-builder-stop-list.svelte';
|
||||
import Presets from './gradient-builder-presets.svelte';
|
||||
import { gradientToCss, type Gradient } from '$libs/gradient';
|
||||
import type { GradientBuilderProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
// Surface-agnostic by default: the builder adapts to the container's surface
|
||||
// and paints none of its own. `surface` / `outline` opt INTO a framed card.
|
||||
variant = 'ghost',
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
presets,
|
||||
maxPresets = 10,
|
||||
children,
|
||||
...rest
|
||||
}: GradientBuilderProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
|
||||
setGradientBuilderVisualContext({
|
||||
getSize: () => resolvedSize,
|
||||
getVariant: () => variant
|
||||
});
|
||||
|
||||
// Saved presets — the user adds the current gradient (mirrors the ColorPicker's
|
||||
// saved swatches). Ephemeral per-instance; shown alongside the provided presets.
|
||||
let savedPresets = $state<Gradient[]>([]);
|
||||
const canSave = $derived.by(() => {
|
||||
if (value === undefined || savedPresets.length >= maxPresets) return false;
|
||||
const k = gradientToCss(value);
|
||||
return ![...(presets ?? []), ...savedPresets].some((g) => gradientToCss(g) === k);
|
||||
});
|
||||
function savePreset(): void {
|
||||
if (canSave && value) savedPresets = [...savedPresets, value];
|
||||
}
|
||||
function deletePreset(i: number): void {
|
||||
savedPresets = savedPresets.filter((_, idx) => idx !== i);
|
||||
}
|
||||
</script>
|
||||
|
||||
<GradientBuilder.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
{disabled}
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- Editor: preview · stop rail (click a stop → color popover, with
|
||||
delete-this-stop inside) · add · type · angle · presets. -->
|
||||
<Preview />
|
||||
<StopColor>
|
||||
<Track />
|
||||
</StopColor>
|
||||
<div data-gradient-builder-stop-actions>
|
||||
<AddStop />
|
||||
<KindSwitch />
|
||||
</div>
|
||||
<AngleDial />
|
||||
<StopList />
|
||||
{#if presets !== undefined}
|
||||
<Presets
|
||||
provided={presets}
|
||||
{savedPresets}
|
||||
{canSave}
|
||||
onSave={savePreset}
|
||||
onDelete={deletePreset}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</GradientBuilder.Provider>
|
||||
@ -0,0 +1,68 @@
|
||||
// GradientBuilder — eidos compound API.
|
||||
//
|
||||
// import { GradientBuilder } from '$uix/eidos/components/gradient-builder';
|
||||
//
|
||||
// <GradientBuilder bind:value size="md">
|
||||
// <GradientBuilder.Preview />
|
||||
// <GradientBuilder.Track />
|
||||
// <GradientBuilder.StopColor />
|
||||
// <GradientBuilder.KindSwitch />
|
||||
// <GradientBuilder.AngleDial />
|
||||
// <GradientBuilder.AddStop />
|
||||
// </GradientBuilder>
|
||||
//
|
||||
// The Track default-renders one Stop per gradient stop. Behaviour (drag,
|
||||
// keyboard, the role=slider stops) lives in soma; eidos composes Slider /
|
||||
// ToggleGroup / Button / ColorPicker and paints the surfaces.
|
||||
import GradientBuilderComponent from './gradient-builder.svelte';
|
||||
import Preview from './gradient-builder-preview.svelte';
|
||||
import Track from './gradient-builder-track.svelte';
|
||||
import Stop from './gradient-builder-stop.svelte';
|
||||
import StopColor from './gradient-builder-stop-color.svelte';
|
||||
import AngleDial from './gradient-builder-angle-dial.svelte';
|
||||
import KindSwitch from './gradient-builder-kind-switch.svelte';
|
||||
import AddStop from './gradient-builder-add-stop.svelte';
|
||||
import RemoveStop from './gradient-builder-remove-stop.svelte';
|
||||
import StopList from './gradient-builder-stop-list.svelte';
|
||||
|
||||
type GradientBuilderNamespace = typeof GradientBuilderComponent & {
|
||||
Preview: typeof Preview;
|
||||
Track: typeof Track;
|
||||
Stop: typeof Stop;
|
||||
StopColor: typeof StopColor;
|
||||
AngleDial: typeof AngleDial;
|
||||
KindSwitch: typeof KindSwitch;
|
||||
AddStop: typeof AddStop;
|
||||
RemoveStop: typeof RemoveStop;
|
||||
StopList: typeof StopList;
|
||||
};
|
||||
|
||||
const GradientBuilder = GradientBuilderComponent as GradientBuilderNamespace;
|
||||
GradientBuilder.Preview = Preview;
|
||||
GradientBuilder.Track = Track;
|
||||
GradientBuilder.Stop = Stop;
|
||||
GradientBuilder.StopColor = StopColor;
|
||||
GradientBuilder.AngleDial = AngleDial;
|
||||
GradientBuilder.KindSwitch = KindSwitch;
|
||||
GradientBuilder.AddStop = AddStop;
|
||||
GradientBuilder.RemoveStop = RemoveStop;
|
||||
GradientBuilder.StopList = StopList;
|
||||
|
||||
export { GradientBuilder };
|
||||
|
||||
export default GradientBuilder;
|
||||
|
||||
export type {
|
||||
GradientBuilderProps,
|
||||
GradientBuilderTrackProps as TrackProps,
|
||||
GradientBuilderStopProps as StopProps,
|
||||
GradientBuilderPreviewProps as PreviewProps,
|
||||
GradientBuilderStopColorProps as StopColorProps,
|
||||
GradientBuilderAngleDialProps as AngleDialProps,
|
||||
GradientBuilderKindSwitchProps as KindSwitchProps,
|
||||
GradientBuilderAddStopProps as AddStopProps,
|
||||
GradientBuilderStopListProps as StopListProps,
|
||||
GradientBuilderStopListRowProps as StopListRowProps,
|
||||
GradientBuilderSize,
|
||||
GradientBuilderVariant
|
||||
} from './types';
|
||||
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Bridge between the gradient model's {@link StopColor} and the ColorPicker's
|
||||
* {@link ColorValue} — the one non-obvious seam in the GradientBuilder eidos.
|
||||
*
|
||||
* The ColorPicker is an sRGB HSV editor; the gradient model stores OKLCH /
|
||||
* role-refs / raw CSS. v1 reads any stop into the picker (best-effort) and
|
||||
* writes the picked color back as a `css` hex stop (lossless for what the
|
||||
* picker can express, and `gradientToCss` emits css stops verbatim). Role and
|
||||
* OKLCH stops still RENDER correctly via `stopColorToCss`; editing one through
|
||||
* the picker re-bakes it to a `css` hex literal.
|
||||
*/
|
||||
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
|
||||
import { oklchToHex } from '$color';
|
||||
import type { StopColor } from '$libs/gradient';
|
||||
|
||||
/**
|
||||
* Seed a {@link ColorValue} from a {@link StopColor} for the embedded picker.
|
||||
* `css` stops are parsed; `oklch` stops are converted to hex first; `role`
|
||||
* stops can't be resolved statically (they're `var(--color-…)`) so the picker
|
||||
* falls back to the default until the user picks — which bakes a css stop.
|
||||
*/
|
||||
export function stopColorToColorValue(c: StopColor): ColorValue {
|
||||
if (c.kind === 'oklch') {
|
||||
const hsv = parseColor(oklchToHex(c.value));
|
||||
return hsv ? colorValueFromHsv(hsv) : DEFAULT_COLOR;
|
||||
}
|
||||
if (c.kind === 'css') {
|
||||
const hsv = parseColor(c.value);
|
||||
return hsv ? colorValueFromHsv(hsv) : DEFAULT_COLOR;
|
||||
}
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
/** Write a picked {@link ColorValue} back as a `css` hex stop (alpha baked in). */
|
||||
export function colorValueToStopColor(v: ColorValue): StopColor {
|
||||
return { kind: 'css', value: v.hex };
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
# Eidos GradientPicker
|
||||
|
||||
The visual layer over the headless
|
||||
[`GradientPicker`](../../../soma/components/gradient-picker/README.md) — a
|
||||
field-shaped trigger carrying a live gradient chip that opens the
|
||||
[`GradientBuilder`](../gradient-builder/README.md) in a Popover, with a
|
||||
Clear / Cancel / Save footer. Identity wrapper over the composed Popover.
|
||||
|
||||
## Surface
|
||||
|
||||
```svelte
|
||||
<GradientPicker bind:value size="md" variant="surface">
|
||||
<GradientPicker.Trigger />
|
||||
<GradientPicker.Portal>
|
||||
<GradientPicker.Content />
|
||||
</GradientPicker.Portal>
|
||||
</GradientPicker>
|
||||
```
|
||||
|
||||
`<GradientPicker.Content />` renders a default body — the GradientBuilder editor
|
||||
+ the `Clear / Cancel / Save` footer — so it works out of the box. Pass children
|
||||
to the Content to customise it.
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Composes | Notes |
|
||||
| ---------------------------- | ---------------------- | ---------------------------------------------------- |
|
||||
| `Provider` | soma Provider | Sets a size/variant visual context. |
|
||||
| `Trigger` | soma Trigger + Popover | Field-shaped pill with the gradient chip. |
|
||||
| `ValueSwatch` | soma ValueSwatch | Standalone gradient chip (paints `--gp-current-gradient`). |
|
||||
| `Content` | Popover + PickerShell | The floating editor: GradientBuilder + footer. |
|
||||
| `Footer` / `Clear` / `Cancel` / `Close` | PickerShell | The shared footer actions (read `pickerShellContext`).|
|
||||
| `Portal` / `Anchor` / `Overlay` / `Arrow` | Popover | Re-exports. |
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | ----------------------------------- | ----------- | ---------------------------------------- |
|
||||
| `value` | `Gradient` | 2-stop | The committed gradient. Bindable. |
|
||||
| `open` | `boolean` | `false` | Popover open state. Bindable. |
|
||||
| `mode` | `'inline' \| 'modal'` | `'inline'` | Footer layout (Clear/Cancel vs Cancel/Save). |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Trigger / popover / editor size. |
|
||||
| `variant` | `'surface' \| 'outline' \| 'ghost'` | `'surface'` | Trigger framing. |
|
||||
| `disabled`| `boolean` | `false` | Disables the trigger. |
|
||||
|
||||
## The value bridge (the one subtle bit)
|
||||
|
||||
`Content` renders `<GradientBuilder bind:value={draft} onValueCommit=…>`: the
|
||||
embedded builder edits a local `draft` ($state); its `onValueCommit` pushes
|
||||
settled values to `provider.setValue`; an `$effect` (`if (v !== draft) draft = v`)
|
||||
flows external resets (Cancel / Clear) back. Commit-only push + the ref guard =
|
||||
no loop.
|
||||
|
||||
## Recipe
|
||||
|
||||
Self-contained (gradient-picker is on the active dev track). The trigger is a
|
||||
field-shaped pill; the chip + standalone swatch paint `--gp-current-gradient`
|
||||
(stamped inline by soma). The popover content + editor reuse the Popover +
|
||||
PickerShell + GradientBuilder recipes.
|
||||
|
||||
## Notes
|
||||
|
||||
- **No `color` prop** (like GradientBuilder). Demo:
|
||||
`web/routes/uix/components/gradient-picker/+page.svelte`.
|
||||
@ -0,0 +1,20 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { GradientPickerSize, GradientPickerVariant } from './types';
|
||||
|
||||
const GRADIENT_PICKER_VISUAL_CONTEXT = Symbol('uix.eidos.gradient-picker.visual');
|
||||
|
||||
export interface GradientPickerVisualContext {
|
||||
getSize(): GradientPickerSize;
|
||||
getVariant(): GradientPickerVariant;
|
||||
}
|
||||
|
||||
export function setGradientPickerVisualContext(
|
||||
ctx: GradientPickerVisualContext
|
||||
): GradientPickerVisualContext {
|
||||
setContext(GRADIENT_PICKER_VISUAL_CONTEXT, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function getGradientPickerVisualContext(): GradientPickerVisualContext | undefined {
|
||||
return getContext<GradientPickerVisualContext | undefined>(GRADIENT_PICKER_VISUAL_CONTEXT);
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import PopoverContent from '../popover/popover-content.svelte';
|
||||
import { PickerShell } from '../picker-shell';
|
||||
import { GradientBuilder } from '../gradient-builder';
|
||||
import { GradientPickerProvider } from '$soma/components/gradient-picker';
|
||||
import { getGradientPickerVisualContext } from './context.svelte';
|
||||
import type { GradientPickerContentProps } from './types';
|
||||
import type { Gradient } from '$libs/gradient';
|
||||
|
||||
let {
|
||||
size,
|
||||
align = 'start',
|
||||
sideOffset,
|
||||
collisionPadding = 12,
|
||||
children: bodyContent,
|
||||
...rest
|
||||
}: GradientPickerContentProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const visual = getGradientPickerVisualContext();
|
||||
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
|
||||
|
||||
const provider = GradientPickerProvider.require();
|
||||
|
||||
// Two-way bridge to the picker's value: the GradientBuilder edits `draft`
|
||||
// live; settled changes commit to the provider. External resets (Cancel /
|
||||
// Clear) flow back through the effect. Commit-only push avoids a loop.
|
||||
let draft = $state<Gradient>(provider.value);
|
||||
$effect(() => {
|
||||
const v = provider.value;
|
||||
if (v !== draft) draft = v;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PopoverContent
|
||||
{...rest}
|
||||
{align}
|
||||
{sideOffset}
|
||||
{collisionPadding}
|
||||
size={resolvedSize}
|
||||
data-picker-size={resolvedSize}
|
||||
data-gradient-picker-content=""
|
||||
>
|
||||
{#snippet children(snippetProps)}
|
||||
<PickerShell>
|
||||
{#if bodyContent}
|
||||
{@render bodyContent(snippetProps)}
|
||||
{:else}
|
||||
<PickerShell.Body>
|
||||
<GradientBuilder
|
||||
bind:value={draft}
|
||||
size={resolvedSize}
|
||||
onValueCommit={(g) => provider.setValue(g)}
|
||||
>
|
||||
<GradientBuilder.Preview />
|
||||
<GradientBuilder.Track />
|
||||
<div class="gradient-picker-controls">
|
||||
<GradientBuilder.KindSwitch />
|
||||
<GradientBuilder.AddStop />
|
||||
</div>
|
||||
<GradientBuilder.AngleDial />
|
||||
<GradientBuilder.StopColor />
|
||||
</GradientBuilder>
|
||||
</PickerShell.Body>
|
||||
<PickerShell.Footer>
|
||||
<PickerShell.Clear />
|
||||
<PickerShell.Cancel />
|
||||
<PickerShell.Close />
|
||||
</PickerShell.Footer>
|
||||
{/if}
|
||||
</PickerShell>
|
||||
{/snippet}
|
||||
</PopoverContent>
|
||||
@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<GradientPicker.Trigger>` — paint wrapper. Soma owns the value and
|
||||
* stamps `--gp-current-gradient` inline; this wrapper adds the visual
|
||||
* data-attrs + the gradient preview chip. CSS reads the var to paint it.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as GradientPicker from '$soma/components/gradient-picker';
|
||||
import { getGradientPickerVisualContext } from './context.svelte';
|
||||
import type { GradientPickerTriggerProps } from './types';
|
||||
|
||||
let {
|
||||
size,
|
||||
variant,
|
||||
showSwatch = true,
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: GradientPickerTriggerProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const visual = getGradientPickerVisualContext();
|
||||
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
|
||||
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'surface');
|
||||
const label = $derived(eidos.langs.ts('#?components.gradient-picker.trigger|Open gradient editor'));
|
||||
</script>
|
||||
|
||||
<GradientPicker.Trigger
|
||||
{...rest}
|
||||
data-size={resolvedSize}
|
||||
data-variant={resolvedVariant}
|
||||
data-show-swatch={showSwatch ? '' : undefined}
|
||||
{style}
|
||||
>
|
||||
{#if showSwatch}
|
||||
<span class="gradient-picker-trigger-swatch" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<span class="gradient-picker-trigger-label">{label}</span>
|
||||
{/if}
|
||||
</GradientPicker.Trigger>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as GradientPicker from '$soma/components/gradient-picker';
|
||||
import type { GradientPickerValueSwatchProps } from './types';
|
||||
|
||||
let { children, ...rest }: GradientPickerValueSwatchProps = $props();
|
||||
</script>
|
||||
|
||||
<GradientPicker.ValueSwatch {...rest}>{@render children?.()}</GradientPicker.ValueSwatch>
|
||||
@ -0,0 +1,106 @@
|
||||
/* GradientPicker — visual layer.
|
||||
*
|
||||
* Self-contained recipe (gradient-picker is on the active dev track, so it
|
||||
* does NOT register tokens in recipes/base.ts yet). The trigger is a
|
||||
* field-shaped pill carrying a gradient preview chip; the popover content +
|
||||
* editor layout reuse the shared Popover + PickerShell + GradientBuilder
|
||||
* recipes. Internal `--_gradient-picker-*` tokens derive from global tokens.
|
||||
*
|
||||
* Soma stamps `--gp-current-gradient` inline on the trigger + value swatch, so
|
||||
* the chip paints purely from CSS — soma owns the value, eidos owns the paint.
|
||||
*/
|
||||
|
||||
[data-gradient-picker] {
|
||||
--_gradient-picker-trigger-height: var(--control-height-md);
|
||||
--_gradient-picker-trigger-px: var(--space-2-5);
|
||||
--_gradient-picker-trigger-font-size: var(--font-size-md);
|
||||
--_gradient-picker-chip-size: calc(var(--control-height-md) - var(--space-2));
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-gradient-picker][data-size='sm'] {
|
||||
--_gradient-picker-trigger-height: var(--control-height-sm);
|
||||
--_gradient-picker-trigger-px: var(--space-2);
|
||||
--_gradient-picker-trigger-font-size: var(--font-size-sm);
|
||||
--_gradient-picker-chip-size: calc(var(--control-height-sm) - var(--space-2));
|
||||
}
|
||||
|
||||
[data-gradient-picker][data-size='lg'] {
|
||||
--_gradient-picker-trigger-height: var(--control-height-lg);
|
||||
--_gradient-picker-trigger-px: var(--space-3);
|
||||
--_gradient-picker-trigger-font-size: var(--font-size-lg);
|
||||
--_gradient-picker-chip-size: calc(var(--control-height-lg) - var(--space-2));
|
||||
}
|
||||
|
||||
/* ── Trigger — field-shaped pill with a gradient chip ────────────────────── */
|
||||
[data-gradient-picker-trigger] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
block-size: var(--_gradient-picker-trigger-height);
|
||||
padding-inline: var(--_gradient-picker-trigger-px);
|
||||
font-size: var(--_gradient-picker-trigger-font-size);
|
||||
color: var(--color-content-primary);
|
||||
background: var(--color-surface-default);
|
||||
border: var(--border-width) solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--ease-default),
|
||||
border-color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
[data-gradient-picker-trigger]:hover {
|
||||
background: var(--color-surface-overlay);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
[data-gradient-picker-trigger]:focus-visible {
|
||||
outline: 2px solid var(--color-primary-solid);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
[data-gradient-picker-trigger][data-disabled] {
|
||||
opacity: var(--opacity-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gradient-picker-trigger-swatch {
|
||||
flex: none;
|
||||
inline-size: var(--_gradient-picker-chip-size);
|
||||
block-size: var(--_gradient-picker-chip-size);
|
||||
border-radius: var(--radius-sm);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--gp-current-gradient, transparent);
|
||||
}
|
||||
.gradient-picker-trigger-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Standalone value swatch ─────────────────────────────────────────────── */
|
||||
[data-gradient-picker-value-swatch] {
|
||||
inline-size: var(--_gradient-picker-chip-size);
|
||||
block-size: var(--_gradient-picker-chip-size);
|
||||
border-radius: var(--radius-sm);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--gp-current-gradient, transparent);
|
||||
}
|
||||
|
||||
/* ── Content — sized to fit the embedded editor (Area + sliders) ─────────── */
|
||||
[data-gradient-picker-content] {
|
||||
inline-size: 20rem;
|
||||
}
|
||||
[data-gradient-picker-content][data-picker-size='sm'] {
|
||||
inline-size: 18rem;
|
||||
}
|
||||
[data-gradient-picker-content][data-picker-size='lg'] {
|
||||
inline-size: 22rem;
|
||||
}
|
||||
|
||||
.gradient-picker-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
// The trigger composes Button-shaped chrome and the content composes Popover +
|
||||
// PickerShell + the GradientBuilder, so load their recipes here first.
|
||||
import '$uix/eidos/components/popover/popover.css';
|
||||
import '$uix/eidos/components/picker-shell/picker-shell.css';
|
||||
import '$uix/eidos/components/gradient-builder/gradient-builder.css';
|
||||
import './gradient-picker.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as GradientPicker from '$soma/components/gradient-picker';
|
||||
import { setGradientPickerVisualContext } from './context.svelte';
|
||||
import { setPickerShellVisual } from '$uix/eidos/components/picker-shell/visual-context';
|
||||
import type { GradientPickerProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
variant = 'surface',
|
||||
value = $bindable(),
|
||||
open = $bindable(false),
|
||||
mode = 'inline',
|
||||
disabled = false,
|
||||
children,
|
||||
...rest
|
||||
}: GradientPickerProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
|
||||
setGradientPickerVisualContext({
|
||||
getSize: () => resolvedSize,
|
||||
getVariant: () => variant
|
||||
});
|
||||
// Footer controls follow the picker size.
|
||||
setPickerShellVisual({ getSize: () => resolvedSize });
|
||||
</script>
|
||||
|
||||
<GradientPicker.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
bind:open
|
||||
{mode}
|
||||
{disabled}
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
>
|
||||
{@render children?.()}
|
||||
</GradientPicker.Provider>
|
||||
@ -0,0 +1,67 @@
|
||||
// GradientPicker — eidos compound API. Trigger + Popover wrapping the
|
||||
// interactive GradientBuilder. Identity wrapper over the composed Popover.
|
||||
//
|
||||
// import { GradientPicker } from '$uix/eidos/components/gradient-picker';
|
||||
//
|
||||
// <GradientPicker bind:value bind:open>
|
||||
// <GradientPicker.Trigger><GradientPicker.ValueSwatch /></GradientPicker.Trigger>
|
||||
// <GradientPicker.Portal>
|
||||
// <GradientPicker.Content />
|
||||
// </GradientPicker.Portal>
|
||||
// </GradientPicker>
|
||||
import GradientPickerComponent from './gradient-picker.svelte';
|
||||
import Trigger from './gradient-picker-trigger.svelte';
|
||||
import ValueSwatch from './gradient-picker-value-swatch.svelte';
|
||||
import Content from './gradient-picker-content.svelte';
|
||||
import Anchor from '../popover/popover-anchor.svelte';
|
||||
import Overlay from '../popover/popover-overlay.svelte';
|
||||
import Arrow from '../popover/popover-arrow.svelte';
|
||||
import PopoverClose from '../popover/popover-close.svelte';
|
||||
// Footer / Clear / Cancel / Close are the canonical PickerShell parts.
|
||||
import Footer from '../picker-shell/picker-shell-footer.svelte';
|
||||
import Clear from '../picker-shell/picker-shell-clear.svelte';
|
||||
import Cancel from '../picker-shell/picker-shell-cancel.svelte';
|
||||
import PickerCloseAction from '../picker-shell/picker-shell-close.svelte';
|
||||
import { Portal } from '$soma/components/internal';
|
||||
|
||||
type GradientPickerNamespace = typeof GradientPickerComponent & {
|
||||
Trigger: typeof Trigger;
|
||||
ValueSwatch: typeof ValueSwatch;
|
||||
Portal: typeof Portal;
|
||||
Anchor: typeof Anchor;
|
||||
Overlay: typeof Overlay;
|
||||
Content: typeof Content;
|
||||
Arrow: typeof Arrow;
|
||||
PopoverClose: typeof PopoverClose;
|
||||
Footer: typeof Footer;
|
||||
Clear: typeof Clear;
|
||||
Cancel: typeof Cancel;
|
||||
Close: typeof PickerCloseAction;
|
||||
};
|
||||
|
||||
const GradientPicker = GradientPickerComponent as GradientPickerNamespace;
|
||||
GradientPicker.Trigger = Trigger;
|
||||
GradientPicker.ValueSwatch = ValueSwatch;
|
||||
GradientPicker.Portal = Portal;
|
||||
GradientPicker.Anchor = Anchor;
|
||||
GradientPicker.Overlay = Overlay;
|
||||
GradientPicker.Content = Content;
|
||||
GradientPicker.Arrow = Arrow;
|
||||
GradientPicker.PopoverClose = PopoverClose;
|
||||
GradientPicker.Footer = Footer;
|
||||
GradientPicker.Clear = Clear;
|
||||
GradientPicker.Cancel = Cancel;
|
||||
GradientPicker.Close = PickerCloseAction;
|
||||
|
||||
export { GradientPicker };
|
||||
|
||||
export default GradientPicker;
|
||||
|
||||
export type {
|
||||
GradientPickerProps,
|
||||
GradientPickerTriggerProps as TriggerProps,
|
||||
GradientPickerValueSwatchProps as ValueSwatchProps,
|
||||
GradientPickerContentProps as ContentProps,
|
||||
GradientPickerSize,
|
||||
GradientPickerVariant
|
||||
} from './types';
|
||||
@ -0,0 +1,34 @@
|
||||
import type {
|
||||
ProviderProps as SomaProviderProps,
|
||||
TriggerProps as SomaTriggerProps,
|
||||
ValueSwatchProps as SomaValueSwatchProps
|
||||
} from '$soma/components/gradient-picker';
|
||||
import type { PopoverContentProps } from '../popover/types';
|
||||
import type { ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
import type { GradientBuilderSize } from '../gradient-builder/types';
|
||||
|
||||
export type GradientPickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
export type GradientPickerVariant = ControlVariant;
|
||||
|
||||
export type GradientPickerProps = SomaProviderProps & {
|
||||
/** Visual size for the trigger, popover and editor. @default 'md' */
|
||||
size?: ResponsiveProp<GradientPickerSize>;
|
||||
/** Visual treatment of the trigger. @default 'surface' */
|
||||
variant?: GradientPickerVariant;
|
||||
};
|
||||
|
||||
export type GradientPickerTriggerProps = SomaTriggerProps & {
|
||||
size?: ResponsiveProp<GradientPickerSize>;
|
||||
variant?: GradientPickerVariant;
|
||||
/** When true (default) the trigger shows the gradient preview chip. */
|
||||
showSwatch?: boolean;
|
||||
};
|
||||
|
||||
export type GradientPickerValueSwatchProps = SomaValueSwatchProps;
|
||||
|
||||
export type GradientPickerContentProps = Omit<PopoverContentProps, 'size'> & {
|
||||
size?: ResponsiveProp<GradientPickerSize>;
|
||||
};
|
||||
|
||||
/** The builder size matched to a picker size (sm/md/lg → sm/md/lg). */
|
||||
export type GradientPickerBuilderSize = GradientBuilderSize;
|
||||
@ -0,0 +1,23 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the gradient-picker component. Merged under
|
||||
* `components.gradient-picker.*` by `ActiveUix` (via `componentLangs` barrel).
|
||||
*
|
||||
* Morfo + eidos reference entries by absolute idlangref:
|
||||
* `'#?components.gradient-picker.{key}|fallback'`.
|
||||
*/
|
||||
export const gradientPickerLangs = {
|
||||
label: {
|
||||
es: 'Selector de degradado',
|
||||
en: 'Gradient picker'
|
||||
},
|
||||
trigger: {
|
||||
es: 'Abrir editor de degradado',
|
||||
en: 'Open gradient editor'
|
||||
},
|
||||
content: {
|
||||
es: 'Editor de degradado',
|
||||
en: 'Gradient editor'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,140 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* GradientPicker — composite popover containing the interactive GradientBuilder.
|
||||
* The morfo declares the picker's own surface (trigger + value swatch + the
|
||||
* content shell + the clear value-semantic); the editing events live on the
|
||||
* `gradient-builder` morfo inside.
|
||||
*
|
||||
* Identity wrapper over a composed Popover (§2): the Popover IS the dialog.
|
||||
* This morfo declares no dialog semantics (no role:dialog, no focus trap, no
|
||||
* open/close events) — they belong to the composed Popover. Footer / Clear /
|
||||
* Cancel / Close are the shared `picker-shell` chrome.
|
||||
*/
|
||||
export const gradientPickerMorfo = {
|
||||
name: 'GradientPicker',
|
||||
kebab: 'gradient-picker',
|
||||
scope: ['soma', 'sema', 'eidos'],
|
||||
expression: 'delegated',
|
||||
texts: {
|
||||
label: '#?components.gradient-picker.label|Gradient Picker',
|
||||
trigger: '#?components.gradient-picker.trigger|Open gradient editor',
|
||||
content: '#?components.gradient-picker.content|Gradient editor'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
// User reset the gradient to its default (the Clear action).
|
||||
// Picker-specific value semantic — NOT a dialog event.
|
||||
name: 'commit-reset',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'reset',
|
||||
target: v.partRef('content'),
|
||||
sequence: 'post',
|
||||
intent: 'neutral'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
|
||||
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional',
|
||||
ariaBoolean: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Control',
|
||||
kebab: 'control',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
archetype: 'field-trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.gradient-picker.trigger|Open gradient editor'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Enter', action: 'open' },
|
||||
{ key: ' ', action: 'open' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// The gradient preview chip on the trigger — decorative.
|
||||
name: 'ValueSwatch',
|
||||
kebab: 'value-swatch',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
kebab: 'content',
|
||||
archetype: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'data-last-action',
|
||||
values: ['committed', 'cancelled', 'dismissed', 'dismissed-outside'],
|
||||
severity: 'optional'
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.gradient-picker.content|Gradient editor'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'Tab', action: 'focus-next' },
|
||||
{ key: 'Shift+Tab', action: 'focus-prev' }
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,56 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { gradientBuilderMorfo } from '$uix/morfo/components/gradient-builder';
|
||||
import { sound } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* GradientBuilder perceptual defaults.
|
||||
*
|
||||
* A color stop on the track is a direct-manipulation surface — it mirrors the
|
||||
* slider's `handle-pick / handle-drag` shape so dragging a stop carries the
|
||||
* same auditory / tactile vocabulary as moving a slider thumb.
|
||||
*
|
||||
* • handle-pick — single pluck when a stop is grabbed (pickup cue)
|
||||
* • handle-drag — continuous stream while dragging (sound + haptic
|
||||
* channels open; per-emit payload owns pitch / gain)
|
||||
* • commit-set — final pluck at release / stop add·remove / recolor
|
||||
* • commit-reset — same release cue when the gradient is reset
|
||||
*
|
||||
* The embedded ColorPicker + the angle Slider + the kind ToggleGroup bring
|
||||
* their own `color-picker:* / slider:* / toggle-group:*` events automatically —
|
||||
* no extra rules needed here.
|
||||
*/
|
||||
|
||||
const onTrack = (matchers?: Parameters<typeof semaSelector<typeof gradientBuilderMorfo>>[2]) =>
|
||||
semaSelector(gradientBuilderMorfo, 'track', matchers);
|
||||
|
||||
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof gradientBuilderMorfo>>[2]) =>
|
||||
semaSelector(gradientBuilderMorfo, 'provider', matchers);
|
||||
|
||||
export const gradientBuilderSema: Sema = {
|
||||
name: 'gradient-builder',
|
||||
cascade: [
|
||||
{
|
||||
selector: onTrack({ eventName: 'handle-pick' }),
|
||||
channels: ['sound', 'haptic'],
|
||||
sound: sound('handle.pickup.air', { gain: 0.12 }),
|
||||
haptic: { kind: 'tick', intensity: 0.22, duration: 6 }
|
||||
},
|
||||
{
|
||||
selector: onTrack({ eventName: 'handle-drag' }),
|
||||
channels: ['sound', 'haptic']
|
||||
},
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-set' }),
|
||||
channels: ['sound', 'haptic'],
|
||||
sound: sound('handle.release.soft', { gain: 0.1 }),
|
||||
haptic: { kind: 'tick', intensity: 0.18, duration: 8 }
|
||||
},
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-reset' }),
|
||||
channels: ['sound', 'haptic'],
|
||||
sound: sound('handle.release.soft', { gain: 0.1 }),
|
||||
haptic: { kind: 'tick', intensity: 0.18, duration: 8 }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
# GradientPicker
|
||||
|
||||
Headless popover-picker shell around the
|
||||
[`GradientBuilder`](../gradient-builder/README.md). An **identity wrapper over a
|
||||
composed Popover** (§2 — the Popover is the dialog): this provider owns the
|
||||
`open` state and the commit / cancel / clear lifecycle, exposed to
|
||||
`<PickerShell.*>` via `pickerShellContext`. The gradient editing flows through
|
||||
the embedded GradientBuilder, which binds the SAME `value` — so `cancel()`
|
||||
reverts to the value the popover opened with.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<GradientPicker.Provider bind:value bind:open>
|
||||
<GradientPicker.Trigger>
|
||||
<GradientPicker.ValueSwatch />
|
||||
</GradientPicker.Trigger>
|
||||
<!-- eidos composes the Popover content + PickerShell + GradientBuilder body -->
|
||||
</GradientPicker.Provider>
|
||||
```
|
||||
|
||||
The root wrapper creates a `PopoverProvider` sharing the same `open` writable, so
|
||||
the Trigger composes `PopoverTriggerProvider` for free.
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| ------------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `Provider` | `<div>` | Owns `open` + commit/cancel/clear (via `pickerShellContext`). |
|
||||
| `Trigger` | `<button>` | Opens the popover. `aria-haspopup="dialog"` + `aria-expanded`. |
|
||||
| `ValueSwatch` | `<div>` | The gradient preview chip (paints `--gp-current-gradient`). |
|
||||
|
||||
`Content` / footer (`Clear` / `Cancel` / `Close`) are the composed **Popover** +
|
||||
shared **PickerShell** parts — re-exported under the eidos namespace.
|
||||
|
||||
## Provider API (via `GradientPickerProvider.require()`)
|
||||
|
||||
`value` (getter), `setValue(g)`, `css` (derived `gradientToCss`), `isDisabled`,
|
||||
`triggerNode` · `commit()` (save + close), `cancel()` (revert to open-edge value
|
||||
+ close), `clear()` (reset to the default gradient). `valueOnOpen` is snapshotted
|
||||
on the opening edge to power `cancel()`.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| -------- | ----------------------------- | ------------------ |
|
||||
| Provider | `data-gradient-picker` | Always present |
|
||||
| Provider | `data-state` | `open` \| `closed` |
|
||||
| Trigger | `data-gradient-picker-trigger`| Always present |
|
||||
| Trigger | `data-state` | `open` \| `closed` |
|
||||
| Content | `data-gradient-picker-content`| Present when open |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `Enter` / `Space` | Open the editor (on the trigger) |
|
||||
| `Escape` | Close the popover |
|
||||
| `Tab` | Move focus within the editor |
|
||||
|
||||
## Sema events
|
||||
|
||||
| Event | Family | Verb | Target | When |
|
||||
| -------------- | -------- | ------- | --------- | ----------------------------- |
|
||||
| `commit-reset` | `commit` | `reset` | `content` | The Clear action resets it. |
|
||||
|
||||
`expression: 'delegated'` — no dialog semantics here (open / close / focus trap
|
||||
belong to the composed Popover). The editing firma (handle / commit) lives on the
|
||||
embedded GradientBuilder; the open / close firma lives on the Popover.
|
||||
|
||||
## Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { GradientPicker } from '$uix/eidos/components/gradient-picker';
|
||||
let value = $state({ kind: 'linear', angle: 45, stops: [/* … */] });
|
||||
</script>
|
||||
|
||||
<GradientPicker bind:value mode="inline">
|
||||
<GradientPicker.Trigger />
|
||||
<GradientPicker.Portal>
|
||||
<GradientPicker.Content />
|
||||
</GradientPicker.Portal>
|
||||
</GradientPicker>
|
||||
```
|
||||
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { PopoverTriggerProvider } from '../../popover/popover-provider.svelte';
|
||||
import { GradientPickerTriggerProvider } from '../gradient-picker-provider.svelte';
|
||||
import type { GradientPickerTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'gradient-picker-trigger'),
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GradientPickerTriggerProps = $props();
|
||||
|
||||
const triggerState = GradientPickerTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
ariaLabel: readableActive(() => ariaLabel ?? undefined)
|
||||
});
|
||||
|
||||
const popoverTriggerProps = PopoverTriggerProvider.create({
|
||||
id: readableActive(() => `${id}-popover-trigger`),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
openOnHover: readableActive(() => false),
|
||||
openDelay: readableActive(() => 0),
|
||||
closeDelay: readableActive(() => 0)
|
||||
}).props;
|
||||
|
||||
const mergedProps = $derived(
|
||||
mergeProps(restProps, triggerState.props, popoverTriggerProps as Record<string, unknown>)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { GradientPickerValueSwatchProvider } from '../gradient-picker-provider.svelte';
|
||||
import type { GradientPickerValueSwatchProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'gradient-picker-value-swatch'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GradientPickerValueSwatchProps = $props();
|
||||
|
||||
const state = GradientPickerValueSwatchProvider.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}
|
||||
@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { GradientPickerProvider, DEFAULT_PICKER_GRADIENT } from '../gradient-picker-provider.svelte';
|
||||
import { PopoverProvider } from '../../popover/popover-provider.svelte';
|
||||
import type { GradientPickerProviderProps } from '../types';
|
||||
import type { Gradient } from '$libs/gradient';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'gradient-picker'),
|
||||
value = $bindable<Gradient>(DEFAULT_PICKER_GRADIENT),
|
||||
onValueCommit = () => {},
|
||||
open = $bindable(false),
|
||||
onOpenChange = () => {},
|
||||
mode = 'inline',
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GradientPickerProviderProps = $props();
|
||||
|
||||
const sharedOpen = writableActive(
|
||||
() => open,
|
||||
(v) => {
|
||||
open = v;
|
||||
onOpenChange(v);
|
||||
}
|
||||
);
|
||||
|
||||
const picker = GradientPickerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => (value = v)
|
||||
),
|
||||
open: sharedOpen,
|
||||
onValueCommit: readableActive(() => onValueCommit),
|
||||
mode: readableActive(() => mode),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
// Composed Popover — the picker is an identity wrapper over it (§2).
|
||||
PopoverProvider.create({
|
||||
id: readableActive(() => `${id}-popover`),
|
||||
open: sharedOpen,
|
||||
modal: readableActive(() => mode === 'modal'),
|
||||
onOpenChangeComplete: readableActive(() => () => {})
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, picker.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,16 @@
|
||||
export { default as Provider } from './components/gradient-picker.svelte';
|
||||
export { default as Trigger } from './components/gradient-picker-trigger.svelte';
|
||||
export { default as ValueSwatch } from './components/gradient-picker-value-swatch.svelte';
|
||||
|
||||
export {
|
||||
GradientPickerProvider,
|
||||
GradientPickerTriggerProvider,
|
||||
GradientPickerValueSwatchProvider,
|
||||
DEFAULT_PICKER_GRADIENT
|
||||
} from './gradient-picker-provider.svelte';
|
||||
|
||||
export type {
|
||||
GradientPickerProviderProps as ProviderProps,
|
||||
GradientPickerTriggerProps as TriggerProps,
|
||||
GradientPickerValueSwatchProps as ValueSwatchProps
|
||||
} from './types';
|
||||
@ -0,0 +1,206 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { watch } from '$libs/reactive';
|
||||
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
|
||||
import { boolToEmptyStrOrUndef } from '$adom';
|
||||
import { type ActiveProps, type StateProps } from '$libs/reactive';
|
||||
import type { OnChangeFn } from '../../types';
|
||||
import { gradientToCss, type Gradient } from '$libs/gradient';
|
||||
import { pickerShellContext, type PickerShellHandle, type PickerShellMode } from '../picker-shell';
|
||||
|
||||
import { gradientPickerMorfo } from '../../../morfo/components/gradient-picker';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
|
||||
/** The fallback gradient the Clear action restores (matches GradientBuilder's default). */
|
||||
export const DEFAULT_PICKER_GRADIENT: Gradient = {
|
||||
kind: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ color: { kind: 'css', value: '#000000' } },
|
||||
{ color: { kind: 'css', value: '#ffffff' } }
|
||||
]
|
||||
};
|
||||
|
||||
// ── Root ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GradientPickerOpts
|
||||
extends ProviderOpts,
|
||||
StateProps<{ value: Gradient; open: boolean }>,
|
||||
ActiveProps<{
|
||||
disabled: boolean;
|
||||
mode: PickerShellMode;
|
||||
onValueCommit: OnChangeFn<Gradient>;
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* Headless GradientPicker — the popover-picker shell around the interactive
|
||||
* GradientBuilder. Owns the `open` state and the commit / cancel / clear
|
||||
* lifecycle (exposed to `<PickerShell.*>` via `pickerShellContext`). The
|
||||
* gradient editing itself flows through the composed GradientBuilder, which
|
||||
* binds the SAME `value` — so `cancel()` reverts to the value the popover
|
||||
* opened with.
|
||||
*/
|
||||
export class GradientPickerProvider {
|
||||
readonly opts: GradientPickerOpts;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly soma: Soma;
|
||||
static readonly ctx = context<GradientPickerProvider>('GradientPicker');
|
||||
static get(): GradientPickerProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as GradientPickerProvider | undefined;
|
||||
}
|
||||
static require(): GradientPickerProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: GradientPickerOpts) {
|
||||
return new GradientPickerProvider(opts);
|
||||
}
|
||||
|
||||
triggerNode = $state<HTMLElement | null>(null);
|
||||
|
||||
get isDisabled(): boolean {
|
||||
return this.opts.disabled.current;
|
||||
}
|
||||
|
||||
/** The committed gradient, serialized for the trigger swatch / value chip. */
|
||||
readonly css = $derived.by<string>(() => gradientToCss(this.opts.value.current));
|
||||
|
||||
/** The live gradient — read/written by the composed GradientBuilder body. */
|
||||
get value(): Gradient {
|
||||
return this.opts.value.current;
|
||||
}
|
||||
setValue(next: Gradient): void {
|
||||
this.opts.value.current = next;
|
||||
}
|
||||
|
||||
private constructor(opts: GradientPickerOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(gradientPickerMorfo, {
|
||||
states: { open: () => this.opts.open.current },
|
||||
props: { disabled: () => this.isDisabled }
|
||||
});
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: GradientPickerProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
// Snapshot the value on the opening edge so `cancel()` can revert.
|
||||
watch(
|
||||
() => this.opts.open.current,
|
||||
(now, prev) => {
|
||||
if (now && !prev) this.valueOnOpen = untrack(() => this.opts.value.current);
|
||||
}
|
||||
);
|
||||
|
||||
// Expose commit / cancel / clear to descendant `<PickerShell.*>` parts.
|
||||
pickerShellContext.set(this.pickerShellHandle);
|
||||
}
|
||||
|
||||
setTriggerNode(el: HTMLElement | null): void {
|
||||
this.triggerNode = el;
|
||||
}
|
||||
|
||||
// ── PickerShell lifecycle ────────────────────────────────────────────────
|
||||
private valueOnOpen: Gradient | undefined = undefined;
|
||||
|
||||
/** Save the current gradient and close (drives `<PickerShell.Close>`). */
|
||||
commit(): void {
|
||||
this.opts.open.current = false;
|
||||
this.opts.onValueCommit.current(this.opts.value.current);
|
||||
}
|
||||
|
||||
/** Revert to the value the popover opened with and close (`<PickerShell.Cancel>`). */
|
||||
cancel(): void {
|
||||
if (this.valueOnOpen !== undefined) this.opts.value.current = this.valueOnOpen;
|
||||
this.opts.open.current = false;
|
||||
}
|
||||
|
||||
/** Reset the gradient to the default without closing (`<PickerShell.Clear>`). */
|
||||
clear(): void {
|
||||
if (this.isDisabled) return;
|
||||
this.opts.value.current = DEFAULT_PICKER_GRADIENT;
|
||||
void this.runtime.trigger('commit-reset');
|
||||
}
|
||||
|
||||
readonly pickerShellHandle: PickerShellHandle = {
|
||||
getMode: () => this.opts.mode.current,
|
||||
commit: () => this.commit(),
|
||||
cancel: () => this.cancel(),
|
||||
clear: () => this.clear()
|
||||
};
|
||||
|
||||
get props() {
|
||||
return this.runtimePart.renderProps();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trigger ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GradientPickerTriggerOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{ ariaLabel: string | undefined }> {}
|
||||
|
||||
export class GradientPickerTriggerProvider {
|
||||
readonly opts: GradientPickerTriggerOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider = GradientPickerProvider.require();
|
||||
static create(opts: GradientPickerTriggerOpts) {
|
||||
return new GradientPickerTriggerProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: GradientPickerTriggerOpts) {
|
||||
this.opts = opts;
|
||||
this.runtimePart = this.provider.runtime.part('trigger', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
onRefChange: (v) => this.provider.setTriggerNode(v)
|
||||
});
|
||||
}
|
||||
|
||||
/** Stamp the live gradient so eidos paints the swatch purely via CSS. */
|
||||
readonly inlineStyle = $derived.by<string>(() => `--gp-current-gradient: ${this.provider.css}`);
|
||||
|
||||
get props() {
|
||||
const ariaLabel = this.opts.ariaLabel.current;
|
||||
return {
|
||||
...this.runtimePart.renderProps(),
|
||||
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
||||
disabled: this.provider.isDisabled || undefined,
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
|
||||
style: this.inlineStyle
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── ValueSwatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class GradientPickerValueSwatchProvider {
|
||||
readonly opts: WithRefOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider = GradientPickerProvider.require();
|
||||
static create(opts: WithRefOpts) {
|
||||
return new GradientPickerValueSwatchProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: WithRefOpts) {
|
||||
this.opts = opts;
|
||||
this.runtimePart = this.provider.runtime.part('value-swatch', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
});
|
||||
}
|
||||
|
||||
readonly backgroundStyle = $derived.by<string>(
|
||||
() => `background: ${this.provider.css}; --gp-current-gradient: ${this.provider.css};`
|
||||
);
|
||||
|
||||
get props() {
|
||||
return { ...this.runtimePart.renderProps(), style: this.backgroundStyle };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,37 @@
|
||||
import type {
|
||||
WithChild,
|
||||
Without,
|
||||
OnChangeFn,
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveButtonAttributes
|
||||
} from '../../types';
|
||||
import type { Gradient } from '$libs/gradient';
|
||||
import type { PickerShellMode } from '../picker-shell';
|
||||
|
||||
export type GradientPickerProviderProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** The gradient being edited. Bindable. */
|
||||
value?: Gradient;
|
||||
/** Called when the user commits (Save / Done). */
|
||||
onValueCommit?: OnChangeFn<Gradient>;
|
||||
/** Popover open state. Bindable. @default false */
|
||||
open?: boolean;
|
||||
/** Called when the open state changes. */
|
||||
onOpenChange?: OnChangeFn<boolean>;
|
||||
/** Footer layout mode — `inline` (clear/cancel) vs `modal` (cancel/save). @default 'inline' */
|
||||
mode?: PickerShellMode;
|
||||
/** @default false */
|
||||
disabled?: boolean;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type GradientPickerTriggerProps = WithChild<{
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type GradientPickerValueSwatchProps = WithChild<{
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* GradientPicker — the GradientBuilder wrapped in a Popover (trigger + chip +
|
||||
* Clear/Cancel/Save footer). Identity wrapper over the composed Popover.
|
||||
* Follows the v2 demo layout (shared harness).
|
||||
*/
|
||||
import {
|
||||
GradientPicker,
|
||||
type GradientPickerSize,
|
||||
type GradientPickerVariant
|
||||
} from '$uix/eidos/components/gradient-picker';
|
||||
import { gradientToCss, type Gradient } from '$libs/gradient';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { gradientPickerMorfo } from '@/uix/morfo/components/gradient-picker';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
|
||||
import SystemAxes from '../../lib/SystemAxes.svelte';
|
||||
import MotionPanel from '../../lib/MotionPanel.svelte';
|
||||
import SemaPanel from '../../lib/SemaPanel.svelte';
|
||||
import { DemoTrace } from '../../lib/harness.svelte';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const sizes: GradientPickerSize[] = ['sm', 'md', 'lg'];
|
||||
const variants: GradientPickerVariant[] = ['surface', 'outline', 'ghost'];
|
||||
|
||||
// ── Live (component API) state ──────────────────────────────────────────
|
||||
let size = $state<GradientPickerSize>('md');
|
||||
let variant = $state<GradientPickerVariant>('surface');
|
||||
let mode = $state<'inline' | 'modal'>('inline');
|
||||
let disabled = $state(false);
|
||||
let open = $state(false);
|
||||
let value = $state<Gradient>({
|
||||
kind: 'linear',
|
||||
angle: 45,
|
||||
stops: [
|
||||
{ color: { kind: 'css', value: '#ec4899' }, position: 0 },
|
||||
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
|
||||
]
|
||||
});
|
||||
const css = $derived(gradientToCss(value));
|
||||
|
||||
// ── System (foundation) axes — applied to the stage ─────────────────────
|
||||
let density = $state('comfortable');
|
||||
let scaling = $state(1);
|
||||
let stageMode = $state<'inherit' | 'light' | 'dark'>('inherit');
|
||||
let dir = $state<'ltr' | 'rtl'>('ltr');
|
||||
let borderWidth = $state(1);
|
||||
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
|
||||
|
||||
// ── Live trace (data-event) ─────────────────────────────────────────────
|
||||
const trace = new DemoTrace();
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
if (stageRef) return trace.observe(stageRef);
|
||||
});
|
||||
|
||||
// ── Compiled morfo ──────────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(gradientPickerMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const actions = $derived([...compiled.actions.byName.values()]);
|
||||
|
||||
const snippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { GradientPicker } from '$uix/eidos/components/gradient-picker';",
|
||||
' let value = $state({ kind: "linear", angle: 45, stops: [...] });',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<GradientPicker',
|
||||
' bind:value',
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
variant !== 'surface' && ` variant="${variant}"`,
|
||||
mode !== 'inline' && ` mode="${mode}"`,
|
||||
disabled && ' disabled',
|
||||
'>',
|
||||
' <GradientPicker.Trigger />',
|
||||
' <GradientPicker.Portal>',
|
||||
' <GradientPicker.Content />',
|
||||
' </GradientPicker.Portal>',
|
||||
'</GradientPicker>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Controls · GradientPicker</div>
|
||||
<h1 data-uix-page-title>GradientPicker</h1>
|
||||
<p data-uix-page-lede>
|
||||
The <a href="/uix/components/gradient-builder">GradientBuilder</a> wrapped in a Popover —
|
||||
a field-shaped trigger carrying a live gradient chip opens the editor, with a
|
||||
Clear / Cancel / Save footer. An identity wrapper over the composed Popover (the Popover is
|
||||
the dialog); Cancel reverts to the value the popover opened with.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{actions.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>{sizes.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>{variants.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>model</span>$libs/gradient</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live preview always rendered, with the System axes applied to the stage -->
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
data-density={density}
|
||||
data-theme={stageMode === 'inherit' ? undefined : stageMode}
|
||||
data-mode={stageMode === 'inherit' ? undefined : stageMode}
|
||||
dir={dir}
|
||||
style={stageStyle}
|
||||
>
|
||||
<GradientPicker bind:value bind:open {size} {variant} {mode} {disabled}>
|
||||
<GradientPicker.Trigger />
|
||||
<GradientPicker.Portal>
|
||||
<GradientPicker.Content />
|
||||
</GradientPicker.Portal>
|
||||
</GradientPicker>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.entries.length === 0}
|
||||
<span>open the picker, edit, then Save / Cancel</span>
|
||||
{:else}
|
||||
{#each trace.entries.slice(0, 3) as entry}
|
||||
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>open</span> {open ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> System
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>Motion</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span><span data-uix-tab-count>{actions.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>Services</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span><span data-uix-tab-count>{partsList.length}p · {actions.length}e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
The component API. Like GradientBuilder it has no <code>color</code> prop. Foundation
|
||||
axes live in the <button data-uix-linklike onclick={() => (tab = 'system')}>System</button> tab.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> size · variant
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each sizes as s}
|
||||
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>variant <span data-uix-control-hint>trigger framing</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each variants as v}
|
||||
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="soma">soma</span> behavior
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>mode <span data-uix-control-hint>footer layout</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['inline', 'modal'] as m}
|
||||
<button data-uix-chip data-active={mode === m} onclick={() => (mode = m as typeof mode)}>{m}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>disabled</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>{disabled ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>open</span>
|
||||
<span data-uix-switch><input type="checkbox" bind:checked={open} /><span data-uix-switch-label>{open ? 'on' : 'off'}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>committed value · <code>gradientToCss(value)</code></div>
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">$libs/gradient</span>
|
||||
<span>the bound gradient, serialized</span>
|
||||
<span data-uix-code-lang>css</span>
|
||||
</div>
|
||||
<pre><code>background: {css};</code></pre>
|
||||
</div>
|
||||
<div style={`block-size: 3rem; border-radius: var(--uix-radius-md, 8px); border: 1px solid var(--color-border-subtle); background: ${css};`}></div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · trigger + popover</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{snippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'system'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
|
||||
<p data-uix-section-desc>
|
||||
Foundation knobs applied to the stage. Open the picker and switch mode to preview the
|
||||
popover in dark.
|
||||
</p>
|
||||
<SystemAxes bind:density bind:scaling bind:mode={stageMode} bind:dir bind:borderWidth />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'motion'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Motion catalog</h2>
|
||||
<p data-uix-section-desc>
|
||||
The picker's motion is the composed Popover's enter / exit (the emerge firma) plus the
|
||||
editor's direct-manipulation feedback. The catalog teaches the system presets.
|
||||
</p>
|
||||
<MotionPanel note="The popover open/close is owned by the composed Popover (emerge family). The editor inside is pure direct manipulation." />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> firma + sound</h2>
|
||||
<p data-uix-section-desc>
|
||||
The picker declares one value event — <code>commit-reset</code> (the Clear action). The
|
||||
editing firma (handle / commit) lives on the embedded GradientBuilder; the open / close
|
||||
firma lives on the composed Popover. Turn on <strong>Semantics → Sound</strong>, then ▶ play.
|
||||
</p>
|
||||
<SemaPanel {actions} {uix} getTarget={() => stageRef?.querySelector('[data-gradient-picker]') ?? stageRef} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'services'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Services</h2>
|
||||
<p data-uix-section-desc>
|
||||
GradientPicker consumes <strong>langs</strong> (i18n): the trigger / content labels +
|
||||
the footer Clear / Cancel / Save (from the shared picker-shell). Switch language in the
|
||||
topbar to see them follow.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Text key</th><th>Default</th><th>Service</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">label</td><td class="type">Gradient picker</td><td>langs</td></tr>
|
||||
<tr><td class="name">trigger</td><td class="type">Open gradient editor</td><td>langs</td></tr>
|
||||
<tr><td class="name">content</td><td class="type">Gradient editor</td><td>langs</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">value</td><td class="type">Gradient</td><td class="default">2-stop linear</td><td>The committed gradient. Bindable.</td></tr>
|
||||
<tr><td class="name">open</td><td class="type">boolean</td><td class="default">false</td><td>Popover open state. Bindable.</td></tr>
|
||||
<tr><td class="name">mode</td><td class="type">'inline' | 'modal'</td><td class="default">'inline'</td><td>Footer layout (Clear/Cancel vs Cancel/Save).</td></tr>
|
||||
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Trigger / popover / editor size.</td></tr>
|
||||
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Trigger framing.</td></tr>
|
||||
<tr><td class="name">onValueCommit</td><td class="type">(g: Gradient) => void</td><td class="default empty">—</td><td>Fires on Save.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
|
||||
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/gradient-picker.ts</code> · <code>expression: 'delegated'</code> (the Popover is the dialog).</p>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Events</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>target</th></tr></thead>
|
||||
<tbody>
|
||||
{#each actions as action}
|
||||
{@const sem = action.semantic}
|
||||
<tr>
|
||||
<td class="name">{action.name}</td>
|
||||
<td class="type">{sem.family}</td>
|
||||
<td>{sem.verb ?? '—'}</td>
|
||||
<td>{sem.sequence ?? 'pre'}</td>
|
||||
<td>{action.target}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Selectors at <code>src/uix/eidos/components/gradient-picker/gradient-picker.css</code>.
|
||||
The trigger is a field-shaped pill carrying a gradient chip (<code>--gp-current-gradient</code>,
|
||||
stamped inline by soma); the popover content + editor reuse the Popover + PickerShell +
|
||||
GradientBuilder recipes.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">[data-gradient-picker]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-gradient-picker][data-size='X'] / [data-variant='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-gradient-picker-trigger][data-state='open']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">.gradient-picker-trigger-swatch</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-gradient-picker-content]</td><td><span data-uix-tag data-kind="soma">morfo</span> + Popover</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<p data-uix-section-desc>
|
||||
The trigger is <code>aria-haspopup="dialog"</code> + <code>aria-expanded</code>; the
|
||||
Popover provides the <code>role="dialog"</code>, focus trap and Escape-to-close. Inside,
|
||||
the GradientBuilder's keyboard-accessible stops apply.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Key</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Open the editor (on the trigger)</td></tr>
|
||||
<tr><td><span data-uix-kbd>Escape</span></td><td>Close the popover</td></tr>
|
||||
<tr><td><span data-uix-kbd>Tab</span></td><td>Move focus within the editor</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-uix-linklike] {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--uix-accent-text);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue