pickers: Footer parts = pure composition (drop *Button props)

User correctly pointed out: button visibility should not be exposed as
properties on the picker root. It should be expressed via composition
— if you include <DatePicker.Clear/> inside <DatePicker.Footer/>, it
shows; if you omit it, it doesn't. Same as how Header parts work, and
extensible to derivatives (date-range-picker follows the same rule).

This commit removes the visibility props + visibility checks. Pure
composition wins.

soma:
- DatePickerProvider opts: drop clearButton / cancelButton /
  closeButton. The root soma component drops the props + the
  readableActive passes. Same for DateRangePickerProvider.
- types.ts: drop the prop declarations + JSDoc.
- Test fixtures: drop the state() entries for the removed opts.

eidos parts:
- date-picker-clear / cancel / close: drop the `visible` $derived
  and the {#if visible} guard. Render unconditionally.
- date-range-picker-clear / cancel / close: same.
- date-picker-footer / date-range-picker-footer: also drop the
  combined `visible` $derived. The Footer container always renders
  whatever children are composed inside.

Modal mode: previously the Close part forced itself visible whenever
mode='modal'. That magic is gone too — the consumer is now responsible
for including <Close/> if mode='modal'; otherwise the modal has no
exit affordance (and that's documented in the Close part's comment).

demos:
- Drop clearButton/cancelButton/closeButton state vars.
- Drop the prop pass-through on <DatePicker> / <DateRangePicker>.
- Drop the 'footer buttons' switch group.
- Drop snippet code refs to those props.
- Keep the same <Footer><Clear/><Cancel/><Close/></Footer> markup
  inside the calendar branches — now visibility is purely structural.

Verification: 0 type errors, 7/7 date-picker + date-range-picker
soma tests, 67/67 component:audit PASS. The picker still renders
with all three buttons by default (because the demos compose them).

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

@ -5,7 +5,8 @@
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.
// closes the popover. Renders unconditionally — visibility is the
// consumer's responsibility (include the part to show it).
interface CancelProps extends HTMLButtonAttributes {
children?: Snippet;
}
@ -14,7 +15,6 @@
const uix = getActiveUix();
const provider = DatePickerProvider.require();
const visible = $derived(provider.opts.cancelButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.cancel();
@ -22,19 +22,17 @@
}
</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}
<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>

@ -5,7 +5,8 @@
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.
// popover open. Renders unconditionally — visibility is the
// consumer's responsibility (include the part to show it).
interface ClearProps extends HTMLButtonAttributes {
children?: Snippet;
}
@ -14,7 +15,6 @@
const uix = getActiveUix();
const provider = DatePickerProvider.require();
const visible = $derived(provider.opts.clearButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.clear();
@ -22,19 +22,17 @@
}
</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}
<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>

@ -5,9 +5,12 @@
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).
// popover. Renders unconditionally — visibility is the consumer's
// responsibility (include the part to show it).
//
// In `mode='modal'` the consumer MUST include this part (or another
// route out) because outside-click and Escape are intentionally
// blocked — otherwise the picker has no exit affordance.
interface CloseProps extends HTMLButtonAttributes {
children?: Snippet;
}
@ -16,9 +19,6 @@
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();
@ -26,19 +26,17 @@
}
</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}
<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>

@ -3,9 +3,14 @@
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.
// Footer container for the picker calendar. Pure composition: render
// whatever you want inside it (Clear / Cancel / Close — or arbitrary
// children). Visibility is the consumer's responsibility — include
// the part to show it, omit it to hide it.
//
// Carries `data-mode` so the CSS can react to the picker's mode
// without re-reading provider context (e.g. enforce a minimum width
// in modal, change the visual emphasis of the close button, etc.).
interface FooterProps extends HTMLAttributes<HTMLDivElement> {
children?: Snippet;
}
@ -14,14 +19,8 @@
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}
<div data-date-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>

@ -12,7 +12,6 @@
const uix = getActiveUix();
const provider = DateRangePickerProvider.require();
const visible = $derived(provider.opts.cancelButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.cancel();
@ -20,19 +19,17 @@
}
</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}
<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>

@ -12,7 +12,6 @@
const uix = getActiveUix();
const provider = DateRangePickerProvider.require();
const visible = $derived(provider.opts.clearButton.current);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.clear();
@ -20,19 +19,17 @@
}
</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}
<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>

@ -12,9 +12,6 @@
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();
@ -22,19 +19,17 @@
}
</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}
<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>

@ -3,6 +3,9 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
// Pure composition: render whatever you want inside it (Clear /
// Cancel / Close — or arbitrary children). Visibility is the
// consumer's responsibility — include the part to show it.
interface FooterProps extends HTMLAttributes<HTMLDivElement> {
children?: Snippet;
}
@ -11,14 +14,8 @@
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}
<div data-date-range-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>

@ -25,9 +25,6 @@
closeOnDateSelect = true,
mode = 'inline',
kind = 'date',
clearButton = false,
cancelButton = false,
closeButton = false,
validate,
onInvalid,
minValue,
@ -140,9 +137,6 @@
closeOnDateSelect: readableActive(() => closeOnDateSelect),
mode: readableActive(() => mode),
kind: readableActive(() => kind),
clearButton: readableActive(() => clearButton),
cancelButton: readableActive(() => cancelButton),
closeButton: readableActive(() => closeButton),
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),

