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
parent
5d007ed9f9
commit
714ee3735c
@ -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
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in new issue