feat(picker-shell): extract shared Footer/Clear/Cancel/Close + apply to color-picker

Introduces PickerShell — a soma context + eidos parts shared by every
composite picker (date, time, color, future range pickers). Aligns with
norm N-8 (reuse components across complex composites) so the
Footer/Clear/Cancel/Close behavior lives in ONE place instead of being
duplicated per picker family.

New files
---------
- src/uix/soma/components/picker-shell/
    picker-shell-handle.svelte.ts  → pickerShellContext +
                                     PickerShellHandle interface
                                     (getMode / commit / cancel / clear).
    exports.ts, index.ts            → public surface.
- src/uix/eidos/components/picker-shell/
    picker-shell-footer.svelte      → [data-picker-footer][data-mode]
    picker-shell-clear.svelte       → [data-picker-clear][data-action]
    picker-shell-cancel.svelte      → [data-picker-cancel][data-action]
    picker-shell-close.svelte       → [data-picker-close][data-action]
    picker-shell.css                → canonical action-row recipe.
    index.ts, types.ts              → PickerShell namespace + prop types.
- Registered in src/uix/eidos/index.css.

Color-picker now consumes the shell
-----------------------------------
- ColorPickerProvider gets `mode: 'inline' | 'modal'` opt + a
  `valueOnOpen` snapshot via watch(open) so cancel() can revert.
- New `commit()` / `cancel()` / `clear()` methods with shell-picker
  semantics. The previous `commit()` (fires onValueChangeEnd) was
  renamed `commitChange()` since the picker-shell `commit()` means
  "save + close". All internal callers (area-thumb pointerup,
  swatch click, eyedropper open, channel-slider drop) updated.
- Constructor registers the handle:
  `pickerShellContext.set(this.pickerShellHandle)`.
- PopoverProvider receives `modal: mode === 'modal'` so outside-click
  + Escape get blocked when modal.
- Eidos `ColorPicker.Footer / .Clear / .Cancel / .Close` re-export
  the shared parts. The old `ColorPicker.Close = PopoverClose` was
  renamed `ColorPicker.PopoverClose`; the canonical `Close` is now
  the picker-shell save action.

Demo
----
- New `mode` chip (inline | modal).
- Footer composition switches (render footer / clear / cancel / close).
- Renders <ColorPicker.Footer> with shell parts inside <Content>.

Visual fixes
------------
- Horizontal overflow on the popover content: added `min-inline-size: 0
  / max-inline-size: 100% / box-sizing: border-box / overflow-x: hidden`
  on `[data-popover-content][data-color-picker-content]` and on its
  direct children. Inner ChannelInput allowed to wrap segments instead
  of pushing the popover wider.
- ColorField segments inside the popover gain `flex: 0 1 auto`.

Norms
-----
- N-8 case study appended to PENDIENTES.md documenting the
  PickerShell extraction alongside the channel-slider refactor.

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

Follow-up (task #81): migrate date-picker / time-picker Footer to use
PickerShell too — drop the duplicate `date-picker-footer/clear/
cancel/close.svelte` and `time-picker-footer/clear/cancel/close.svelte`
files; update CSS selectors to `[data-picker-footer/...]`.

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

@ -200,3 +200,21 @@ behavior queda en una sola función bridge (`setChannel` + gradient
inline en `--cp-channel-gradient`). Resultado: −200 líneas en soma, inline en `--cp-channel-gradient`). Resultado: −200 líneas en soma,
eventos `slider:handle-pick/drag/commit-set` propagados, tokens eventos `slider:handle-pick/drag/commit-set` propagados, tokens
unificados con time-picker / standalone Slider. unificados con time-picker / standalone Slider.
Segundo caso 2026-05-21 — **PickerShell**. El Footer + Clear/Cancel/
Close de los pickers eran cuatro archivos `.svelte` casi idénticos
por picker (date-picker, time-picker, color-picker). Extracción a:
- `src/uix/soma/components/picker-shell/` — `pickerShellContext` +
`PickerShellHandle` interface (`getMode / commit / cancel / clear`).
- `src/uix/eidos/components/picker-shell/` — parts compartidos
`<PickerShell.Footer/Clear/Cancel/Close>` que leen el handle.
- `src/uix/eidos/components/picker-shell/picker-shell.css` — CSS
canónica `[data-picker-footer/clear/cancel/close]`.
Cada provider registra su handle en el context via
`pickerShellContext.set(this.pickerShellHandle)`. El namespace de cada
picker eidos re-exporta `X.Footer/Clear/Cancel/Close` apuntando a los
componentes compartidos — la API pública no cambia. Aplicado al
ColorPicker en este commit. Migración pendiente para date-picker y
time-picker (issue separado).

