Appearance
Flatpickr — live demo
This is the real @rozie-ui/flatpickr-vue package running on this page (VitePress is itself a Vue app). Pick a date, switch to range mode, toggle the inline calendar — then drive the imperative handle with the toolbar. Everything below is the same Flatpickr.rozie source that compiles to all six frameworks.
The selected value is two-way bound with v-model:date — the readout above updates live as you pick, and the toolbar drives the imperative handle (openPicker, closePicker, jumpToDate, clear) plus reactive props (mode, inline). The reactive mode change reconciles into the live picker via flatpickr's set(); inline is construction-time, so the demo re-keys the component to rebuild the engine. See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Flatpickr.rozie — data-bound port of flatpickr (chmln/flatpickr).
The "killer demo" companion that does for date pickers what SortableList does
for drag-and-drop: replace five hand-maintained, stale, feature-incomplete
per-framework wrappers (react-flatpickr, vue-flatpickr-component,
angularx-flatpickr [~2yr stale], svelte-flatpickr [no Svelte-5 runes],
lit-flatpickr [a divergent reimplementation], and NO Solid wrapper at all)
with ONE Rozie source.
This is the expanded surface validated 2026-06 (compiles clean to all 6
targets). Design decisions locked here:
• Value contract — `r-model:date` (the formatted STRING is the authoritative
two-way value) PLUS `selectedDates: Date[]` delivered on the `change`
event payload. Consumers that want the Date objects read them off the
event; the two-way bind stays a simple string, sidestepping the awkward
Date[]-two-way that trips up every existing wrapper.
• Range-commit semantics — flatpickr fires onChange on the FIRST click in
range mode (a partial range). Committing then is the bug every wrapper
ships. We commit the string only when the range is COMPLETE (2 dates),
unless the consumer opts into `commitOn: 'change'`.
• Round-trip-guarded reconcile — the #1 bug class (React "can't be
controlled", Vue "infinite loop") fixed uniformly: write back only when
the value actually differs from the live input.
IMPERATIVE HANDLE (Phase 21 $expose, now shipped): a consumer-callable
handle exposing clear() / openPicker() / closePicker() / selectDate() /
jumpToDate(). Two methods are renamed off flatpickr's own to dodge emitted-
surface collisions: `selectDate` (setDate collides with the React auto-setter
for the `date` model prop, ROZ524) and `openPicker`/`closePicker` (open/close
collide with the open/close EVENTS this component emits). See the $expose
block for the full rationale.
lowered to each target's native ref mechanism (Vue defineExpose, React
useImperativeHandle, Svelte instance export, Angular/Lit public method,
Solid callback ref). See the `$expose({ ... })` block in <script>. The
value contract (r-model:date + events) remains the primary surface; the
handle covers the imperative-only methods props can't express.
Construction-time-only options (altInput / enableTime / noCalendar) are NOT
runtime-settable via flatpickr.set(); to retune them live, re-key the
component (`<Flatpickr :key="…">`) — the same documented idiom as SortableList.
-->
<rozie name="Flatpickr">
<props>
{
date: {
type: String,
default: '',
model: true,
docs: {
description:
'The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.',
example: '<Flatpickr r-model:date="picked" @change="onChange" />',
},
},
mode: {
type: String,
default: 'single',
docs: {
description:
"Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.",
},
},
dateFormat: {
type: String,
default: 'Y-m-d',
docs: {
description:
'flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.',
},
},
altInput: {
type: Boolean,
default: false,
docs: {
description:
'Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.',
},
},
altFormat: {
type: String,
default: 'F j, Y',
docs: {
description:
'Format token string for the human-readable alt input (used only when `altInput` is on).',
},
},
enableTime: {
type: Boolean,
default: false,
docs: {
description:
'Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.',
},
},
enableSeconds: {
type: Boolean,
default: false,
docs: {
description: 'Add a seconds input to the time picker (used with `enableTime`).',
},
},
time24hr: {
type: Boolean,
default: false,
docs: {
description: 'Display time in 24-hour format instead of the AM/PM clock.',
},
},
noCalendar: {
type: Boolean,
default: false,
docs: {
description:
'Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.',
},
},
minDate: {
type: String,
default: null,
docs: {
description: 'Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.',
},
},
maxDate: {
type: String,
default: null,
docs: {
description: 'Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.',
},
},
placeholder: {
type: String,
default: 'Select a date…',
docs: {
description: 'Placeholder text for the rendered input when no date is selected.',
},
},
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.',
},
},
commitOn: {
type: String,
default: 'complete',
docs: {
description:
"When to commit the two-way `date` in `mode=\"range\"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.",
},
},
options: {
type: Object,
default: () => ({}),
docs: {
description:
'Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.',
},
},
// GAP-1 — forms drop-in. Forwarded onto the rendered input so the component
// submits like a native form control (RHF `register`, Angular reactive
// forms `formControlName`). When `altInput` is on, flatpickr creates a
// hidden mirror input and MOVES the original's `name` onto it, so the
// submitted value carries `name` for both altInput on/off.
name: {
type: String,
default: '',
docs: {
description:
'HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.',
},
},
// GAP-5 — common UI knobs promoted out of the `options` escape hatch to
// first-class typed props. All construction-time only (no flatpickr.set()
// path) — to retune live, re-key the component (see the guide's `:key`
// idiom). Mapped 1:1 to the flatpickr option of the same name.
inline: {
type: Boolean,
default: false,
docs: {
description:
'Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.',
},
},
// flatpickr's option is `static`, but `static` is a JS reserved word — the
// React emitter's props-passthrough destructure emits it as a binding
// identifier (`const { …, static, … } = _props`), which TS rejects in
// strict-mode modules (TS1214). Expose it under the valid identifier
// `staticPosition` and map it to flatpickr's `static` option in $onMount.
staticPosition: {
type: Boolean,
default: false,
docs: {
description:
"flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.",
},
},
position: {
type: String,
default: 'auto',
docs: {
description:
"Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.",
},
},
appendTo: {
type: Object,
default: null,
docs: {
description:
'A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.',
},
},
showMonths: {
type: Number,
default: 1,
docs: {
description: 'Number of calendar months to render side by side. **Construction-time only**.',
},
},
weekNumbers: {
type: Boolean,
default: false,
docs: {
description: 'Show ISO week numbers down the left edge of the calendar. **Construction-time only**.',
},
},
monthSelectorType: {
type: String,
default: 'dropdown',
docs: {
description:
"Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.",
},
},
prevArrow: {
type: String,
default: null,
docs: {
description:
"HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.",
},
},
nextArrow: {
type: String,
default: null,
docs: {
description:
"HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.",
},
},
// GAP-6a — allow typed entry directly in the input. Construction-time only.
allowInput: {
type: Boolean,
default: false,
docs: {
description:
'Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.',
},
},
// GAP-2 — disabled/enabled date sets. Each accepts an
// `Array<Date | string | { from, to } | (date: Date) => boolean>`: explicit
// dates, "Y-m-d" strings, {from,to} ranges, and/or predicate functions.
// RUNTIME-UPDATABLE via flatpickr `set('disable'|'enable', …)` (see $watch).
// Render-neutral: an empty array is NEVER passed at construction (flatpickr
// treats `enable: []` as "nothing enabled"), so the default leaves the
// baseline render untouched (see the conditional spreads in $onMount).
disable: {
type: Array,
default: () => [],
docs: {
description:
'Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.',
},
},
enable: {
type: Array,
default: () => [],
docs: {
description:
'Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.',
},
},
// GAP-3 — internationalization. `locale` takes a flatpickr locale OBJECT;
// the consumer lazy-imports it themselves (`flatpickr/dist/l10n/<x>.js` — the
// wrapper adds no locale dependency). `firstDayOfWeek` FOLDS INTO the locale
// option (flatpickr's locale carries `firstDayOfWeek`); when both are set,
// firstDayOfWeek overrides the locale's own. Both RUNTIME-UPDATABLE via
// `set('locale', mergedLocale)`. flatpickr's default firstDayOfWeek is 0
// (Sunday) — render-neutral, so it is only passed when non-zero.
locale: {
type: Object,
default: null,
docs: {
description:
'A flatpickr locale object (e.g. `import fr from \'flatpickr/dist/l10n/fr.js\'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set(\'locale\', …)`.',
},
},
firstDayOfWeek: {
type: Number,
default: 0,
docs: {
description:
'First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale\'s own first weekday when set. Runtime-updatable.',
},
},
// GAP-6b — custom parse/format functions. CONSTRUCTION-TIME only (no
// flatpickr.set() path) — re-key the component to retune live. null default
// is render-neutral (omitted from the options object when unset).
parseDate: {
type: Function,
default: null,
docs: {
description:
'Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr\'s token grammar cannot express. **Construction-time only** — re-key the component to change it live.',
},
},
formatDate: {
type: Function,
default: null,
docs: {
description:
'Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr\'s token grammar cannot express. **Construction-time only** — re-key the component to change it live.',
},
},
// GAP-4 — plugins. An array of flatpickr plugin INSTANCES (consumer imports
// from `flatpickr/dist/plugins/…`; headline: `rangePlugin` for two-input
// ranges). CONSTRUCTION-TIME only — re-key to swap plugins live. Empty array
// is render-neutral (never passed at construction).
plugins: {
type: Array,
default: () => [],
docs: {
description:
'An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.',
},
},
}
</props>
<script>
import flatpickr from 'flatpickr'
let instance = null
$onMount(() => {
instance = flatpickr($refs.inputEl, {
mode: $props.mode,
dateFormat: $props.dateFormat,
altInput: $props.altInput,
altFormat: $props.altFormat,
enableTime: $props.enableTime,
enableSeconds: $props.enableSeconds,
time_24hr: $props.time24hr,
noCalendar: $props.noCalendar,
minDate: $props.minDate,
maxDate: $props.maxDate,
defaultDate: $props.date || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: $props.inline,
static: $props.staticPosition,
position: $props.position,
showMonths: $props.showMonths,
weekNumbers: $props.weekNumbers,
monthSelectorType: $props.monthSelectorType,
allowInput: $props.allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...($props.appendTo != null ? { appendTo: $props.appendTo } : {}),
...($props.prevArrow != null ? { prevArrow: $props.prevArrow } : {}),
...($props.nextArrow != null ? { nextArrow: $props.nextArrow } : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...($props.disable.length ? { disable: $props.disable } : {}),
...($props.enable.length ? { enable: $props.enable } : {}),
...($props.parseDate != null ? { parseDate: $props.parseDate } : {}),
...($props.formatDate != null ? { formatDate: $props.formatDate } : {}),
...($props.plugins.length ? { plugins: $props.plugins } : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(($props.locale != null || $props.firstDayOfWeek !== 0)
? { locale: { ...($props.locale ?? {}), ...($props.firstDayOfWeek !== 0 ? { firstDayOfWeek: $props.firstDayOfWeek } : {}) } }
: {}),
...$props.options,
onChange: (selectedDates, dateStr) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = $props.mode === 'range'
const complete = !isRange || selectedDates.length === 2
if (($props.commitOn === 'change' || complete) && dateStr !== $props.date) {
$model.date = dateStr
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
$emit('change', { value: dateStr, selectedDates })
},
onReady: (d, s) => $emit('ready', { value: s, selectedDates: d }),
onOpen: () => $emit('open'),
onClose: () => $emit('close'),
onMonthChange: () => $emit('monthChange'),
onYearChange: () => $emit('yearChange'),
onValueUpdate: (d, s) => $emit('valueUpdate', { value: s, selectedDates: d }),
onDayCreate: (_d, _s, _fp, dayElem) => $emit('dayCreate', dayElem),
})
if ($props.disabled) instance.input.disabled = true
return () => instance?.destroy()
})
// Round-trip-guarded two-way reconcile (the #1 bug class, fixed uniformly).
// When the user picks a date, flatpickr fires onChange → we write $model.date
// → $watch sees the change → without this guard we'd call setDate and re-fire
// onChange. Skip when already in sync.
$watch(() => $props.date, (v) => {
if (!instance) return
if (v !== instance.input.value) instance.setDate(v, false)
})
// Runtime-settable options via flatpickr's supported set() path.
$watch(() => $props.mode, (v) => instance?.set('mode', v))
$watch(() => $props.minDate, (v) => instance?.set('minDate', v))
$watch(() => $props.maxDate, (v) => instance?.set('maxDate', v))
$watch(() => $props.dateFormat, (v) => instance?.set('dateFormat', v))
$watch(() => $props.disabled, (v) => {
if (instance) instance.input.disabled = v
})
// GAP-2 runtime reconcilers. flatpickr's set() honors live disable/enable
// changes. NOTE: we pass the live value VERBATIM — even an empty array — on a
// runtime CHANGE, because `set('enable', [])` is the legitimate "disable
// everything" and `set('disable', [])` clears the disabled set. The empty-array
// guard only applies to the CONSTRUCTION-time spread in $onMount (where a bare
// `enable: []` at init means "nothing enabled"); a deliberate runtime clear is
// a different, valid intent.
$watch(() => $props.disable, (v) => instance?.set('disable', v))
$watch(() => $props.enable, (v) => instance?.set('enable', v))
// GAP-3 runtime reconcilers. locale + firstDayOfWeek both fold into the single
// `locale` option; two watchers each recompute the SAME merge as $onMount and
// re-set it, so a live change to either reconciles. (plugins/parseDate/
// formatDate are construction-time and intentionally have NO reconciler.)
$watch(() => $props.locale, (v) =>
instance?.set('locale', { ...(v ?? {}), ...($props.firstDayOfWeek !== 0 ? { firstDayOfWeek: $props.firstDayOfWeek } : {}) }))
$watch(() => $props.firstDayOfWeek, (v) =>
instance?.set('locale', { ...($props.locale ?? {}), ...(v !== 0 ? { firstDayOfWeek: v } : {}) }))
// Imperative handle (Phase 21 $expose). The flatpickr instance methods a
// consumer can't drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each guards on `instance`
// (null before $onMount and after destroy). selectDate forwards flatpickr's own
// triggerChange arg; leaving it undefined keeps flatpickr's default (no
// onChange refire), so a programmatic selectDate does not bounce through the
// round-trip-guarded $watch above.
//
// Two method names are deliberately NOT flatpickr's own, to avoid collisions
// with this component's emitted surface (a real cross-target footgun — see the
// Step-4 gap report):
// - `selectDate` (not `setDate`): the `date` prop is `model: true`, so React's
// emitter auto-generates a `setDate` setter for it (useControllableState
// destructure). A user `setDate` collides — ROZ524 ("already declared" +
// infinite-recursion of the model-write rewrite). selectDate wraps
// flatpickr's instance.setDate.
// - `openPicker` / `closePicker` (not `open` / `close`): this component emits
// `open` and `close` EVENTS (onOpen/onClose -> $emit). On targets that
// materialize events as named members (Angular `output()`), a method named
// `open`/`close` collides with the event member and the emitter silently
// renames the method to `_open`/`_close` — breaking the uniform handle. No
// diagnostic fires today (unlike the model-setter ROZ524); flagged for a
// future ROZ "expose-name vs event-name collision" check. Prefixing the
// methods sidesteps it.
function clear() { instance?.clear() }
function openPicker() { instance?.open() }
function closePicker() { instance?.close() }
function selectDate(date, triggerChange) { instance?.setDate(date, triggerChange) }
function jumpToDate(date) { instance?.jumpToDate(date) }
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
function getSelectedDates() { return instance ? instance.selectedDates : [] }
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
function togglePicker() { instance?.toggle() }
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
function changeMonth(value, isOffset) { instance?.changeMonth(value, isOffset) }
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
function changeYear(year) { instance?.changeYear(year) }
$expose({ clear, openPicker, closePicker, selectDate, jumpToDate, getSelectedDates, togglePicker, changeMonth, changeYear })
</script>
<template>
<input
ref="inputEl"
type="text"
class="rozie-flatpickr"
:name="$props.name"
:placeholder="$props.placeholder"
/>
</template>
<style>
.rozie-flatpickr {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/flatpickr-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './Flatpickr.css';
import flatpickr from 'flatpickr';
interface FlatpickrProps {
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
date?: string;
defaultDate?: string;
onDateChange?: (date: string) => void;
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
mode?: string;
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
dateFormat?: string;
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
altInput?: boolean;
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
altFormat?: string;
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
enableTime?: boolean;
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
enableSeconds?: boolean;
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
time24hr?: boolean;
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
noCalendar?: boolean;
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
minDate?: (string) | null;
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
maxDate?: (string) | null;
/**
* Placeholder text for the rendered input when no date is selected.
*/
placeholder?: string;
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
disabled?: boolean;
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
commitOn?: string;
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
options?: Record<string, any>;
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
name?: string;
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
inline?: boolean;
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
staticPosition?: boolean;
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
position?: string;
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
appendTo?: (Record<string, any>) | null;
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
showMonths?: number;
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
weekNumbers?: boolean;
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
monthSelectorType?: string;
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
prevArrow?: (string) | null;
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
nextArrow?: (string) | null;
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
allowInput?: boolean;
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
disable?: any[];
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
enable?: any[];
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
locale?: (Record<string, any>) | null;
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
firstDayOfWeek?: number;
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
parseDate?: ((...args: any[]) => any) | null;
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
formatDate?: ((...args: any[]) => any) | null;
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
plugins?: any[];
onChange?: (...args: any[]) => void;
onReady?: (...args: any[]) => void;
onOpen?: (...args: any[]) => void;
onClose?: (...args: any[]) => void;
onMonthChange?: (...args: any[]) => void;
onYearChange?: (...args: any[]) => void;
onValueUpdate?: (...args: any[]) => void;
onDayCreate?: (...args: any[]) => void;
}
export interface FlatpickrHandle {
clear: (...args: any[]) => any;
openPicker: (...args: any[]) => any;
closePicker: (...args: any[]) => any;
selectDate: (...args: any[]) => any;
jumpToDate: (...args: any[]) => any;
getSelectedDates: (...args: any[]) => any;
togglePicker: (...args: any[]) => any;
changeMonth: (...args: any[]) => any;
changeYear: (...args: any[]) => any;
}
const Flatpickr = forwardRef<FlatpickrHandle, FlatpickrProps>(function Flatpickr(_props: FlatpickrProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => ({}))())[0];
const __defaultDisable = useState(() => (() => [])())[0];
const __defaultEnable = useState(() => (() => [])())[0];
const __defaultPlugins = useState(() => (() => [])())[0];
const props: Omit<FlatpickrProps, 'mode' | 'dateFormat' | 'altInput' | 'altFormat' | 'enableTime' | 'enableSeconds' | 'time24hr' | 'noCalendar' | 'minDate' | 'maxDate' | 'placeholder' | 'disabled' | 'commitOn' | 'options' | 'name' | 'inline' | 'staticPosition' | 'position' | 'appendTo' | 'showMonths' | 'weekNumbers' | 'monthSelectorType' | 'prevArrow' | 'nextArrow' | 'allowInput' | 'disable' | 'enable' | 'locale' | 'firstDayOfWeek' | 'parseDate' | 'formatDate' | 'plugins'> & { mode: string; dateFormat: string; altInput: boolean; altFormat: string; enableTime: boolean; enableSeconds: boolean; time24hr: boolean; noCalendar: boolean; minDate: (string) | null; maxDate: (string) | null; placeholder: string; disabled: boolean; commitOn: string; options: Record<string, any>; name: string; inline: boolean; staticPosition: boolean; position: string; appendTo: (Record<string, any>) | null; showMonths: number; weekNumbers: boolean; monthSelectorType: string; prevArrow: (string) | null; nextArrow: (string) | null; allowInput: boolean; disable: any[]; enable: any[]; locale: (Record<string, any>) | null; firstDayOfWeek: number; parseDate: ((...args: any[]) => any) | null; formatDate: ((...args: any[]) => any) | null; plugins: any[] } = {
..._props,
mode: _props.mode ?? 'single',
dateFormat: _props.dateFormat ?? 'Y-m-d',
altInput: _props.altInput ?? false,
altFormat: _props.altFormat ?? 'F j, Y',
enableTime: _props.enableTime ?? false,
enableSeconds: _props.enableSeconds ?? false,
time24hr: _props.time24hr ?? false,
noCalendar: _props.noCalendar ?? false,
minDate: _props.minDate ?? null,
maxDate: _props.maxDate ?? null,
placeholder: _props.placeholder ?? 'Select a date…',
disabled: _props.disabled ?? false,
commitOn: _props.commitOn ?? 'complete',
options: _props.options ?? __defaultOptions,
name: _props.name ?? '',
inline: _props.inline ?? false,
staticPosition: _props.staticPosition ?? false,
position: _props.position ?? 'auto',
appendTo: _props.appendTo ?? null,
showMonths: _props.showMonths ?? 1,
weekNumbers: _props.weekNumbers ?? false,
monthSelectorType: _props.monthSelectorType ?? 'dropdown',
prevArrow: _props.prevArrow ?? null,
nextArrow: _props.nextArrow ?? null,
allowInput: _props.allowInput ?? false,
disable: _props.disable ?? __defaultDisable,
enable: _props.enable ?? __defaultEnable,
locale: _props.locale ?? null,
firstDayOfWeek: _props.firstDayOfWeek ?? 0,
parseDate: _props.parseDate ?? null,
formatDate: _props.formatDate ?? null,
plugins: _props.plugins ?? __defaultPlugins,
};
const attrs: Record<string, unknown> = (() => {
const { date, mode, dateFormat, altInput, altFormat, enableTime, enableSeconds, time24hr, noCalendar, minDate, maxDate, placeholder, disabled, commitOn, options, name, inline, staticPosition, position, appendTo, showMonths, weekNumbers, monthSelectorType, prevArrow, nextArrow, allowInput, disable, enable, locale, firstDayOfWeek, parseDate, formatDate, plugins, defaultValue, onDateChange, defaultDate, ...rest } = _props as FlatpickrProps & Record<string, unknown>;
void date; void mode; void dateFormat; void altInput; void altFormat; void enableTime; void enableSeconds; void time24hr; void noCalendar; void minDate; void maxDate; void placeholder; void disabled; void commitOn; void options; void name; void inline; void staticPosition; void position; void appendTo; void showMonths; void weekNumbers; void monthSelectorType; void prevArrow; void nextArrow; void allowInput; void disable; void enable; void locale; void firstDayOfWeek; void parseDate; void formatDate; void plugins; void defaultValue; void onDateChange; void defaultDate;
return rest;
})();
const instance = useRef<any>(null);
const [date, setDate] = useControllableState({
value: props.date,
defaultValue: props.defaultDate ?? '',
onValueChange: props.onDateChange,
});
const _dateFormatRef = useRef(props.dateFormat);
_dateFormatRef.current = props.dateFormat;
const _disableRef = useRef(props.disable);
_disableRef.current = props.disable;
const _disabledRef = useRef(props.disabled);
_disabledRef.current = props.disabled;
const _enableRef = useRef(props.enable);
_enableRef.current = props.enable;
const _firstDayOfWeekRef = useRef(props.firstDayOfWeek);
_firstDayOfWeekRef.current = props.firstDayOfWeek;
const _localeRef = useRef(props.locale);
_localeRef.current = props.locale;
const _maxDateRef = useRef(props.maxDate);
_maxDateRef.current = props.maxDate;
const _minDateRef = useRef(props.minDate);
_minDateRef.current = props.minDate;
const _modeRef = useRef(props.mode);
_modeRef.current = props.mode;
const _dateRef = useRef(date);
_dateRef.current = date;
const inputEl = useRef<HTMLInputElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const _watch8First = useRef(true);
const _watch9First = useRef(true);
// Imperative handle (Phase 21 $expose). The flatpickr instance methods a
// consumer can't drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each guards on `instance`
// (null before $onMount and after destroy). selectDate forwards flatpickr's own
// triggerChange arg; leaving it undefined keeps flatpickr's default (no
// onChange refire), so a programmatic selectDate does not bounce through the
// round-trip-guarded $watch above.
//
// Two method names are deliberately NOT flatpickr's own, to avoid collisions
// with this component's emitted surface (a real cross-target footgun — see the
// Step-4 gap report):
// - `selectDate` (not `setDate`): the `date` prop is `model: true`, so React's
// emitter auto-generates a `setDate` setter for it (useControllableState
// destructure). A user `setDate` collides — ROZ524 ("already declared" +
// infinite-recursion of the model-write rewrite). selectDate wraps
// flatpickr's instance.setDate.
// - `openPicker` / `closePicker` (not `open` / `close`): this component emits
// `open` and `close` EVENTS (onOpen/onClose -> $emit). On targets that
// materialize events as named members (Angular `output()`), a method named
// `open`/`close` collides with the event member and the emitter silently
// renames the method to `_open`/`_close` — breaking the uniform handle. No
// diagnostic fires today (unlike the model-setter ROZ524); flagged for a
// future ROZ "expose-name vs event-name collision" check. Prefixing the
// methods sidesteps it.
function clear() {
instance.current?.clear();
}
function openPicker() {
instance.current?.open();
}
function closePicker() {
instance.current?.close();
}
function selectDate(date: any, triggerChange: any) {
instance.current?.setDate(date, triggerChange);
}
function jumpToDate(date: any) {
instance.current?.jumpToDate(date);
}
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
function getSelectedDates() {
return instance.current ? instance.current.selectedDates : [];
}
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
function togglePicker() {
instance.current?.toggle();
}
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
function changeMonth(value: any, isOffset: any) {
instance.current?.changeMonth(value, isOffset);
}
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
function changeYear(year: any) {
instance.current?.changeYear(year);
}
useEffect(() => {
instance.current = flatpickr(inputEl.current!, {
mode: _modeRef.current,
dateFormat: _dateFormatRef.current,
altInput: props.altInput,
altFormat: props.altFormat,
enableTime: props.enableTime,
enableSeconds: props.enableSeconds,
time_24hr: props.time24hr,
noCalendar: props.noCalendar,
minDate: _minDateRef.current,
maxDate: _maxDateRef.current,
defaultDate: _dateRef.current || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: props.inline,
static: props.staticPosition,
position: props.position,
showMonths: props.showMonths,
weekNumbers: props.weekNumbers,
monthSelectorType: props.monthSelectorType,
allowInput: props.allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(props.appendTo != null ? {
appendTo: props.appendTo
} : {}),
...(props.prevArrow != null ? {
prevArrow: props.prevArrow
} : {}),
...(props.nextArrow != null ? {
nextArrow: props.nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(_disableRef.current.length ? {
disable: _disableRef.current
} : {}),
...(_enableRef.current.length ? {
enable: _enableRef.current
} : {}),
...(props.parseDate != null ? {
parseDate: props.parseDate
} : {}),
...(props.formatDate != null ? {
formatDate: props.formatDate
} : {}),
...(props.plugins.length ? {
plugins: props.plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(_localeRef.current != null || _firstDayOfWeekRef.current !== 0 ? {
locale: {
...(_localeRef.current ?? {}),
...(_firstDayOfWeekRef.current !== 0 ? {
firstDayOfWeek: _firstDayOfWeekRef.current
} : {})
}
} : {}),
...props.options,
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = _modeRef.current === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((props.commitOn === 'change' || complete) && dateStr !== _dateRef.current) {
setDate(dateStr);
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
props.onChange && props.onChange({
value: dateStr,
selectedDates
});
},
onReady: (d: any, s: any) => props.onReady && props.onReady({
value: s,
selectedDates: d
}),
onOpen: () => props.onOpen && props.onOpen(),
onClose: () => props.onClose && props.onClose(),
onMonthChange: () => props.onMonthChange && props.onMonthChange(),
onYearChange: () => props.onYearChange && props.onYearChange(),
onValueUpdate: (d: any, s: any) => props.onValueUpdate && props.onValueUpdate({
value: s,
selectedDates: d
}),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => props.onDayCreate && props.onDayCreate(dayElem)
});
if (_disabledRef.current) instance.current.input.disabled = true;
return () => instance.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = date;
if (!instance.current) return;
if (v !== instance.current.input.value) instance.current.setDate(v, false);
}, [date]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = props.mode;
instance.current?.set('mode', v);
}, [props.mode]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = props.minDate;
instance.current?.set('minDate', v);
}, [props.minDate]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.maxDate;
instance.current?.set('maxDate', v);
}, [props.maxDate]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = props.dateFormat;
instance.current?.set('dateFormat', v);
}, [props.dateFormat]);
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.disabled;
if (instance.current) instance.current.input.disabled = v;
}, [props.disabled]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.disable;
instance.current?.set('disable', v);
}, [props.disable]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = props.enable;
instance.current?.set('enable', v);
}, [props.enable]);
useEffect(() => {
if (_watch8First.current) { _watch8First.current = false; return; }
const v = props.locale;
instance.current?.set('locale', {
...(v ?? {}),
...(props.firstDayOfWeek !== 0 ? {
firstDayOfWeek: props.firstDayOfWeek
} : {})
});
}, [props.locale]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch9First.current) { _watch9First.current = false; return; }
const v = props.firstDayOfWeek;
instance.current?.set('locale', {
...(props.locale ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
});
}, [props.firstDayOfWeek]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ clear, openPicker, closePicker, selectDate, jumpToDate, getSelectedDates, togglePicker, changeMonth, changeYear });
_rozieExposeRef.current = { clear, openPicker, closePicker, selectDate, jumpToDate, getSelectedDates, togglePicker, changeMonth, changeYear };
useImperativeHandle(ref, () => ({ clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), openPicker: (...args: Parameters<typeof openPicker>): ReturnType<typeof openPicker> => _rozieExposeRef.current.openPicker(...args), closePicker: (...args: Parameters<typeof closePicker>): ReturnType<typeof closePicker> => _rozieExposeRef.current.closePicker(...args), selectDate: (...args: Parameters<typeof selectDate>): ReturnType<typeof selectDate> => _rozieExposeRef.current.selectDate(...args), jumpToDate: (...args: Parameters<typeof jumpToDate>): ReturnType<typeof jumpToDate> => _rozieExposeRef.current.jumpToDate(...args), getSelectedDates: (...args: Parameters<typeof getSelectedDates>): ReturnType<typeof getSelectedDates> => _rozieExposeRef.current.getSelectedDates(...args), togglePicker: (...args: Parameters<typeof togglePicker>): ReturnType<typeof togglePicker> => _rozieExposeRef.current.togglePicker(...args), changeMonth: (...args: Parameters<typeof changeMonth>): ReturnType<typeof changeMonth> => _rozieExposeRef.current.changeMonth(...args), changeYear: (...args: Parameters<typeof changeYear>): ReturnType<typeof changeYear> => _rozieExposeRef.current.changeYear(...args) }), []);
return (
<>
<input ref={inputEl} type="text" name={props.name} placeholder={props.placeholder} {...attrs} className={clsx("rozie-flatpickr", (attrs.className as string | undefined))} data-rozie-s-159070d4="" />
</>
);
});
export default Flatpickr;vue
<template>
<input ref="inputElRef" type="text" class="rozie-flatpickr" :name="props.name" :placeholder="props.placeholder" v-bind="$attrs" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
mode?: string;
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
dateFormat?: string;
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
altInput?: boolean;
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
altFormat?: string;
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
enableTime?: boolean;
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
enableSeconds?: boolean;
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
time24hr?: boolean;
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
noCalendar?: boolean;
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
minDate?: string | null;
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
maxDate?: string | null;
/**
* Placeholder text for the rendered input when no date is selected.
*/
placeholder?: string;
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
disabled?: boolean;
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
commitOn?: string;
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
options?: Record<string, any>;
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
name?: string;
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
inline?: boolean;
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
staticPosition?: boolean;
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
position?: string;
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
appendTo?: Record<string, any> | null;
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
showMonths?: number;
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
weekNumbers?: boolean;
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
monthSelectorType?: string;
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
prevArrow?: string | null;
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
nextArrow?: string | null;
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
allowInput?: boolean;
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
disable?: any[];
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
enable?: any[];
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
locale?: Record<string, any> | null;
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
firstDayOfWeek?: number;
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
parseDate?: ((...args: any[]) => any) | null;
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
formatDate?: ((...args: any[]) => any) | null;
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
plugins?: any[];
}>(),
{ mode: 'single', dateFormat: 'Y-m-d', altInput: false, altFormat: 'F j, Y', enableTime: false, enableSeconds: false, time24hr: false, noCalendar: false, minDate: null, maxDate: null, placeholder: 'Select a date…', disabled: false, commitOn: 'complete', options: () => ({}), name: '', inline: false, staticPosition: false, position: 'auto', appendTo: null, showMonths: 1, weekNumbers: false, monthSelectorType: 'dropdown', prevArrow: null, nextArrow: null, allowInput: false, disable: () => [], enable: () => [], locale: null, firstDayOfWeek: 0, parseDate: null, formatDate: null, plugins: () => [] }
);
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
const date = defineModel<string>('date', { default: '' });
const emit = defineEmits<{
change: [...args: any[]];
ready: [...args: any[]];
open: [...args: any[]];
close: [...args: any[]];
monthChange: [...args: any[]];
yearChange: [...args: any[]];
valueUpdate: [...args: any[]];
dayCreate: [...args: any[]];
}>();
const inputElRef = ref<HTMLInputElement>();
import flatpickr from 'flatpickr';
let instance: any = null;
// Imperative handle (Phase 21 $expose). The flatpickr instance methods a
// consumer can't drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each guards on `instance`
// (null before $onMount and after destroy). selectDate forwards flatpickr's own
// triggerChange arg; leaving it undefined keeps flatpickr's default (no
// onChange refire), so a programmatic selectDate does not bounce through the
// round-trip-guarded $watch above.
//
// Two method names are deliberately NOT flatpickr's own, to avoid collisions
// with this component's emitted surface (a real cross-target footgun — see the
// Step-4 gap report):
// - `selectDate` (not `setDate`): the `date` prop is `model: true`, so React's
// emitter auto-generates a `setDate` setter for it (useControllableState
// destructure). A user `setDate` collides — ROZ524 ("already declared" +
// infinite-recursion of the model-write rewrite). selectDate wraps
// flatpickr's instance.setDate.
// - `openPicker` / `closePicker` (not `open` / `close`): this component emits
// `open` and `close` EVENTS (onOpen/onClose -> $emit). On targets that
// materialize events as named members (Angular `output()`), a method named
// `open`/`close` collides with the event member and the emitter silently
// renames the method to `_open`/`_close` — breaking the uniform handle. No
// diagnostic fires today (unlike the model-setter ROZ524); flagged for a
// future ROZ "expose-name vs event-name collision" check. Prefixing the
// methods sidesteps it.
function clear() {
instance?.clear();
}
function openPicker() {
instance?.open();
}
function closePicker() {
instance?.close();
}
function selectDate(date: any, triggerChange: any) {
instance?.setDate(date, triggerChange);
}
function jumpToDate(date: any) {
instance?.jumpToDate(date);
}
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
function getSelectedDates() {
return instance ? instance.selectedDates : [];
}
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
function togglePicker() {
instance?.toggle();
}
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
function changeMonth(value: any, isOffset: any) {
instance?.changeMonth(value, isOffset);
}
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
function changeYear(year: any) {
instance?.changeYear(year);
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
instance = (flatpickr as any)(inputElRef.value!, {
mode: props.mode,
dateFormat: props.dateFormat,
altInput: props.altInput,
altFormat: props.altFormat,
enableTime: props.enableTime,
enableSeconds: props.enableSeconds,
time_24hr: props.time24hr,
noCalendar: props.noCalendar,
minDate: props.minDate,
maxDate: props.maxDate,
defaultDate: date.value || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: props.inline,
static: props.staticPosition,
position: props.position,
showMonths: props.showMonths,
weekNumbers: props.weekNumbers,
monthSelectorType: props.monthSelectorType,
allowInput: props.allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(props.appendTo != null ? {
appendTo: props.appendTo
} : {}),
...(props.prevArrow != null ? {
prevArrow: props.prevArrow
} : {}),
...(props.nextArrow != null ? {
nextArrow: props.nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(props.disable.length ? {
disable: props.disable
} : {}),
...(props.enable.length ? {
enable: props.enable
} : {}),
...(props.parseDate != null ? {
parseDate: props.parseDate
} : {}),
...(props.formatDate != null ? {
formatDate: props.formatDate
} : {}),
...(props.plugins.length ? {
plugins: props.plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(props.locale != null || props.firstDayOfWeek !== 0 ? {
locale: {
...(props.locale ?? {}),
...(props.firstDayOfWeek !== 0 ? {
firstDayOfWeek: props.firstDayOfWeek
} : {})
}
} : {}),
...props.options,
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = props.mode === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((props.commitOn === 'change' || complete) && dateStr !== date.value) {
date.value = dateStr;
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
emit('change', {
value: dateStr,
selectedDates
});
},
onReady: (d: any, s: any) => emit('ready', {
value: s,
selectedDates: d
}),
onOpen: () => emit('open'),
onClose: () => emit('close'),
onMonthChange: () => emit('monthChange'),
onYearChange: () => emit('yearChange'),
onValueUpdate: (d: any, s: any) => emit('valueUpdate', {
value: s,
selectedDates: d
}),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => emit('dayCreate', dayElem)
});
if (props.disabled) instance.input.disabled = true;
_cleanup_0 = () => instance?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => date.value, (v: any) => {
if (!instance) return;
if (v !== instance.input.value) instance.setDate(v, false);
});
watch(() => props.mode, (v: any) => instance?.set('mode', v));
watch(() => props.minDate, (v: any) => instance?.set('minDate', v));
watch(() => props.maxDate, (v: any) => instance?.set('maxDate', v));
watch(() => props.dateFormat, (v: any) => instance?.set('dateFormat', v));
watch(() => props.disabled, (v: any) => {
if (instance) instance.input.disabled = v;
});
watch(() => props.disable, (v: any) => instance?.set('disable', v));
watch(() => props.enable, (v: any) => instance?.set('enable', v));
watch(() => props.locale, (v: any) => instance?.set('locale', {
...(v ?? {}),
...(props.firstDayOfWeek !== 0 ? {
firstDayOfWeek: props.firstDayOfWeek
} : {})
}));
watch(() => props.firstDayOfWeek, (v: any) => instance?.set('locale', {
...(props.locale ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
}));
defineExpose({ clear, openPicker, closePicker, selectDate, jumpToDate, getSelectedDates, togglePicker, changeMonth, changeYear });
</script>
<style scoped>
.rozie-flatpickr {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
date?: string;
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
mode?: string;
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
dateFormat?: string;
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
altInput?: boolean;
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
altFormat?: string;
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
enableTime?: boolean;
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
enableSeconds?: boolean;
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
time24hr?: boolean;
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
noCalendar?: boolean;
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
minDate?: (string) | null;
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
maxDate?: (string) | null;
/**
* Placeholder text for the rendered input when no date is selected.
*/
placeholder?: string;
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
disabled?: boolean;
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
commitOn?: string;
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
options?: any;
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
name?: string;
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
inline?: boolean;
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
staticPosition?: boolean;
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
position?: string;
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
appendTo?: (any) | null;
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
showMonths?: number;
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
weekNumbers?: boolean;
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
monthSelectorType?: string;
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
prevArrow?: (string) | null;
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
nextArrow?: (string) | null;
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
allowInput?: boolean;
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
disable?: any[];
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
enable?: any[];
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
locale?: (any) | null;
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
firstDayOfWeek?: number;
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
parseDate?: ((...args: any[]) => any) | null;
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
formatDate?: ((...args: any[]) => any) | null;
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
plugins?: any[];
onchange?: (...args: unknown[]) => void;
onready?: (...args: unknown[]) => void;
onopen?: (...args: unknown[]) => void;
onclose?: (...args: unknown[]) => void;
onmonthchange?: (...args: unknown[]) => void;
onyearchange?: (...args: unknown[]) => void;
onvalueupdate?: (...args: unknown[]) => void;
ondaycreate?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => ({}))();
let __defaultDisable = (() => [])();
let __defaultEnable = (() => [])();
let __defaultPlugins = (() => [])();
let {
date = $bindable(''),
mode = 'single',
dateFormat = 'Y-m-d',
altInput = false,
altFormat = 'F j, Y',
enableTime = false,
enableSeconds = false,
time24hr = false,
noCalendar = false,
minDate = null,
maxDate = null,
placeholder = 'Select a date…',
disabled = false,
commitOn = 'complete',
options = __defaultOptions,
name = '',
inline = false,
staticPosition = false,
position = 'auto',
appendTo = null,
showMonths = 1,
weekNumbers = false,
monthSelectorType = 'dropdown',
prevArrow = null,
nextArrow = null,
allowInput = false,
disable = __defaultDisable,
enable = __defaultEnable,
locale = null,
firstDayOfWeek = 0,
parseDate = null,
formatDate = null,
plugins = __defaultPlugins,
onchange,
onready,
onopen,
onclose,
onmonthchange,
onyearchange,
onvalueupdate,
ondaycreate,
...__rozieAttrs
}: Props = $props();
let inputEl = $state<HTMLInputElement | undefined>(undefined);
import flatpickr from 'flatpickr';
let instance: any = null;
// Imperative handle (Phase 21 $expose). The flatpickr instance methods a
// consumer can't drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each guards on `instance`
// (null before $onMount and after destroy). selectDate forwards flatpickr's own
// triggerChange arg; leaving it undefined keeps flatpickr's default (no
// onChange refire), so a programmatic selectDate does not bounce through the
// round-trip-guarded $watch above.
//
// Two method names are deliberately NOT flatpickr's own, to avoid collisions
// with this component's emitted surface (a real cross-target footgun — see the
// Step-4 gap report):
// - `selectDate` (not `setDate`): the `date` prop is `model: true`, so React's
// emitter auto-generates a `setDate` setter for it (useControllableState
// destructure). A user `setDate` collides — ROZ524 ("already declared" +
// infinite-recursion of the model-write rewrite). selectDate wraps
// flatpickr's instance.setDate.
// - `openPicker` / `closePicker` (not `open` / `close`): this component emits
// `open` and `close` EVENTS (onOpen/onClose -> $emit). On targets that
// materialize events as named members (Angular `output()`), a method named
// `open`/`close` collides with the event member and the emitter silently
// renames the method to `_open`/`_close` — breaking the uniform handle. No
// diagnostic fires today (unlike the model-setter ROZ524); flagged for a
// future ROZ "expose-name vs event-name collision" check. Prefixing the
// methods sidesteps it.
export function clear() {
instance?.clear();
}
export function openPicker() {
instance?.open();
}
export function closePicker() {
instance?.close();
}
export function selectDate(date: any, triggerChange: any) {
instance?.setDate(date, triggerChange);
}
export function jumpToDate(date: any) {
instance?.jumpToDate(date);
}
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
export function getSelectedDates() {
return instance ? instance.selectedDates : [];
}
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
export function togglePicker() {
instance?.toggle();
}
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
export function changeMonth(value: any, isOffset: any) {
instance?.changeMonth(value, isOffset);
}
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
export function changeYear(year: any) {
instance?.changeYear(year);
}
onMount(() => {
instance = flatpickr(inputEl!, {
mode: mode,
dateFormat: dateFormat,
altInput: altInput,
altFormat: altFormat,
enableTime: enableTime,
enableSeconds: enableSeconds,
time_24hr: time24hr,
noCalendar: noCalendar,
minDate: minDate,
maxDate: maxDate,
defaultDate: date || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: inline,
static: staticPosition,
position: position,
showMonths: showMonths,
weekNumbers: weekNumbers,
monthSelectorType: monthSelectorType,
allowInput: allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(appendTo != null ? {
appendTo: appendTo
} : {}),
...(prevArrow != null ? {
prevArrow: prevArrow
} : {}),
...(nextArrow != null ? {
nextArrow: nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(disable.length ? {
disable: disable
} : {}),
...(enable.length ? {
enable: enable
} : {}),
...(parseDate != null ? {
parseDate: parseDate
} : {}),
...(formatDate != null ? {
formatDate: formatDate
} : {}),
...(plugins.length ? {
plugins: plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(locale != null || firstDayOfWeek !== 0 ? {
locale: {
...(locale ?? {}),
...(firstDayOfWeek !== 0 ? {
firstDayOfWeek: firstDayOfWeek
} : {})
}
} : {}),
...options,
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = mode === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((commitOn === 'change' || complete) && dateStr !== date) {
date = dateStr;
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
onchange?.({
value: dateStr,
selectedDates
});
},
onReady: (d: any, s: any) => onready?.({
value: s,
selectedDates: d
}),
onOpen: () => onopen?.(),
onClose: () => onclose?.(),
onMonthChange: () => onmonthchange?.(),
onYearChange: () => onyearchange?.(),
onValueUpdate: (d: any, s: any) => onvalueupdate?.({
value: s,
selectedDates: d
}),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => ondaycreate?.(dayElem)
});
if (disabled) instance.input.disabled = true;
return () => instance?.destroy();
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => date)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!instance) return;
if (v !== instance.input.value) instance.setDate(v, false);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => mode)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => instance?.set('mode', v))(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => minDate)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => instance?.set('minDate', v))(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => maxDate)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => instance?.set('maxDate', v))(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => dateFormat)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => instance?.set('dateFormat', v))(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => disabled)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => {
if (instance) instance.input.disabled = v;
})(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => disable)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => instance?.set('disable', v))(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => enable)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => instance?.set('enable', v))(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { const __watchVal = (() => locale)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } ((v: any) => instance?.set('locale', {
...(v ?? {}),
...(firstDayOfWeek !== 0 ? {
firstDayOfWeek: firstDayOfWeek
} : {})
}))(__watchVal); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { const __watchVal = (() => firstDayOfWeek)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } ((v: any) => instance?.set('locale', {
...(locale ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
}))(__watchVal); }); });
</script>
<input bind:this={inputEl} type="text" name={name} placeholder={placeholder} {...__rozieAttrs} class={["rozie-flatpickr", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-159070d4 />
<style>
:global {
.rozie-flatpickr[data-rozie-s-159070d4] {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr[data-rozie-s-159070d4]:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import flatpickr from 'flatpickr';
@Component({
selector: 'rozie-flatpickr',
standalone: true,
template: `
<input #inputEl type="text" class="rozie-flatpickr" [name]="name()" [placeholder]="placeholder()" #rozieSpread_0 #rozieListenersTarget_1 />
`,
styles: [`
.rozie-flatpickr {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Flatpickr),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Flatpickr {
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
date = model<string>('');
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
mode = input<string>('single');
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
dateFormat = input<string>('Y-m-d');
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
altInput = input<boolean>(false);
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
altFormat = input<string>('F j, Y');
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
enableTime = input<boolean>(false);
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
enableSeconds = input<boolean>(false);
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
time24hr = input<boolean>(false);
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
noCalendar = input<boolean>(false);
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
minDate = input<(string) | null>(null);
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
maxDate = input<(string) | null>(null);
/**
* Placeholder text for the rendered input when no date is selected.
*/
placeholder = input<string>('Select a date…');
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
disabled = input<boolean>(false);
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
commitOn = input<string>('complete');
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
options = input<Record<string, any>>((() => ({}))());
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
name = input<string>('');
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
inline = input<boolean>(false);
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
staticPosition = input<boolean>(false);
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
position = input<string>('auto');
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
appendTo = input<(Record<string, any>) | null>(null);
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
showMonths = input<number>(1);
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
weekNumbers = input<boolean>(false);
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
monthSelectorType = input<string>('dropdown');
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
prevArrow = input<(string) | null>(null);
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
nextArrow = input<(string) | null>(null);
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
allowInput = input<boolean>(false);
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
disable = input<any[]>((() => [])());
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
enable = input<any[]>((() => [])());
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
locale = input<(Record<string, any>) | null>(null);
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
firstDayOfWeek = input<number>(0);
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
parseDate = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
formatDate = input<((...args: unknown[]) => unknown) | null>(null);
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
plugins = input<any[]>((() => [])());
inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
change = output<unknown>();
ready = output<unknown>();
open = output<void>();
close = output<void>();
monthChange = output<void>();
yearChange = output<void>();
valueUpdate = output<unknown>();
dayCreate = output<unknown>();
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
constructor() {
effect(() => { const __watchVal = (() => this.date())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.instance) return;
if (v !== this.instance.input.value) this.instance.setDate(v, false);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.mode())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => this.instance?.set('mode', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.minDate())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => this.instance?.set('minDate', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.maxDate())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => this.instance?.set('maxDate', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.dateFormat())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => this.instance?.set('dateFormat', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => (this.disabled() || this.__rozieCvaDisabled()))(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => {
if (this.instance) this.instance.input.disabled = v;
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.disable())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => this.instance?.set('disable', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.enable())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => this.instance?.set('enable', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.locale())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } ((v: any) => this.instance?.set('locale', {
...(v ?? {}),
...(this.firstDayOfWeek() !== 0 ? {
firstDayOfWeek: this.firstDayOfWeek()
} : {})
}))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.firstDayOfWeek())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } ((v: any) => this.instance?.set('locale', {
...(this.locale() ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
}))(__watchVal); }); });
}
ngAfterViewInit() {
const __appendTo = this.appendTo();
const __prevArrow = this.prevArrow();
const __nextArrow = this.nextArrow();
const __disable = this.disable();
const __enable = this.enable();
const __parseDate = this.parseDate();
const __formatDate = this.formatDate();
const __plugins = this.plugins();
const __locale = this.locale();
const __firstDayOfWeek = this.firstDayOfWeek();
this.instance = (flatpickr as any)(this.inputEl()!.nativeElement, {
mode: this.mode(),
dateFormat: this.dateFormat(),
altInput: this.altInput(),
altFormat: this.altFormat(),
enableTime: this.enableTime(),
enableSeconds: this.enableSeconds(),
time_24hr: this.time24hr(),
noCalendar: this.noCalendar(),
minDate: this.minDate(),
maxDate: this.maxDate(),
defaultDate: this.date() || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: this.inline(),
static: this.staticPosition(),
position: this.position(),
showMonths: this.showMonths(),
weekNumbers: this.weekNumbers(),
monthSelectorType: this.monthSelectorType(),
allowInput: this.allowInput(),
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(__appendTo != null ? {
appendTo: __appendTo
} : {}),
...(__prevArrow != null ? {
prevArrow: __prevArrow
} : {}),
...(__nextArrow != null ? {
nextArrow: __nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(__disable.length ? {
disable: __disable
} : {}),
...(__enable.length ? {
enable: __enable
} : {}),
...(__parseDate != null ? {
parseDate: __parseDate
} : {}),
...(__formatDate != null ? {
formatDate: __formatDate
} : {}),
...(__plugins.length ? {
plugins: __plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(__locale != null || __firstDayOfWeek !== 0 ? {
locale: {
...(__locale ?? {}),
...(__firstDayOfWeek !== 0 ? {
firstDayOfWeek: __firstDayOfWeek
} : {})
}
} : {}),
...this.options(),
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = this.mode() === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((this.commitOn() === 'change' || complete) && dateStr !== this.date()) {
this.date.set(dateStr), this.__rozieCvaOnChange(dateStr);
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
this.change.emit({
value: dateStr,
selectedDates
});
},
onReady: (d: any, s: any) => this.ready.emit({
value: s,
selectedDates: d
}),
onOpen: () => this.open.emit(),
onClose: () => this.close.emit(),
onMonthChange: () => this.monthChange.emit(),
onYearChange: () => this.yearChange.emit(),
onValueUpdate: (d: any, s: any) => this.valueUpdate.emit({
value: s,
selectedDates: d
}),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => this.dayCreate.emit(dayElem)
});
if ((this.disabled() || this.__rozieCvaDisabled())) this.instance.input.disabled = true;
this.__rozieDestroyRef.onDestroy(() => this.instance?.destroy());
}
instance: any = null;
clear = () => {
this.instance?.clear();
};
openPicker = () => {
this.instance?.open();
};
closePicker = () => {
this.instance?.close();
};
selectDate = (date: any, triggerChange: any) => {
this.instance?.setDate(date, triggerChange);
};
jumpToDate = (date: any) => {
this.instance?.jumpToDate(date);
};
getSelectedDates = () => {
return this.instance ? this.instance.selectedDates : [];
};
togglePicker = () => {
this.instance?.toggle();
};
changeMonth = (value: any, isOffset: any) => {
this.instance?.changeMonth(value, isOffset);
};
changeYear = (year: any) => {
this.instance?.changeYear(year);
};
private __rozieCvaOnChange: (v: string) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: string | null): void {
this.date.set(v ?? '');
}
registerOnChange(fn: (v: string) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
}
export default Flatpickr;tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
import flatpickr from 'flatpickr';
__rozieInjectStyle('Flatpickr-159070d4', `.rozie-flatpickr[data-rozie-s-159070d4] {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr[data-rozie-s-159070d4]:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}`);
interface FlatpickrProps {
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
date?: string;
defaultDate?: string;
onDateChange?: (date: string) => void;
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
mode?: string;
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
dateFormat?: string;
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
altInput?: boolean;
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
altFormat?: string;
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
enableTime?: boolean;
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
enableSeconds?: boolean;
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
time24hr?: boolean;
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
noCalendar?: boolean;
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
minDate?: (string) | null;
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
maxDate?: (string) | null;
/**
* Placeholder text for the rendered input when no date is selected.
*/
placeholder?: string;
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
disabled?: boolean;
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
commitOn?: string;
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
options?: Record<string, any>;
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
name?: string;
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
inline?: boolean;
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
staticPosition?: boolean;
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
position?: string;
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
appendTo?: (Record<string, any>) | null;
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
showMonths?: number;
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
weekNumbers?: boolean;
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
monthSelectorType?: string;
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
prevArrow?: (string) | null;
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
nextArrow?: (string) | null;
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
allowInput?: boolean;
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
disable?: any[];
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
enable?: any[];
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
locale?: (Record<string, any>) | null;
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
firstDayOfWeek?: number;
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
parseDate?: ((...args: unknown[]) => unknown) | null;
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
formatDate?: ((...args: unknown[]) => unknown) | null;
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
plugins?: any[];
onChange?: (...args: unknown[]) => void;
onReady?: (...args: unknown[]) => void;
onOpen?: (...args: unknown[]) => void;
onClose?: (...args: unknown[]) => void;
onMonthChange?: (...args: unknown[]) => void;
onYearChange?: (...args: unknown[]) => void;
onValueUpdate?: (...args: unknown[]) => void;
onDayCreate?: (...args: unknown[]) => void;
ref?: (h: FlatpickrHandle) => void;
}
export interface FlatpickrHandle {
clear: (...args: any[]) => any;
openPicker: (...args: any[]) => any;
closePicker: (...args: any[]) => any;
selectDate: (...args: any[]) => any;
jumpToDate: (...args: any[]) => any;
getSelectedDates: (...args: any[]) => any;
togglePicker: (...args: any[]) => any;
changeMonth: (...args: any[]) => any;
changeYear: (...args: any[]) => any;
}
export default function Flatpickr(_props: FlatpickrProps): JSX.Element {
const _merged = mergeProps({ mode: 'single', dateFormat: 'Y-m-d', altInput: false, altFormat: 'F j, Y', enableTime: false, enableSeconds: false, time24hr: false, noCalendar: false, minDate: null, maxDate: null, placeholder: 'Select a date…', disabled: false, commitOn: 'complete', options: (() => ({}))(), name: '', inline: false, staticPosition: false, position: 'auto', appendTo: null, showMonths: 1, weekNumbers: false, monthSelectorType: 'dropdown', prevArrow: null, nextArrow: null, allowInput: false, disable: (() => [])(), enable: (() => [])(), locale: null, firstDayOfWeek: 0, parseDate: null, formatDate: null, plugins: (() => [])() }, _props);
const [local, attrs] = splitProps(_merged, ['date', 'mode', 'dateFormat', 'altInput', 'altFormat', 'enableTime', 'enableSeconds', 'time24hr', 'noCalendar', 'minDate', 'maxDate', 'placeholder', 'disabled', 'commitOn', 'options', 'name', 'inline', 'staticPosition', 'position', 'appendTo', 'showMonths', 'weekNumbers', 'monthSelectorType', 'prevArrow', 'nextArrow', 'allowInput', 'disable', 'enable', 'locale', 'firstDayOfWeek', 'parseDate', 'formatDate', 'plugins', 'ref']);
onMount(() => { local.ref?.({ clear, openPicker, closePicker, selectDate, jumpToDate, getSelectedDates, togglePicker, changeMonth, changeYear }); });
const [date, setDate] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'date', '');
onMount(() => {
const _cleanup = (() => {
instance = flatpickr(inputElRef, {
mode: local.mode,
dateFormat: local.dateFormat,
altInput: local.altInput,
altFormat: local.altFormat,
enableTime: local.enableTime,
enableSeconds: local.enableSeconds,
time_24hr: local.time24hr,
noCalendar: local.noCalendar,
minDate: local.minDate,
maxDate: local.maxDate,
defaultDate: date() || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: local.inline,
static: local.staticPosition,
position: local.position,
showMonths: local.showMonths,
weekNumbers: local.weekNumbers,
monthSelectorType: local.monthSelectorType,
allowInput: local.allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(local.appendTo != null ? {
appendTo: local.appendTo
} : {}),
...(local.prevArrow != null ? {
prevArrow: local.prevArrow
} : {}),
...(local.nextArrow != null ? {
nextArrow: local.nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(local.disable.length ? {
disable: local.disable
} : {}),
...(local.enable.length ? {
enable: local.enable
} : {}),
...(local.parseDate != null ? {
parseDate: local.parseDate
} : {}),
...(local.formatDate != null ? {
formatDate: local.formatDate
} : {}),
...(local.plugins.length ? {
plugins: local.plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(local.locale != null || local.firstDayOfWeek !== 0 ? {
locale: {
...(local.locale ?? {}),
...(local.firstDayOfWeek !== 0 ? {
firstDayOfWeek: local.firstDayOfWeek
} : {})
}
} : {}),
...local.options,
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = local.mode === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((local.commitOn === 'change' || complete) && dateStr !== date()) {
setDate(dateStr);
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
_props.onChange?.({
value: dateStr,
selectedDates
});
},
onReady: (d: any, s: any) => _props.onReady?.({
value: s,
selectedDates: d
}),
onOpen: () => _props.onOpen?.(),
onClose: () => _props.onClose?.(),
onMonthChange: () => _props.onMonthChange?.(),
onYearChange: () => _props.onYearChange?.(),
onValueUpdate: (d: any, s: any) => _props.onValueUpdate?.({
value: s,
selectedDates: d
}),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => _props.onDayCreate?.(dayElem)
});
if (local.disabled) instance.input.disabled = true;
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => instance?.destroy());
});
createEffect(on(() => (() => date())(), (v) => untrack(() => ((v: any) => {
if (!instance) return;
if (v !== instance.input.value) instance.setDate(v, false);
})(v)), { defer: true }));
createEffect(on(() => (() => local.mode)(), (v) => untrack(() => ((v: any) => instance?.set('mode', v))(v)), { defer: true }));
createEffect(on(() => (() => local.minDate)(), (v) => untrack(() => ((v: any) => instance?.set('minDate', v))(v)), { defer: true }));
createEffect(on(() => (() => local.maxDate)(), (v) => untrack(() => ((v: any) => instance?.set('maxDate', v))(v)), { defer: true }));
createEffect(on(() => (() => local.dateFormat)(), (v) => untrack(() => ((v: any) => instance?.set('dateFormat', v))(v)), { defer: true }));
createEffect(on(() => (() => local.disabled)(), (v) => untrack(() => ((v: any) => {
if (instance) instance.input.disabled = v;
})(v)), { defer: true }));
createEffect(on(() => (() => local.disable)(), (v) => untrack(() => ((v: any) => instance?.set('disable', v))(v)), { defer: true }));
createEffect(on(() => (() => local.enable)(), (v) => untrack(() => ((v: any) => instance?.set('enable', v))(v)), { defer: true }));
createEffect(on(() => (() => local.locale)(), (v) => untrack(() => ((v: any) => instance?.set('locale', {
...(v ?? {}),
...(local.firstDayOfWeek !== 0 ? {
firstDayOfWeek: local.firstDayOfWeek
} : {})
}))(v)), { defer: true }));
createEffect(on(() => (() => local.firstDayOfWeek)(), (v) => untrack(() => ((v: any) => instance?.set('locale', {
...(local.locale ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
}))(v)), { defer: true }));
let inputElRef: HTMLElement | null = null;
let instance: any = null;
// Imperative handle (Phase 21 $expose). The flatpickr instance methods a
// consumer can't drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each guards on `instance`
// (null before $onMount and after destroy). selectDate forwards flatpickr's own
// triggerChange arg; leaving it undefined keeps flatpickr's default (no
// onChange refire), so a programmatic selectDate does not bounce through the
// round-trip-guarded $watch above.
//
// Two method names are deliberately NOT flatpickr's own, to avoid collisions
// with this component's emitted surface (a real cross-target footgun — see the
// Step-4 gap report):
// - `selectDate` (not `setDate`): the `date` prop is `model: true`, so React's
// emitter auto-generates a `setDate` setter for it (useControllableState
// destructure). A user `setDate` collides — ROZ524 ("already declared" +
// infinite-recursion of the model-write rewrite). selectDate wraps
// flatpickr's instance.setDate.
// - `openPicker` / `closePicker` (not `open` / `close`): this component emits
// `open` and `close` EVENTS (onOpen/onClose -> $emit). On targets that
// materialize events as named members (Angular `output()`), a method named
// `open`/`close` collides with the event member and the emitter silently
// renames the method to `_open`/`_close` — breaking the uniform handle. No
// diagnostic fires today (unlike the model-setter ROZ524); flagged for a
// future ROZ "expose-name vs event-name collision" check. Prefixing the
// methods sidesteps it.
function clear() {
instance?.clear();
}
function openPicker() {
instance?.open();
}
function closePicker() {
instance?.close();
}
function selectDate(date: any, triggerChange: any) {
instance?.setDate(date, triggerChange);
}
function jumpToDate(date: any) {
instance?.jumpToDate(date);
}
// getSelectedDates closes a real asymmetry: the two-way `date` model is a
// formatted STRING, but the parsed Date[] is otherwise only delivered on the
// `change` event payload — a consumer needing the current Date objects on demand
// (range bounds, multi-select, validation) had no path. `[]` before mount.
function getSelectedDates() {
return instance ? instance.selectedDates : [];
}
// togglePicker = open-or-close in one call (natural for a single trigger button).
// `toggle` is not an emit, but suffixed `togglePicker` for symmetry with
// openPicker/closePicker.
function togglePicker() {
instance?.toggle();
}
// Programmatic calendar navigation for custom prev/next / "jump N months" UI.
// changeMonth(value, isOffset?) — isOffset defaults to true (flatpickr). NOT
// `monthChange`, which is the emitted event (so ROZ121-clear).
function changeMonth(value: any, isOffset: any) {
instance?.changeMonth(value, isOffset);
}
// changeYear(year) — jump to an absolute year. NOT `yearChange` (the emit).
function changeYear(year: any) {
instance?.changeYear(year);
}
return (
<>
<input ref={(el) => { inputElRef = el as HTMLElement; }} type="text" name={local.name} placeholder={local.placeholder} {...attrs} class={"rozie-flatpickr" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-159070d4="" />
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import flatpickr from 'flatpickr';
@customElement('rozie-flatpickr')
export default class Flatpickr extends SignalWatcher(LitElement) {
static styles = css`
.rozie-flatpickr[data-rozie-s-159070d4] {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-flatpickr[data-rozie-s-159070d4]:focus {
outline: 2px solid rgba(0, 100, 255, 0.4);
outline-offset: -1px;
}
`;
/**
* The two-way value (`r-model:date`) — the **formatted string** flatpickr produces, not a `Date`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`. Consumers that need the parsed `Date[]` read them off the `change` event payload instead.
* @example
* <Flatpickr r-model:date="picked" @change="onChange" />
*/
@property({ type: String, attribute: 'date' }) _date_attr: string = '';
private _dateControllable = createLitControllableProperty<string>({ host: this, eventName: 'date-change', defaultValue: '', initialControlledValue: undefined });
/**
* Selection mode: `'single'`, `'multiple'`, `'range'`, or `'time'`. In `'range'` mode the two-way `date` commits per `commitOn`. Runtime-updatable via flatpickr's `set()`.
*/
@property({ type: String, reflect: true }) mode: string = 'single';
/**
* flatpickr date-format token string controlling how the value is formatted and parsed. Runtime-updatable via `set()`.
*/
@property({ type: String, reflect: true }) dateFormat: string = 'Y-m-d';
/**
* Show a human-readable alt input (formatted with `altFormat`) while submitting the machine-format value. flatpickr creates a hidden mirror input and moves the original `name` onto it. **Construction-time only** — re-key the component to retune live.
*/
@property({ type: Boolean, reflect: true }) altInput: boolean = false;
/**
* Format token string for the human-readable alt input (used only when `altInput` is on).
*/
@property({ type: String, reflect: true }) altFormat: string = 'F j, Y';
/**
* Add a time picker alongside the calendar. **Construction-time only** — re-key the component to retune live.
*/
@property({ type: Boolean, reflect: true }) enableTime: boolean = false;
/**
* Add a seconds input to the time picker (used with `enableTime`).
*/
@property({ type: Boolean, reflect: true }) enableSeconds: boolean = false;
/**
* Display time in 24-hour format instead of the AM/PM clock.
*/
@property({ type: Boolean, reflect: true }) time24hr: boolean = false;
/**
* Hide the calendar to make a time-only picker (pair with `enableTime`). **Construction-time only** — re-key the component to retune live.
*/
@property({ type: Boolean, reflect: true }) noCalendar: boolean = false;
/**
* Earliest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
@property({ type: String, reflect: true }) minDate: string | null = null;
/**
* Latest selectable date (a `dateFormat`-formatted string). Runtime-updatable via `set()`.
*/
@property({ type: String, reflect: true }) maxDate: string | null = null;
/**
* Placeholder text for the rendered input when no date is selected.
*/
@property({ type: String, reflect: true }) placeholder: string = 'Select a date…';
/**
* Disable the underlying input so the picker cannot be opened or edited. On Angular it OR-merges with the form `setDisabledState`. Runtime-updatable.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* When to commit the two-way `date` in `mode="range"`: `'complete'` (the default — only once both ends are picked) or `'change'` (on every click, including the partial first click). The `change` event always fires on every click regardless, so partial ranges are observable off the event without polluting the two-way value.
*/
@property({ type: String, reflect: true }) commitOn: string = 'complete';
/**
* Verbatim flatpickr options pass-through for anything the named props do not cover. It is spread **after** the named props, so a key here overrides the equivalent named prop on conflict.
*/
@property({ type: Object }) options: any = {};
/**
* HTML form-control `name` forwarded onto the rendered input — the forms drop-in, so `Flatpickr` submits like a native control. When `altInput` is on, flatpickr moves the `name` onto the hidden mirror input, so the submitted value carries it either way.
*/
@property({ type: String, reflect: true }) name: string = '';
/**
* Render an always-visible calendar inline instead of a popup — useful for dashboards and embedded pickers. **Construction-time only** — re-key the component to toggle live.
*/
@property({ type: Boolean, reflect: true }) inline: boolean = false;
/**
* flatpickr's `static` option — positions the calendar relative to the input rather than absolutely off `<body>`. Exposed as `staticPosition` because `static` is a JS reserved word. **Construction-time only**.
*/
@property({ type: Boolean, reflect: true }) staticPosition: boolean = false;
/**
* Calendar popup position: `'auto'`, `'above'`, `'below'`, or per-axis forms like `'above center'`. **Construction-time only**.
*/
@property({ type: String, reflect: true }) position: string = 'auto';
/**
* A DOM element to append the calendar popup to, useful for escaping `overflow: hidden` ancestors. **Construction-time only**.
*/
@property({ type: Object }) appendTo: any = null;
/**
* Number of calendar months to render side by side. **Construction-time only**.
*/
@property({ type: Number, reflect: true }) showMonths: number = 1;
/**
* Show ISO week numbers down the left edge of the calendar. **Construction-time only**.
*/
@property({ type: Boolean, reflect: true }) weekNumbers: boolean = false;
/**
* Month-selector style in the calendar header: `'dropdown'` or `'static'`. **Construction-time only**.
*/
@property({ type: String, reflect: true }) monthSelectorType: string = 'dropdown';
/**
* HTML string for the previous-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
@property({ type: String, reflect: true }) prevArrow: string | null = null;
/**
* HTML string for the next-month navigation arrow, overriding flatpickr's built-in SVG. **Construction-time only**.
*/
@property({ type: String, reflect: true }) nextArrow: string | null = null;
/**
* Allow the user to type a date directly into the input instead of only picking from the calendar. **Construction-time only**.
*/
@property({ type: Boolean, reflect: true }) allowInput: boolean = false;
/**
* Dates to disable: a mixed array of `Date` objects, `"Y-m-d"` strings, `{ from, to }` range objects, and/or predicate functions `(date: Date) => boolean`. Runtime-updatable via `set()` — a runtime `disable: []` clears the exclusion set.
*/
@property({ type: Array }) disable: any[] = [];
/**
* Allow-list (the inverse of `disable`): when non-empty, ONLY these dates/ranges/predicates are selectable and everything else is disabled. Same element shapes as `disable`. Runtime-updatable via `set()`.
*/
@property({ type: Array }) enable: any[] = [];
/**
* A flatpickr locale object (e.g. `import fr from 'flatpickr/dist/l10n/fr.js'`). The consumer lazy-imports it themselves — the wrapper adds no locale dependency. Runtime-updatable via `set('locale', …)`.
*/
@property({ type: Object }) locale: any = null;
/**
* First weekday of the calendar (`0` = Sunday … `1` = Monday). Folded into the `locale` option and overrides the locale's own first weekday when set. Runtime-updatable.
*/
@property({ type: Number, reflect: true }) firstDayOfWeek: number = 0;
/**
* Custom parser `(dateStr: string, format: string) => Date` for input formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
@property({ type: Function }) parseDate: ((...args: unknown[]) => unknown) | null = null;
/**
* Custom formatter `(date: Date, format: string, locale) => string` for output formats flatpickr's token grammar cannot express. **Construction-time only** — re-key the component to change it live.
*/
@property({ type: Function }) formatDate: ((...args: unknown[]) => unknown) | null = null;
/**
* An array of flatpickr plugin instances (imported from `flatpickr/dist/plugins/…`); the headline use is `rangePlugin` for two-input ranges. **Construction-time only** — re-key the component to swap plugins live.
*/
@property({ type: Array }) plugins: any[] = [];
@query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
firstUpdated(): void {
this._disconnectCleanups.push((() => this.instance?.destroy()));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.date)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.instance) return;
if (v !== this.instance.input.value) this.instance.setDate(v, false);
})(__watchVal); }); }));
this.instance = flatpickr(this._refInputEl, {
mode: this.mode,
dateFormat: this.dateFormat,
altInput: this.altInput,
altFormat: this.altFormat,
enableTime: this.enableTime,
enableSeconds: this.enableSeconds,
time_24hr: this.time24hr,
noCalendar: this.noCalendar,
minDate: this.minDate,
maxDate: this.maxDate,
defaultDate: this.date || null,
// GAP-5 UI passthrough (construction-time only) + GAP-6a allowInput.
// These match flatpickr's own defaults so passing them is render-neutral.
inline: this.inline,
static: this.staticPosition,
position: this.position,
showMonths: this.showMonths,
weekNumbers: this.weekNumbers,
monthSelectorType: this.monthSelectorType,
allowInput: this.allowInput,
// `appendTo` / `prevArrow` / `nextArrow` default to null here but flatpickr
// expects them ABSENT (its own defaults are `undefined` for appendTo and
// built-in SVG strings for the arrows). Passing an explicit null breaks
// construction, so include each ONLY when the consumer set a real value.
...(this.appendTo != null ? {
appendTo: this.appendTo
} : {}),
...(this.prevArrow != null ? {
prevArrow: this.prevArrow
} : {}),
...(this.nextArrow != null ? {
nextArrow: this.nextArrow
} : {}),
// GAP-2/3/4/6b conditional-spread passthrough. NEVER pass an empty array /
// null / default-0, because flatpickr treats `enable: []` as "nothing
// enabled" and a null locale/parseDate/formatDate breaks construction —
// each guard keeps the default render byte-identical to before.
...(this.disable.length ? {
disable: this.disable
} : {}),
...(this.enable.length ? {
enable: this.enable
} : {}),
...(this.parseDate != null ? {
parseDate: this.parseDate
} : {}),
...(this.formatDate != null ? {
formatDate: this.formatDate
} : {}),
...(this.plugins.length ? {
plugins: this.plugins
} : {}),
// locale + firstDayOfWeek merge: emit a single `locale` entry present when
// EITHER a locale object is set OR firstDayOfWeek is non-default (0). The
// merge folds firstDayOfWeek INTO the locale object so it overrides the
// locale's own. Kept a PURE expression (no statements) so Angular can splice
// it into a binding context safely.
...(this.locale != null || this.firstDayOfWeek !== 0 ? {
locale: {
...(this.locale ?? {}),
...(this.firstDayOfWeek !== 0 ? {
firstDayOfWeek: this.firstDayOfWeek
} : {})
}
} : {}),
...this.options,
onChange: (selectedDates: any, dateStr: any) => {
// Value contract + range-commit semantics. In range mode flatpickr fires
// onChange on the FIRST click (partial range) — committing then is the
// bug every wrapper ships. Commit the string only when the range is
// complete (2 dates) unless the consumer opted into commitOn:'change'.
const isRange = this.mode === 'range';
const complete = !isRange || selectedDates.length === 2;
if ((this.commitOn === 'change' || complete) && dateStr !== this.date) {
this._dateControllable.write(dateStr);
}
// Always surface BOTH the formatted string and the Date[] so consumers
// that need the parsed objects (range bounds, multi-select) get them.
this.dispatchEvent(new CustomEvent("change", {
detail: {
value: dateStr,
selectedDates
},
bubbles: true,
composed: true
}));
},
onReady: (d: any, s: any) => this.dispatchEvent(new CustomEvent("ready", {
detail: {
value: s,
selectedDates: d
},
bubbles: true,
composed: true
})),
onOpen: () => this.dispatchEvent(new CustomEvent("open", {
detail: undefined,
bubbles: true,
composed: true
})),
onClose: () => this.dispatchEvent(new CustomEvent("close", {
detail: undefined,
bubbles: true,
composed: true
})),
onMonthChange: () => this.dispatchEvent(new CustomEvent("monthChange", {
detail: undefined,
bubbles: true,
composed: true
})),
onYearChange: () => this.dispatchEvent(new CustomEvent("yearChange", {
detail: undefined,
bubbles: true,
composed: true
})),
onValueUpdate: (d: any, s: any) => this.dispatchEvent(new CustomEvent("valueUpdate", {
detail: {
value: s,
selectedDates: d
},
bubbles: true,
composed: true
})),
onDayCreate: (_d: any, _s: any, _fp: any, dayElem: any) => this.dispatchEvent(new CustomEvent("dayCreate", {
detail: dayElem,
bubbles: true,
composed: true
}))
});
if (this.disabled) this.instance.input.disabled = true;
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('mode'))) { const __watchVal = (() => this.mode)(); ((v: any) => this.instance?.set('mode', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('minDate'))) { const __watchVal = (() => this.minDate)(); ((v: any) => this.instance?.set('minDate', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('maxDate'))) { const __watchVal = (() => this.maxDate)(); ((v: any) => this.instance?.set('maxDate', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('dateFormat'))) { const __watchVal = (() => this.dateFormat)(); ((v: any) => this.instance?.set('dateFormat', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('disabled'))) { const __watchVal = (() => this.disabled)(); ((v: any) => {
if (this.instance) this.instance.input.disabled = v;
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('disable'))) { const __watchVal = (() => this.disable)(); ((v: any) => this.instance?.set('disable', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('enable'))) { const __watchVal = (() => this.enable)(); ((v: any) => this.instance?.set('enable', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('locale'))) { const __watchVal = (() => this.locale)(); ((v: any) => this.instance?.set('locale', {
...(v ?? {}),
...(this.firstDayOfWeek !== 0 ? {
firstDayOfWeek: this.firstDayOfWeek
} : {})
}))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('firstDayOfWeek'))) { const __watchVal = (() => this.firstDayOfWeek)(); ((v: any) => this.instance?.set('locale', {
...(this.locale ?? {}),
...(v !== 0 ? {
firstDayOfWeek: v
} : {})
}))(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'date') this._dateControllable.notifyAttributeChange(value as unknown as string);
}
render() {
return html`
<input class="rozie-flatpickr" type="text" name=${this.name} placeholder=${this.placeholder} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="inputEl" data-rozie-s-159070d4 />
`;
}
instance: any = null;
clear() {
this.instance?.clear();
}
openPicker() {
this.instance?.open();
}
closePicker() {
this.instance?.close();
}
selectDate(date: any, triggerChange: any) {
this.instance?.setDate(date, triggerChange);
}
jumpToDate(date: any) {
this.instance?.jumpToDate(date);
}
getSelectedDates() {
return this.instance ? this.instance.selectedDates : [];
}
togglePicker() {
this.instance?.toggle();
}
changeMonth(value: any, isOffset: any) {
this.instance?.changeMonth(value, isOffset);
}
changeYear(year: any) {
this.instance?.changeYear(year);
}
get date(): string { return this._dateControllable.read(); }
set date(v: string) { this._dateControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['date', 'mode', 'date-format', 'dateformat', 'alt-input', 'altinput', 'alt-format', 'altformat', 'enable-time', 'enabletime', 'enable-seconds', 'enableseconds', 'time24hr', 'no-calendar', 'nocalendar', 'min-date', 'mindate', 'max-date', 'maxdate', 'placeholder', 'disabled', 'commit-on', 'commiton', 'options', 'name', 'inline', 'static-position', 'staticposition', 'position', 'append-to', 'appendto', 'show-months', 'showmonths', 'week-numbers', 'weeknumbers', 'month-selector-type', 'monthselectortype', 'prev-arrow', 'prevarrow', 'next-arrow', 'nextarrow', 'allow-input', 'allowinput', 'disable', 'enable', 'locale', 'first-day-of-week', 'firstdayofweek', 'parse-date', 'parsedate', 'format-date', 'formatdate', 'plugins']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component with a ControlValueAccessor, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, all from the one source above.
See also
- Flatpickr — showcase & API — install, quick starts for all six frameworks, and the full reference.
- Flatpickr example & per-target output — the live source plus compiled React/Vue/Svelte/Angular/Solid/Lit output side by side.