diff --git a/src/uix/PENDIENTES.md b/src/uix/PENDIENTES.md index cb320948e..432775bfc 100644 --- a/src/uix/PENDIENTES.md +++ b/src/uix/PENDIENTES.md @@ -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, eventos `slider:handle-pick/drag/commit-set` propagados, tokens 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 + `` 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). diff --git a/src/uix/eidos/components/color-picker/color-picker.css b/src/uix/eidos/components/color-picker/color-picker.css index 80519e639..76e436adc 100644 --- a/src/uix/eidos/components/color-picker/color-picker.css +++ b/src/uix/eidos/components/color-picker/color-picker.css @@ -99,6 +99,19 @@ flex-direction: column; gap: var(--color-picker-content-gap); 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 ─────────────────────────────────────────────────────────────── */ @@ -457,10 +470,22 @@ [data-popover-content][data-color-picker-content] [data-color-field-input] { background: var(--color-surface-overlay); 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] { font-size: var(--font-size-xs); + margin-inline-start: auto; } @media (prefers-reduced-motion: reduce) { diff --git a/src/uix/eidos/components/color-picker/index.ts b/src/uix/eidos/components/color-picker/index.ts index 17c24df2f..8b014f22a 100644 --- a/src/uix/eidos/components/color-picker/index.ts +++ b/src/uix/eidos/components/color-picker/index.ts @@ -24,6 +24,12 @@ import SwatchIndicator from './color-picker-swatch-indicator.svelte' import ChannelInput from './color-picker-channel-input.svelte' import ChannelSegment from './color-picker-channel-segment.svelte' import FormatSelect from './color-picker-format-select.svelte' +// 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 Overlay from '../popover/popover-overlay.svelte' import Arrow from '../popover/popover-arrow.svelte' @@ -42,7 +48,7 @@ type ColorPickerNamespace = typeof ColorPickerComponent & { Overlay: typeof Overlay Content: typeof Content Arrow: typeof Arrow - Close: typeof PopoverClose + PopoverClose: typeof PopoverClose Area: typeof Area AreaBackground: typeof AreaBackground AreaThumb: typeof AreaThumb @@ -58,6 +64,11 @@ type ColorPickerNamespace = typeof ColorPickerComponent & { ChannelInput: typeof ChannelInput ChannelSegment: typeof ChannelSegment 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 @@ -72,7 +83,7 @@ ColorPicker.Anchor = Anchor ColorPicker.Overlay = Overlay ColorPicker.Content = Content ColorPicker.Arrow = Arrow -ColorPicker.Close = PopoverClose +ColorPicker.PopoverClose = PopoverClose ColorPicker.Area = Area ColorPicker.AreaBackground = AreaBackground ColorPicker.AreaThumb = AreaThumb @@ -88,6 +99,10 @@ ColorPicker.SwatchIndicator = SwatchIndicator ColorPicker.ChannelInput = ChannelInput ColorPicker.ChannelSegment = ChannelSegment ColorPicker.FormatSelect = FormatSelect +ColorPicker.Footer = Footer +ColorPicker.Clear = Clear +ColorPicker.Cancel = Cancel +ColorPicker.Close = PickerCloseAction export { ColorPicker } diff --git a/src/uix/eidos/components/picker-shell/index.ts b/src/uix/eidos/components/picker-shell/index.ts new file mode 100644 index 000000000..eb54a64f3 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/index.ts @@ -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'; + * + * + * + * + * + * + * + * Each composite picker re-exports these as `X.Footer / X.Clear / …` + * for namespace consistency: + * + * + * + * + * + * + * + * 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' diff --git a/src/uix/eidos/components/picker-shell/picker-shell-cancel.svelte b/src/uix/eidos/components/picker-shell/picker-shell-cancel.svelte new file mode 100644 index 000000000..9bd061082 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/picker-shell-cancel.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/uix/eidos/components/picker-shell/picker-shell-clear.svelte b/src/uix/eidos/components/picker-shell/picker-shell-clear.svelte new file mode 100644 index 000000000..564bda427 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/picker-shell-clear.svelte @@ -0,0 +1,31 @@ + + + diff --git a/src/uix/eidos/components/picker-shell/picker-shell-close.svelte b/src/uix/eidos/components/picker-shell/picker-shell-close.svelte new file mode 100644 index 000000000..85140d834 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/picker-shell-close.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/uix/eidos/components/picker-shell/picker-shell-footer.svelte b/src/uix/eidos/components/picker-shell/picker-shell-footer.svelte new file mode 100644 index 000000000..fd34aba11 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/picker-shell-footer.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/picker-shell/picker-shell.css b/src/uix/eidos/components/picker-shell/picker-shell.css new file mode 100644 index 000000000..348d991bb --- /dev/null +++ b/src/uix/eidos/components/picker-shell/picker-shell.css @@ -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; + } +} diff --git a/src/uix/eidos/components/picker-shell/types.ts b/src/uix/eidos/components/picker-shell/types.ts new file mode 100644 index 000000000..a9d3ea266 --- /dev/null +++ b/src/uix/eidos/components/picker-shell/types.ts @@ -0,0 +1,22 @@ +import type { Snippet } from 'svelte' +import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements' + +/** Props for ``. */ +export type PickerShellFooterProps = HTMLAttributes & { + children?: Snippet +} + +/** Props for ``. */ +export type PickerShellClearProps = HTMLButtonAttributes & { + children?: Snippet +} + +/** Props for ``. */ +export type PickerShellCancelProps = HTMLButtonAttributes & { + children?: Snippet +} + +/** Props for ``. */ +export type PickerShellCloseProps = HTMLButtonAttributes & { + children?: Snippet +} diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index ece532cc1..bab90e2ab 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -91,6 +91,7 @@ @import './components/date-range-picker/date-range-picker.css'; @import './components/time-field/time-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/color-picker/color-picker.css'; @import './components/slider/slider.css'; diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts index 250c7a82f..cec4fd1c1 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts @@ -76,6 +76,7 @@ function createColorPickerOpts() { enableAlpha: state(true), closeOnSelect: state(false), inline: state(false), + mode: state<'inline' | 'modal'>('inline'), areaChannels: state<{ x: ColorChannel; y: ColorChannel }>({ x: 'saturation', y: 'brightness' @@ -219,7 +220,7 @@ describe('ColorPickerProvider', () => { result.thumb.props.onkeydown(keyEvent('ArrowRight') as never); expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(26); - result.provider.commit(); + result.provider.commitChange(); expect(onCommit).toHaveBeenCalledWith(opts.value.current); cleanup(); @@ -279,7 +280,7 @@ describe('ColorPickerProvider', () => { expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180); // And what onValueCommit calls when the user releases the pointer. - result.provider.commit(); + result.provider.commitChange(); expect(onCommit).toHaveBeenCalledWith(opts.value.current); cleanup(); diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts index b5261b71e..1164a96dc 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts @@ -16,6 +16,11 @@ import { type EditableColorSegmentPart } from '$libs/color'; import { FieldProvider } from '../field/field-provider.svelte'; +import { + pickerShellContext, + type PickerShellHandle, + type PickerShellMode +} from '../picker-shell'; import { COLOR_PICKER_LANGS } from './langs'; import type { ColorChannel } from './types'; import type { ColorValidator, ColorOnInvalid } from '../color-field/types'; @@ -133,6 +138,7 @@ export interface ColorPickerOpts enableAlpha: boolean; closeOnSelect: boolean; inline: boolean; + mode: PickerShellMode; areaChannels: { x: ColorChannel; y: ColorChannel }; readonlySegments: EditableColorSegmentPart[]; validate: ColorValidator | undefined; @@ -181,6 +187,16 @@ export class ColorPickerProvider { 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 // click). `onValueChange` fires on every intermediate change via the // `writableActive` setter in the root wrapper. @@ -195,6 +211,10 @@ export class ColorPickerProvider { } } ); + + // Expose our commit/cancel/clear/mode handle to descendant Eidos + // parts (``, ``, etc.). + pickerShellContext.set(this.pickerShellHandle); } // ── Merged flags ──────────────────────────────────────────────────────── @@ -258,8 +278,14 @@ export class ColorPickerProvider { 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 ``). + */ + commitChange(): void { 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 `` 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 + * `` 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 + * `` 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 ─────────────────────────────────────────────────── setTriggerNode(el: HTMLElement | null) { @@ -645,7 +712,7 @@ export class ColorPickerAreaProvider { readonly onpointerup = (e: PointerEvent): void => { if (this.pointerId !== e.pointerId) return; this.pointerId = null; - this.provider.commit(); + this.provider.commitChange(); }; readonly props = $derived.by(() => @@ -975,7 +1042,7 @@ export class ColorPickerSwatchTriggerProvider { const hsv = parseColor(this.opts.color.current); if (!hsv) return; this.provider.opts.value.current = colorValueFromHsv(hsv); - this.provider.commit(); + this.provider.commitChange(); this.provider.handleSelect(); }; @@ -1109,7 +1176,7 @@ export class ColorPickerEyeDropperProvider { const hsv = parseColor(sRGBHex); if (!hsv) return; this.provider.opts.value.current = colorValueFromHsv(hsv); - this.provider.commit(); + this.provider.commitChange(); this.provider.handleSelect(); } catch { // User cancelled the picker — silent. diff --git a/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte b/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte index 77cf67b37..818e7c93f 100644 --- a/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte +++ b/src/uix/soma/components/color-picker/components/color-picker-channel-slider.svelte @@ -69,7 +69,7 @@ if (v[0] !== undefined) cp.setChannel(channel, v[0]); } ), - onValueCommit: readableActive(() => () => cp.commit()), + onValueCommit: readableActive(() => () => cp.commitChange()), min: readableActive(() => range.min), max: readableActive(() => range.max), step: readableActive(() => stepForChannel), diff --git a/src/uix/soma/components/color-picker/components/color-picker.svelte b/src/uix/soma/components/color-picker/components/color-picker.svelte index bd0d804c3..f02a8251b 100644 --- a/src/uix/soma/components/color-picker/components/color-picker.svelte +++ b/src/uix/soma/components/color-picker/components/color-picker.svelte @@ -33,6 +33,7 @@ onOpenChangeComplete = () => {}, inline = false, closeOnSelect = false, + mode = 'inline', disabled = false, readonly = false, required = false, @@ -143,6 +144,7 @@ enableAlpha: readableActive(() => enableAlpha), closeOnSelect: readableActive(() => closeOnSelect), inline: readableActive(() => inline), + mode: readableActive(() => mode), areaChannels: readableActive( () => areaChannels ?? { x: 'saturation' as const, y: 'brightness' as const } ), @@ -168,7 +170,7 @@ id: readableActive(() => `${id}-popover`), open: sharedOpen, onOpenChangeComplete: readableActive(() => onOpenChangeComplete), - modal: readableActive(() => false) + modal: readableActive(() => mode === 'modal') }); } diff --git a/src/uix/soma/components/color-picker/types.ts b/src/uix/soma/components/color-picker/types.ts index cccf5630f..1c9d27081 100644 --- a/src/uix/soma/components/color-picker/types.ts +++ b/src/uix/soma/components/color-picker/types.ts @@ -103,6 +103,13 @@ export type ColorPickerProviderProps = WithChild<{ inline?: boolean; /** Auto-close after the user selects a swatch / eyedropper color. @default false */ closeOnSelect?: boolean; + /** + * Picker interaction mode. `inline` (default) closes the popover on + * outside-click + Escape. `modal` blocks them; the user must exit + * via `` (commit) or `` (revert) in the footer. + * @default 'inline' + */ + mode?: import('../picker-shell').PickerShellMode; // Flags disabled?: boolean; diff --git a/src/uix/soma/components/picker-shell/exports.ts b/src/uix/soma/components/picker-shell/exports.ts new file mode 100644 index 000000000..2fd1aba4f --- /dev/null +++ b/src/uix/soma/components/picker-shell/exports.ts @@ -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'; diff --git a/src/uix/soma/components/picker-shell/index.ts b/src/uix/soma/components/picker-shell/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/picker-shell/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/picker-shell/picker-shell-handle.svelte.ts b/src/uix/soma/components/picker-shell/picker-shell-handle.svelte.ts new file mode 100644 index 000000000..e74525591 --- /dev/null +++ b/src/uix/soma/components/picker-shell/picker-shell-handle.svelte.ts @@ -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 ``, ``, `` + * and `` 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('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); +} diff --git a/web/routes/uix/components/color-picker/+page.svelte b/web/routes/uix/components/color-picker/+page.svelte index 8ece30690..bd022ecaf 100644 --- a/web/routes/uix/components/color-picker/+page.svelte +++ b/web/routes/uix/components/color-picker/+page.svelte @@ -45,6 +45,12 @@ let open = $state(false); let inline = $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 readonly = $state(false); let enableAlpha = $state(true); @@ -218,6 +224,7 @@ bind:format bind:open {inline} + {mode} {closeOnSelect} {disabled} {readonly} @@ -264,6 +271,14 @@
+ + {#if showFooter && (showClear || showCancel || showClose)} + + {#if showClear}{/if} + {#if showCancel}{/if} + {#if showClose}{/if} + + {/if} @@ -363,11 +378,23 @@ {/each} + +