@ -99,6 +99,19 @@
flex-direction: column; flex-direction: column;
gap: var(--color-picker-content-gap); gap: var(--color-picker-content-gap);
padding: var(--color-picker-content-padding-md); padding: var(--color-picker-content-padding-md);
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
/* All direct rows of the picker content (area, sliders, channel input,
swatches, footer, etc.) must shrink to fit the popover width — never
force horizontal scroll. */
[data-popover-content][data-color-picker-content] > * {
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
} }
/* ── Trigger ─────────────────────────────────────────────────────────────── */ /* ── Trigger ─────────────────────────────────────────────────────────────── */
@ -457,10 +470,22 @@
[data-popover-content][data-color-picker-content] [data-color-field-input] { [data-popover-content][data-color-picker-content] [data-color-field-input] {
background: var(--color-surface-overlay); background: var(--color-surface-overlay);
border-color: transparent; border-color: transparent;
inline-size: 100%;
max-inline-size: 100%;
min-inline-size: 0;
overflow-x: hidden;
box-sizing: border-box;
flex-wrap: wrap;
}
[data-popover-content][data-color-picker-content] [data-color-field-segment] {
flex: 0 1 auto;
min-inline-size: 1ch;
} }
[data-popover-content][data-color-picker-content] [data-color-field-format-select] { [data-popover-content][data-color-picker-content] [data-color-field-format-select] {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
margin-inline-start: auto;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

@ -24,6 +24,12 @@ import SwatchIndicator from './color-picker-swatch-indicator.svelte'
import ChannelInput from './color-picker-channel-input.svelte' import ChannelInput from './color-picker-channel-input.svelte'
import ChannelSegment from './color-picker-channel-segment.svelte' import ChannelSegment from './color-picker-channel-segment.svelte'
import FormatSelect from './color-picker-format-select.svelte' import FormatSelect from './color-picker-format-select.svelte'
// Footer / Clear / Cancel / Close are the canonical PickerShell parts —
// they read the picker's `commit/cancel/clear` via `pickerShellContext`.
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 Anchor from '../popover/popover-anchor.svelte' import Anchor from '../popover/popover-anchor.svelte'
import Overlay from '../popover/popover-overlay.svelte' import Overlay from '../popover/popover-overlay.svelte'
import Arrow from '../popover/popover-arrow.svelte' import Arrow from '../popover/popover-arrow.svelte'
@ -42,7 +48,7 @@ type ColorPickerNamespace = typeof ColorPickerComponent & {
Overlay: typeof Overlay Overlay: typeof Overlay
Content: typeof Content Content: typeof Content
Arrow: typeof Arrow Arrow: typeof Arrow
Close: typeof PopoverClose PopoverClose: typeof PopoverClose
Area: typeof Area Area: typeof Area
AreaBackground: typeof AreaBackground AreaBackground: typeof AreaBackground
AreaThumb: typeof AreaThumb AreaThumb: typeof AreaThumb
@ -58,6 +64,11 @@ type ColorPickerNamespace = typeof ColorPickerComponent & {
ChannelInput: typeof ChannelInput ChannelInput: typeof ChannelInput
ChannelSegment: typeof ChannelSegment ChannelSegment: typeof ChannelSegment
FormatSelect: typeof FormatSelect FormatSelect: typeof FormatSelect
// PickerShell parts (the canonical Footer + actions).
Footer: typeof Footer
Clear: typeof Clear
Cancel: typeof Cancel
Close: typeof PickerCloseAction
} }
const ColorPicker = ColorPickerComponent as ColorPickerNamespace const ColorPicker = ColorPickerComponent as ColorPickerNamespace
@ -72,7 +83,7 @@ ColorPicker.Anchor = Anchor
ColorPicker.Overlay = Overlay ColorPicker.Overlay = Overlay
ColorPicker.Content = Content ColorPicker.Content = Content
ColorPicker.Arrow = Arrow ColorPicker.Arrow = Arrow
ColorPicker.Close = PopoverClose ColorPicker.PopoverClose = PopoverClose
ColorPicker.Area = Area ColorPicker.Area = Area
ColorPicker.AreaBackground = AreaBackground ColorPicker.AreaBackground = AreaBackground
ColorPicker.AreaThumb = AreaThumb ColorPicker.AreaThumb = AreaThumb
@ -88,6 +99,10 @@ ColorPicker.SwatchIndicator = SwatchIndicator
ColorPicker.ChannelInput = ChannelInput ColorPicker.ChannelInput = ChannelInput
ColorPicker.ChannelSegment = ChannelSegment ColorPicker.ChannelSegment = ChannelSegment
ColorPicker.FormatSelect = FormatSelect ColorPicker.FormatSelect = FormatSelect
ColorPicker.Footer = Footer
ColorPicker.Clear = Clear
ColorPicker.Cancel = Cancel
ColorPicker.Close = PickerCloseAction
export { ColorPicker } export { ColorPicker }

@ -0,0 +1,43 @@
import Footer from './picker-shell-footer.svelte'
import Clear from './picker-shell-clear.svelte'
import Cancel from './picker-shell-cancel.svelte'
import Close from './picker-shell-close.svelte'
/**
* PickerShell — generic Footer + action row shared by every composite
* picker that registers a `PickerShellHandle` via `pickerShellContext`.
*
* import { PickerShell } from '$uix/eidos/components/picker-shell';
*
* <PickerShell.Footer>
* <PickerShell.Clear />
* <PickerShell.Cancel />
* <PickerShell.Close />
* </PickerShell.Footer>
*
* Each composite picker re-exports these as `X.Footer / X.Clear / …`
* for namespace consistency:
*
* <ColorPicker.Footer>
* <ColorPicker.Clear />
* <ColorPicker.Cancel />
* <ColorPicker.Close />
* </ColorPicker.Footer>
*
* is the same render — both reach the host picker through context.
*/
export const PickerShell = {
Footer,
Clear,
Cancel,
Close
}
export { Footer, Clear, Cancel, Close }
export type {
PickerShellFooterProps as FooterProps,
PickerShellClearProps as ClearProps,
PickerShellCancelProps as CancelProps,
PickerShellCloseProps as CloseProps
} from './types'

@ -0,0 +1,30 @@
<script lang="ts">
import { getPickerShellHandle } from '$soma/components/picker-shell';
import { getActiveUix } from '$active-uix';
import type { PickerShellCancelProps } from './types';
// Cancel action — reverts to the value snapshot taken on the OPEN edge
// and closes the popover. Reads the picker's handle from
// `pickerShellContext`.
const { children, onclick, ...rest }: PickerShellCancelProps = $props();
const uix = getActiveUix();
const shell = getPickerShellHandle();
const label = uix.langs.ts('#?components.picker-shell.cancel|Cancel');
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.cancel();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-picker-cancel
data-action="cancel"
aria-label={label}
onclick={handleClick}
{...rest}
>
{#if children}{@render children()}{:else}{label}{/if}
</button>

@ -0,0 +1,31 @@
<script lang="ts">
import { getPickerShellHandle } from '$soma/components/picker-shell';
import { getActiveUix } from '$active-uix';
import type { PickerShellClearProps } from './types';
// Clear action — resets the host picker's value to undefined and keeps
// the popover open. Reads the picker's `commit() / cancel() / clear()`
// handle from `pickerShellContext`; any picker that registers the
// handle gets this button working with zero per-component code.
const { children, onclick, ...rest }: PickerShellClearProps = $props();
const uix = getActiveUix();
const shell = getPickerShellHandle();
const label = uix.langs.ts('#?components.picker-shell.clear|Clear');
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.clear();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-picker-clear
data-action="clear"
aria-label={label}
onclick={handleClick}
{...rest}
>
{#if children}{@render children()}{:else}{label}{/if}
</button>

@ -0,0 +1,30 @@
<script lang="ts">
import { getPickerShellHandle } from '$soma/components/picker-shell';
import { getActiveUix } from '$active-uix';
import type { PickerShellCloseProps } from './types';
// Close / Save action — commits the current value and closes the
// popover. In `mode='modal'` the consumer MUST include this part
// because outside-click and Escape are blocked.
const { children, onclick, ...rest }: PickerShellCloseProps = $props();
const uix = getActiveUix();
const shell = getPickerShellHandle();
const label = uix.langs.ts('#?components.picker-shell.close|Done');
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.commit();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-picker-close
data-action="close"
aria-label={label}
onclick={handleClick}
{...rest}
>
{#if children}{@render children()}{:else}{label}{/if}
</button>

@ -0,0 +1,17 @@
<script lang="ts">
import { getPickerShellHandle } from '$soma/components/picker-shell';
import type { PickerShellFooterProps } from './types';
// Generic footer container for any composite picker that exposes a
// `PickerShellHandle` via context. Pure composition — visibility is the
// consumer's responsibility (omit the part to hide it, omit children to
// hide buttons inside).
let { children, ...rest }: PickerShellFooterProps = $props();
const shell = getPickerShellHandle();
const mode = $derived(shell?.getMode() ?? 'inline');
</script>
<div data-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,82 @@
/* PickerShell — visual layer for the canonical Footer + action row
* shared by every composite picker (date, time, color, range).
*
* The host picker scopes us via the parent `[data-{x}-picker]` /
* `[data-{x}-picker-content]` element, but the action selectors
* (`[data-picker-footer/clear/cancel/close]`) are intentionally generic
* so every picker gets the same visual contract for free.
*/
[data-picker-footer] {
display: flex;
align-items: center;
gap: var(--space-2);
padding-block-start: var(--space-3);
margin-block-start: var(--space-3);
border-block-start: var(--border-width) solid var(--color-border-subtle);
}
[data-picker-clear],
[data-picker-cancel] {
margin-inline-end: auto;
}
[data-picker-clear],
[data-picker-cancel],
[data-picker-close] {
inline-size: max-content;
min-block-size: var(--control-height-sm);
padding-inline: var(--space-3);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
font: inherit;
font-size: var(--font-size-sm);
line-height: var(--leading-ui);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-picker-clear]:hover,
[data-picker-cancel]:hover {
background: var(--color-surface-overlay);
}
[data-picker-close] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
[data-picker-close]:hover {
background: var(--color-primary-solid-hover);
}
[data-picker-clear]:focus-visible,
[data-picker-cancel]:focus-visible,
[data-picker-close]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
[data-picker-clear][data-disabled],
[data-picker-cancel][data-disabled],
[data-picker-close][data-disabled],
[data-picker-clear]:disabled,
[data-picker-cancel]:disabled,
[data-picker-close]:disabled {
cursor: not-allowed;
opacity: 0.56;
}
@media (prefers-reduced-motion: reduce) {
[data-picker-clear],
[data-picker-cancel],
[data-picker-close] {
transition: none;
}
}

@ -0,0 +1,22 @@
import type { Snippet } from 'svelte'
import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements'
/** Props for `<PickerShell.Footer>`. */
export type PickerShellFooterProps = HTMLAttributes<HTMLDivElement> & {
children?: Snippet
}
/** Props for `<PickerShell.Clear>`. */
export type PickerShellClearProps = HTMLButtonAttributes & {
children?: Snippet
}
/** Props for `<PickerShell.Cancel>`. */
export type PickerShellCancelProps = HTMLButtonAttributes & {
children?: Snippet
}
/** Props for `<PickerShell.Close>`. */
export type PickerShellCloseProps = HTMLButtonAttributes & {
children?: Snippet
}

@ -91,6 +91,7 @@
@import './components/date-range-picker/date-range-picker.css'; @import './components/date-range-picker/date-range-picker.css';
@import './components/time-field/time-field.css'; @import './components/time-field/time-field.css';
@import './components/color-field/color-field.css'; @import './components/color-field/color-field.css';
@import './components/picker-shell/picker-shell.css';
@import './components/time-picker/time-picker.css'; @import './components/time-picker/time-picker.css';
@import './components/color-picker/color-picker.css'; @import './components/color-picker/color-picker.css';
@import './components/slider/slider.css'; @import './components/slider/slider.css';

@ -76,6 +76,7 @@ function createColorPickerOpts() {
enableAlpha: state(true), enableAlpha: state(true),
closeOnSelect: state(false), closeOnSelect: state(false),
inline: state(false), inline: state(false),
mode: state<'inline' | 'modal'>('inline'),
areaChannels: state<{ x: ColorChannel; y: ColorChannel }>({ areaChannels: state<{ x: ColorChannel; y: ColorChannel }>({
x: 'saturation', x: 'saturation',
y: 'brightness' y: 'brightness'
@ -219,7 +220,7 @@ describe('ColorPickerProvider', () => {
result.thumb.props.onkeydown(keyEvent('ArrowRight') as never); result.thumb.props.onkeydown(keyEvent('ArrowRight') as never);
expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(26); expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(26);
result.provider.commit(); result.provider.commitChange();
expect(onCommit).toHaveBeenCalledWith(opts.value.current); expect(onCommit).toHaveBeenCalledWith(opts.value.current);
cleanup(); cleanup();
@ -279,7 +280,7 @@ describe('ColorPickerProvider', () => {
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180); expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180);
// And what onValueCommit calls when the user releases the pointer. // And what onValueCommit calls when the user releases the pointer.
result.provider.commit(); result.provider.commitChange();
expect(onCommit).toHaveBeenCalledWith(opts.value.current); expect(onCommit).toHaveBeenCalledWith(opts.value.current);
cleanup(); cleanup();

@ -16,6 +16,11 @@ import {
type EditableColorSegmentPart type EditableColorSegmentPart
} from '$libs/color'; } from '$libs/color';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field/field-provider.svelte';
import {
pickerShellContext,
type PickerShellHandle,
type PickerShellMode
} from '../picker-shell';
import { COLOR_PICKER_LANGS } from './langs'; import { COLOR_PICKER_LANGS } from './langs';
import type { ColorChannel } from './types'; import type { ColorChannel } from './types';
import type { ColorValidator, ColorOnInvalid } from '../color-field/types'; import type { ColorValidator, ColorOnInvalid } from '../color-field/types';
@ -133,6 +138,7 @@ export interface ColorPickerOpts
enableAlpha: boolean; enableAlpha: boolean;
closeOnSelect: boolean; closeOnSelect: boolean;
inline: boolean; inline: boolean;
mode: PickerShellMode;
areaChannels: { x: ColorChannel; y: ColorChannel }; areaChannels: { x: ColorChannel; y: ColorChannel };
readonlySegments: EditableColorSegmentPart[]; readonlySegments: EditableColorSegmentPart[];
validate: ColorValidator | undefined; validate: ColorValidator | undefined;
@ -181,6 +187,16 @@ export class ColorPickerProvider {
context: ColorPickerProvider.ctx context: ColorPickerProvider.ctx
}); });
// Capture 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);
}
}
);
// Fire `onValueChangeEnd` only on committed transitions (drag end / swatch // Fire `onValueChangeEnd` only on committed transitions (drag end / swatch
// click). `onValueChange` fires on every intermediate change via the // click). `onValueChange` fires on every intermediate change via the
// `writableActive` setter in the root wrapper. // `writableActive` setter in the root wrapper.
@ -195,6 +211,10 @@ export class ColorPickerProvider {
} }
} }
); );
// Expose our commit/cancel/clear/mode handle to descendant Eidos
// parts (`<PickerShell.Footer>`, `<PickerShell.Clear>`, etc.).
pickerShellContext.set(this.pickerShellHandle);
} }
// ── Merged flags ──────────────────────────────────────────────────────── // ── Merged flags ────────────────────────────────────────────────────────
@ -258,8 +278,14 @@ export class ColorPickerProvider {
this.opts.value.current = color; this.opts.value.current = color;
} }
/** Fire `onValueChangeEnd` for commit transitions (drag end, swatch select, etc). */ /**
commit(): void { * Fire `onValueChangeEnd` for value-commit transitions (drag end on a
* channel slider / area thumb, swatch click, eyedropper pick, …).
*
* Distinct from `commit()` below, which closes the popover with the
* current value (Save/Done — invoked by `<PickerShell.Close>`).
*/
commitChange(): void {
this.opts.onValueChangeEnd.current?.(this.opts.value.current); this.opts.onValueChangeEnd.current?.(this.opts.value.current);
} }
@ -270,6 +296,47 @@ export class ColorPickerProvider {
} }
} }
// ── PickerShell handle (mode + commit/cancel/clear) ──────────────────────
/**
* Snapshot of `value` captured on the OPEN edge. Used by `cancel()` to
* revert any mid-session edits back to the value the popover opened with.
*/
private valueOnOpen: ColorValue | undefined = undefined;
/**
* Save the current value and close the popover. Drives `<X.Close>` in
* the canonical picker footer.
*/
commit(): void {
this.opts.open.current = false;
this.commitChange();
}
/**
* Discard any edits made since the popover opened and close. Drives
* `<X.Cancel>` in the footer.
*/
cancel(): void {
this.opts.value.current = this.valueOnOpen;
this.opts.open.current = false;
}
/**
* Reset value to undefined without closing the popover. Drives
* `<X.Clear>` in the footer.
*/
clear(): void {
this.opts.value.current = undefined;
}
readonly pickerShellHandle: PickerShellHandle = {
getMode: () => this.opts.mode.current,
commit: () => this.commit(),
cancel: () => this.cancel(),
clear: () => this.clear()
};
// ── Node registration ─────────────────────────────────────────────────── // ── Node registration ───────────────────────────────────────────────────
setTriggerNode(el: HTMLElement | null) { setTriggerNode(el: HTMLElement | null) {
@ -645,7 +712,7 @@ export class ColorPickerAreaProvider {
readonly onpointerup = (e: PointerEvent): void => { readonly onpointerup = (e: PointerEvent): void => {
if (this.pointerId !== e.pointerId) return; if (this.pointerId !== e.pointerId) return;
this.pointerId = null; this.pointerId = null;
this.provider.commit(); this.provider.commitChange();
}; };
readonly props = $derived.by(() => readonly props = $derived.by(() =>
@ -975,7 +1042,7 @@ export class ColorPickerSwatchTriggerProvider {
const hsv = parseColor(this.opts.color.current); const hsv = parseColor(this.opts.color.current);
if (!hsv) return; if (!hsv) return;
this.provider.opts.value.current = colorValueFromHsv(hsv); this.provider.opts.value.current = colorValueFromHsv(hsv);
this.provider.commit(); this.provider.commitChange();
this.provider.handleSelect(); this.provider.handleSelect();
}; };
@ -1109,7 +1176,7 @@ export class ColorPickerEyeDropperProvider {
const hsv = parseColor(sRGBHex); const hsv = parseColor(sRGBHex);
if (!hsv) return; if (!hsv) return;
this.provider.opts.value.current = colorValueFromHsv(hsv); this.provider.opts.value.current = colorValueFromHsv(hsv);
this.provider.commit(); this.provider.commitChange();
this.provider.handleSelect(); this.provider.handleSelect();
} catch { } catch {
// User cancelled the picker — silent. // User cancelled the picker — silent.

@ -69,7 +69,7 @@
if (v[0] !== undefined) cp.setChannel(channel, v[0]); if (v[0] !== undefined) cp.setChannel(channel, v[0]);
} }
), ),
onValueCommit: readableActive(() => () => cp.commit()), onValueCommit: readableActive(() => () => cp.commitChange()),
min: readableActive(() => range.min), min: readableActive(() => range.min),
max: readableActive(() => range.max), max: readableActive(() => range.max),
step: readableActive(() => stepForChannel), step: readableActive(() => stepForChannel),

@ -33,6 +33,7 @@
onOpenChangeComplete = () => {}, onOpenChangeComplete = () => {},
inline = false, inline = false,
closeOnSelect = false, closeOnSelect = false,
mode = 'inline',
disabled = false, disabled = false,
readonly = false, readonly = false,
required = false, required = false,
@ -143,6 +144,7 @@
enableAlpha: readableActive(() => enableAlpha), enableAlpha: readableActive(() => enableAlpha),
closeOnSelect: readableActive(() => closeOnSelect), closeOnSelect: readableActive(() => closeOnSelect),
inline: readableActive(() => inline), inline: readableActive(() => inline),
mode: readableActive(() => mode),
areaChannels: readableActive( areaChannels: readableActive(
() => areaChannels ?? { x: 'saturation' as const, y: 'brightness' as const } () => areaChannels ?? { x: 'saturation' as const, y: 'brightness' as const }
), ),
@ -168,7 +170,7 @@
id: readableActive(() => `${id}-popover`), id: readableActive(() => `${id}-popover`),
open: sharedOpen, open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete), onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => false) modal: readableActive(() => mode === 'modal')
}); });
} }