@ -78,9 +78,6 @@ function createDatePickerOpts() {
closeOnDateSelect: state(true),
mode: state<DatePickerMode>('inline'),
kind: state<DatePickerKind>('date'),
clearButton: state(false),
cancelButton: state(false),
closeButton: state(false),
granularity: state<Granularity | undefined>(undefined),
hideTimeZone: state(false),
hourCycle: state<HourCycle | undefined>(undefined),

@ -74,9 +74,6 @@ interface DatePickerOpts
closeOnDateSelect: boolean;
mode: DatePickerMode;
kind: DatePickerKind;
clearButton: boolean;
cancelButton: boolean;
closeButton: boolean;
// Format / i18n
granularity: Granularity | undefined;
hideTimeZone: boolean;

@ -74,24 +74,6 @@ type DatePickerBaseProps = {
* @default 'date'
*/
kind?: import('./date-picker-provider.svelte').DatePickerKind;
/**
* Whether the footer renders a Clear button (resets value, keeps popover
* open). @default false
*/
clearButton?: boolean;
/**
* Whether the footer renders a Cancel button (reverts to value-at-open
* and closes). @default false
*/
cancelButton?: boolean;
/**
* Whether the footer renders a Close/Save button (commits current value
* and closes). In `mode='modal'` this is forced ON so the user always has
* a way out — modal pickers don't dismiss on outside click or Escape.
* @default false (inline) · forced true (modal)
*/
closeButton?: boolean;
// ── Validation ──
/** Custom validator — return an error message when invalid. */
validate?: DateValidator;

@ -32,9 +32,6 @@
onOpenChangeComplete = () => {},
closeOnRangeSelect = true,
mode = 'inline',
clearButton = false,
cancelButton = false,
closeButton = false,
validate,
onInvalid,
minValue,
@ -178,9 +175,6 @@
required: readableActive(() => required),
closeOnRangeSelect: readableActive(() => closeOnRangeSelect),
mode: readableActive(() => mode),
clearButton: readableActive(() => clearButton),
cancelButton: readableActive(() => cancelButton),
closeButton: readableActive(() => closeButton),
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),

@ -81,9 +81,6 @@ function createDateRangePickerOpts() {
required: state(false),
closeOnRangeSelect: state(true),
mode: state<'inline' | 'modal'>('inline'),
clearButton: state(false),
cancelButton: state(false),
closeButton: state(false),
granularity: state<Granularity | undefined>(undefined),
hideTimeZone: state(false),
hourCycle: state<HourCycle | undefined>(undefined),

@ -60,9 +60,6 @@ interface DateRangePickerOpts
required: boolean;
closeOnRangeSelect: boolean;
mode: DateRangePickerMode;
clearButton: boolean;
cancelButton: boolean;
closeButton: boolean;
granularity: Granularity | undefined;
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;

@ -58,24 +58,6 @@ type DateRangePickerBaseProps = {
* @default 'inline'
*/
mode?: import('./date-range-picker-provider.svelte').DateRangePickerMode;
/**
* Whether the footer renders a Clear button (resets value, keeps popover
* open). @default false
*/
clearButton?: boolean;
/**
* Whether the footer renders a Cancel button (reverts to value-at-open
* and closes). @default false
*/
cancelButton?: boolean;
/**
* Whether the footer renders a Close/Save button (commits current value
* and closes). In `mode='modal'` this is forced ON so the user always has
* a way out — modal pickers don't dismiss on outside click or Escape.
* @default false (inline) · forced true (modal)
*/
closeButton?: boolean;
// ── Validation ──
/** Custom range validator. */
validate?: DateRangeValidator;

@ -56,9 +56,6 @@
let closeOnDateSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let kind = $state<'date' | 'month' | 'year'>('date');
let clearButton = $state(true);
let cancelButton = $state(true);
let closeButton = $state(true);
let disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(false);
let pagedNavigation = $state(false);
@ -277,9 +274,6 @@
maxValue && ' {maxValue}',
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
clearButton && ' clearButton',
cancelButton && ' cancelButton',
closeButton && ' closeButton',
kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
@ -352,9 +346,6 @@
maxValue && ' {maxValue}',
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
clearButton && ' clearButton',
cancelButton && ' cancelButton',
closeButton && ' closeButton',
kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
@ -452,9 +443,6 @@
{color}
{mode}
{kind}
{clearButton}
{cancelButton}
{closeButton}
minValue={minValue}
maxValue={maxValue}
weekStartsOn={resolvedWeekStartsOn}
@ -649,12 +637,6 @@
</label>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>footer buttons</span>
<span data-uix-switch><input type="checkbox" bind:checked={clearButton} /><span data-uix-switch-label>clear</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={cancelButton} /><span data-uix-switch-label>cancel</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={closeButton} /><span data-uix-switch-label>close</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label
>kind <span data-uix-control-hint>{kind === 'year' ? 'YYYY' : kind === 'month' ? 'MM/YYYY' : 'MM/DD/YYYY'}</span></span

@ -60,9 +60,6 @@
let allowSingleDay = $state(false);
let closeOnRangeSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let clearButton = $state(true);
let cancelButton = $state(true);
let closeButton = $state(true);
let disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(true);
let pagedNavigation = $state(false);
@ -486,9 +483,6 @@
{allowSingleDay}
{closeOnRangeSelect}
{mode}
{clearButton}
{cancelButton}
{closeButton}
{disableDaysOutsideMonth}
{fixedWeeks}
{pagedNavigation}
@ -702,12 +696,6 @@
<span data-uix-radio-label>modal</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>footer buttons</span>
<span data-uix-switch><input type="checkbox" bind:checked={clearButton} /><span data-uix-switch-label>clear</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={cancelButton} /><span data-uix-switch-label>cancel</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={closeButton} /><span data-uix-switch-label>close</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>states</span>
<span data-uix-switch><input type="checkbox" bind:checked={required} /><span data-uix-switch-label>required</span></span>

Loading…
Cancel
Save

Powered by TurnKey Linux.