pickers: promote Footer/Clear/Cancel/Close to canonical Eidos parts

The footer affordances (Clear / Cancel / Close) lived inline in each
demo as picker-actions.svelte. Promoted to canonical Eidos surface so
the API is consistent and the contract is auditable.

morfo:
- date-picker + date-range-picker: added Footer (archetype 'footer'),
  Clear/Cancel/Close (archetype 'trigger', kind 'public', optional).
  Each part declares aria-label via idlangref and data-action="…".
- types.ts: added 'footer' to ARCHETYPE_VOCABULARY array + the
  MorfoArchetype union (the type had it, the runtime list did not).
- lang catalogs: added clear / cancel / close idlangref entries to
  date-picker.ts and date-range-picker.ts.

eidos:
- Created date-picker-footer/clear/cancel/close.svelte and the range
  mirrors. Each part:
  - Pulls the provider from context via DatePickerProvider.require()
    (analogous DateRangePickerProvider.require() for the range).
  - Renders nothing when the corresponding *Button opt is false; the
    Close part stays visible whenever mode === 'modal' (modal pickers
    need a way out — outside-click and Escape are blocked).
  - Emits data-{component}-{part} + data-action so the recipe selector
    matches the morfo declaration.
  - aria-label resolves via uix.langs.ts('#?components.X.{action}|…').
  - onclick calls provider.clear() / cancel() / commit() then forwards
    any consumer-supplied onclick.
- index.ts barrels: registered Footer/Clear/Cancel + replaced
  Close (was popover-close.svelte) with the new picker-aware Close.
  Soma popover-close still drives the old aria; date-picker's Close
  adds the modal-conditional visibility + commit semantics.
- *.css: folded the inline picker-actions styles into the recipes —
  data-{name}-footer flex row + data-{name}-{clear,cancel,close}
  buttons (clear/cancel margin-inline-end: auto so close sits flush
  right). prefers-reduced-motion already covered.

soma:
- date-picker exports.ts: surfaces DatePickerProvider + DatePickerMode
  + datePickerAttrs so the Eidos parts can consume them (mirrors what
  date-range-picker already exposed). No new behavior — just plumbing.

demo:
- date-picker + date-range-picker demos: replaced
  <PickerActions /> with <DatePicker.Footer>
    <DatePicker.Clear /><DatePicker.Cancel /><DatePicker.Close />
  </DatePicker.Footer>. The two picker-actions.svelte files are
  deleted.

Verification: 0 type errors, morfo:check PASS (Footer parts not
required in DOM; conditional visibility honored), component:audit
67/67 PASS.

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

@ -13,8 +13,8 @@
| `MonthRangePicker` (Chakra-style) | **implementar** | Componente nuevo. Segments month/year + grid de meses, sin calendario de días. | | `MonthRangePicker` (Chakra-style) | **implementar** | Componente nuevo. Segments month/year + grid de meses, sin calendario de días. |
| `YearRangePicker` | **implementar** | Componente nuevo. Segments year + grid de años. | | `YearRangePicker` | **implementar** | Componente nuevo. Segments year + grid de años. |
| `MonthPicker` / `YearPicker` (single) | **implementar** | Variantes hermanas de los anteriores. | | `MonthPicker` / `YearPicker` (single) | **implementar** | Variantes hermanas de los anteriores. |
| Botón Clear/Save/Close como **eidos parts** canónicos (no inline en demo) | **implementar** | Hoy viven en `picker-actions.svelte` del demo. Promover a `<DateRangePicker.Footer>` + `<DateRangePicker.Clear>` etc. y registrar en morfo. | | Botón Clear/Save/Close como **eidos parts** canónicos (no inline en demo) | **hecho** 2026-05-21 | Promovidos: `<DatePicker.Footer/.Clear/.Cancel/.Close>` + `<DateRangePicker.Footer/.Clear/.Cancel/.Close>` consumiendo `provider.commit/cancel/clear`. Visibility owned por provider opts. `picker-actions.svelte` borrados. CSS folded into `*.css`. |
| Morfo `date-range-picker`: añadir parts Footer/Clear/Cancel/Close + eventos `commit-save` y `commit-clear-from-footer` | **implementar** | Hace falta para que el audit no marque ARIA gaps cuando el footer está activo. | | Morfo `date-range-picker` + `date-picker`: añadir parts Footer/Clear/Cancel/Close | **hecho** 2026-05-21 | Ambos morfos declaran las 4 partes (Footer = archetype `footer` añadido al vocabulario; Clear/Cancel/Close = `trigger`). |
| Tests browser del flujo completo (Playwright) | **implementar** | Abrir/cerrar, modal/inline, snapshot/cancel, clear, click-outside, Escape. | | Tests browser del flujo completo (Playwright) | **implementar** | Abrir/cerrar, modal/inline, snapshot/cancel, clear, click-outside, Escape. |
| Sema event al bloquear click cuando `mode='modal'` | **diferir** | Útil para screen reader feedback. No bloquea. | | Sema event al bloquear click cuando `mode='modal'` | **diferir** | Útil para screen reader feedback. No bloquea. |

