Add reactive date presentation config to acel calendars

main
dev 6 months ago
parent e192a2f815
commit cf186d3712

@ -3,17 +3,35 @@
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
import type { HourCycle } from "./date/types.ts";
/**
* Nivel de detalle en el formateo de fecha u hora.
* Mapea directamente a los valores que acepta `Intl.DateTimeFormat`.
*/
export type DatiStyleType = 'short' | 'medium' | 'long' | 'full';
/** Formato de horas: reloj de 12 o 24 horas. */
/**
* Preferencia de reloj para el usuario: `'12h'` o `'24h'`.
* Para pasar a APIs de `Intl` (que esperan el número), usa `datiTimeFormatToHourCycle`.
*/
export type DatiTimeFormat = '12h' | '24h';
/** Orden preferido de los componentes de fecha (informativo para la UI). */
export type DatiDateOrder = 'DMY' | 'MDY' | 'YMD';
/** Valores por defecto compartidos para componentes desacoplados de contexto. */
export const DEFAULT_DATI_TIME_FORMAT: DatiTimeFormat = '24h';
export const DEFAULT_DATI_DATE_ORDER: DatiDateOrder = 'DMY';
/** Convierte la preferencia de usuario `DatiTimeFormat` al `HourCycle` numérico de `Intl`. */
export function datiTimeFormatToHourCycle(fmt: DatiTimeFormat): HourCycle {
return fmt === '12h' ? 12 : 24;
}
/** Convierte un `HourCycle` numérico a la preferencia de usuario `DatiTimeFormat`. */
export function hourCycleToDatiTimeFormat(cycle: HourCycle): DatiTimeFormat {
return cycle === 12 ? '12h' : '24h';
}
/**
* Opciones de `f()`:
* - `'short'` / `'medium'` / `'long'` / `'full'` : solo fecha en ese estilo.

@ -1,5 +1,6 @@
<script lang="ts">
import { Calendar } from '$uix/acel';
import type { DatiDateOrder } from '$lib/glob/lib';
import type { DateValue } from '$lib/glob/lib/date';
import { today, getLocalTimeZone, CalendarDate, isSameDay } from '$lib/glob/lib/date';
@ -12,6 +13,20 @@
const todayDate = today(getLocalTimeZone());
// ── Selector de locale global ─────────────────────────────────────────────
const LOCALES: { id: string; label: string; hint: string }[] = [
{ id: 'es-ES', label: 'es-ES', hint: 'DD/MM/AAAA' },
{ id: 'en-US', label: 'en-US', hint: 'MM/DD/YYYY' },
{ id: 'en-GB', label: 'en-GB', hint: 'DD/MM/YYYY' },
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ar-SA', label: 'ar-SA', hint: 'RTL هـ' },
];
let locale = $state('es-ES');
const DATE_ORDERS: DatiDateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DatiDateOrder>('DMY');
// Fechas deshabilitadas: fines de semana (0 = dom, 6 = sáb en getDay())
function isWeekend(date: DateValue): boolean {
const d = date.toDate(getLocalTimeZone());
@ -104,6 +119,30 @@
</div>
</header>
<div class="type-bar">
<span class="type-bar-label">Locale:</span>
{#each LOCALES as loc}
<button
class="type-btn"
class:type-btn--active={locale === loc.id}
onclick={() => (locale = loc.id)}
title={loc.hint}
>{loc.label}</button>
{/each}
<span class="type-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
</div>
<div class="type-bar type-bar--sub">
<span class="type-bar-label">Date order:</span>
{#each DATE_ORDERS as order}
<button
class="type-btn"
class:type-btn--active={dateOrder === order}
onclick={() => (dateOrder = order)}
>{order}</button>
{/each}
</div>
<main>
<div class="grid">
@ -115,7 +154,7 @@
</header>
<p class="card-desc">Selección de una fecha. Haz clic en un día para seleccionarlo.</p>
<Calendar.Root type="single" bind:value={singleValue} class="cal">
<Calendar.Root type="single" bind:value={singleValue} {locale} {dateOrder} class="cal">
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
@ -168,6 +207,8 @@
type="multiple"
bind:value={multiPre}
maxDays={5}
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
@ -225,6 +266,8 @@
maxValue={maxDate}
isDateDisabled={isWeekend}
isDateUnavailable={isUnavailable}
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
@ -274,13 +317,18 @@
Navegación rápida usando selectores de mes y año.
</p>
<Calendar.Root type="single" bind:value={rangeValue} class="cal">
{#snippet children({ months, weekdays })}
<Calendar.Root type="single" bind:value={rangeValue} {locale} {dateOrder} class="cal">
{#snippet children({ months, weekdays, dateOrder })}
<Calendar.Header class="cal-hd cal-hd--sel">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<div class="sel-group">
<Calendar.MonthSelect class="cal-select" />
<Calendar.YearSelect class="cal-select" />
{#if dateOrder === 'YMD'}
<Calendar.YearSelect class="cal-select" />
<Calendar.MonthSelect class="cal-select" />
{:else}
<Calendar.MonthSelect class="cal-select" />
<Calendar.YearSelect class="cal-select" />
{/if}
</div>
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
@ -333,6 +381,8 @@
fixedWeeks
weekStartsOn={1}
weekdayFormat="short"
{locale}
{dateOrder}
class="cal cal--two"
>
{#snippet children({ months, weekdays })}
@ -402,6 +452,8 @@
type="single"
readonly
value={todayDate}
{locale}
{dateOrder}
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
@ -442,6 +494,8 @@
type="single"
disabled
value={todayDate}
{locale}
{dateOrder}
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
@ -499,6 +553,8 @@
onPlaceholderChange={onRangeStartPlaceholderChange}
maxValue={rangeEnd}
preventDeselect
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
@ -549,6 +605,8 @@
onPlaceholderChange={onRangeEndPlaceholderChange}
minValue={rangeStart}
preventDeselect
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
@ -847,4 +905,23 @@
padding : .1rem .35rem;
border-radius: 3px;
}
/* ── Type switcher ─────────────────────────────────────────────────────── */
.type-bar {
max-width: 1200px; margin: 0 auto; padding: .6rem 1.5rem;
display: flex; align-items: center; gap: .5rem;
border-bottom: 1px solid #26262f;
background: #0e0e14;
}
.type-bar--sub { border-top: 1px solid #171720; }
.type-bar-label { font-size: .65rem; color: #555; letter-spacing: .08em; margin-right: .25rem; }
.type-btn {
font-family: inherit; font-size: .65rem; letter-spacing: .05em;
padding: .2rem .55rem; border-radius: 3px; cursor: pointer;
background: #1a1a24; border: 1px solid #26262f; color: #666;
transition: border-color .15s, color .15s;
}
.type-btn:hover { border-color: #818cf8; color: #aaa; }
.type-btn--active { border-color: #818cf8; color: #818cf8; background: #1e1e30; }
.type-badge { margin-left: auto; font-size: .6rem; color: #2dd4bf; letter-spacing: .1em; }
</style>

@ -0,0 +1,418 @@
<script lang="ts">
import { DateRangeField } from '$uix/acel';
import type { DatiDateOrder, DatiTimeFormat } from '$lib/glob/lib';
import type { DateValue } from '$lib/glob/lib/date';
import { today, getLocalTimeZone, CalendarDate } from '$lib/glob/lib/date';
const tz = getLocalTimeZone();
const now = today(tz);
// ── Selector de locale global ─────────────────────────────────────────────
const LOCALES: { id: string; label: string; hint: string }[] = [
{ id: 'es-ES', label: 'es-ES', hint: 'DD/MM/AAAA' },
{ id: 'en-US', label: 'en-US', hint: 'MM/DD/YYYY' },
{ id: 'en-GB', label: 'en-GB', hint: 'DD/MM/YYYY' },
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ar-SA', label: 'ar-SA', hint: 'RTL هـ' },
];
let locale = $state('es-ES');
const DATE_ORDERS: DatiDateOrder[] = ['DMY', 'MDY', 'YMD'];
const TIME_FORMATS: DatiTimeFormat[] = ['24h', '12h'];
let dateOrder = $state<DatiDateOrder>('DMY');
let timeFormat = $state<DatiTimeFormat>('24h');
// ── 01. Básico ───────────────────────────────────────────────────────────
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
// ── 02. Pre-seleccionado ─────────────────────────────────────────────────
let value2 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: new CalendarDate(now.year, now.month, 5),
end: new CalendarDate(now.year, now.month, 20),
});
// ── 03. Validación ───────────────────────────────────────────────────────
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
function validateRange(range: { start: DateValue; end: DateValue }) {
const days = Math.round(
(range.end.toDate(tz).getTime() - range.start.toDate(tz).getTime()) / 86_400_000
);
if (days < 2) return 'El rango debe ser de al menos 2 días';
if (days > 30) return 'El rango no puede superar 30 días';
}
// ── 04. Hora (granularity=hour) ──────────────────────────────────────────
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
// ── 05. disabled / readonly ──────────────────────────────────────────────
const fixedVal = {
start: new CalendarDate(now.year, now.month, 10),
end: new CalendarDate(now.year, now.month, 25),
};
function fmt(v: { start?: DateValue; end?: DateValue } | undefined) {
if (!v) return '—';
const d = (x: DateValue | undefined) =>
x ? `${x.year}-${String(x.month).padStart(2,'0')}-${String(x.day).padStart(2,'0')}` : '?';
return `${d(v.start)} → ${d(v.end)}`;
}
</script>
<div class="page">
<header class="ph">
<div class="ph-inner">
<div class="ph-brand">
<span class="ph-dot"></span>
<span class="ph-title">date-range<em>-field</em></span>
</div>
<span class="ph-sub">acel · campo de entrada de rango de fechas</span>
</div>
</header>
<div class="type-bar">
<span class="type-bar-label">Locale:</span>
{#each LOCALES as loc}
<button
class="type-btn"
class:type-btn--active={locale === loc.id}
onclick={() => (locale = loc.id)}
title={loc.hint}
>{loc.label}</button>
{/each}
<span class="type-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
</div>
<div class="type-bar type-bar--sub">
<span class="type-bar-label">Date order:</span>
{#each DATE_ORDERS as order}
<button
class="type-btn"
class:type-btn--active={dateOrder === order}
onclick={() => (dateOrder = order)}
>{order}</button>
{/each}
<span class="type-bar-label type-bar-label--spaced">Time format:</span>
{#each TIME_FORMATS as format}
<button
class="type-btn"
class:type-btn--active={timeFormat === format}
onclick={() => (timeFormat = format)}
>{format}</button>
{/each}
</div>
<main>
<div class="grid">
<!-- ── 01. Básico ────────────────────────────────────────────── -->
<section class="card" data-color="teal">
<header class="card-hd">
<span class="tag">01</span>
<span class="card-title">Básico</span>
</header>
<p class="card-desc">Campo de rango de fechas. Navega con Tab entre segmentos.</p>
<div class="field-wrap">
<DateRangeField.Root bind:value={value1} {locale} {dateOrder} {timeFormat} class="drf">
<DateRangeField.Label class="drf-label">Rango de fechas</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(value1)}</span>
</div>
</section>
<!-- ── 02. Pre-seleccionado ──────────────────────────────────── -->
<section class="card" data-color="violet">
<header class="card-hd">
<span class="tag">02</span>
<span class="card-title">Pre-seleccionado</span>
</header>
<p class="card-desc">Rango cargado con días 5–20 del mes actual.</p>
<div class="field-wrap">
<DateRangeField.Root bind:value={value2} {locale} {dateOrder} {timeFormat} class="drf">
<DateRangeField.Label class="drf-label">Rango de fechas</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(value2)}</span>
</div>
</section>
<!-- ── 03. Validación ────────────────────────────────────────── -->
<section class="card" data-color="amber">
<header class="card-hd">
<span class="tag">03</span>
<span class="card-title">Validación: 2–30 días</span>
</header>
<p class="card-desc">El rango debe tener entre 2 y 30 días.</p>
<div class="field-wrap">
<DateRangeField.Root bind:value={value3} {locale} {dateOrder} {timeFormat} validate={validateRange} class="drf">
<DateRangeField.Label class="drf-label">Rango de fechas</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(value3)}</span>
</div>
</section>
<!-- ── 04. granularity=hour ──────────────────────────────────── -->
<section class="card" data-color="indigo">
<header class="card-hd">
<span class="tag">04</span>
<span class="card-title">granularity="hour"</span>
</header>
<p class="card-desc">Incluye hora de inicio y fin. Alterna entre 12h y 24h.</p>
<div class="field-wrap">
<DateRangeField.Root bind:value={value4} {locale} {dateOrder} {timeFormat} granularity="hour" class="drf">
<DateRangeField.Label class="drf-label">Reserva de sala</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(value4)}</span>
</div>
</section>
<!-- ── 05. disabled / readonly ───────────────────────────────── -->
<section class="card" data-color="rose">
<header class="card-hd">
<span class="tag">05</span>
<span class="card-title">disabled / readonly</span>
</header>
<p class="card-desc">Estados de interacción bloqueada.</p>
<div class="pair-col">
<div>
<p class="pair-label">readonly</p>
<div class="field-wrap">
<DateRangeField.Root value={fixedVal} {dateOrder} {timeFormat} readonly class="drf">
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
</div>
<div>
<p class="pair-label">disabled</p>
<div class="field-wrap">
<DateRangeField.Root value={fixedVal} {dateOrder} {timeFormat} disabled class="drf">
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
<span class="drf-sep">→</span>
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
{/each}
{/snippet}
</DateRangeField.Input>
</div>
</DateRangeField.Root>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height: 100vh; background: #0e0e14; color: #d4d2cc;
font-family: 'DM Mono', 'Fira Code', monospace; font-size: 13px;
}
.ph { border-bottom: 2px solid #818cf8; background: #080810; }
.ph-inner {
max-width: 1200px; margin: 0 auto; padding: .75rem 1.5rem;
display: flex; align-items: center; justify-content: space-between;
}
.ph-brand { display: flex; align-items: center; gap: .5rem; }
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #818cf8; }
.ph-title { font-size: .85rem; font-weight: 700; letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef; }
.ph-title em { color: #818cf8; font-style: normal; }
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: .75rem; }
.card {
background: #16161f; border: 1px solid #26262f; border-radius: 6px;
padding: 1.1rem; display: flex; flex-direction: column; gap: .75rem;
border-top: 3px solid transparent;
}
.card[data-color="teal"] { border-top-color: #2dd4bf; }
.card[data-color="violet"] { border-top-color: #a78bfa; }
.card[data-color="amber"] { border-top-color: #e5a100; }
.card[data-color="indigo"] { border-top-color: #818cf8; }
.card[data-color="rose"] { border-top-color: #fb7185; }
.card-hd { display: flex; align-items: center; gap: .5rem; padding-bottom: .6rem; border-bottom: 1px solid #26262f; }
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
/* Field */
.field-wrap { display: flex; flex-direction: column; gap: .4rem; }
:global(.drf) { display: flex; flex-direction: column; gap: .3rem; }
:global(.drf-label) { font-size: .65rem; color: #666; letter-spacing: .08em; }
.drf-inputs {
display: flex; align-items: center; gap: .4rem;
background: #1a1a24; border: 1px solid #26262f; border-radius: 4px;
padding: .4rem .6rem;
}
:global(.drf-input) { display: flex; align-items: center; gap: 1px; }
.drf-sep { color: #555; font-size: .75rem; flex-shrink: 0; }
:global(.drf-seg) {
display: inline-block; padding: .05rem .15rem; border-radius: 2px;
min-width: 1.6em; text-align: center; font-size: .78rem; color: #d4d2cc;
cursor: default; outline: none;
}
:global(.drf-seg[data-type="literal"]) { color: #444; min-width: auto; padding: 0 1px; }
:global(.drf-seg:focus), :global(.drf-seg[data-focused]) {
background: #818cf8; color: #fff;
}
:global(.drf-seg[data-placeholder]) { color: #555; }
:global(.drf[data-readonly] .drf-seg) { pointer-events: none; color: #777; }
:global(.drf[data-disabled] .drf-seg) { pointer-events: none; color: #444; opacity: .6; }
:global(.drf[data-invalid] .drf-input) { border-color: #fb7185; }
/* Value display */
.val-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
.val-label { color: #555; }
.val-val { font-weight: 700; color: #818cf8; }
/* Pair */
.pair-col { display: flex; flex-direction: column; gap: .75rem; }
.pair-label { font-size: .62rem; color: #555; margin-bottom: .3rem; letter-spacing: .1em; }
/* Type switcher */
.type-bar {
max-width: 1200px; margin: 0 auto; padding: .6rem 1.5rem;
display: flex; align-items: center; gap: .5rem;
border-bottom: 1px solid #26262f;
background: #0e0e14;
}
.type-bar-label { font-size: .65rem; color: #555; letter-spacing: .08em; margin-right: .25rem; }
.type-btn {
font-family: inherit; font-size: .65rem; letter-spacing: .05em;
padding: .2rem .55rem; border-radius: 3px; cursor: pointer;
background: #1a1a24; border: 1px solid #26262f; color: #666;
transition: border-color .15s, color .15s;
}
.type-btn:hover { border-color: #818cf8; color: #aaa; }
.type-btn--active { border-color: #818cf8; color: #818cf8; background: #1e1e30; }
.type-badge { margin-left: auto; font-size: .6rem; color: #2dd4bf; letter-spacing: .1em; }
.type-bar--sub { border-top: 1px solid #171720; }
.type-bar-label--spaced { margin-left: 1rem; }
</style>

@ -0,0 +1,578 @@
<script lang="ts">
import { RangeCalendar } from '$uix/acel';
import type { DatiDateOrder } from '$lib/glob/lib';
import type { DateValue } from '$lib/glob/lib/date';
import { today, getLocalTimeZone, CalendarDate } from '$lib/glob/lib/date';
const tz = getLocalTimeZone();
const now = today(tz);
// ── Selector de locale global ─────────────────────────────────────────────
const LOCALES: { id: string; label: string; hint: string }[] = [
{ id: 'es-ES', label: 'es-ES', hint: 'DD/MM/AAAA' },
{ id: 'en-US', label: 'en-US', hint: 'MM/DD/YYYY' },
{ id: 'en-GB', label: 'en-GB', hint: 'DD/MM/YYYY' },
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ar-SA', label: 'ar-SA', hint: 'RTL هـ' },
];
let locale = $state('es-ES');
const DATE_ORDERS: DatiDateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DatiDateOrder>('DMY');
// ── 01. Básico ───────────────────────────────────────────────────────────
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined,
});
// ── 02. Pre-seleccionado ─────────────────────────────────────────────────
let value2 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: new CalendarDate(now.year, now.month, 5),
end: new CalendarDate(now.year, now.month, 14),
});
// ── 03. Min/Max + días deshabilitados ────────────────────────────────────
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
const minVal = now.subtract({ months: 1 });
const maxVal = now.add({ months: 2 });
function isWeekend(date: DateValue) {
const d = date.toDate(tz).getDay();
return d === 0 || d === 6;
}
// ── 04. minDays / maxDays ────────────────────────────────────────────────
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
// ── 05. Dos meses ────────────────────────────────────────────────────────
let value5 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
// ── 06. readonly / disabled ──────────────────────────────────────────────
const fixedRange = {
start: new CalendarDate(now.year, now.month, 8),
end: new CalendarDate(now.year, now.month, 18),
};
function fmt(v: { start: DateValue | undefined; end: DateValue | undefined }) {
const d = (x: DateValue | undefined) =>
x ? `${x.year}-${String(x.month).padStart(2,'0')}-${String(x.day).padStart(2,'0')}` : '—';
return `${d(v.start)} → ${d(v.end)}`;
}
function days(v: { start: DateValue | undefined; end: DateValue | undefined }) {
if (!v.start || !v.end) return null;
const s = v.start.toDate(tz);
const e = v.end.toDate(tz);
return Math.round((e.getTime() - s.getTime()) / 86_400_000);
}
</script>
<div class="page">
<header class="ph">
<div class="ph-inner">
<div class="ph-brand">
<span class="ph-dot"></span>
<span class="ph-title">range<em>-calendar</em></span>
</div>
<span class="ph-sub">acel · selección de rango en calendario</span>
</div>
</header>
<div class="type-bar">
<span class="type-bar-label">Locale:</span>
{#each LOCALES as loc}
<button
class="type-btn"
class:type-btn--active={locale === loc.id}
onclick={() => (locale = loc.id)}
title={loc.hint}
>{loc.label}</button>
{/each}
<span class="type-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
</div>
<div class="type-bar type-bar--sub">
<span class="type-bar-label">Date order:</span>
{#each DATE_ORDERS as order}
<button
class="type-btn"
class:type-btn--active={dateOrder === order}
onclick={() => (dateOrder = order)}
>{order}</button>
{/each}
</div>
<main>
<div class="grid">
<!-- ── 01. Básico ────────────────────────────────────────────── -->
<section class="card" data-color="teal">
<header class="card-hd">
<span class="tag">01</span>
<span class="card-title">Básico</span>
</header>
<p class="card-desc">Selección de un rango de fechas. Clic en inicio, luego en fin.</p>
<RangeCalendar.Root bind:value={value1} {locale} {dateOrder} class="cal">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(value1)}</span>
{#if days(value1) !== null}<span class="val-days">{days(value1)}d</span>{/if}
</div>
</section>
<!-- ── 02. Pre-seleccionado ──────────────────────────────────── -->
<section class="card" data-color="violet">
<header class="card-hd">
<span class="tag">02</span>
<span class="card-title">Pre-seleccionado</span>
</header>
<p class="card-desc">Rango cargado con días 5–14 del mes actual.</p>
<RangeCalendar.Root bind:value={value2} {locale} {dateOrder} class="cal">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(value2)}</span>
{#if days(value2) !== null}<span class="val-days">{days(value2)}d</span>{/if}
</div>
</section>
<!-- ── 03. minValue / maxValue + isDateDisabled ─────────────── -->
<section class="card" data-color="amber">
<header class="card-hd">
<span class="tag">03</span>
<span class="card-title">minValue / maxValue + isDateDisabled</span>
</header>
<p class="card-desc">Rango permitido: ±1–2 meses. Fines de semana deshabilitados.</p>
<RangeCalendar.Root
bind:value={value3}
minValue={minVal}
maxValue={maxVal}
isDateDisabled={isWeekend}
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(value3)}</span>
</div>
</section>
<!-- ── 04. minDays / maxDays ─────────────────────────────────── -->
<section class="card" data-color="indigo">
<header class="card-hd">
<span class="tag">04</span>
<span class="card-title">minDays=3 / maxDays=10</span>
</header>
<p class="card-desc">El rango debe tener entre 3 y 10 días.</p>
<RangeCalendar.Root bind:value={value4} minDays={3} maxDays={10} {locale} {dateOrder} class="cal">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(value4)}</span>
{#if days(value4) !== null}<span class="val-days">{days(value4)}d</span>{/if}
</div>
</section>
<!-- ── 05. Dos meses ─────────────────────────────────────────── -->
<section class="card card--wide" data-color="rose">
<header class="card-hd">
<span class="tag">05</span>
<span class="card-title">numberOfMonths=2 + weekStartsOn=1</span>
</header>
<p class="card-desc">Dos meses simultáneos. Semana empieza el lunes.</p>
<RangeCalendar.Root
bind:value={value5}
numberOfMonths={2}
weekStartsOn={1}
weekdayFormat="short"
{locale}
{dateOrder}
class="cal cal--two"
>
{#snippet children({ months, weekdays })}
<div class="two-month-layout">
{#each months as month, i}
<div class="month-col">
<div class="month-nav">
{#if i === 0}
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
{:else}
<span></span>
{/if}
<RangeCalendar.Heading class="cal-heading" />
{#if i === months.length - 1}
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
{:else}
<span></span>
{/if}
</div>
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</div>
{/each}
</div>
{/snippet}
</RangeCalendar.Root>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(value5)}</span>
{#if days(value5) !== null}<span class="val-days">{days(value5)}d</span>{/if}
</div>
</section>
<!-- ── 06. readonly / disabled ───────────────────────────────── -->
<section class="card" data-color="green">
<header class="card-hd">
<span class="tag">06</span>
<span class="card-title">readonly / disabled</span>
</header>
<p class="card-desc">
<code>readonly</code>: navegable pero no editable.
<code>disabled</code>: completamente inactivo.
</p>
<div class="pair-row">
<div>
<p class="pair-label">readonly</p>
<RangeCalendar.Root value={fixedRange} {locale} {dateOrder} readonly class="cal cal--sm">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
</div>
<div>
<p class="pair-label">disabled</p>
<RangeCalendar.Root value={fixedRange} {locale} {dateOrder} disabled class="cal cal--sm">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="cal-heading" />
<RangeCalendar.NextButton class="cal-btn">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month}
<RangeCalendar.Grid class="cal-grid">
<RangeCalendar.GridHead class="cal-thead">
<RangeCalendar.GridRow class="cal-tr">
{#each weekdays as day}
<RangeCalendar.HeadCell class="cal-th">{day}</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<RangeCalendar.GridRow class="cal-tr">
{#each week as date}
<RangeCalendar.Cell {date} month={month.value} class="cal-td">
<RangeCalendar.Day class="cal-day" />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
</div>
</div>
</section>
</div>
</main>
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height : 100vh;
background : #0e0e14;
color : #d4d2cc;
font-family: 'DM Mono', 'Fira Code', monospace;
font-size : 13px;
}
.ph { border-bottom: 2px solid #2dd4bf; background: #080810; }
.ph-inner {
max-width: 1200px; margin: 0 auto;
padding : .75rem 1.5rem;
display : flex; align-items: center; justify-content: space-between;
}
.ph-brand { display: flex; align-items: center; gap: .5rem; }
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; }
.ph-title { font-size: .85rem; font-weight: 700; letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef; }
.ph-title em { color: #2dd4bf; font-style: normal; }
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
gap: .75rem;
}
.card {
background: #16161f; border: 1px solid #26262f; border-radius: 6px;
padding: 1.1rem; display: flex; flex-direction: column; gap: .75rem;
border-top: 3px solid transparent;
}
.card--wide { grid-column: 1 / -1; }
.card[data-color="teal"] { border-top-color: #2dd4bf; }
.card[data-color="violet"] { border-top-color: #a78bfa; }
.card[data-color="amber"] { border-top-color: #e5a100; }
.card[data-color="indigo"] { border-top-color: #818cf8; }
.card[data-color="rose"] { border-top-color: #fb7185; }
.card[data-color="green"] { border-top-color: #4ade80; }
.card-hd { display: flex; align-items: center; gap: .5rem; padding-bottom: .6rem; border-bottom: 1px solid #26262f; }
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
code { background: #26262f; padding: .1rem .35rem; border-radius: 2px; font-size: .68rem; color: #a78bfa; font-family: inherit; }
/* Calendar */
:global(.cal) {
background: #1a1a24; border: 1px solid #26262f; border-radius: 6px;
padding: .75rem; display: inline-block; width: 100%;
}
:global(.cal--sm) { font-size: .68rem; }
:global(.cal--two) { width: 100%; }
:global(.cal-hd) { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
:global(.cal-heading) { font-size: .72rem; font-weight: 700; color: #d4d2cc; text-align: center; }
:global(.cal-btn) {
background: transparent; border: 1px solid #26262f; border-radius: 3px;
color: #aaa; cursor: pointer; width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
font-size: .85rem; transition: background .1s, color .1s; flex-shrink: 0;
}
:global(.cal-btn:hover:not([disabled])) { background: #26262f; color: #f4f3ef; }
:global(.cal-btn[disabled]) { opacity: .35; cursor: not-allowed; }
:global(.cal-grid) { width: 100%; border-collapse: collapse; }
:global(.cal-th) { width: 14.28%; text-align: center; font-size: .6rem; font-weight: 600; color: #555; padding: .2rem 0; }
:global(.cal-td) { padding: 2px; text-align: center; }
:global(.cal-day) {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin: 0 auto; border-radius: 50%;
font-size: .72rem; cursor: pointer; color: #d4d2cc; transition: background .1s, color .1s;
}
:global(.cal-day:hover:not([aria-disabled="true"]):not([data-outside-visible-months])) { background: #26262f; }
:global(.cal-day[data-selected]) { background: #2dd4bf; color: #0a0a0a; font-weight: 700; }
:global(.cal-day[data-selection-start]),
:global(.cal-day[data-selection-end]) { background: #2dd4bf; color: #0a0a0a; font-weight: 700; }
:global(.cal-day[data-highlighted]) { background: rgba(45, 212, 191, 0.15); border-radius: 0; }
:global(.cal-day[data-today]:not([data-selected]):not([data-highlighted])) { border: 1px solid #2dd4bf; color: #2dd4bf; }
:global(.cal-day[data-outside-visible-months]) { color: #333; cursor: default; }
:global(.cal-day[data-disabled]), :global(.cal-day[aria-disabled="true"]) { color: #333; cursor: not-allowed; pointer-events: none; }
:global(.cal-day[data-unavailable]) { color: #fb7185; text-decoration: line-through; }
:global(.cal[data-readonly]) { opacity: .75; }
:global(.cal[data-disabled]) { opacity: .45; pointer-events: none; }
/* Two months */
.two-month-layout { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.month-col { display: flex; flex-direction: column; gap: .4rem; }
.month-nav { display: flex; align-items: center; justify-content: space-between; }
/* Value display */
.val-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; flex-wrap: wrap; }
.val-label { color: #555; }
.val-val { font-weight: 700; color: #2dd4bf; font-size: .72rem; }
.val-days { background: #0d2a27; color: #2dd4bf; font-size: .6rem; font-weight: 700; padding: .1rem .35rem; border-radius: 3px; }
/* Pair */
.pair-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pair-label { font-size: .62rem; color: #555; margin-bottom: .3rem; letter-spacing: .1em; }
/* Locale bar */
.type-bar {
max-width: 1200px; margin: 0 auto; padding: .6rem 1.5rem;
display: flex; align-items: center; gap: .5rem;
border-bottom: 1px solid #26262f; background: #0e0e14;
}
.type-bar-label { font-size: .65rem; color: #555; letter-spacing: .08em; margin-right: .25rem; }
.type-btn {
font-family: inherit; font-size: .65rem; letter-spacing: .05em;
padding: .2rem .55rem; border-radius: 3px; cursor: pointer;
background: #1a1a24; border: 1px solid #26262f; color: #666;
transition: border-color .15s, color .15s;
}
.type-btn:hover { border-color: #2dd4bf; color: #aaa; }
.type-btn--active { border-color: #2dd4bf; color: #2dd4bf; background: #0d2a27; }
.type-badge { margin-left: auto; font-size: .6rem; color: #2dd4bf; letter-spacing: .1em; }
</style>

@ -1,10 +1,11 @@
import {
type DateValue,
type DatiDateOrder,
getLocalTimeZone,
isSameDay,
isSameMonth,
isToday,
} from "$lib/glob/lib/date";
} from "$lib/glob/lib";
import { DEV } from "esm-env";
import { onMount, untrack } from "svelte";
import {
@ -25,7 +26,14 @@ import type {
WithRefOpts,
} from "../utilities/types";
import { useId } from "../utilities/use-id";
import type { DateMatcher, Month } from "../utilities/index";
import {
type DateMatcher,
type Month,
type WeekStartsOn,
getDateValueType,
isBefore,
dateValueToDate as toDate,
} from "$lib/glob/lib";
import { type Announcer, getAnnouncer } from "../utilities/datetime/announcer";
import { type Formatter, createFormatter } from "../utilities/datetime/formatter";
import {
@ -47,8 +55,6 @@ import {
useMonthViewOptionsSync,
useMonthViewPlaceholderSync,
} from "../utilities/datetime/calendar-helpers.svelte";
import { getDateValueType, isBefore, toDate } from "../utilities/datetime/utils";
import type { WeekStartsOn } from "../utilities/datetime/date-types";
// ─── Root ─────────────────────────────────────────────────────────────────────
@ -71,6 +77,7 @@ interface CalendarRootStateOpts
fixedWeeks: boolean;
numberOfMonths: number;
locale: string;
dateOrder: DatiDateOrder;
calendarLabel: string;
type: "single" | "multiple";
readonly: boolean;
@ -485,6 +492,7 @@ export class CalendarRootState {
readonly snippetProps = $derived.by(() => ({
months: this.months,
weekdays: this.weekdays,
dateOrder: this.opts.dateOrder.current,
}));
getAcelAttr: (typeof calendarAttrs)["getAttr"] = (part) => {

@ -1,13 +1,14 @@
<script lang="ts">
import { watch } from "runed";
import { boxWith, mergeProps } from "../../utilities";
import type { DateValue } from "$lib/glob/lib/date";
import type { DateValue } from "$lib/glob/lib";
import { DEFAULT_DATI_DATE_ORDER } from "$lib/glob/lib";
import { CalendarRootState } from "../calendar.svelte";
import type { CalendarRootProps } from "../types";
import { useId } from "../../utilities/use-id";
import { noop } from "../../utilities/noop";
import { getDefaultDate } from "../../utilities/datetime/utils";
import { actx } from "$lib/actx/lib/instance_actx.svelte";
import { getDefaultDate } from "$lib/glob/lib";
import { resolveLocaleProp } from "../../comn/config/prop-resolvers";
let {
child,
@ -26,6 +27,7 @@
fixedWeeks = false,
numberOfMonths = 1,
locale: localeProp = undefined,
dateOrder = DEFAULT_DATI_DATE_ORDER,
calendarLabel = "",
disabled = false,
readonly = false,
@ -41,14 +43,16 @@
...restProps
}: CalendarRootProps = $props();
// Locale reactivo: usa el prop si se pasó, si no sigue actx.locale automáticamente
const locale = $derived(localeProp ?? actx.locale);
// Locale reactivo: usa el prop si se pasó, si no usa AcelConfig o "en"
const localeResolved = resolveLocaleProp(() => localeProp);
const defaultPlaceholder = getDefaultDate({ defaultValue: value, minValue, maxValue });
function getDefaultPlaceholder() {
return getDefaultDate({ defaultValue: value, minValue, maxValue });
}
function handleDefaultPlaceholder() {
if (placeholder !== undefined) return;
placeholder = defaultPlaceholder;
placeholder = getDefaultPlaceholder();
}
// SSR
@ -82,7 +86,8 @@
isDateUnavailable: boxWith(() => isDateUnavailable),
fixedWeeks: boxWith(() => fixedWeeks),
numberOfMonths: boxWith(() => numberOfMonths),
locale: boxWith(() => locale),
locale: localeResolved,
dateOrder: boxWith(() => dateOrder),
calendarLabel: boxWith(() => calendarLabel),
readonly: boxWith(() => readonly),
disabled: boxWith(() => disabled),
@ -111,7 +116,7 @@
monthFormat: boxWith(() => monthFormat),
yearFormat: boxWith(() => yearFormat),
onDateSelect: boxWith(() => undefined),
defaultPlaceholder,
defaultPlaceholder: getDefaultPlaceholder(),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));

@ -1,22 +1,23 @@
import type { DateValue } from "$lib/glob/lib/date";
import type { DateValue, DatiDateOrder } from "$lib/glob/lib";
import type { OnChangeFn, WithChild, Without } from "../utilities/types";
import type {
BitsPrimitiveButtonAttributes,
BitsPrimitiveDivAttributes,
BitsPrimitiveHeaderAttributes,
BitsPrimitiveSelectAttributes,
BitsPrimitiveTableAttributes,
BitsPrimitiveTbodyAttributes,
BitsPrimitiveTdAttributes,
BitsPrimitiveThAttributes,
BitsPrimitiveTheadAttributes,
BitsPrimitiveTrAttributes,
AcelPrimitiveButtonAttributes,
AcelPrimitiveDivAttributes,
AcelPrimitiveHeaderAttributes,
AcelPrimitiveSelectAttributes,
AcelPrimitiveTableAttributes,
AcelPrimitiveTbodyAttributes,
AcelPrimitiveTdAttributes,
AcelPrimitiveThAttributes,
AcelPrimitiveTheadAttributes,
AcelPrimitiveTrAttributes,
} from "../utilities/attributes";
import type { DateMatcher, Month, WeekStartsOn } from "../utilities/datetime/date-types";
import type { DateMatcher, Month, WeekStartsOn } from "$lib/glob/lib";
export type CalendarRootSnippetProps = {
months: Month<DateValue>[];
weekdays: string[];
dateOrder: DatiDateOrder;
};
export type CalendarCellSnippetProps = {
@ -64,6 +65,7 @@ type CalendarBaseRootPropsWithoutHTML = {
numberOfMonths?: number;
calendarLabel?: string;
locale?: string;
dateOrder?: DatiDateOrder;
readonly?: boolean;
initialFocus?: boolean;
disableDaysOutsideMonth?: boolean;
@ -87,7 +89,7 @@ export type CalendarSingleRootProps = WithChild<
_CalendarSingleRootPropsWithoutHTML,
CalendarRootSnippetProps
> &
Without<BitsPrimitiveDivAttributes, _CalendarSingleRootPropsWithoutHTML>;
Without<AcelPrimitiveDivAttributes, _CalendarSingleRootPropsWithoutHTML>;
// ─── Multiple ─────────────────────────────────────────────────────────────────
@ -104,7 +106,7 @@ export type CalendarMultipleRootProps = WithChild<
_CalendarMultipleRootPropsWithoutHTML,
CalendarRootSnippetProps
> &
Without<BitsPrimitiveDivAttributes, _CalendarMultipleRootPropsWithoutHTML>;
Without<AcelPrimitiveDivAttributes, _CalendarMultipleRootPropsWithoutHTML>;
export type CalendarRootProps = CalendarSingleRootProps | CalendarMultipleRootProps;
@ -115,27 +117,35 @@ export type CalendarCellPropsWithoutHTML = WithChild<
CalendarCellSnippetProps
>;
export type CalendarCellProps = CalendarCellPropsWithoutHTML &
Without<BitsPrimitiveTdAttributes, CalendarCellPropsWithoutHTML>;
Without<AcelPrimitiveTdAttributes, CalendarCellPropsWithoutHTML>;
export type CalendarDayPropsWithoutHTML = WithChild<{}, CalendarDaySnippetProps>;
export type CalendarDayProps = CalendarDayPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, CalendarDayPropsWithoutHTML>;
export type CalendarGridProps = WithChild & Without<BitsPrimitiveTableAttributes, WithChild>;
export type CalendarGridBodyProps = WithChild & Without<BitsPrimitiveTbodyAttributes, WithChild>;
export type CalendarGridHeadProps = WithChild & Without<BitsPrimitiveTheadAttributes, WithChild>;
export type CalendarGridRowProps = WithChild & Without<BitsPrimitiveTrAttributes, WithChild>;
export type CalendarHeadCellProps = WithChild & Without<BitsPrimitiveThAttributes, WithChild>;
export type CalendarHeaderProps = WithChild & Without<BitsPrimitiveHeaderAttributes, WithChild>;
Without<AcelPrimitiveDivAttributes, CalendarDayPropsWithoutHTML>;
export type CalendarGridPropsWithoutHTML = WithChild;
export type CalendarGridProps = CalendarGridPropsWithoutHTML & Without<AcelPrimitiveTableAttributes, CalendarGridPropsWithoutHTML>;
export type CalendarGridBodyPropsWithoutHTML = WithChild;
export type CalendarGridBodyProps = CalendarGridBodyPropsWithoutHTML & Without<AcelPrimitiveTbodyAttributes, CalendarGridBodyPropsWithoutHTML>;
export type CalendarGridHeadPropsWithoutHTML = WithChild;
export type CalendarGridHeadProps = CalendarGridHeadPropsWithoutHTML & Without<AcelPrimitiveTheadAttributes, CalendarGridHeadPropsWithoutHTML>;
export type CalendarGridRowPropsWithoutHTML = WithChild;
export type CalendarGridRowProps = CalendarGridRowPropsWithoutHTML & Without<AcelPrimitiveTrAttributes, CalendarGridRowPropsWithoutHTML>;
export type CalendarHeadCellPropsWithoutHTML = WithChild;
export type CalendarHeadCellProps = CalendarHeadCellPropsWithoutHTML & Without<AcelPrimitiveThAttributes, CalendarHeadCellPropsWithoutHTML>;
export type CalendarHeaderPropsWithoutHTML = WithChild;
export type CalendarHeaderProps = CalendarHeaderPropsWithoutHTML & Without<AcelPrimitiveHeaderAttributes, CalendarHeaderPropsWithoutHTML>;
export type CalendarHeadingPropsWithoutHTML = WithChild<{}, CalendarHeadingSnippetProps>;
export type CalendarHeadingProps = CalendarHeadingPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, CalendarHeadingPropsWithoutHTML>;
Without<AcelPrimitiveDivAttributes, CalendarHeadingPropsWithoutHTML>;
export type CalendarNextButtonProps = WithChild &
Without<BitsPrimitiveButtonAttributes, WithChild>;
export type CalendarPrevButtonProps = WithChild &
Without<BitsPrimitiveButtonAttributes, WithChild>;
export type CalendarNextButtonPropsWithoutHTML = WithChild;
export type CalendarNextButtonProps = CalendarNextButtonPropsWithoutHTML &
Without<AcelPrimitiveButtonAttributes, CalendarNextButtonPropsWithoutHTML>;
export type CalendarPrevButtonPropsWithoutHTML = WithChild;
export type CalendarPrevButtonProps = CalendarPrevButtonPropsWithoutHTML &
Without<AcelPrimitiveButtonAttributes, CalendarPrevButtonPropsWithoutHTML>;
export type CalendarMonthSelectPropsWithoutHTML = WithChild<
{
@ -146,7 +156,7 @@ export type CalendarMonthSelectPropsWithoutHTML = WithChild<
CalendarMonthSelectSnippetProps
>;
export type CalendarMonthSelectProps = CalendarMonthSelectPropsWithoutHTML &
Without<BitsPrimitiveSelectAttributes, CalendarMonthSelectPropsWithoutHTML>;
Without<AcelPrimitiveSelectAttributes, CalendarMonthSelectPropsWithoutHTML>;
export type CalendarYearSelectPropsWithoutHTML = WithChild<
{
@ -157,4 +167,4 @@ export type CalendarYearSelectPropsWithoutHTML = WithChild<
CalendarYearSelectSnippetProps
>;
export type CalendarYearSelectProps = CalendarYearSelectPropsWithoutHTML &
Without<BitsPrimitiveSelectAttributes, CalendarYearSelectPropsWithoutHTML>;
Without<AcelPrimitiveSelectAttributes, CalendarYearSelectPropsWithoutHTML>;

@ -3,9 +3,14 @@
import { boxWith } from "../../utilities";
import { DateFieldRootState } from "../date-field.svelte.js";
import type { DateFieldRootProps } from "../types.js";
import { noop } from "../../utilities/noop.ts";
import { getDefaultDate } from "../utilities/date/get-default-date.js";
import { resolveLocaleProp } from "../../utilities/config/prop-resolvers.ts";
import { noop } from "../../utilities";
import { resolveLocaleProp } from "../../comn/config/prop-resolvers.ts";
import {
DEFAULT_DATI_DATE_ORDER,
DEFAULT_DATI_TIME_FORMAT,
datiTimeFormatToHourCycle,
getDefaultDate
} from "$lib/glob/lib";
let {
disabled = false,
@ -13,6 +18,8 @@
hideTimeZone = false,
hourCycle,
locale,
dateOrder,
timeFormat,
maxValue,
minValue,
onPlaceholderChange = noop,
@ -82,8 +89,9 @@
disabled: boxWith(() => disabled),
granularity: boxWith(() => granularity),
hideTimeZone: boxWith(() => hideTimeZone),
hourCycle: boxWith(() => hourCycle),
hourCycle: boxWith(() => timeFormat ? datiTimeFormatToHourCycle(timeFormat) : (hourCycle ?? datiTimeFormatToHourCycle(DEFAULT_DATI_TIME_FORMAT))),
locale: resolveLocaleProp(() => locale),
dateOrder: boxWith(() => dateOrder ?? DEFAULT_DATI_DATE_ORDER),
maxValue: boxWith(() => maxValue),
minValue: boxWith(() => minValue),
validate: boxWith(() => validate),

@ -1,5 +1,4 @@
import type { Updater } from "svelte/store";
import type { DateValue } from "@internationalized/date";
import {
type WritableBox,
boxWith,
@ -9,41 +8,44 @@ import {
type ReadableBoxedValues,
type WritableBoxedValues,
simpleBox,
} from "svelte-toolbelt";
} from "../utilities";
import { onMount, untrack } from "svelte";
import { Context, watch } from "runed";
import type { DateRangeFieldRootState } from "../date-range-field/date-range-field.svelte.js";
import type {
BitsFocusEvent,
BitsKeyboardEvent,
BitsMouseEvent,
AcelFocusEvent,
AcelKeyboardEvent,
AcelMouseEvent,
WithRefOpts,
RefAttachment,
} from "$lib/internal/types.js";
} from "../utilities";
import {
createBitsAttrs,
createAcelAttrs,
boolToStr,
boolToStrTrueOrUndef,
boolToEmptyStrOrUndef,
} from "$lib/internal/attrs.js";
import { isBrowser, isNumberString } from "$lib/internal/is.js";
import { kbd } from "$lib/internal/kbd.js";
import { useId } from "$lib/internal/use-id.js";
} from "../utilities";
import { isBrowser, isNumberString } from "../utilities";
import { kbd } from "../utilities/keyboard/kbd.ts";
import { useId } from "../utilities";
import type {
DateAndTimeSegmentObj,
DateOnInvalid,
DateSegmentObj,
DateSegmentPart,
DateValidator,
Granularity,
HourCycle,
DatiDateOrder,
} from "$lib/glob/lib";
import type {
DateAndTimeSegmentObj,
DateSegmentObj,
DateSegmentPart,
SegmentPart,
SegmentValueObj,
TimeSegmentObj,
EditableTimeSegmentPart,
} from "$lib/shared/date/types.js";
import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js";
import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js";
} from "../utilities/datetime/types.ts";
import { type Formatter, createFormatter } from "../utilities/datetime/formatter.ts";
import { type Announcer, getAnnouncer } from "../utilities/datetime/announcer.ts";
import {
areAllSegmentsFilled,
createContent,
@ -58,21 +60,22 @@ import {
isFirstSegment,
removeDescriptionElement,
setDescription,
} from "$lib/internal/date-time/field/helpers.js";
} from "../utilities/datetime/field/helpers.ts";
import {
DATE_SEGMENT_PARTS,
EDITABLE_TIME_SEGMENT_PARTS,
} from "$lib/internal/date-time/field/parts.js";
import { getDaysInMonth, isBefore, toDate } from "$lib/internal/date-time/utils.js";
} from "../utilities/datetime/field/parts.ts";
import { getDaysInMonth, isBefore, dateValueToDate as toDate } from "$lib/glob/lib";
import {
getFirstSegment,
handleSegmentNavigation,
isSegmentNavigationKey,
moveToNextSegment,
moveToPrevSegment,
} from "$lib/internal/date-time/field/segments.js";
} from "../utilities/datetime/field/segments.ts";
import type { DateValue } from '@/glob/lib';
export const dateFieldAttrs = createBitsAttrs({
export const dateFieldAttrs = createAcelAttrs({
component: "date-field",
parts: ["input", "label", "segment"],
});
@ -172,6 +175,7 @@ interface DateFieldRootStateOpts
granularity: Granularity | undefined;
hourCycle: HourCycle | undefined;
locale: string;
dateOrder: DatiDateOrder;
hideTimeZone: boolean;
required: boolean;
errorMessageId: string | undefined;
@ -194,6 +198,7 @@ export class DateFieldRootState {
readonlySegments: DateFieldRootStateOpts["readonlySegments"];
hourCycle: DateFieldRootStateOpts["hourCycle"];
locale: DateFieldRootStateOpts["locale"];
dateOrder: DateFieldRootStateOpts["dateOrder"];
hideTimeZone: DateFieldRootStateOpts["hideTimeZone"];
required: DateFieldRootStateOpts["required"];
onInvalid: DateFieldRootStateOpts["onInvalid"];
@ -236,6 +241,7 @@ export class DateFieldRootState {
: props.readonlySegments;
this.hourCycle = rangeRoot ? rangeRoot.opts.hourCycle : props.hourCycle;
this.locale = rangeRoot ? rangeRoot.opts.locale : props.locale;
this.dateOrder = rangeRoot ? rangeRoot.opts.dateOrder : props.dateOrder;
this.hideTimeZone = rangeRoot ? rangeRoot.opts.hideTimeZone : props.hideTimeZone;
this.required = rangeRoot ? rangeRoot.opts.required : props.required;
this.onInvalid = rangeRoot ? rangeRoot.opts.onInvalid : props.onInvalid;
@ -536,6 +542,7 @@ export class DateFieldRootState {
dateRef: this.dateRef,
hideTimeZone: this.hideTimeZone.current,
hourCycle: this.hourCycle.current,
dateOrder: this.dateOrder.current,
});
});
@ -680,7 +687,7 @@ export class DateFieldRootState {
}
}
handleSegmentClick(e: BitsMouseEvent) {
handleSegmentClick(e: AcelMouseEvent) {
if (this.disabled.current) {
e.preventDefault();
}
@ -820,7 +827,7 @@ export class DateFieldLabelState {
this.attachment = attachRef(opts.ref, (v) => this.root.setLabelNode(v));
}
onclick(_: BitsMouseEvent) {
onclick(_: AcelMouseEvent) {
if (this.root.disabled.current) return;
const firstSegment = getFirstSegment(this.root.getFieldNode());
if (!firstSegment) return;
@ -882,7 +889,7 @@ abstract class BaseNumericSegmentState {
return str;
}
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
const placeholder = this.root.value.current ?? this.root.placeholder.current;
if (e.ctrlKey || e.metaKey || this.root.disabled.current) return;
@ -967,7 +974,7 @@ abstract class BaseNumericSegmentState {
});
}
#handleNumberKey(e: BitsKeyboardEvent) {
#handleNumberKey(e: AcelKeyboardEvent) {
const num = Number.parseInt(e.key);
let moveToNext = false;
const max = this.#getMax();
@ -1053,7 +1060,7 @@ abstract class BaseNumericSegmentState {
}
}
#handleBackspace(e: BitsKeyboardEvent) {
#handleBackspace(e: AcelKeyboardEvent) {
const stateKey = this.part as keyof typeof this.root.states;
if (stateKey in this.root.states) {
this.root.states[stateKey].hasLeftFocus = false;
@ -1089,7 +1096,7 @@ abstract class BaseNumericSegmentState {
}
}
onfocusout(_: BitsFocusEvent) {
onfocusout(_: AcelFocusEvent) {
const stateKey = this.part as keyof typeof this.root.states;
if (stateKey in this.root.states) {
this.root.states[stateKey].hasLeftFocus = true;
@ -1162,7 +1169,7 @@ class DateFieldYearSegmentState extends BaseNumericSegmentState {
super(opts, root, "year", SEGMENT_CONFIGS.year);
}
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
if (e.ctrlKey || e.metaKey || this.root.disabled.current) return;
if (e.key !== kbd.TAB) e.preventDefault();
if (!isAcceptableSegmentKey(e.key)) return;
@ -1202,7 +1209,7 @@ class DateFieldYearSegmentState extends BaseNumericSegmentState {
this.#backspaceCount++;
}
#handleYearNumberKey(e: BitsKeyboardEvent) {
#handleYearNumberKey(e: AcelKeyboardEvent) {
this.#pressedKeys.push(e.key);
let moveToNext = false;
const num = Number.parseInt(e.key);
@ -1257,7 +1264,7 @@ class DateFieldYearSegmentState extends BaseNumericSegmentState {
}
}
#handleYearBackspace(e: BitsKeyboardEvent) {
#handleYearBackspace(e: AcelKeyboardEvent) {
this.#pressedKeys = [];
this.#incrementBackspaceCount();
let moveToPrev = false;
@ -1285,7 +1292,7 @@ class DateFieldYearSegmentState extends BaseNumericSegmentState {
}
}
onfocusout(_: BitsFocusEvent) {
onfocusout(_: AcelFocusEvent) {
this.root.states.year.hasLeftFocus = true;
this.#pressedKeys = [];
this.#resetBackspaceCount();
@ -1316,7 +1323,7 @@ class DateFieldHourSegmentState extends BaseNumericSegmentState {
}
// Override to handle special hour logic
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
// Add special handling for hour display with dayPeriod
if (isNumberString(e.key)) {
const oldUpdateSegment = this.root.updateSegment.bind(this.root);
@ -1380,7 +1387,7 @@ export class DateFieldDayPeriodSegmentState {
this.attachment = attachRef(opts.ref, (v) => (this.root.dayPeriodNode = v));
}
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
if (e.ctrlKey || e.metaKey || this.root.disabled.current) return;
if (e.key !== kbd.TAB) e.preventDefault();
@ -1494,7 +1501,7 @@ export class DateFieldTimeZoneSegmentState {
this.attachment = attachRef(opts.ref);
}
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
if (e.key !== kbd.TAB) e.preventDefault();
if (this.root.disabled.current) return;
if (isSegmentNavigationKey(e.key)) {

@ -1,17 +1,17 @@
import type { DateValue } from "@internationalized/date";
import type {
DateValue,
DateOnInvalid,
DateValidator,
EditableSegmentPart,
SegmentPart,
WithChildren,
} from "$lib/shared/index.js";
import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js";
Granularity,
DatiDateOrder,
DatiTimeFormat,
} from "$lib/glob/lib";
import type { EditableSegmentPart, SegmentPart } from "../utilities/datetime/types";
import type { WithChildren, OnChangeFn, WithChild, Without } from "../utilities/types";
import type {
BitsPrimitiveDivAttributes,
BitsPrimitiveSpanAttributes,
} from "$lib/shared/attributes.js";
import type { Granularity } from "$lib/shared/date/types.js";
AcelPrimitiveDivAttributes,
AcelPrimitiveSpanAttributes,
} from "../utilities/attributes";
export type DateFieldRootPropsWithoutHTML = WithChildren<{
/**
@ -103,6 +103,21 @@ export type DateFieldRootPropsWithoutHTML = WithChildren<{
*/
locale?: string;
/**
* Preferred order for date segments in the input.
*
* @defaultValue 'DMY'
*/
dateOrder?: DatiDateOrder;
/**
* Preferred time format for time segments in the input.
* When provided, it overrides `hourCycle`.
*
* @defaultValue '24h'
*/
timeFormat?: DatiTimeFormat;
/**
* The granularity of the date field. This determines which
* segments will be includes in the segments array used to
@ -161,16 +176,16 @@ export type DateFieldInputPropsWithoutHTML = WithChild<
>;
export type DateFieldInputProps = DateFieldInputPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, DateFieldInputPropsWithoutHTML>;
Without<AcelPrimitiveDivAttributes, DateFieldInputPropsWithoutHTML>;
export type DateFieldSegmentPropsWithoutHTML = WithChild<{
part: SegmentPart;
}>;
export type DateFieldSegmentProps = DateFieldSegmentPropsWithoutHTML &
Without<BitsPrimitiveSpanAttributes, DateFieldSegmentPropsWithoutHTML>;
Without<AcelPrimitiveSpanAttributes, DateFieldSegmentPropsWithoutHTML>;
export type DateFieldLabelPropsWithoutHTML = WithChild;
export type DateFieldLabelProps = DateFieldLabelPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, DateFieldLabelPropsWithoutHTML>;
Without<AcelPrimitiveDivAttributes, DateFieldLabelPropsWithoutHTML>;

@ -0,0 +1,153 @@
<script lang="ts">
import { watch } from "runed";
import { boxWith, mergeProps } from "../../utilities/index";
import type { DateValue } from "$lib/glob/lib";
import { DateRangeFieldRootState } from "../date-range-field.svelte.ts";
import type { DateRangeFieldRootProps } from "../types.js";
import { createId, noop } from "../../utilities/index";
import type { DateRange } from "$lib/glob/lib";
import {
DEFAULT_DATI_DATE_ORDER,
DEFAULT_DATI_TIME_FORMAT,
datiTimeFormatToHourCycle,
getDefaultDate,
} from "$lib/glob/lib";
import { resolveLocaleProp } from "../../comn/config/prop-resolvers.ts";
const uid = $props.id();
let {
id = createId(uid),
ref = $bindable(null),
value = $bindable(),
onValueChange = noop,
placeholder = $bindable(),
onPlaceholderChange = noop,
disabled = false,
readonly = false,
required = false,
hourCycle,
timeFormat,
granularity,
locale,
dateOrder,
hideTimeZone = false,
validate = noop,
onInvalid = noop,
maxValue,
minValue,
readonlySegments = [],
children,
child,
onStartValueChange = noop,
onEndValueChange = noop,
errorMessageId,
...restProps
}: DateRangeFieldRootProps = $props();
let startValue = $state<DateValue | undefined>(value?.start);
let endValue = $state<DateValue | undefined>(value?.end);
function handleDefaultPlaceholder() {
if (placeholder !== undefined) return;
const defaultPlaceholder = getDefaultDate({
granularity,
defaultValue: value?.start,
minValue,
maxValue,
});
placeholder = defaultPlaceholder;
}
// SSR
handleDefaultPlaceholder();
watch.pre(
() => placeholder,
() => {
handleDefaultPlaceholder();
}
);
function handleDefaultValue() {
if (value !== undefined) return;
const defaultValue = { start: undefined, end: undefined };
value = defaultValue;
}
// SSR
handleDefaultValue();
/**
* Covers an edge case where when a spread props object is reassigned,
* the props are reset to their default values, which would make value
* undefined which causes errors to be thrown.
*/
watch.pre(
() => value,
() => {
handleDefaultValue();
}
);
const rootState = DateRangeFieldRootState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
disabled: boxWith(() => disabled),
readonly: boxWith(() => readonly),
required: boxWith(() => required),
hourCycle: boxWith(() => timeFormat ? datiTimeFormatToHourCycle(timeFormat) : (hourCycle ?? datiTimeFormatToHourCycle(DEFAULT_DATI_TIME_FORMAT))),
granularity: boxWith(() => granularity),
locale: resolveLocaleProp(() => locale),
dateOrder: boxWith(() => dateOrder ?? DEFAULT_DATI_DATE_ORDER),
hideTimeZone: boxWith(() => hideTimeZone),
validate: boxWith(() => validate),
maxValue: boxWith(() => maxValue),
minValue: boxWith(() => minValue),
placeholder: boxWith(
() => placeholder as DateValue,
(v) => {
placeholder = v;
onPlaceholderChange(v);
}
),
readonlySegments: boxWith(() => readonlySegments),
value: boxWith(
() => value as DateRange,
(v) => {
value = v;
onValueChange(v);
}
),
startValue: boxWith(
() => startValue,
(v) => {
startValue = v;
onStartValueChange(v);
}
),
endValue: boxWith(
() => endValue,
(v) => {
endValue = v;
onEndValueChange(v);
}
),
onInvalid: boxWith(() => onInvalid),
errorMessageId: boxWith(() => errorMessageId),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,309 @@
import { Context, watch } from "runed";
import type { DateValue, DatiDateOrder } from "$lib/glob/lib";
import type {
ReadableBoxedValues,
WritableBoxedValues,
RefAttachment,
WithRefOpts,
} from "../utilities";
import {
boxWith,
onDestroyEffect,
attachRef,
DOMContext,
useId,
createAcelAttrs,
boolToEmptyStrOrUndef
} from "../utilities";
import { DateFieldInputState, DateFieldRootState } from "../date-field/date-field.svelte.ts";
import type {
DateOnInvalid,
DateRange,
DateRangeValidator,
Granularity,
} from "$lib/glob/lib";
import { isBefore } from "$lib/glob/lib";
import type { SegmentPart } from "../utilities/datetime/types.ts";
import { type Formatter, createFormatter } from "../utilities/datetime/formatter.js";
import { removeDescriptionElement } from "../utilities/datetime/field/helpers.js";
import { getFirstSegment } from "../utilities/datetime/field/segments.ts";
export const dateRangeFieldAttrs = createAcelAttrs({
component: "date-range-field",
parts: ["root", "label"],
});
export const DateRangeFieldRootContext = new Context<DateRangeFieldRootState>(
"DateRangeField.Root"
);
interface DateRangeFieldRootStateOpts
extends WithRefOpts,
WritableBoxedValues<{
value: DateRange;
placeholder: DateValue;
startValue: DateValue | undefined;
endValue: DateValue | undefined;
}>,
ReadableBoxedValues<{
readonlySegments: SegmentPart[];
validate: DateRangeValidator | undefined;
onInvalid: DateOnInvalid | undefined;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
disabled: boolean;
readonly: boolean;
granularity: Granularity | undefined;
hourCycle: 12 | 24 | undefined;
locale: string;
dateOrder: DatiDateOrder;
hideTimeZone: boolean;
required: boolean;
errorMessageId: string | undefined;
}> {}
export class DateRangeFieldRootState {
static create(opts: DateRangeFieldRootStateOpts) {
return DateRangeFieldRootContext.set(new DateRangeFieldRootState(opts));
}
readonly opts: DateRangeFieldRootStateOpts;
startFieldState: DateFieldRootState | undefined = undefined;
endFieldState: DateFieldRootState | undefined = undefined;
descriptionId = useId();
formatter: Formatter;
fieldNode = $state<HTMLElement | null>(null);
labelNode = $state<HTMLElement | null>(null);
descriptionNode = $state<HTMLElement | null>(null);
readonly startValueComplete = $derived.by(() => this.opts.startValue.current !== undefined);
readonly endValueComplete = $derived.by(() => this.opts.endValue.current !== undefined);
readonly rangeComplete = $derived(this.startValueComplete && this.endValueComplete);
domContext: DOMContext;
readonly attachment: RefAttachment;
constructor(opts: DateRangeFieldRootStateOpts) {
this.opts = opts;
this.formatter = createFormatter({
initialLocale: this.opts.locale.current,
monthFormat: boxWith(() => "long"),
yearFormat: boxWith(() => "numeric"),
});
this.domContext = new DOMContext(this.opts.ref);
this.attachment = attachRef(this.opts.ref, (v) => (this.fieldNode = v));
onDestroyEffect(() => {
removeDescriptionElement(this.descriptionId, this.domContext.getDocument());
});
$effect(() => {
if (this.formatter.getLocale() === this.opts.locale.current) return;
this.formatter.setLocale(this.opts.locale.current);
});
/**
* Synchronize the start and end values with the `value` in case
* it is updated externally.
*/
watch(
() => this.opts.value.current,
(value) => {
if (value.start && value.end) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = value.end;
} else if (value.start) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = undefined;
} else if (value.start === undefined && value.end === undefined) {
this.opts.startValue.current = undefined;
this.opts.endValue.current = undefined;
}
}
);
/**
* Synchronize the placeholder value with the current start value
*/
watch(
() => this.opts.value.current,
(value) => {
const startValue = value.start;
if (startValue && this.opts.placeholder.current !== startValue) {
this.opts.placeholder.current = startValue;
}
}
);
watch(
[() => this.opts.startValue.current, () => this.opts.endValue.current],
([startValue, endValue]) => {
if (
this.opts.value.current &&
this.opts.value.current.start === startValue &&
this.opts.value.current.end === endValue
) {
return;
}
if (startValue && endValue) {
this.#updateValue((prev) => {
if (prev.start === startValue && prev.end === endValue) {
return prev;
}
return {
start: startValue,
end: endValue,
};
});
} else if (
this.opts.value.current &&
this.opts.value.current.start &&
this.opts.value.current.end
) {
this.opts.value.current.start = undefined;
this.opts.value.current.end = undefined;
}
}
);
}
readonly validationStatus = $derived.by(() => {
const value = this.opts.value.current;
if (value === undefined) return false as const;
if (value.start === undefined || value.end === undefined) return false as const;
const msg = this.opts.validate.current?.({
start: value.start,
end: value.end,
});
if (msg) {
return {
reason: "custom",
message: msg,
} as const;
}
const minValue = this.opts.minValue.current;
if (minValue && value.start && isBefore(value.start, minValue)) {
return {
reason: "min",
} as const;
}
const maxValue = this.opts.maxValue.current;
if (
(maxValue && value.end && isBefore(maxValue, value.end)) ||
(maxValue && value.start && isBefore(maxValue, value.start))
) {
return {
reason: "max",
} as const;
}
return false as const;
});
readonly isInvalid = $derived.by(() => {
if (this.validationStatus === false) return false;
return true;
});
#updateValue(cb: (value: DateRange) => DateRange) {
const value = this.opts.value.current;
const newValue = cb(value);
this.opts.value.current = newValue;
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "group",
[dateRangeFieldAttrs.root]: "",
"data-invalid": boolToEmptyStrOrUndef(this.isInvalid),
...this.attachment,
}) as const
);
}
interface DateRangeFieldLabelStateOpts extends WithRefOpts {}
export class DateRangeFieldLabelState {
static create(opts: DateRangeFieldLabelStateOpts) {
return new DateRangeFieldLabelState(opts, DateRangeFieldRootContext.get());
}
readonly opts: DateRangeFieldLabelStateOpts;
readonly root: DateRangeFieldRootState;
readonly attachment: RefAttachment;
constructor(opts: DateRangeFieldLabelStateOpts, root: DateRangeFieldRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref, (v) => (this.root.labelNode = v));
}
#onclick = () => {
if (this.root.opts.disabled.current) return;
const firstSegment = getFirstSegment(this.root.fieldNode);
if (!firstSegment) return;
firstSegment.focus();
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-invalid": boolToEmptyStrOrUndef(this.root.isInvalid),
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
[dateRangeFieldAttrs.label]: "",
onclick: this.#onclick,
...this.attachment,
}) as const
);
}
interface DateRangeFieldInputStateOpts
extends WithRefOpts,
WritableBoxedValues<{
value: DateValue | undefined;
}>,
ReadableBoxedValues<{
name: string;
}> {}
export class DateRangeFieldInputState {
static create(opts: Omit<DateRangeFieldInputStateOpts, "value">, type: "start" | "end") {
const root = DateRangeFieldRootContext.get();
const fieldState = DateFieldRootState.create(
{
value: type === "start" ? root.opts.startValue : root.opts.endValue,
disabled: root.opts.disabled,
readonly: root.opts.readonly,
readonlySegments: root.opts.readonlySegments,
validate: boxWith(() => undefined),
minValue: root.opts.minValue,
maxValue: root.opts.maxValue,
hourCycle: root.opts.hourCycle,
locale: root.opts.locale,
dateOrder: root.opts.dateOrder,
hideTimeZone: root.opts.hideTimeZone,
required: root.opts.required,
granularity: root.opts.granularity,
placeholder: root.opts.placeholder,
onInvalid: root.opts.onInvalid,
errorMessageId: root.opts.errorMessageId,
isInvalidProp: boxWith(() => root.isInvalid),
},
root
);
return new DateFieldInputState({ name: opts.name, id: opts.id, ref: opts.ref }, fieldState);
}
}

@ -0,0 +1,213 @@
import type { DateValue } from "$lib/glob/lib";
import type { OnChangeFn, WithChild, Without } from "../utilities";
import type {
AcelPrimitiveDivAttributes,
AcelPrimitiveSpanAttributes,
} from "../utilities";
import type {
DateOnInvalid,
DateRange,
DateRangeValidator,
Granularity,
DatiDateOrder,
DatiTimeFormat,
} from "$lib/glob/lib";
import type { EditableSegmentPart, SegmentPart } from "../utilities/datetime/types.ts";
import type {
DateFieldSegmentProps,
DateFieldSegmentPropsWithoutHTML
} from '../date-field/types';
export type DateRangeFieldRootPropsWithoutHTML = WithChild<{
/**
* The value of the date range field.
*
* @bindable
*/
value?: DateRange;
/**
* A callback that is called when the value of the date range field changes.
*/
onValueChange?: OnChangeFn<DateRange | undefined>;
/**
* The placeholder value of the date field. This determines the format
* and what date the field starts at when it is empty.
*
* @bindable
*/
placeholder?: DateValue;
/**
* A callback that is called when the date field's placeholder value changes.
*/
onPlaceholderChange?: OnChangeFn<DateValue | undefined>;
/**
* A function that returns a string or array of strings as validation errors if the date is
* invalid, or nothing if the date is valid
*/
validate?: DateRangeValidator;
/**
* A callback fired when the date field's value is invalid. Use this to display an error
* message to the user.
*/
onInvalid?: DateOnInvalid;
/**
* The minimum acceptable date. When provided, the date field
* will be marked as invalid if the user enters a date before this date.
*/
minValue?: DateValue;
/**
* The maximum acceptable date. When provided, the date field
* will be marked as invalid if the user enters a date after this date.
*/
maxValue?: DateValue;
/**
* If true, the date field will be disabled and users will not be able
* to interact with it. This also disables the hidden input element if
* the date field is used in a form.
*
* @defaultValue false
*/
disabled?: boolean;
/**
* If true, the date field will be readonly, and users will not be able to
* edit the values of any of the individual segments.
*
* @defaultValue false
*/
readonly?: boolean;
/**
* If true, the date field will be required, which is useful when used within
* a form. If the date field is empty when the form is submitted, the form
* will not be valid.
*
* @defaultValue false
*/
required?: boolean;
/**
* An array of segment names that should be readonly. If provided, only the
* segments not in this array will be editable.
*/
readonlySegments?: EditableSegmentPart[];
/**
* The format to use for displaying the time in the input.
* If using a 12 hour clock, ensure you also include the `dayPeriod`
* segment in your input to ensure the user can select AM/PM.
*
* @defaultValue the locale's default time format
*/
hourCycle?: 12 | 24;
/**
* The locale to use for formatting the date field.
*
* @defaultValue 'en'
*/
locale?: string;
/**
* Preferred order for date segments in the input.
*
* @defaultValue 'DMY'
*/
dateOrder?: DatiDateOrder;
/**
* Preferred time format for time segments in the input.
* When provided, it overrides `hourCycle`.
*
* @defaultValue '24h'
*/
timeFormat?: DatiTimeFormat;
/**
* The granularity of the date field. This determines which
* segments will be includes in the segments array used to
* build the date field.
*
* By default, when a `CalendarDate` value is used, the granularity
* will default to `'day'`, and when a `CalendarDateTime` or `ZonedDateTime`
* value is used, the granularity will default to `'minute'`.
*
* Granularity is only used for visual purposes, and does not impact
* the value of the date field. You can have the same value synced
* between multiple date fields with different granularities and they
* will all contain the same value.
*
* @defaultValue 'day'
*/
granularity?: Granularity;
/**
* Whether or not to hide the timeZoneName segment from the date field.
*
* @defaultValue false;
*/
hideTimeZone?: boolean;
/**
* A callback function called when the start value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onStartValueChange?: OnChangeFn<DateValue | undefined>;
/**
* A callback function called when the end value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onEndValueChange?: OnChangeFn<DateValue | undefined>;
/**
* The `id` of the element which contains the error messages for the date field when the
* date is invalid.
*/
errorMessageId?: string;
}>;
export type DateRangeFieldRootProps = DateRangeFieldRootPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, DateRangeFieldRootPropsWithoutHTML>;
export type DateRangeFieldLabelPropsWithoutHTML = WithChild;
export type DateRangeFieldLabelProps = DateRangeFieldLabelPropsWithoutHTML &
Without<AcelPrimitiveSpanAttributes, DateRangeFieldLabelPropsWithoutHTML>;
export type DateRangeFieldInputSnippetProps = {
segments: Array<{ part: SegmentPart; value: string }>;
};
export type DateRangeFieldInputPropsWithoutHTML = WithChild<
{
/**
* The name to use for the hidden input element associated with this input
* used for form submission.
*/
name?: string;
/**
* Whether this input represents the start or end of the date range.
*/
type: "start" | "end";
},
DateRangeFieldInputSnippetProps
>;
export type DateRangeFieldInputProps = DateRangeFieldInputPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, DateRangeFieldInputPropsWithoutHTML>;
export type DateRangeFieldSegmentPropsWithoutHTML = DateFieldSegmentPropsWithoutHTML;
export type DateRangeFieldSegmentProps = DateFieldSegmentProps;

@ -0,0 +1,63 @@
<script lang="ts">
import { boxWith, mergeProps, createId } from "../../utilities/index";
import type { DateRangePickerCalendarProps } from "../types.ts";
import { DateRangePickerRootContext } from "../date-range-picker.svelte.ts";
import { RangeCalendarRootState } from "../../range-calendar/range-calendar.svelte.ts";
const uid = $props.id();
let {
children,
child,
id = createId(uid),
ref = $bindable(null),
...restProps
}: DateRangePickerCalendarProps = $props();
const dateRangePickerRootState = DateRangePickerRootContext.get();
const rangeCalendarState = RangeCalendarRootState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
calendarLabel: dateRangePickerRootState.opts.calendarLabel,
fixedWeeks: dateRangePickerRootState.opts.fixedWeeks,
isDateDisabled: dateRangePickerRootState.opts.isDateDisabled,
isDateUnavailable: dateRangePickerRootState.opts.isDateUnavailable,
locale: dateRangePickerRootState.opts.locale,
dateOrder: dateRangePickerRootState.opts.dateOrder,
numberOfMonths: dateRangePickerRootState.opts.numberOfMonths,
pagedNavigation: dateRangePickerRootState.opts.pagedNavigation,
preventDeselect: dateRangePickerRootState.opts.preventDeselect,
readonly: dateRangePickerRootState.opts.readonly,
weekStartsOn: dateRangePickerRootState.opts.weekStartsOn,
weekdayFormat: dateRangePickerRootState.opts.weekdayFormat,
disabled: dateRangePickerRootState.opts.disabled,
disableDaysOutsideMonth: dateRangePickerRootState.opts.disableDaysOutsideMonth,
maxValue: dateRangePickerRootState.opts.maxValue,
minValue: dateRangePickerRootState.opts.minValue,
placeholder: dateRangePickerRootState.opts.placeholder,
value: dateRangePickerRootState.opts.value,
excludeDisabled: dateRangePickerRootState.opts.excludeDisabled,
onRangeSelect: dateRangePickerRootState.opts.onRangeSelect,
startValue: dateRangePickerRootState.opts.startValue,
endValue: dateRangePickerRootState.opts.endValue,
defaultPlaceholder: dateRangePickerRootState.opts.defaultPlaceholder,
minDays: dateRangePickerRootState.opts.minDays,
maxDays: dateRangePickerRootState.opts.maxDays,
monthFormat: dateRangePickerRootState.opts.monthFormat,
yearFormat: dateRangePickerRootState.opts.yearFormat,
});
const mergedProps = $derived(mergeProps(restProps, rangeCalendarState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rangeCalendarState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rangeCalendarState.snippetProps)}
</div>
{/if}

@ -15,7 +15,13 @@
import { DateRangeFieldRootState } from "../../date-range-field/date-range-field.svelte.ts";
import FloatingLayer from "../../comn/floating-layer/components/floating-layer.svelte";
import { type DateRange, getDefaultDate } from "$lib/glob/lib";
import {
type DateRange,
DEFAULT_DATI_DATE_ORDER,
DEFAULT_DATI_TIME_FORMAT,
datiTimeFormatToHourCycle,
getDefaultDate
} from "$lib/glob/lib";
import { resolveLocaleProp } from "../../comn/config/prop-resolvers.ts";
let {
@ -38,6 +44,8 @@
readonlySegments = [],
hourCycle,
locale,
dateOrder,
timeFormat,
hideTimeZone = false,
required = false,
calendarLabel = "Event",
@ -87,16 +95,18 @@
}
);
const defaultPlaceholder = getDefaultDate({
granularity,
defaultValue: value?.start,
minValue,
maxValue,
});
function getDefaultPlaceholder() {
return getDefaultDate({
granularity,
defaultValue: value?.start,
minValue,
maxValue,
});
}
function handleDefaultPlaceholder() {
if (placeholder !== undefined) return;
placeholder = defaultPlaceholder;
placeholder = getDefaultPlaceholder();
}
// SSR
@ -151,8 +161,9 @@
readonly: boxWith(() => readonly),
granularity: boxWith(() => granularity),
readonlySegments: boxWith(() => readonlySegments),
hourCycle: boxWith(() => hourCycle),
hourCycle: boxWith(() => timeFormat ? datiTimeFormatToHourCycle(timeFormat) : (hourCycle ?? datiTimeFormatToHourCycle(DEFAULT_DATI_TIME_FORMAT))),
locale: resolveLocaleProp(() => locale),
dateOrder: boxWith(() => dateOrder ?? DEFAULT_DATI_DATE_ORDER),
hideTimeZone: boxWith(() => hideTimeZone),
required: boxWith(() => required),
calendarLabel: boxWith(() => calendarLabel),
@ -182,7 +193,7 @@
),
monthFormat: boxWith(() => monthFormat),
yearFormat: boxWith(() => yearFormat),
defaultPlaceholder,
defaultPlaceholder: getDefaultPlaceholder(),
});
PopoverRootState.create({
@ -202,6 +213,7 @@
hideTimeZone: pickerRootState.opts.hideTimeZone,
hourCycle: pickerRootState.opts.hourCycle,
locale: pickerRootState.opts.locale,
dateOrder: pickerRootState.opts.dateOrder,
required: pickerRootState.opts.required,
placeholder: pickerRootState.opts.placeholder,
id: boxWith(() => id),
@ -231,3 +243,5 @@

@ -0,0 +1,67 @@
import type { DateValue } from "$lib/glob/lib";
import { Context } from "runed";
import { type ReadableBoxedValues, type WritableBoxedValues } from "../utilities";
import type {
DateMatcher,
DateRange,
Granularity,
HourCycle,
WeekStartsOn,
DatiDateOrder,
} from "$lib/glob/lib";
import type { SegmentPart } from "../utilities/datetime/types";
export const DateRangePickerRootContext = new Context<DateRangePickerRootState>(
"DateRangePicker.Root"
);
interface DateRangePickerRootStateOpts
extends WritableBoxedValues<{
value: DateRange;
startValue: DateValue | undefined;
endValue: DateValue | undefined;
open: boolean;
placeholder: DateValue;
}>,
ReadableBoxedValues<{
readonlySegments: SegmentPart[];
isDateUnavailable: DateMatcher;
isDateDisabled: DateMatcher;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
minDays: number | undefined;
maxDays: number | undefined;
disabled: boolean;
readonly: boolean;
granularity: Granularity | undefined;
hourCycle: HourCycle | undefined;
locale: string;
dateOrder: DatiDateOrder;
hideTimeZone: boolean;
required: boolean;
preventDeselect: boolean;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
fixedWeeks: boolean;
numberOfMonths: number;
calendarLabel: string;
disableDaysOutsideMonth: boolean;
excludeDisabled: boolean;
onRangeSelect?: () => void;
monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
}> {
defaultPlaceholder: DateValue;
}
export class DateRangePickerRootState {
static create(opts: DateRangePickerRootStateOpts) {
return DateRangePickerRootContext.set(new DateRangePickerRootState(opts));
}
readonly opts: DateRangePickerRootStateOpts;
constructor(opts: DateRangePickerRootStateOpts) {
this.opts = opts;
}
}

@ -0,0 +1,391 @@
import type { DateValue } from "$lib/glob/lib";
import type { OnChangeFn, WithChild, Without } from "../utilities/types.ts";
import type { AcelPrimitiveDivAttributes } from "../utilities/attributes";
import type {
DateMatcher,
DateOnInvalid,
DateRange,
DateRangeValidator,
Granularity,
WeekStartsOn,
DatiDateOrder,
DatiTimeFormat,
} from "$lib/glob/lib";
import type { EditableSegmentPart } from "../utilities/datetime/types";
import type { CalendarRootSnippetProps } from "../calendar/types.ts";
export type DateRangePickerRootPropsWithoutHTML = WithChild<{
/**
* The value of the date picker.
*
* @bindable
*/
value?: DateRange;
/**
* A callback function called when the value changes.
*/
onValueChange?: OnChangeFn<DateRange>;
/**
* The placeholder value of the date field. This determines the format
* and what date the field starts at when it is empty.
* @bindable
*/
placeholder?: DateValue;
/**
* A callback function called when the placeholder value changes.
*/
onPlaceholderChange?: OnChangeFn<DateValue>;
/**
* The open state of the date picker popover.
*
* @bindable
*/
open?: boolean;
/**
* A callback function called when the open state changes.
*/
onOpenChange?: OnChangeFn<boolean>;
/**
* A callback function called when the open state changes complete.
*/
onOpenChangeComplete?: OnChangeFn<boolean>;
/**
* A function that returns true if the given date is unavailable,
* where if selected, the date field will be marked as invalid.
*/
isDateUnavailable?: DateMatcher;
/**
* A function that returns true if the given date is disabled,
* which makes the date unable to be selected in the calendar.
*/
isDateDisabled?: DateMatcher;
/**
* A function that returns a string or array of strings as validation errors if the date is
* invalid, or nothing if the date is valid
*/
validate?: DateRangeValidator;
/**
* A callback fired when the date field's value is invalid. Use this to display an error
* message to the user.
*/
onInvalid?: DateOnInvalid;
/**
* The minimum acceptable date. When provided, the date field
* will be marked as invalid if the user enters a date before this date.
*/
minValue?: DateValue;
/**
* The maximum acceptable date. When provided, the date field
* will be marked as invalid if the user enters a date after this date.
*/
maxValue?: DateValue;
/**
* If true, the date field will be disabled and users will not be able
* to interact with it. This also disables the hidden input element if
* the date field is used in a form.
*
* @defaultValue false
*/
disabled?: boolean;
/**
* If true, the date field will be readonly, and users will not be able to
* edit the values of any of the individual segments.
*
* @defaultValue false
*/
readonly?: boolean;
/**
* An array of segment names that should be readonly. If provided, only the
* segments not in this array will be editable.
*/
readonlySegments?: EditableSegmentPart[];
/**
* The format to use for displaying the time in the input.
* If using a 12 hour clock, ensure you also include the `dayPeriod`
* segment in your input to ensure the user can select AM/PM.
*
* @defaultValue the locale's default time format
*/
hourCycle?: 12 | 24;
/**
* The locale to use for formatting the date field.
*
* @defaultValue 'en'
*/
locale?: string;
/**
* Preferred order for date segments in the input.
*
* @defaultValue 'DMY'
*/
dateOrder?: DatiDateOrder;
/**
* Preferred time format for time segments in the input.
* When provided, it overrides `hourCycle`.
*
* @defaultValue '24h'
*/
timeFormat?: DatiTimeFormat;
/**
* The granularity of the date field. This determines which
* segments will be includes in the segments array used to
* build the date field.
*
* By default, when a `CalendarDate` value is used, the granularity
* will default to `'day'`, and when a `CalendarDateTime` or `ZonedDateTime`
* value is used, the granularity will default to `'minute'`.
*
* Granularity is only used for visual purposes, and does not impact
* the value of the date field. You can have the same value synced
* between multiple date fields with different granularities and they
* will all contain the same value.
*
* @defaultValue 'day'
*/
granularity?: Granularity;
/**
* Whether or not to hide the timeZoneName segment from the date field.
*
* @defaultValue false;
*/
hideTimeZone?: boolean;
/**
* Whether or not the hidden input of the date field requires a value
* to be submitted.
*
* @defaultValue false
*/
required?: boolean;
/**
* Applicable only when `numberOfMonths` is greater than 1.
*
* Controls whether to use paged navigation for the next and previous buttons in the
* date picker. With paged navigation set to `true`, clicking the next/prev buttons
* changes all months in view. When set to `false`, it shifts the view by a single month.
*
* For example, with `pagedNavigation` set to `true` and 2 months displayed (January and
* February), clicking the next button changes the view to March and April. If `pagedNavigation`
* is `false`, the view shifts to February and March.
*
* @defaultValue false
*/
pagedNavigation?: boolean;
/**
* The day of the week to start the calendar on, which must
* be a number between 0 and 6, where 0 is Sunday and 6 is
* Saturday.
*
* @defaultValue 0 (Sunday)
*/
weekStartsOn?: WeekStartsOn;
/**
* How the string representation of the weekdays provided via the `weekdays` state store
* should be formatted.
*
* ```md
* - "long": "Sunday", "Monday", "Tuesday", etc.
* - "short": "Sun", "Mon", "Tue", etc.
* - "narrow": "S", "M", "T", etc.
*```
*
* @defaultValue "narrow"
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#weekday
*/
weekdayFormat?: Intl.DateTimeFormatOptions["weekday"];
/**
* Display 6 weeks per month, regardless the month's number of weeks.
* This is useful for displaying a consistent calendar, where the size
* of the calendar doesn't change month to month.
*
* To display 6 weeks per month, you will need to render out the previous
* and next month's dates in the calendar as well.
*
* @defaultValue false
*/
fixedWeeks?: boolean;
/**
* Determines the number of months to display on the calendar simultaneously.
* For navigation between months, refer to the `pagedNavigation` prop.
*
* @defaultValue 1
*/
numberOfMonths?: number;
/**
* This label is exclusively used for accessibility, remaining hidden from the page.
* It's read by screen readers when the calendar is opened. The current month and year
* are automatically appended to the label, so you only need to provide the base label.
*
* For instance:
* - 'Date of birth' will be read as 'Date of birth, January 2021' if the current month is January 2021.
* - 'Appointment date' will be read as 'Appointment date, January 2021' if the current month is January 2021.
* - 'Booking date' will be read as 'Booking date, January 2021' if the current month is January 2021.
*/
calendarLabel?: string;
/**
* Whether to disable the selection of days outside the current month. By default,
* days outside the current month are rendered to fill the calendar grid, but they
* are not selectable. Setting this prop to `true` will disable this behavior.
*
* @defaultValue false
*/
disableDaysOutsideMonth?: boolean;
/**
* Whether or not users can deselect a date once selected
* without selecting another date.
*
* @defaultValue false
*/
preventDeselect?: boolean;
/**
* Whether to close the popover when a date range is selected.
*
* @defaultValue true
*/
closeOnRangeSelect?: boolean;
/**
* A callback function called when the start value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onStartValueChange?: OnChangeFn<DateValue | undefined>;
/**
* A callback function called when the end value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onEndValueChange?: OnChangeFn<DateValue | undefined>;
/**
* The `id` of the element which contains the error messages for the date field when the
* date is invalid.
*/
errorMessageId?: string;
/**
* The minimum number of days that can be selected in a range.
*
* @default undefined
*/
minDays?: number;
/**
* The maximum number of days that can be selected in a range.
*
* @default undefined
*/
maxDays?: number;
/**
* Whether to automatically reset the range if any date within the selected range
* becomes disabled. When true, the entire range will be cleared if a disabled
* date is found between the start and end dates.
*
* @default false
*/
excludeDisabled?: boolean;
/**
* The format of the month names in the calendar.
*
* @default "long"
*/
monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
/**
* The format of the year names in the calendar.
*
* @default "numeric"
*/
yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
}>;
export type DateRangePickerRootProps = DateRangePickerRootPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, DateRangePickerRootPropsWithoutHTML>;
export type {
PopoverTriggerPropsWithoutHTML as DateRangePickerTriggerPropsWithoutHTML,
PopoverTriggerProps as DateRangePickerTriggerProps,
PopoverContentPropsWithoutHTML as DateRangePickerContentPropsWithoutHTML,
PopoverContentProps as DateRangePickerContentProps,
PopoverArrowPropsWithoutHTML as DateRangePickerArrowPropsWithoutHTML,
PopoverArrowProps as DateRangePickerArrowProps,
PopoverClosePropsWithoutHTML as DateRangePickerClosePropsWithoutHTML,
PopoverCloseProps as DateRangePickerCloseProps,
} from "../popover/types.ts";
export type {
DateRangeFieldInputPropsWithoutHTML as DateRangePickerInputPropsWithoutHTML,
DateRangeFieldInputProps as DateRangePickerInputProps,
DateRangeFieldLabelPropsWithoutHTML as DateRangePickerLabelPropsWithoutHTML,
DateRangeFieldLabelProps as DateRangePickerLabelProps,
DateRangeFieldSegmentPropsWithoutHTML as DateRangePickerSegmentPropsWithoutHTML,
DateRangeFieldSegmentProps as DateRangePickerSegmentProps,
} from "../date-range-field/types.ts";
export type DateRangePickerCalendarPropsWithoutHTML = WithChild<{}, CalendarRootSnippetProps>;
export type DateRangePickerCalendarProps = DateRangePickerCalendarPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, DateRangePickerCalendarPropsWithoutHTML>;
export type {
RangeCalendarCellPropsWithoutHTML as DateRangePickerCellPropsWithoutHTML,
RangeCalendarCellProps as DateRangePickerCellProps,
RangeCalendarDayPropsWithoutHTML as DateRangePickerDayPropsWithoutHTML,
RangeCalendarDayProps as DateRangePickerDayProps,
RangeCalendarGridPropsWithoutHTML as DateRangePickerGridPropsWithoutHTML,
RangeCalendarGridProps as DateRangePickerGridProps,
RangeCalendarGridBodyPropsWithoutHTML as DateRangePickerGridBodyPropsWithoutHTML,
RangeCalendarGridBodyProps as DateRangePickerGridBodyProps,
RangeCalendarGridHeadPropsWithoutHTML as DateRangePickerGridHeadPropsWithoutHTML,
RangeCalendarGridHeadProps as DateRangePickerGridHeadProps,
RangeCalendarGridRowPropsWithoutHTML as DateRangePickerGridRowPropsWithoutHTML,
RangeCalendarGridRowProps as DateRangePickerGridRowProps,
RangeCalendarHeadCellPropsWithoutHTML as DateRangePickerHeadCellPropsWithoutHTML,
RangeCalendarHeadCellProps as DateRangePickerHeadCellProps,
RangeCalendarHeaderPropsWithoutHTML as DateRangePickerHeaderPropsWithoutHTML,
RangeCalendarHeaderProps as DateRangePickerHeaderProps,
RangeCalendarHeadingPropsWithoutHTML as DateRangePickerHeadingPropsWithoutHTML,
RangeCalendarHeadingProps as DateRangePickerHeadingProps,
RangeCalendarNextButtonPropsWithoutHTML as DateRangePickerNextButtonPropsWithoutHTML,
RangeCalendarNextButtonProps as DateRangePickerNextButtonProps,
RangeCalendarPrevButtonPropsWithoutHTML as DateRangePickerPrevButtonPropsWithoutHTML,
RangeCalendarPrevButtonProps as DateRangePickerPrevButtonProps,
RangeCalendarMonthSelectProps as DateRangePickerMonthSelectProps,
RangeCalendarMonthSelectPropsWithoutHTML as DateRangePickerMonthSelectPropsWithoutHTML,
RangeCalendarYearSelectProps as DateRangePickerYearSelectProps,
RangeCalendarYearSelectPropsWithoutHTML as DateRangePickerYearSelectPropsWithoutHTML,
} from "../range-calendar/types";

@ -0,0 +1,157 @@
<script lang="ts">
import { watch } from "runed";
import { boxWith, mergeProps } from "../../utilities/index";
import { type DateValue, DEFAULT_DATI_DATE_ORDER } from "$lib/glob/lib";
import type { RangeCalendarRootProps } from "../types.js";
import { RangeCalendarRootState } from "../range-calendar.svelte.ts";
import { noop } from "../../utilities/noop";
import { createId } from "../../utilities/create-id";
import { getDefaultDate } from "$lib/glob/lib";
import { resolveLocaleProp } from "../../comn/config/prop-resolvers";
const uid = $props.id();
let {
children,
child,
id = createId(uid),
ref = $bindable(null),
value = $bindable(),
onValueChange = noop,
placeholder = $bindable(),
onPlaceholderChange = noop,
weekdayFormat = "narrow",
weekStartsOn,
pagedNavigation = false,
isDateDisabled = () => false,
isDateUnavailable = () => false,
fixedWeeks = false,
numberOfMonths = 1,
locale,
dateOrder = DEFAULT_DATI_DATE_ORDER,
calendarLabel = "Event",
disabled = false,
readonly = false,
minValue = undefined,
maxValue = undefined,
preventDeselect = false,
disableDaysOutsideMonth = true,
minDays,
maxDays,
onStartValueChange = noop,
onEndValueChange = noop,
excludeDisabled = false,
monthFormat = "long",
yearFormat = "numeric",
...restProps
}: RangeCalendarRootProps = $props();
let startValue = $state<DateValue | undefined>(value?.start);
let endValue = $state<DateValue | undefined>(value?.end);
function getDefaultPlaceholder() {
return getDefaultDate({
defaultValue: value?.start,
minValue,
maxValue,
});
}
function handleDefaultPlaceholder() {
if (placeholder !== undefined) return;
placeholder = getDefaultPlaceholder();
}
// SSR
handleDefaultPlaceholder();
watch.pre(
() => placeholder,
() => {
handleDefaultPlaceholder();
}
);
function handleDefaultValue() {
if (value !== undefined) return;
value = { start: undefined, end: undefined };
}
// SSR
handleDefaultValue();
watch.pre(
() => value,
() => {
handleDefaultValue();
}
);
const rootState = RangeCalendarRootState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
value: boxWith(
() => value!,
(v) => {
value = v;
onValueChange(v);
}
),
placeholder: boxWith(
() => placeholder!,
(v) => {
placeholder = v;
onPlaceholderChange(v);
}
),
disabled: boxWith(() => disabled),
readonly: boxWith(() => readonly),
preventDeselect: boxWith(() => preventDeselect),
minValue: boxWith(() => minValue),
maxValue: boxWith(() => maxValue),
isDateUnavailable: boxWith(() => isDateUnavailable),
isDateDisabled: boxWith(() => isDateDisabled),
pagedNavigation: boxWith(() => pagedNavigation),
weekStartsOn: boxWith(() => weekStartsOn),
weekdayFormat: boxWith(() => weekdayFormat),
numberOfMonths: boxWith(() => numberOfMonths),
locale: resolveLocaleProp(() => locale),
dateOrder: boxWith(() => dateOrder),
calendarLabel: boxWith(() => calendarLabel),
fixedWeeks: boxWith(() => fixedWeeks),
disableDaysOutsideMonth: boxWith(() => disableDaysOutsideMonth),
minDays: boxWith(() => minDays),
maxDays: boxWith(() => maxDays),
excludeDisabled: boxWith(() => excludeDisabled),
startValue: boxWith(
() => startValue,
(v) => {
startValue = v;
onStartValueChange(v);
}
),
endValue: boxWith(
() => endValue,
(v) => {
endValue = v;
onEndValueChange(v);
}
),
monthFormat: boxWith(() => monthFormat),
yearFormat: boxWith(() => yearFormat),
defaultPlaceholder: getDefaultPlaceholder(),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
{/if}

@ -0,0 +1,925 @@
import {
type DateValue,
type DateRange,
type Month,
type WeekStartsOn,
type DatiDateOrder,
getLocalTimeZone,
isSameDay,
isSameMonth,
isToday,
areAllDaysBetweenValid,
getDateValueType,
isAfter,
isBefore,
isBetweenInclusive,
dateValueToDate as toDate,
} from "$lib/glob/lib";
import {
attachRef,
DOMContext,
type ReadableBoxedValues,
type WritableBoxedValues,
} from "../utilities/index";
import { Context, watch } from "runed";
import { CalendarRootContext } from "../calendar/calendar.svelte.ts";
import type {
AcelFocusEvent,
AcelKeyboardEvent,
AcelMouseEvent,
RefAttachment,
WithRefOpts,
} from "../utilities/types";
import { useId } from "../utilities/use-id";
import { boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs";
import { type Announcer, getAnnouncer } from "../utilities/datetime/announcer";
import { type Formatter, createFormatter } from "../utilities/datetime/formatter";
import {
calendarAttrs,
createMonths,
getCalendarElementProps,
getCalendarHeadingValue,
getDefaultYears,
getIsNextButtonDisabled,
getIsPrevButtonDisabled,
getWeekdays,
handleCalendarKeydown,
handleCalendarNextPage,
handleCalendarPrevPage,
shiftCalendarFocus,
useEnsureNonDisabledPlaceholder,
useMonthViewOptionsSync,
useMonthViewPlaceholderSync,
} from "../utilities/datetime/calendar-helpers.svelte";
import { onMount, untrack } from "svelte";
const RangeCalendarCellContext = new Context<RangeCalendarCellState>("RangeCalendar.Cell");
interface RangeCalendarRootStateOpts
extends WithRefOpts,
WritableBoxedValues<{
value: DateRange;
placeholder: DateValue;
startValue: DateValue | undefined;
endValue: DateValue | undefined;
}>,
ReadableBoxedValues<{
preventDeselect: boolean;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
disabled: boolean;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
isDateDisabled: (date: DateValue) => boolean;
isDateUnavailable: (date: DateValue) => boolean;
fixedWeeks: boolean;
numberOfMonths: number;
locale: string;
dateOrder: DatiDateOrder;
calendarLabel: string;
readonly: boolean;
disableDaysOutsideMonth: boolean;
excludeDisabled: boolean;
minDays: number | undefined;
maxDays: number | undefined;
/**
* This is strictly used by the `DateRangePicker` component to close the popover when a date range
* is selected. It is not intended to be used by the user.
*/
onRangeSelect?: () => void;
monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
}> {
defaultPlaceholder: DateValue;
}
export class RangeCalendarRootState {
static create(opts: RangeCalendarRootStateOpts) {
const state = new RangeCalendarRootState(opts);
CalendarRootContext.set(state as never);
return state;
}
readonly opts: RangeCalendarRootStateOpts;
readonly attachment: RefAttachment;
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
months: Month<DateValue>[] = $state([]);
announcer: Announcer;
formatter: Formatter;
accessibleHeadingId = useId();
focusedValue = $state<DateValue | undefined>(undefined);
lastPressedDateValue: DateValue | undefined = undefined;
domContext: DOMContext;
/**
* This derived state holds an array of localized day names for the current
* locale and calendar view. It dynamically syncs with the 'weekStartsOn' option,
* updating its content when the option changes. Using this state to render the
* calendar's days of the week is strongly recommended, as it guarantees that
* the days are correctly formatted for the current locale and calendar view.
*/
readonly weekdays = $derived.by(() => {
return getWeekdays({
months: this.months,
formatter: this.formatter,
weekdayFormat: this.opts.weekdayFormat.current,
});
});
readonly isStartInvalid = $derived.by(() => {
if (!this.opts.startValue.current) return false;
return (
this.isDateUnavailable(this.opts.startValue.current) ||
this.isDateDisabled(this.opts.startValue.current)
);
});
readonly isEndInvalid = $derived.by(() => {
if (!this.opts.endValue.current) return false;
return (
this.isDateUnavailable(this.opts.endValue.current) ||
this.isDateDisabled(this.opts.endValue.current)
);
});
readonly isInvalid = $derived.by(() => {
if (this.isStartInvalid || this.isEndInvalid) return true;
if (
this.opts.endValue.current &&
this.opts.startValue.current &&
isBefore(this.opts.endValue.current, this.opts.startValue.current)
)
return true;
return false;
});
readonly isNextButtonDisabled = $derived.by(() => {
return getIsNextButtonDisabled({
maxValue: this.opts.maxValue.current,
months: this.months,
disabled: this.opts.disabled.current,
});
});
readonly isPrevButtonDisabled = $derived.by(() => {
return getIsPrevButtonDisabled({
minValue: this.opts.minValue.current,
months: this.months,
disabled: this.opts.disabled.current,
});
});
readonly headingValue = $derived.by(() => {
this.opts.monthFormat.current;
this.opts.yearFormat.current;
return getCalendarHeadingValue({
months: this.months,
formatter: this.formatter,
locale: this.opts.locale.current,
});
});
readonly fullCalendarLabel = $derived.by(
() => `${this.opts.calendarLabel.current} ${this.headingValue}`
);
readonly highlightedRange = $derived.by(() => {
if (this.opts.startValue.current && this.opts.endValue.current) return null;
if (!this.opts.startValue.current || !this.focusedValue) return null;
const isStartBeforeFocused = isBefore(this.opts.startValue.current, this.focusedValue);
const start = isStartBeforeFocused ? this.opts.startValue.current : this.focusedValue;
const end = isStartBeforeFocused ? this.focusedValue : this.opts.startValue.current;
const range = { start, end };
if (isSameDay(start.add({ days: 1 }), end) || isSameDay(start, end)) {
return range;
}
const isValid = areAllDaysBetweenValid(
start,
end,
this.isDateUnavailable,
this.isDateDisabled
);
if (isValid) return range;
return null;
});
readonly initialPlaceholderYear = $derived.by(() =>
untrack(() => this.opts.placeholder.current.year)
);
readonly defaultYears = $derived.by(() => {
return getDefaultYears({
minValue: this.opts.minValue.current,
maxValue: this.opts.maxValue.current,
placeholderYear: this.initialPlaceholderYear,
});
});
constructor(opts: RangeCalendarRootStateOpts) {
this.opts = opts;
this.attachment = attachRef(opts.ref);
this.domContext = new DOMContext(opts.ref);
this.announcer = getAnnouncer(null);
this.formatter = createFormatter({
initialLocale: this.opts.locale.current,
monthFormat: this.opts.monthFormat,
yearFormat: this.opts.yearFormat,
});
this.months = createMonths({
dateObj: this.opts.placeholder.current,
weekStartsOn: this.opts.weekStartsOn.current,
locale: this.opts.locale.current,
fixedWeeks: this.opts.fixedWeeks.current,
numberOfMonths: this.opts.numberOfMonths.current,
});
$effect.pre(() => {
if (this.formatter.getLocale() === this.opts.locale.current) return;
this.formatter.setLocale(this.opts.locale.current);
});
onMount(() => {
this.announcer = getAnnouncer(this.domContext.getDocument());
});
/**
* Updates the displayed months based on changes in the placeholder values,
* which determines the month to show in the calendar.
*/
useMonthViewPlaceholderSync({
placeholder: this.opts.placeholder,
getVisibleMonths: () => this.visibleMonths,
weekStartsOn: this.opts.weekStartsOn,
locale: this.opts.locale,
fixedWeeks: this.opts.fixedWeeks,
numberOfMonths: this.opts.numberOfMonths,
setMonths: this.setMonths,
});
/**
* Updates the displayed months based on changes in the options values,
* which determines the month to show in the calendar.
*/
useMonthViewOptionsSync({
fixedWeeks: this.opts.fixedWeeks,
locale: this.opts.locale,
numberOfMonths: this.opts.numberOfMonths,
placeholder: this.opts.placeholder,
setMonths: this.setMonths,
weekStartsOn: this.opts.weekStartsOn,
});
/**
* Update the accessible heading's text content when the `fullCalendarLabel`
* changes.
*/
$effect(() => {
const node = this.domContext.getElementById(this.accessibleHeadingId);
if (!node) return;
node.textContent = this.fullCalendarLabel;
});
/**
* Synchronize the start and end values with the `value` in case
* it is updated externally.
*/
watch(
() => this.opts.value.current,
(value) => {
if (value.start && value.end) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = value.end;
} else if (value.start) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = undefined;
} else if (value.start === undefined && value.end === undefined) {
this.opts.startValue.current = undefined;
this.opts.endValue.current = undefined;
}
}
);
/**
* Synchronize the placeholder value with the current start value
*/
watch(
() => this.opts.value.current,
(value) => {
const startValue = value.start;
if (startValue && this.opts.placeholder.current !== startValue) {
this.opts.placeholder.current = startValue;
}
}
);
/**
* Check for disabled dates in the selected range when excludeDisabled is enabled
*/
watch(
[
() => this.opts.startValue.current,
() => this.opts.endValue.current,
() => this.opts.excludeDisabled.current,
],
([startValue, endValue, excludeDisabled]) => {
if (!excludeDisabled || !startValue || !endValue) return;
if (this.#hasDisabledDatesInRange(startValue, endValue)) {
this.#setStartValue(undefined);
this.#setEndValue(undefined);
this.#announceEmpty();
}
}
);
watch(
[() => this.opts.startValue.current, () => this.opts.endValue.current],
([startValue, endValue]) => {
if (
this.opts.value.current &&
this.opts.value.current.start === startValue &&
this.opts.value.current.end === endValue
) {
return;
}
if (startValue && endValue) {
this.#updateValue((prev) => {
if (prev.start === startValue && prev.end === endValue) {
return prev;
}
if (isBefore(endValue, startValue)) {
const start = startValue;
const end = endValue;
this.#setStartValue(end);
this.#setEndValue(start);
if (!this.#isRangeValid(endValue, startValue)) {
this.#setStartValue(startValue);
this.#setEndValue(undefined);
return { start: startValue, end: undefined };
}
return { start: endValue, end: startValue };
} else {
if (!this.#isRangeValid(startValue, endValue)) {
this.#setStartValue(endValue);
this.#setEndValue(undefined);
return { start: endValue, end: undefined };
}
return {
start: startValue,
end: endValue,
};
}
});
} else if (
this.opts.value.current &&
this.opts.value.current.start &&
this.opts.value.current.end
) {
this.opts.value.current.start = undefined;
this.opts.value.current.end = undefined;
}
}
);
this.shiftFocus = this.shiftFocus.bind(this);
this.handleCellClick = this.handleCellClick.bind(this);
this.onkeydown = this.onkeydown.bind(this);
this.nextPage = this.nextPage.bind(this);
this.prevPage = this.prevPage.bind(this);
this.nextYear = this.nextYear.bind(this);
this.prevYear = this.prevYear.bind(this);
this.setYear = this.setYear.bind(this);
this.setMonth = this.setMonth.bind(this);
this.isDateDisabled = this.isDateDisabled.bind(this);
this.isDateUnavailable = this.isDateUnavailable.bind(this);
this.isOutsideVisibleMonths = this.isOutsideVisibleMonths.bind(this);
this.isSelected = this.isSelected.bind(this);
this.getAcelAttr = this.getAcelAttr.bind(this);
useEnsureNonDisabledPlaceholder({
placeholder: opts.placeholder,
defaultPlaceholder: opts.defaultPlaceholder,
isDateDisabled: opts.isDateDisabled,
maxValue: opts.maxValue,
minValue: opts.minValue,
ref: opts.ref,
});
}
getAcelAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
return calendarAttrs.getAttr(part);
};
#updateValue(cb: (value: DateRange) => DateRange) {
const value = this.opts.value.current;
const newValue = cb(value);
this.opts.value.current = newValue;
if (newValue.start && newValue.end) {
this.opts.onRangeSelect?.current?.();
}
}
#setStartValue(value: DateValue | undefined) {
this.opts.startValue.current = value;
// update the main value prop immediately for external consumers
this.#updateValue((prev) => ({
...prev,
start: value,
}));
}
#setEndValue(value: DateValue | undefined) {
this.opts.endValue.current = value;
// update the main value prop immediately for external consumers
this.#updateValue((prev) => ({
...prev,
end: value,
}));
}
setMonths = (months: Month<DateValue>[]) => {
this.months = months;
};
isOutsideVisibleMonths(date: DateValue) {
return !this.visibleMonths.some((month) => isSameMonth(date, month));
}
isDateDisabled(date: DateValue) {
if (this.opts.isDateDisabled.current(date) || this.opts.disabled.current) return true;
const minValue = this.opts.minValue.current;
const maxValue = this.opts.maxValue.current;
if (minValue && isBefore(date, minValue)) return true;
if (maxValue && isAfter(date, maxValue)) return true;
return false;
}
isDateUnavailable(date: DateValue) {
if (this.opts.isDateUnavailable.current(date)) return true;
return false;
}
isSelectionStart(date: DateValue) {
if (!this.opts.startValue.current) return false;
return isSameDay(date, this.opts.startValue.current);
}
isSelectionEnd(date: DateValue) {
if (!this.opts.endValue.current) return false;
return isSameDay(date, this.opts.endValue.current);
}
isSelected(date: DateValue) {
if (this.opts.startValue.current && isSameDay(this.opts.startValue.current, date))
return true;
if (this.opts.endValue.current && isSameDay(this.opts.endValue.current, date)) return true;
if (this.opts.startValue.current && this.opts.endValue.current) {
return isBetweenInclusive(
date,
this.opts.startValue.current,
this.opts.endValue.current
);
}
return false;
}
#isRangeValid(start: DateValue, end: DateValue): boolean {
// ensure we always use the correct order for calculation
const orderedStart = isBefore(end, start) ? end : start;
const orderedEnd = isBefore(end, start) ? start : end;
const startDate = orderedStart.toDate(getLocalTimeZone());
const endDate = orderedEnd.toDate(getLocalTimeZone());
const timeDifference = endDate.getTime() - startDate.getTime();
const daysDifference = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const daysInRange = daysDifference + 1; // +1 to include both start and end days
if (this.opts.minDays.current && daysInRange < this.opts.minDays.current) return false;
if (this.opts.maxDays.current && daysInRange > this.opts.maxDays.current) return false;
// check for disabled dates in range if excludeDisabled is enabled
if (
this.opts.excludeDisabled.current &&
this.#hasDisabledDatesInRange(orderedStart, orderedEnd)
) {
return false;
}
return true;
}
shiftFocus(node: HTMLElement, add: number) {
return shiftCalendarFocus({
node,
add,
placeholder: this.opts.placeholder,
calendarNode: this.opts.ref.current,
isPrevButtonDisabled: this.isPrevButtonDisabled,
isNextButtonDisabled: this.isNextButtonDisabled,
months: this.months,
numberOfMonths: this.opts.numberOfMonths.current,
});
}
#announceEmpty() {
this.announcer.announce("Selected date is now empty.", "polite");
}
#announceSelectedDate(date: DateValue) {
this.announcer.announce(
`Selected Date: ${this.formatter.selectedDate(date, false)}`,
"polite"
);
}
#announceSelectedRange(start: DateValue, end: DateValue) {
this.announcer.announce(
`Selected Dates: ${this.formatter.selectedDate(start, false)} to ${this.formatter.selectedDate(end, false)}`,
"polite"
);
}
handleCellClick(e: Event, date: DateValue) {
if (this.isDateDisabled(date) || this.isDateUnavailable(date)) return;
const prevLastPressedDate = this.lastPressedDateValue;
this.lastPressedDateValue = date;
if (this.opts.startValue.current && this.highlightedRange === null) {
if (
isSameDay(this.opts.startValue.current, date) &&
!this.opts.preventDeselect.current &&
!this.opts.endValue.current
) {
this.#setStartValue(undefined);
this.opts.placeholder.current = date;
this.#announceEmpty();
return;
} else if (!this.opts.endValue.current) {
e.preventDefault();
if (prevLastPressedDate && isSameDay(prevLastPressedDate, date)) {
this.#setStartValue(date);
this.#announceSelectedDate(date);
}
}
}
if (
this.opts.startValue.current &&
this.opts.endValue.current &&
isSameDay(this.opts.endValue.current, date) &&
!this.opts.preventDeselect.current
) {
this.#setStartValue(undefined);
this.#setEndValue(undefined);
this.opts.placeholder.current = date;
this.#announceEmpty();
return;
}
if (!this.opts.startValue.current) {
this.#announceSelectedDate(date);
this.#setStartValue(date);
} else if (!this.opts.endValue.current) {
// determine the start and end dates for validation
const startDate = this.opts.startValue.current;
const endDate = date;
const orderedStart = isBefore(endDate, startDate) ? endDate : startDate;
const orderedEnd = isBefore(endDate, startDate) ? startDate : endDate;
// check if the range violates constraints
if (!this.#isRangeValid(orderedStart, orderedEnd)) {
// reset to just the clicked date
this.#setStartValue(date);
this.#setEndValue(undefined);
this.#announceSelectedDate(date);
} else {
// ensure start and end are properly ordered
if (isBefore(endDate, startDate)) {
// backward selection - reorder the values
this.#setStartValue(endDate);
this.#setEndValue(startDate);
this.#announceSelectedRange(endDate, startDate);
} else {
// forward selection - keep original order
this.#setEndValue(date);
this.#announceSelectedRange(this.opts.startValue.current, date);
}
}
} else if (this.opts.endValue.current && this.opts.startValue.current) {
this.#setEndValue(undefined);
this.#announceSelectedDate(date);
this.#setStartValue(date);
}
}
onkeydown(event: AcelKeyboardEvent) {
return handleCalendarKeydown({
event,
handleCellClick: this.handleCellClick,
placeholderValue: this.opts.placeholder.current,
shiftFocus: this.shiftFocus,
});
}
/**
* Navigates to the next page of the calendar.
*/
nextPage() {
handleCalendarNextPage({
fixedWeeks: this.opts.fixedWeeks.current,
locale: this.opts.locale.current,
numberOfMonths: this.opts.numberOfMonths.current,
pagedNavigation: this.opts.pagedNavigation.current,
setMonths: this.setMonths,
setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
weekStartsOn: this.opts.weekStartsOn.current,
months: this.months,
});
}
/**
* Navigates to the previous page of the calendar.
*/
prevPage() {
handleCalendarPrevPage({
fixedWeeks: this.opts.fixedWeeks.current,
locale: this.opts.locale.current,
numberOfMonths: this.opts.numberOfMonths.current,
pagedNavigation: this.opts.pagedNavigation.current,
setMonths: this.setMonths,
setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
weekStartsOn: this.opts.weekStartsOn.current,
months: this.months,
});
}
nextYear() {
this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 });
}
prevYear() {
this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 });
}
setYear(year: number) {
this.opts.placeholder.current = this.opts.placeholder.current.set({ year });
}
setMonth(month: number) {
this.opts.placeholder.current = this.opts.placeholder.current.set({ month });
}
getBitsAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
return calendarAttrs.getAttr(part, "range-calendar");
};
readonly snippetProps = $derived.by(() => ({
months: this.months,
weekdays: this.weekdays,
dateOrder: this.opts.dateOrder.current,
}));
readonly props = $derived.by(
() =>
({
...getCalendarElementProps({
fullCalendarLabel: this.fullCalendarLabel,
id: this.opts.id.current,
isInvalid: this.isInvalid,
disabled: this.opts.disabled.current,
readonly: this.opts.readonly.current,
}),
[this.getBitsAttr("root")]: "",
//
onkeydown: this.onkeydown,
...this.attachment,
}) as const
);
#hasDisabledDatesInRange(start: DateValue, end: DateValue): boolean {
for (
let date = start;
isBefore(date, end) || isSameDay(date, end);
date = date.add({ days: 1 })
) {
if (this.isDateDisabled(date)) return true;
}
return false;
}
}
interface RangeCalendarCellStateOpts
extends WithRefOpts,
ReadableBoxedValues<{
date: DateValue;
month: DateValue;
}> {}
export class RangeCalendarCellState {
static create(opts: RangeCalendarCellStateOpts) {
return RangeCalendarCellContext.set(
new RangeCalendarCellState(opts, CalendarRootContext.get() as unknown as RangeCalendarRootState)
);
}
readonly opts: RangeCalendarCellStateOpts;
readonly root: RangeCalendarRootState;
readonly attachment: RefAttachment;
readonly cellDate = $derived.by(() => toDate(this.opts.date.current));
readonly isOutsideMonth = $derived.by(
() => !isSameMonth(this.opts.date.current, this.opts.month.current)
);
readonly isDisabled = $derived.by(
() =>
this.root.isDateDisabled(this.opts.date.current) ||
(this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current)
);
readonly isUnavailable = $derived.by(() =>
this.root.opts.isDateUnavailable.current(this.opts.date.current)
);
readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone()));
readonly isOutsideVisibleMonths = $derived.by(() =>
this.root.isOutsideVisibleMonths(this.opts.date.current)
);
readonly isFocusedDate = $derived.by(() =>
isSameDay(this.opts.date.current, this.root.opts.placeholder.current)
);
readonly isSelectedDate = $derived.by(() => this.root.isSelected(this.opts.date.current));
readonly isSelectionStart = $derived.by(() =>
this.root.isSelectionStart(this.opts.date.current)
);
readonly isRangeStart = $derived.by(() => this.root.isSelectionStart(this.opts.date.current));
readonly isRangeEnd = $derived.by(() => {
if (!this.root.opts.endValue.current)
return this.root.isSelectionStart(this.opts.date.current);
return this.root.isSelectionEnd(this.opts.date.current);
});
readonly isRangeMiddle = $derived.by(() => this.isSelectionMiddle);
readonly isSelectionMiddle = $derived.by(() => {
return this.isSelectedDate && !this.isSelectionStart && !this.isSelectionEnd;
});
readonly isSelectionEnd = $derived.by(() => this.root.isSelectionEnd(this.opts.date.current));
readonly isHighlighted = $derived.by(() =>
this.root.highlightedRange
? isBetweenInclusive(
this.opts.date.current,
this.root.highlightedRange.start,
this.root.highlightedRange.end
)
: false
);
readonly labelText = $derived.by(() =>
this.root.formatter.custom(this.cellDate, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})
);
constructor(opts: RangeCalendarCellStateOpts, root: RangeCalendarRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref);
}
readonly snippetProps = $derived.by(() => ({
disabled: this.isDisabled,
unavailable: this.isUnavailable,
selected: this.isSelectedDate,
}));
readonly ariaDisabled = $derived.by(() => {
return (
this.isDisabled ||
(this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current) ||
this.isUnavailable
);
});
readonly sharedDataAttrs = $derived.by(
() =>
({
"data-unavailable": boolToEmptyStrOrUndef(this.isUnavailable),
"data-today": this.isDateToday ? "" : undefined,
"data-outside-month": this.isOutsideMonth ? "" : undefined,
"data-outside-visible-months": this.isOutsideVisibleMonths ? "" : undefined,
"data-focused": this.isFocusedDate ? "" : undefined,
"data-selection-start": this.isSelectionStart ? "" : undefined,
"data-selection-end": this.isSelectionEnd ? "" : undefined,
"data-range-start": this.isRangeStart ? "" : undefined,
"data-range-end": this.isRangeEnd ? "" : undefined,
"data-range-middle": this.isRangeMiddle ? "" : undefined,
"data-highlighted": this.isHighlighted ? "" : undefined,
"data-selected": boolToEmptyStrOrUndef(this.isSelectedDate),
"data-value": this.opts.date.current.toString(),
"data-type": getDateValueType(this.opts.date.current),
"data-disabled": boolToEmptyStrOrUndef(
this.isDisabled ||
(this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current)
),
}) as const
);
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "gridcell",
"aria-selected": boolToStr(this.isSelectedDate),
"aria-disabled": boolToStr(this.ariaDisabled),
...this.sharedDataAttrs,
[this.root.getBitsAttr("cell")]: "",
...this.attachment,
}) as const
);
}
interface RangeCalendarDayStateOpts extends WithRefOpts {}
export class RangeCalendarDayState {
static create(opts: RangeCalendarDayStateOpts) {
return new RangeCalendarDayState(opts, RangeCalendarCellContext.get());
}
readonly opts: RangeCalendarDayStateOpts;
readonly cell: RangeCalendarCellState;
readonly attachment: RefAttachment;
constructor(opts: RangeCalendarDayStateOpts, cell: RangeCalendarCellState) {
this.opts = opts;
this.cell = cell;
this.attachment = attachRef(opts.ref);
this.onclick = this.onclick.bind(this);
this.onmouseenter = this.onmouseenter.bind(this);
this.onfocusin = this.onfocusin.bind(this);
}
readonly #tabindex = $derived.by(() =>
(this.cell.isOutsideMonth && this.cell.root.opts.disableDaysOutsideMonth.current) ||
this.cell.isDisabled
? undefined
: this.cell.isFocusedDate
? 0
: -1
);
onclick(e: AcelMouseEvent) {
if (this.cell.isDisabled) return;
this.cell.root.handleCellClick(e, this.cell.opts.date.current);
}
onmouseenter(_: AcelMouseEvent) {
if (this.cell.isDisabled) return;
this.cell.root.focusedValue = this.cell.opts.date.current;
}
onfocusin(_: AcelFocusEvent) {
if (this.cell.isDisabled) return;
this.cell.root.focusedValue = this.cell.opts.date.current;
}
readonly snippetProps = $derived.by(() => ({
disabled: this.cell.isDisabled,
unavailable: this.cell.isUnavailable,
selected: this.cell.isSelectedDate,
day: `${this.cell.opts.date.current.day}`,
}));
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "button",
"aria-label": this.cell.labelText,
"aria-disabled": boolToStr(this.cell.ariaDisabled),
...this.cell.sharedDataAttrs,
tabindex: this.#tabindex,
[this.cell.root.getBitsAttr("day")]: "",
// Shared logic for range calendar and calendar
"data-bits-day": "",
//
onclick: this.onclick,
onmouseenter: this.onmouseenter,
onfocusin: this.onfocusin,
...this.attachment,
}) as const
);
}

@ -0,0 +1,274 @@
import type { DateValue, DatiDateOrder } from "$lib/glob/lib";
import type { OnChangeFn, WithChild, Without } from "../utilities/types";
import type { DateMatcher, DateRange, Month } from "$lib/glob/lib";
import type { AcelPrimitiveDivAttributes } from "../utilities/attributes";
export type RangeCalendarRootSnippetProps = {
months: Month<DateValue>[];
weekdays: string[];
dateOrder: DatiDateOrder;
};
export type RangeCalendarRootPropsWithoutHTML = WithChild<
{
/**
* The value of the selected date range.
* @bindable
*/
value?: DateRange;
/**
* A callback function called when the value changes.
*/
onValueChange?: OnChangeFn<DateRange>;
/**
* The placeholder date, used to control the view of the
* calendar when no value is present.
*
* @default the current date
*/
placeholder?: DateValue;
/**
* A callback function called when the placeholder value
* changes.
*/
onPlaceholderChange?: OnChangeFn<DateValue>;
/**
* The minimum number of days that can be selected in a range.
*
* @default undefined
*/
minDays?: number;
/**
* The maximum number of days that can be selected in a range.
*
* @default undefined
*/
maxDays?: number;
/**
* Whether or not users can deselect a date once selected
* without selecting another date.
*
* @default false
*/
preventDeselect?: boolean;
/**
* The minimum date that can be selected in the calendar.
*/
minValue?: DateValue;
/**
* The maximum date that can be selected in the calendar.
*/
maxValue?: DateValue;
/**
* Whether or not the calendar is disabled.
*
* @default false
*/
disabled?: boolean;
/**
* Applicable only when `numberOfMonths` is greater than 1.
*
* Controls whether to use paged navigation for the next and previous buttons in the
* date picker. With paged navigation set to `true`, clicking the next/prev buttons
* changes all months in view. When set to `false`, it shifts the view by a single month.
*
* For example, with `pagedNavigation` set to `true` and 2 months displayed (January and
* February), clicking the next button changes the view to March and April. If `pagedNavigation`
* is `false`, the view shifts to February and March.
*
* @default false
*/
pagedNavigation?: boolean;
/**
* The day of the week to start the calendar on, which must
* be a number between 0 and 6, where 0 is Sunday and 6 is
* Saturday.
*
* @default 0 (Sunday)
*/
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/**
* How the string representation of the weekdays provided via the `weekdays` state store
* should be formatted.
*
* ```md
* - "long": "Sunday", "Monday", "Tuesday", etc.
* - "short": "Sun", "Mon", "Tue", etc.
* - "narrow": "S", "M", "T", etc.
*```
*
* @default "narrow"
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#weekday
*/
weekdayFormat?: Intl.DateTimeFormatOptions["weekday"];
/**
* A function that receives a date and returns `true` or `false` to indicate whether
* the date is disabled.
*
* @remarks
* Disabled dates cannot be focused or selected. Additionally, they are tagged
* with a data attribute to enable custom styling.
*
* `[data-disabled]` - applied to disabled dates
*
*/
isDateDisabled?: DateMatcher;
/**
* Dates matching the provided matchers are marked as "unavailable." Unlike disabled dates,
* users can still focus and select unavailable dates. However, selecting an unavailable date
* renders the date picker as invalid.
*
* For example, in a calendar for booking appointments, you might mark already booked dates as
* unavailable. These dates could become available again before the appointment date, allowing
* users to select them to learn more about the appointment.
*
* `[data-unavailable]` - applied to unavailable dates
*
*/
isDateUnavailable?: DateMatcher;
/**
* Display 6 weeks per month, regardless the month's number of weeks.
* This is useful for displaying a consistent calendar, where the size
* of the calendar doesn't change month to month.
*
* To display 6 weeks per month, you will need to render out the previous
* and next month's dates in the calendar as well.
*
* @default false
*/
fixedWeeks?: boolean;
/**
* Determines the number of months to display on the calendar simultaneously.
* For navigation between months, refer to the `pagedNavigation` prop.
*
* @default 1
*/
numberOfMonths?: number;
/**
* This label is exclusively used for accessibility, remaining hidden from the page.
* It's read by screen readers when the calendar is opened. The current month and year
* are automatically appended to the label, so you only need to provide the base label.
*
* For instance:
* - 'Date of birth' will be read as 'Date of birth, January 2021' if the current month is January 2021.
* - 'Appointment date' will be read as 'Appointment date, January 2021' if the current month is January 2021.
* - 'Booking date' will be read as 'Booking date, January 2021' if the current month is January 2021.
*/
calendarLabel?: string;
/**
* The default locale setting.
*
* @default 'en'
*/
locale?: string;
dateOrder?: DatiDateOrder;
/**
* Whether the calendar is readonly. When true, the user will be able
* to focus and navigate the calendar, but will not be able to select
* dates. @see disabled for a similar prop that prevents focusing
* and selecting dates.
*
* @default false
*/
readonly?: boolean;
/**
* Whether to disable the selection of days outside the current month. By default,
* days outside the current month are rendered to fill the calendar grid, but they
* are not selectable. Setting this prop to `true` will disable this behavior.
*
* @default false
*/
disableDaysOutsideMonth?: boolean;
/**
* Whether to automatically reset the range if any date within the selected range
* becomes disabled. When true, the entire range will be cleared if a disabled
* date is found between the start and end dates.
*
* @default false
*/
excludeDisabled?: boolean;
/**
* A callback function called when the start value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onStartValueChange?: OnChangeFn<DateValue | undefined>;
/**
* A callback function called when the end value changes. This doesn't necessarily mean
* the `value` has updated and should be used to apply cosmetic changes to the calendar when
* only part of the value is changed/completed.
*/
onEndValueChange?: OnChangeFn<DateValue | undefined>;
/**
* The format of the month names in the calendar.
*
* @default "long"
*/
monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
/**
* The format of the year names in the calendar.
*
* @default "numeric"
*/
yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
},
RangeCalendarRootSnippetProps
>;
export type RangeCalendarRootProps = RangeCalendarRootPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, RangeCalendarRootPropsWithoutHTML>;
export type {
CalendarPrevButtonProps as RangeCalendarPrevButtonProps,
CalendarPrevButtonPropsWithoutHTML as RangeCalendarPrevButtonPropsWithoutHTML,
CalendarNextButtonProps as RangeCalendarNextButtonProps,
CalendarNextButtonPropsWithoutHTML as RangeCalendarNextButtonPropsWithoutHTML,
CalendarHeadingProps as RangeCalendarHeadingProps,
CalendarHeadingPropsWithoutHTML as RangeCalendarHeadingPropsWithoutHTML,
CalendarGridProps as RangeCalendarGridProps,
CalendarGridPropsWithoutHTML as RangeCalendarGridPropsWithoutHTML,
CalendarCellProps as RangeCalendarCellProps,
CalendarCellPropsWithoutHTML as RangeCalendarCellPropsWithoutHTML,
CalendarDayProps as RangeCalendarDayProps,
CalendarDayPropsWithoutHTML as RangeCalendarDayPropsWithoutHTML,
CalendarGridBodyProps as RangeCalendarGridBodyProps,
CalendarGridBodyPropsWithoutHTML as RangeCalendarGridBodyPropsWithoutHTML,
CalendarGridHeadProps as RangeCalendarGridHeadProps,
CalendarGridHeadPropsWithoutHTML as RangeCalendarGridHeadPropsWithoutHTML,
CalendarGridRowProps as RangeCalendarGridRowProps,
CalendarGridRowPropsWithoutHTML as RangeCalendarGridRowPropsWithoutHTML,
CalendarHeadCellProps as RangeCalendarHeadCellProps,
CalendarHeadCellPropsWithoutHTML as RangeCalendarHeadCellPropsWithoutHTML,
CalendarHeaderProps as RangeCalendarHeaderProps,
CalendarHeaderPropsWithoutHTML as RangeCalendarHeaderPropsWithoutHTML,
CalendarMonthSelectProps as RangeCalendarMonthSelectProps,
CalendarMonthSelectPropsWithoutHTML as RangeCalendarMonthSelectPropsWithoutHTML,
CalendarYearSelectProps as RangeCalendarYearSelectProps,
CalendarYearSelectPropsWithoutHTML as RangeCalendarYearSelectPropsWithoutHTML,
} from "../calendar/types.js";

@ -1,8 +1,16 @@
import type { DateValue } from "$lib/glob/lib/date";
import {
type DateValue,
type Granularity,
type HourCycle,
type DatiDateOrder,
isZonedDateTime,
inferGranularity,
getDefaultHourCycle,
} from "$lib/glob/lib";
export { inferGranularity, getDefaultHourCycle };
import { styleToString } from "../../index";
import type { Formatter } from "../formatter.ts";
import { getPlaceholder } from "../placeholders.ts";
import { hasTime, isZonedDateTime } from "../utils.ts";
import type {
DateAndTimeSegmentObj,
DateSegmentPart,
@ -23,7 +31,6 @@ import { getSegments } from "./segments.ts";
import { isBrowser, isNull, isNumberString } from "../../dom/is-browser";
import { useId } from "../../use-id";
import { kbd } from "../../keyboard/kbd.ts";
import type { Granularity, HourCycle } from "../date-types";
export function initializeSegmentValues(granularity: Granularity) {
const calendarDateTimeGranularities = ["hour", "minute", "second"];
@ -49,6 +56,7 @@ type SharedContentProps = {
formatter: Formatter;
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
dateOrder: DatiDateOrder;
};
type CreateContentObjProps = SharedContentProps & {
@ -173,33 +181,84 @@ function createContentObj(props: CreateContentObjProps) {
}
function createContentArr(props: CreateContentArrProps) {
const { granularity, dateRef, formatter, contentObj, hideTimeZone, hourCycle } = props;
const { granularity, dateRef, formatter, contentObj, hideTimeZone, hourCycle, dateOrder } = props;
const parts = formatter.toParts(dateRef, getOptsByGranularity(granularity, hourCycle));
const segmentContentArr = parts
.map((part) => {
const defaultParts = ["literal", "dayPeriod", "timeZoneName", null];
if (defaultParts.includes(part.type) || !isSegmentPart(part.type)) {
return {
part: part.type,
value: part.value,
};
}
const firstDateIndex = parts.findIndex((part) => isDateSegmentPart(part.type));
let lastDateIndex = -1;
for (let i = parts.length - 1; i >= 0; i--) {
if (isDateSegmentPart(parts[i]?.type)) {
lastDateIndex = i;
break;
}
}
const mapPartToSegment = (part: Intl.DateTimeFormatPart) => {
const defaultParts = ["literal", "dayPeriod", "timeZoneName", null];
if (defaultParts.includes(part.type) || !isSegmentPart(part.type)) {
return {
part: part.type,
value: contentObj[part.type],
value: part.value,
};
})
.filter((segment): segment is { part: SegmentPart; value: string } => {
if (isNull(segment.part) || isNull(segment.value)) return false;
if (segment.part === "timeZoneName" && (!isZonedDateTime(dateRef) || hideTimeZone)) {
return false;
}
return true;
});
}
return {
part: part.type,
value: contentObj[part.type],
};
};
const segmentContentArr = (firstDateIndex === -1 || lastDateIndex === -1
? parts.map(mapPartToSegment)
: [
...parts.slice(0, firstDateIndex).map(mapPartToSegment),
...createOrderedDateSegments(parts.slice(firstDateIndex, lastDateIndex + 1), contentObj, dateOrder),
...parts.slice(lastDateIndex + 1).map(mapPartToSegment),
]).filter((segment): segment is { part: SegmentPart; value: string } => {
if (isNull(segment.part) || isNull(segment.value)) return false;
if (segment.part === "timeZoneName" && (!isZonedDateTime(dateRef) || hideTimeZone)) {
return false;
}
return true;
});
return segmentContentArr;
}
function createOrderedDateSegments(
parts: Intl.DateTimeFormatPart[],
contentObj: SegmentContentObj,
dateOrder: DatiDateOrder
) {
const orderedParts = getDatePartsForOrder(dateOrder);
const separators = parts.filter((part) => part.type === "literal").map((part) => part.value);
const fallbackSeparator = separators[0] ?? "/";
const result: Array<{ part: SegmentPart; value: string }> = [];
orderedParts.forEach((part, index) => {
result.push({ part, value: contentObj[part] });
if (index < orderedParts.length - 1) {
result.push({
part: "literal",
value: separators[index] ?? fallbackSeparator,
});
}
});
return result;
}
function getDatePartsForOrder(dateOrder: DatiDateOrder): DateSegmentPart[] {
switch (dateOrder) {
case "MDY":
return ["month", "day", "year"];
case "YMD":
return ["year", "month", "day"];
case "DMY":
default:
return ["day", "month", "year"];
}
}
type CreateContentProps = CreateContentObjProps;
export function createContent(props: CreateContentProps) {
@ -365,19 +424,6 @@ export function isDateAndTimeSegmentObj(obj: unknown): obj is DateAndTimeSegment
});
}
/**
* Infer the granularity to use based on the
* value and granularity props.
*/
export function inferGranularity(
value: DateValue,
granularity: Granularity | undefined
): Granularity {
if (granularity) return granularity;
if (hasTime(value)) return "minute";
return "day";
}
export function isAcceptableSegmentKey(key: string) {
const acceptableSegmentKeys = [
kbd.ENTER,
@ -450,10 +496,3 @@ export function removeDescriptionElement(id: string, doc: Document) {
doc.body.removeChild(el);
}
export function getDefaultHourCycle(locale: string): 12 | 24 {
const formatter = new Intl.DateTimeFormat(locale, { hour: "numeric" });
const parts = formatter.formatToParts(new Date("2023-01-01T13:00:00"));
const hourPart = parts.find((part) => part.type === "hour");
return hourPart?.value === "1" ? 12 : 24;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.