@ -103,6 +103,13 @@ export type ColorPickerProviderProps = WithChild<{
inline?: boolean; inline?: boolean;
/** Auto-close after the user selects a swatch / eyedropper color. @default false */ /** Auto-close after the user selects a swatch / eyedropper color. @default false */
closeOnSelect?: boolean; closeOnSelect?: boolean;
/**
* Picker interaction mode. `inline` (default) closes the popover on
* outside-click + Escape. `modal` blocks them; the user must exit
* via `<X.Close>` (commit) or `<X.Cancel>` (revert) in the footer.
* @default 'inline'
*/
mode?: import('../picker-shell').PickerShellMode;
// Flags // Flags
disabled?: boolean; disabled?: boolean;

@ -0,0 +1,9 @@
// PickerShell — shared popover-picker semantics (mode + commit/cancel/clear)
// consumed by date-picker, time-picker, color-picker, date-range-picker, ….
export {
pickerShellContext,
getPickerShellHandle,
type PickerShellHandle,
type PickerShellMode
} from './picker-shell-handle.svelte';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,38 @@
import { context } from '../../provider';
/**
* PickerShell — shared semantics across composite pickers (date-picker,
* time-picker, color-picker, date-range-picker, …).
*
* Any picker whose popover follows the canonical contract — `mode='inline'
* | 'modal'`, `commit() / cancel() / clear()` helpers, a Footer + action
* row — exposes its handle to descendants through this context. The
* Eidos `<PickerShell.Footer>`, `<PickerShell.Clear>`, `<PickerShell.Cancel>`
* and `<PickerShell.Close>` parts read it; that way the same buttons work
* for every picker without duplicated `data-{component}-footer/clear/...`
* components per family.
*
* Norm N-8: reuse the same component across complex composites.
*/
export type PickerShellMode = 'inline' | 'modal';
export interface PickerShellHandle {
/** Current interaction mode. `modal` blocks outside-click + Escape. */
getMode(): PickerShellMode;
/** Close the popover preserving the current value (Save / Done). */
commit(): void;
/**
* Revert to the value snapshot captured on the OPEN edge and close.
* Cancels any mid-session edits.
*/
cancel(): void;
/** Reset value to undefined. Does NOT close — pair with Close if needed. */
clear(): void;
}
export const pickerShellContext = context<PickerShellHandle>('PickerShell');
/** Convenience accessor for component code that needs the handle (or `null`). */
export function getPickerShellHandle(): PickerShellHandle | null {
return pickerShellContext.getOr(null as unknown as PickerShellHandle);
}

@ -45,6 +45,12 @@
let open = $state(false); let open = $state(false);
let inline = $state(false); let inline = $state(false);
let closeOnSelect = $state(false); let closeOnSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
// Footer composition switches
let showFooter = $state(true);
let showClear = $state(true);
let showCancel = $state(true);
let showClose = $state(true);
let disabled = $state(false); let disabled = $state(false);
let readonly = $state(false); let readonly = $state(false);
let enableAlpha = $state(true); let enableAlpha = $state(true);
@ -218,6 +224,7 @@
bind:format bind:format
bind:open bind:open
{inline} {inline}
{mode}
{closeOnSelect} {closeOnSelect}
{disabled} {disabled}
{readonly} {readonly}
@ -264,6 +271,14 @@
<div style="display: flex; gap: var(--uix-space-2); align-items: center; justify-content: flex-end;"> <div style="display: flex; gap: var(--uix-space-2); align-items: center; justify-content: flex-end;">
<ColorPicker.EyeDropper /> <ColorPicker.EyeDropper />
</div> </div>
{#if showFooter && (showClear || showCancel || showClose)}
<ColorPicker.Footer>
{#if showClear}<ColorPicker.Clear />{/if}
{#if showCancel}<ColorPicker.Cancel />{/if}
{#if showClose}<ColorPicker.Close />{/if}
</ColorPicker.Footer>
{/if}
</ColorPicker.Content> </ColorPicker.Content>
</ColorPicker.Portal> </ColorPicker.Portal>
</ColorPicker> </ColorPicker>
@ -363,11 +378,23 @@
{/each} {/each}
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
{#each ['inline', 'modal'] as v}
<button
data-uix-chip
data-active={mode === v}
onclick={() => (mode = v as 'inline' | 'modal')}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>popover</span> <span data-uix-control-label>popover</span>
<span data-uix-switch> <span data-uix-switch>
<input type="checkbox" bind:checked={inline} /> <input type="checkbox" bind:checked={inline} />
<span data-uix-switch-label>inline</span> <span data-uix-switch-label>inline (no trigger)</span>
</span> </span>
<span data-uix-switch> <span data-uix-switch>
<input type="checkbox" bind:checked={closeOnSelect} disabled={inline} /> <input type="checkbox" bind:checked={closeOnSelect} disabled={inline} />
@ -381,6 +408,25 @@
<span data-uix-switch-label>enable alpha</span> <span data-uix-switch-label>enable alpha</span>
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>footer</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showFooter} />
<span data-uix-switch-label>render footer</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClear} disabled={!showFooter} />
<span data-uix-switch-label>clear</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showCancel} disabled={!showFooter} />
<span data-uix-switch-label>cancel</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClose} disabled={!showFooter} />
<span data-uix-switch-label>close (save)</span>
</span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>states</span> <span data-uix-control-label>states</span>
<span data-uix-switch> <span data-uix-switch>

Loading…
Cancel
Save

Powered by TurnKey Linux.