@ -0,0 +1,40 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
// Cancel action — reverts to the value snapshot taken on open and
// closes the popover. Hidden when `cancelButton` is false on the root.
interface CancelProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: CancelProps = $props();
const uix = getActiveUix();
const provider = DatePickerProvider.require();
const visible = $derived(provider.opts.cancelButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.cancel();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-picker-cancel
data-action="cancel"
aria-label={uix.langs.ts('#?components.date-picker.cancel|Cancel')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-picker.cancel|Cancel')}
{/if}
</button>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
// Clear action — resets the picker's value (undefined) and keeps the
// popover open. Hidden when `clearButton` is false on the root.
interface ClearProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: ClearProps = $props();
const uix = getActiveUix();
const provider = DatePickerProvider.require();
const visible = $derived(provider.opts.clearButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.clear();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-picker-clear
data-action="clear"
aria-label={uix.langs.ts('#?components.date-picker.clear|Clear date')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-picker.clear|Clear date')}
{/if}
</button>
{/if}

@ -0,0 +1,44 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
// Close / Save action — commits the current value and closes the
// popover. Visible when `closeButton` is true OR `mode='modal'`
// (modal pickers always need a way out — outside-click and Escape
// are intentionally blocked).
interface CloseProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: CloseProps = $props();
const uix = getActiveUix();
const provider = DatePickerProvider.require();
const visible = $derived(
provider.opts.closeButton.current || provider.opts.mode.current === 'modal'
);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.commit();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-picker-close
data-action="close"
aria-label={uix.langs.ts('#?components.date-picker.close|Close')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-picker.close|Close')}
{/if}
</button>
{/if}

@ -0,0 +1,27 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
// Footer container for the picker calendar. Renders only when the
// consumer enabled at least one button (clearButton/cancelButton/
// closeButton on the root) — `mode='modal'` forces Close on.
interface FooterProps extends HTMLAttributes<HTMLDivElement> {
children?: Snippet;
}
const { children, ...rest }: FooterProps = $props();
const provider = DatePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
const showClear = $derived(provider.opts.clearButton.current);
const showCancel = $derived(provider.opts.cancelButton.current);
const showClose = $derived(provider.opts.closeButton.current || mode === 'modal');
const visible = $derived(showClear || showCancel || showClose);
</script>
{#if visible}
<div data-date-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>
{/if}

@ -185,8 +185,71 @@
border-color: transparent; border-color: transparent;
} }
/* ── Footer (Clear / Cancel / Close action row) ──────────────────────────── */
[data-date-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-date-picker-clear],
[data-date-picker-cancel] {
margin-inline-end: auto;
}
[data-date-picker-clear],
[data-date-picker-cancel],
[data-date-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-date-picker-clear]:hover,
[data-date-picker-cancel]:hover {
background: var(--color-surface-overlay);
}
[data-date-picker-close] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
[data-date-picker-close]:hover {
background: var(--color-primary-solid-hover);
}
[data-date-picker-clear]:focus-visible,
[data-date-picker-cancel]:focus-visible,
[data-date-picker-close]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
[data-date-picker-trigger] { [data-date-picker-trigger] {
transition: none; transition: none;
} }
[data-date-picker-clear],
[data-date-picker-cancel],
[data-date-picker-close] {
transition: none;
}
} }

@ -2,6 +2,10 @@ import DatePickerComponent from './date-picker.svelte';
import Trigger from './date-picker-trigger.svelte'; import Trigger from './date-picker-trigger.svelte';
import Content from './date-picker-content.svelte'; import Content from './date-picker-content.svelte';
import Calendar from './date-picker-calendar.svelte'; import Calendar from './date-picker-calendar.svelte';
import Footer from './date-picker-footer.svelte';
import Clear from './date-picker-clear.svelte';
import Cancel from './date-picker-cancel.svelte';
import Close from './date-picker-close.svelte';
import Label from '../date-field/date-field-label.svelte'; import Label from '../date-field/date-field-label.svelte';
import Input from '../date-field/date-field-input.svelte'; import Input from '../date-field/date-field-input.svelte';
import Segment from '../date-field/date-field-segment.svelte'; import Segment from '../date-field/date-field-segment.svelte';
@ -9,7 +13,6 @@ import HiddenInput from '../date-field/date-field-hidden-input.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';
import Close from '../popover/popover-close.svelte';
import Header from '../calendar/calendar-header.svelte'; import Header from '../calendar/calendar-header.svelte';
import Heading from '../calendar/calendar-heading.svelte'; import Heading from '../calendar/calendar-heading.svelte';
import PrevButton from '../calendar/calendar-prev-button.svelte'; import PrevButton from '../calendar/calendar-prev-button.svelte';
@ -33,6 +36,9 @@ type DatePickerNamespace = typeof DatePickerComponent & {
Content: typeof Content; Content: typeof Content;
Arrow: typeof Arrow; Arrow: typeof Arrow;
Close: typeof Close; Close: typeof Close;
Footer: typeof Footer;
Clear: typeof Clear;
Cancel: typeof Cancel;
Label: typeof Label; Label: typeof Label;
Input: typeof Input; Input: typeof Input;
Segment: typeof Segment; Segment: typeof Segment;
@ -61,6 +67,9 @@ DatePicker.Overlay = Overlay;
DatePicker.Content = Content; DatePicker.Content = Content;
DatePicker.Arrow = Arrow; DatePicker.Arrow = Arrow;
DatePicker.Close = Close; DatePicker.Close = Close;
DatePicker.Footer = Footer;
DatePicker.Clear = Clear;
DatePicker.Cancel = Cancel;
DatePicker.Label = Label; DatePicker.Label = Label;
DatePicker.Input = Input; DatePicker.Input = Input;
DatePicker.Segment = Segment; DatePicker.Segment = Segment;

@ -0,0 +1,38 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface CancelProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: CancelProps = $props();
const uix = getActiveUix();
const provider = DateRangePickerProvider.require();
const visible = $derived(provider.opts.cancelButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.cancel();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-range-picker-cancel
data-action="cancel"
aria-label={uix.langs.ts('#?components.date-range-picker.cancel|Cancel')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-range-picker.cancel|Cancel')}
{/if}
</button>
{/if}

@ -0,0 +1,38 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface ClearProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: ClearProps = $props();
const uix = getActiveUix();
const provider = DateRangePickerProvider.require();
const visible = $derived(provider.opts.clearButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.clear();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-range-picker-clear
data-action="clear"
aria-label={uix.langs.ts('#?components.date-range-picker.clear|Clear range')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-range-picker.clear|Clear range')}
{/if}
</button>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { getActiveUix } from '$active-uix';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface CloseProps extends HTMLButtonAttributes {
children?: Snippet;
}
const { children, onclick, ...rest }: CloseProps = $props();
const uix = getActiveUix();
const provider = DateRangePickerProvider.require();
const visible = $derived(
provider.opts.closeButton.current || provider.opts.mode.current === 'modal'
);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.commit();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
{#if visible}
<button
type="button"
data-date-range-picker-close
data-action="close"
aria-label={uix.langs.ts('#?components.date-range-picker.close|Close')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.date-range-picker.close|Close')}
{/if}
</button>
{/if}

@ -0,0 +1,24 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface FooterProps extends HTMLAttributes<HTMLDivElement> {
children?: Snippet;
}
const { children, ...rest }: FooterProps = $props();
const provider = DateRangePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
const showClear = $derived(provider.opts.clearButton.current);
const showCancel = $derived(provider.opts.cancelButton.current);
const showClose = $derived(provider.opts.closeButton.current || mode === 'modal');
const visible = $derived(showClear || showCancel || showClose);
</script>
{#if visible}
<div data-date-range-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>
{/if}

@ -820,6 +820,64 @@
} }
} }
/* ── Footer (Clear / Cancel / Close action row) ──────────────────────────── */
[data-date-range-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-date-range-picker-clear],
[data-date-range-picker-cancel] {
margin-inline-end: auto;
}
[data-date-range-picker-clear],
[data-date-range-picker-cancel],
[data-date-range-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-date-range-picker-clear]:hover,
[data-date-range-picker-cancel]:hover {
background: var(--color-surface-overlay);
}
[data-date-range-picker-close] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
[data-date-range-picker-close]:hover {
background: var(--color-primary-solid-hover);
}
[data-date-range-picker-clear]:focus-visible,
[data-date-range-picker-cancel]:focus-visible,
[data-date-range-picker-close]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
[data-date-range-picker-trigger], [data-date-range-picker-trigger],
[data-date-range-field-input], [data-date-range-field-input],
@ -828,7 +886,10 @@
[data-range-calendar-next-button], [data-range-calendar-next-button],
[data-range-calendar-month-select], [data-range-calendar-month-select],
[data-range-calendar-year-select], [data-range-calendar-year-select],
[data-range-calendar-day] { [data-range-calendar-day],
[data-date-range-picker-clear],
[data-date-range-picker-cancel],
[data-date-range-picker-close] {
transition: none; transition: none;
} }
} }

@ -8,7 +8,10 @@ import Segment from './date-range-picker-segment.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';
import Close from '../popover/popover-close.svelte'; import Close from './date-range-picker-close.svelte';
import Footer from './date-range-picker-footer.svelte';
import Clear from './date-range-picker-clear.svelte';
import Cancel from './date-range-picker-cancel.svelte';
import Header from './date-range-picker-header.svelte'; import Header from './date-range-picker-header.svelte';
import Heading from './date-range-picker-heading.svelte'; import Heading from './date-range-picker-heading.svelte';
import PrevButton from './date-range-picker-prev-button.svelte'; import PrevButton from './date-range-picker-prev-button.svelte';
@ -32,6 +35,9 @@ type DateRangePickerNamespace = typeof DateRangePickerComponent & {
Content: typeof Content; Content: typeof Content;
Arrow: typeof Arrow; Arrow: typeof Arrow;
Close: typeof Close; Close: typeof Close;
Footer: typeof Footer;
Clear: typeof Clear;
Cancel: typeof Cancel;
Label: typeof Label; Label: typeof Label;
Input: typeof Input; Input: typeof Input;
Segment: typeof Segment; Segment: typeof Segment;
@ -59,6 +65,9 @@ DateRangePicker.Overlay = Overlay;
DateRangePicker.Content = Content; DateRangePicker.Content = Content;
DateRangePicker.Arrow = Arrow; DateRangePicker.Arrow = Arrow;
DateRangePicker.Close = Close; DateRangePicker.Close = Close;
DateRangePicker.Footer = Footer;
DateRangePicker.Clear = Clear;
DateRangePicker.Cancel = Cancel;
DateRangePicker.Label = Label; DateRangePicker.Label = Label;
DateRangePicker.Input = Input; DateRangePicker.Input = Input;
DateRangePicker.Segment = Segment; DateRangePicker.Segment = Segment;

@ -19,5 +19,17 @@ export const datePickerLangs = {
calendar: { calendar: {
es: "Calendario de fecha", es: "Calendario de fecha",
en: "Date calendar" en: "Date calendar"
},
clear: {
es: 'Borrar fecha',
en: 'Clear date'
},
cancel: {
es: 'Cancelar',
en: 'Cancel'
},
close: {
es: 'Cerrar',
en: 'Close'
} }
} satisfies LangNode; } satisfies LangNode;

@ -19,5 +19,17 @@ export const dateRangePickerLangs = {
calendar: { calendar: {
es: 'Calendario de rango de fechas', es: 'Calendario de rango de fechas',
en: 'Date range calendar' en: 'Date range calendar'
},
clear: {
es: 'Borrar rango',
en: 'Clear range'
},
cancel: {
es: 'Cancelar',
en: 'Cancel'
},
close: {
es: 'Cerrar',
en: 'Close'
} }
} satisfies LangNode; } satisfies LangNode;

@ -231,6 +231,78 @@ export const datePickerMorfo = {
{ key: 'Tab', action: 'focus-next' }, { key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' } { key: 'Shift+Tab', action: 'focus-prev' }
] ]
},
{
// Optional footer rendered inside the Calendar. Visibility is owned
// by the consumer (`clearButton`/`cancelButton`/`closeButton` props
// on the root). In `mode='modal'` the Close affordance is forced on.
name: 'Footer',
kebab: 'footer',
archetype: 'footer',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-mode', values: ['inline', 'modal'], severity: 'optional' }],
aria: []
},
{
// Clear action — resets value to undefined, keeps the popover open.
name: 'Clear',
kebab: 'clear',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('clear') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-picker.clear|Clear date'),
severity: 'recommended'
}
]
},
{
// Cancel action — reverts to the value snapshot taken on open, closes.
name: 'Cancel',
kebab: 'cancel',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('cancel') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-picker.cancel|Cancel'),
severity: 'recommended'
}
]
},
{
// Close / Save action — commits current value and closes. In modal
// mode this is the only affordance to exit (outside-click + Escape
// are intentionally blocked).
name: 'Close',
kebab: 'close',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('close') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-picker.close|Close'),
severity: 'recommended'
}
]
} }
] ]
} as const satisfies Morfo; } as const satisfies Morfo;

@ -237,6 +237,78 @@ export const dateRangePickerMorfo = {
{ key: 'Tab', action: 'focus-next' }, { key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' } { key: 'Shift+Tab', action: 'focus-prev' }
] ]
},
{
// Optional footer rendered inside the Calendar. Visibility is owned
// by the consumer (`clearButton`/`cancelButton`/`closeButton` props
// on the root). In `mode='modal'` the Close affordance is forced on.
name: 'Footer',
kebab: 'footer',
archetype: 'footer',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-mode', values: ['inline', 'modal'], severity: 'optional' }],
aria: []
},
{
// Clear action — resets value to an empty range, keeps the popover open.
name: 'Clear',
kebab: 'clear',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('clear') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-range-picker.clear|Clear range'),
severity: 'recommended'
}
]
},
{
// Cancel action — reverts to the value snapshot taken on open, closes.
name: 'Cancel',
kebab: 'cancel',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('cancel') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-range-picker.cancel|Cancel'),
severity: 'recommended'
}
]
},
{
// Close / Save action — commits current value and closes. In modal
// mode this is the only affordance to exit (outside-click + Escape
// are intentionally blocked).
name: 'Close',
kebab: 'close',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-action', value: v.literal('close') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.date-range-picker.close|Close'),
severity: 'recommended'
}
]
} }
] ]
} as const satisfies Morfo; } as const satisfies Morfo;

@ -468,6 +468,7 @@ export type MorfoArchetype =
| 'close' // dismiss / close button | 'close' // dismiss / close button
| 'action' // call-to-action button | 'action' // call-to-action button
| 'header' // section header (often above content) | 'header' // section header (often above content)
| 'footer' // section footer (often below content)
| 'image' // <img>-based content | 'image' // <img>-based content
| 'fallback' // shown when primary content unavailable | 'fallback' // shown when primary content unavailable
| 'arrow' // pointer arrow (tooltip / popover) | 'arrow' // pointer arrow (tooltip / popover)
@ -501,6 +502,7 @@ export const ARCHETYPE_VOCABULARY = [
'close', 'close',
'action', 'action',
'header', 'header',
'footer',
'image', 'image',
'fallback', 'fallback',
'arrow', 'arrow',

@ -4,7 +4,7 @@ export { default as Trigger } from './components/date-picker-trigger.svelte';
export { default as Calendar } from './components/date-picker-calendar.svelte'; export { default as Calendar } from './components/date-picker-calendar.svelte';
// Provider class (action helpers: commit / cancel / clear / handleDateSelect) // Provider class (action helpers: commit / cancel / clear / handleDateSelect)
export { DatePickerProvider } from './date-picker-provider.svelte'; export { DatePickerProvider, datePickerAttrs } from './date-picker-provider.svelte';
export type { DatePickerMode } from './date-picker-provider.svelte'; export type { DatePickerMode } from './date-picker-provider.svelte';
// Popover surface (shared via composed PopoverProvider) // Popover surface (shared via composed PopoverProvider)

@ -15,7 +15,6 @@
import { popoverMorfo } from '@/uix/morfo/components/popover'; import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix'; import { getActiveUix } from '$active-uix';
import { CalendarDate, type DateMatcher, type DateValue, type WeekStartsOn } from '$libs/days'; import { CalendarDate, type DateMatcher, type DateValue, type WeekStartsOn } from '$libs/days';
import PickerActions from './picker-actions.svelte';
const uix = getActiveUix(); const uix = getActiveUix();
void uix; void uix;
@ -1022,7 +1021,11 @@
</div> </div>
{/each} {/each}
</div> </div>
<PickerActions /> <DatePicker.Footer>
<DatePicker.Clear />
<DatePicker.Cancel />
<DatePicker.Close />
</DatePicker.Footer>
{/snippet} {/snippet}
<style> <style>

@ -1,111 +0,0 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
// Footer for the DatePicker calendar. Lives inside the provider tree
// (rendered from the Calendar snippet) so it can pull options + helpers
// straight from context.
//
// Buttons are configured via props on the picker:
// - `clearButton` — Clear (reset value, keep open)
// - `cancelButton` — Cancel (revert to value-at-open, close)
// - `closeButton` — Close / Save (commit current value, close)
//
// In `mode='modal'` Close is forced on so the user always has a way out:
// modal popovers ignore outside-click and Escape.
//
// If no buttons are enabled (and the picker is not modal), this footer
// renders nothing.
const provider = DatePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
const showClear = $derived(provider.opts.clearButton.current);
const showCancel = $derived(provider.opts.cancelButton.current);
const showClose = $derived(provider.opts.closeButton.current || mode === 'modal');
const visible = $derived(showClear || showCancel || showClose);
</script>
{#if visible}
<div class="picker-footer" data-date-picker-footer data-mode={mode}>
{#if showClear}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.clear()}
>
Clear
</button>
{/if}
<div class="picker-footer-spacer"></div>
{#if showCancel}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.cancel()}
>
Cancel
</button>
{/if}
{#if showClose}
<button
type="button"
class="picker-footer-button"
data-tone="solid"
onclick={() => provider.commit()}
>
Close
</button>
{/if}
</div>
{/if}
<style>
.picker-footer {
display: flex;
align-items: center;
gap: var(--uix-space-2, 0.5rem);
padding-block-start: var(--uix-space-3, 0.75rem);
margin-block-start: var(--uix-space-3, 0.75rem);
border-block-start: 1px solid var(--color-border-subtle);
}
.picker-footer-spacer {
flex: 1 1 auto;
}
.picker-footer-button {
inline-size: max-content;
min-block-size: var(--control-height-sm);
padding-inline: var(--uix-space-3, 0.75rem);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
font: inherit;
font-size: var(--uix-text-sm);
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);
}
.picker-footer-button[data-tone='ghost']:hover {
background: var(--color-surface-overlay);
}
.picker-footer-button[data-tone='solid'] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
.picker-footer-button[data-tone='solid']:hover {
background: var(--color-primary-solid-hover);
}
.picker-footer-button:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
</style>

@ -14,7 +14,6 @@
import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar'; import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar';
import { popoverMorfo } from '@/uix/morfo/components/popover'; import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix'; import { getActiveUix } from '$active-uix';
import PickerActions from './picker-actions.svelte';
import { import {
CalendarDate, CalendarDate,
getLocalTimeZone, getLocalTimeZone,
@ -1045,7 +1044,11 @@
</div> </div>
{/each} {/each}
</div> </div>
<PickerActions /> <DateRangePicker.Footer>
<DateRangePicker.Clear />
<DateRangePicker.Cancel />
<DateRangePicker.Close />
</DateRangePicker.Footer>
{/snippet} {/snippet}
<style> <style>

@ -1,111 +0,0 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
// Footer for the DateRangePicker calendar. Lives inside the provider tree
// (rendered from the Calendar snippet) so it can pull options + helpers
// straight from context.
//
// Buttons are configured via props on the picker:
// - `clearButton` — Clear (reset value, keep open)
// - `cancelButton` — Cancel (revert to value-at-open, close)
// - `closeButton` — Close / Save (commit current value, close)
//
// In `mode='modal'` Close is forced on so the user always has a way out:
// modal popovers ignore outside-click and Escape.
//
// If no buttons are enabled (and the picker is not modal), this footer
// renders nothing.
const provider = DateRangePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
const showClear = $derived(provider.opts.clearButton.current);
const showCancel = $derived(provider.opts.cancelButton.current);
const showClose = $derived(provider.opts.closeButton.current || mode === 'modal');
const visible = $derived(showClear || showCancel || showClose);
</script>
{#if visible}
<div class="picker-footer" data-date-range-picker-footer data-mode={mode}>
{#if showClear}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.clear()}
>
Clear
</button>
{/if}
<div class="picker-footer-spacer"></div>
{#if showCancel}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.cancel()}
>
Cancel
</button>
{/if}
{#if showClose}
<button
type="button"
class="picker-footer-button"
data-tone="solid"
onclick={() => provider.commit()}
>
Close
</button>
{/if}
</div>
{/if}
<style>
.picker-footer {
display: flex;
align-items: center;
gap: var(--uix-space-2, 0.5rem);
padding-block-start: var(--uix-space-3, 0.75rem);
margin-block-start: var(--uix-space-3, 0.75rem);
border-block-start: 1px solid var(--color-border-subtle);
}
.picker-footer-spacer {
flex: 1 1 auto;
}
.picker-footer-button {
inline-size: max-content;
min-block-size: var(--control-height-sm);
padding-inline: var(--uix-space-3, 0.75rem);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
font: inherit;
font-size: var(--uix-text-sm);
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);
}
.picker-footer-button[data-tone='ghost']:hover {
background: var(--color-surface-overlay);
}
.picker-footer-button[data-tone='solid'] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
.picker-footer-button[data-tone='solid']:hover {
background: var(--color-primary-solid-hover);
}
.picker-footer-button:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.