diff --git a/.specs/calendar.md b/.specs/calendar.md
index 70b19ad38..2e69045a1 100644
--- a/.specs/calendar.md
+++ b/.specs/calendar.md
@@ -1,22 +1,22 @@
---
name: calendar
category: inputs
-structure: monolithic
+structure: composition
status: approved
-spec_version: 1
+spec_version: 3
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=5087-17336
node_id: 5087:17336
-checksum: b12599fa9619bc2e9b19e5e6011c7620099b2be7d92b9ff60e2ce8771bc425b1
+checksum: b1d756030feb2361958413f5d39fbddfebb9a8f652a8e5a98e7074eaf06d2894
created: 2026-06-25
-last_updated: 2026-06-25
+last_updated: 2026-06-30
---
# Calendar — Component Spec
## Purpose
-Inline date picker that renders a month grid for selecting a single date or a date range. Unlike a popover-anchored datepicker, Calendar is always-visible (no overlay, no positioning) and is the standalone surface a consumer embeds in a form, panel, or popover body. Date math uses the native `Date` API only — no date library.
+Date-range picker. A trigger button opens a popover that holds a one-click presets rail, one or more month grids, Start/End date + time fields, an optional timezone selector, and an Apply action; it also offers a "Select Period" mode whose text input parses relative spans (`45m`, `12 hours`, `last month`, `yesterday`, `1/1 - 1/2`). Selection is staged in a draft inside the popover and committed to `v-model` only on Apply (or immediately when `show-apply` is false). Date math uses the native `Date` API only — no date library; timezone is a selectable IANA label used for display formatting via `Intl` (the picker does not re-interpret `Date` objects across zones).
## Usage
@@ -26,73 +26,149 @@ import { ref } from 'vue'
import Calendar from '@aziontech/webkit/calendar'
-const selected = ref(new Date(2026, 9, 8))
+const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+
+const presets = [
+ { label: 'Last 7 days', value: { start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) } },
+ { label: 'Last 30 days', value: { start: new Date(2026, 8, 20), end: new Date(2026, 9, 19) } }
+]
+
+
+
+
+
+```
+
+The root renders the complete picker from props. For custom presets you may compose `` in the `presets` slot and a `` in the `footer` slot:
+
+```vue
+
-
+
+
+ Last 7 days
+
+
+ Clear
+
+
```
+Tree-shaking alternative — the standalone root + each sub-component from its own entry (no `Object.assign` compound pulled in):
+
+```vue
+
+```
+
+## Sub-components
+
+- `calendar-preset/calendar-preset.vue` — context-aware shortcut button; applies its `value` (a `Date` in single mode or a `{ start, end }` range) to the popover's draft selection through injected context and reflects a `data-selected` state when its value matches. Placed in the `presets` slot.
+- `calendar-clear/calendar-clear.vue` — context-aware button that clears the draft selection through injected context; disabled when there is no selection. Placed in the `footer` slot.
+
## Props
| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
-| `modelValue` | `Date \| null \| CalendarRange` | `null` | no | Selected value for v-model. A `Date` (or null) in single mode; a `{ start, end }` range object in range mode. |
-| `mode` | `'single' \| 'range'` | `'single'` | no | Selection mode. Single picks one date; range picks a start and end date. |
+| `modelValue` | `Date \| null \| CalendarRange` | `null` | no | Committed selection for v-model. A `Date` (or null) in single mode; a `{ start, end }` range in range mode. Only updated on Apply (or immediately when `showApply` is false). |
+| `mode` | `'single' \| 'range'` | `'range'` | no | Selection mode. Single picks one date; range picks a start and end date. |
+| `numberOfMonths` | `number` | `1` | no | Number of month grids rendered side-by-side; one shared previous/next pair pages the whole view by a month. |
+| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | no | Size token; affects the trigger, day-cell hit-area, and typography. |
| `min` | `Date \| undefined` | `undefined` | no | Earliest selectable date; earlier days render disabled. |
| `max` | `Date \| undefined` | `undefined` | no | Latest selectable date; later days render disabled. |
-| `disabled` | `boolean` | `false` | no | Disables the whole grid and navigation, applying disabled tokens. |
-| `showHeader` | `boolean` | `true` | no | Shows the month/year label and previous/next month navigation. |
+| `disabled` | `boolean` | `false` | no | Disables the trigger, grid, and all controls, applying disabled tokens. |
+| `open` | `boolean \| undefined` | `undefined` | no | Controlled open state of the popover. Use with v-model:open; omit for uncontrolled. |
+| `placeholder` | `string` | `'Select a Date Range'` | no | Trigger text shown when there is no selection. |
+| `presets` | `CalendarPresetItem[]` | `[]` | no | Data-driven shortcuts rendered in the presets rail; each is `{ label, value }` where value is a `Date` or range. |
+| `showTime` | `boolean` | `false` | no | Shows Start/End time fields alongside the date fields. |
+| `showTimezone` | `boolean` | `false` | no | Shows the timezone selector below the fields. |
+| `timezone` | `string` | `''` | no | Selected IANA timezone for display formatting (v-model:timezone). Empty resolves to the local zone. |
+| `timezones` | `string[]` | `[]` | no | Timezone options for the selector; empty falls back to a curated list derived from `Intl`. |
+| `horizontal` | `boolean` | `false` | no | Lays the fields/apply column beside the calendar instead of below it. |
+| `clearable` | `boolean` | `false` | no | Shows a clear control on the trigger that empties the committed selection. |
+| `showApply` | `boolean` | `true` | no | Stages edits in a draft and requires Apply to commit; when false, every edit commits immediately. |
+| `period` | `boolean` | `false` | no | Enables the "Select Period" relative-time mode: a relative-preset list plus a text input that parses spans like `45m` or `last month`. |
+| `split` | `boolean` | `false` | no | Renders the trigger as a split control with a separate chevron affordance. |
## Events
| Event | Payload | Notes |
|---|---|---|
-| `update:modelValue` | `Date \| null \| CalendarRange` | v-model. The selected `Date` (single) or `{ start, end }` (range). |
-| `month-change` | `CalendarMonth` | Emitted when the visible month changes via navigation or keyboard paging. Payload is `{ year, month }` (month is 0-indexed). |
+| `update:modelValue` | `Date \| null \| CalendarRange` | v-model. The committed `Date` (single) or `{ start, end }` (range); empty value on clear. |
+| `update:open` | `boolean` | v-model:open. Emitted when the popover opens or closes. |
+| `update:timezone` | `string` | v-model:timezone. Emitted when the timezone selection changes. |
+| `month-change` | `CalendarMonth` | Emitted when the first visible month changes via navigation or keyboard paging. Payload is `{ year, month }` (month is 0-indexed). |
+| `apply` | `Date \| null \| CalendarRange` | Emitted when the draft selection is committed via Apply. |
## Slots
-| _none_ | — | — |
+| Slot | Scope | Notes |
+|---|---|---|
+| `trigger` | `{ open, value, displayValue }` | Replaces the default trigger button; scope exposes the open state, raw value, and formatted label. |
+| `presets` | — | Custom presets rail content (e.g. ``); overrides the `presets` prop rendering. |
+| `footer` | — | Custom footer/action-bar content (e.g. ``) alongside Apply. |
## States
-- Visual states: `default`, `hover`, `focus-visible`, `selected`, `in-range`, `disabled`, `today`, `outside` (adjacent-month)
-- `data-selected` mirrors a fully-selected day cell (single value, or a range endpoint)
-- `data-band` on each day cell drives the connected range highlight: `none` | `single` | `start` | `middle` | `end` (endpoints round the outer edge; `middle` paints the in-range band edge-to-edge)
-- `data-today` marks the current date
-- `data-outside` marks an adjacent-month day cell
-- `data-disabled` mirrors the `disabled` prop on the root and unselectable day cells
+- Visual states: `default`, `hover`, `focus-visible`, `selected`, `in-range`, `disabled`, `today`, `outside` (adjacent-month), popover `open` / `closed`
+- `data-state` on the root and trigger mirrors the popover open state (`open` | `closed`)
+- `data-size` mirrors the `size` prop on the root, trigger, and each day cell (`small` | `medium` | `large`)
+- `data-selected` mirrors a fully-selected day cell (single value, or a range endpoint) and an active preset
+- `data-band` on each day cell drives the connected range highlight: `none` | `single` | `start` | `middle` | `end`
+- `data-today` marks the current date; `data-outside` marks an adjacent-month day cell
+- `data-disabled` mirrors the `disabled` prop on the root, unselectable day cells, and the clear control when there is no selection
## Motion & Animations
| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback |
|---|---|---|---|
+| popover open | `animate-popup-scale-in` | semantic (150ms · cubic-bezier) | `motion-reduce:animate-none` (instant) |
+| popover close | `animate-popup-scale-out` | semantic (110ms · cubic-bezier) | `motion-reduce:animate-none` (instant) |
| hover/focus state change | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` |
## Tokens
| Region | Token (DESIGN.md) |
|---|---|
-| panel surface | `var(--bg-surface-raised)` |
-| panel border | `var(--border-default)` |
-| panel shape | `var(--shape-button)` |
-| day cell shape | `var(--shape-elements)` |
-| header typography | `.text-body-sm` |
+| popover surface | `var(--bg-surface-raised)` |
+| popover border | `var(--border-default)` |
+| popover shape | `var(--shape-card)` |
+| popover elevation | `var(--shadow-md)` |
+| trigger / field surface | `var(--bg-surface)` |
+| trigger / field border | `var(--border-default)` |
+| trigger / field shape | `var(--shape-elements)` |
+| section divider | `var(--border-default)` |
+| label typography | `.text-label-sm` |
| weekday typography | `.text-label-sm` |
-| day typography | `.text-body-sm` |
-| header text | `var(--text-default)` |
-| weekday text | `var(--text-muted)` |
-| day text (current month) | `var(--text-default)` |
-| day text (adjacent month / disabled) | `var(--text-disabled)` |
+| day typography (small) | `.text-body-xs` |
+| day typography (medium) | `.text-body-sm` |
+| day typography (large) | `.text-body-md` |
+| body / value typography | `.text-body-sm` |
+| header / muted text | `var(--text-muted)` |
+| default text | `var(--text-default)` |
+| disabled text | `var(--text-disabled)` |
| selected day surface | `var(--secondary)` |
| selected day text | `var(--secondary-contrast)` |
| in-range band | `var(--bg-mask)` |
| hover surface | `var(--bg-hover)` |
-| nav button hover surface | `var(--bg-hover)` |
| spacing (padding) | `var(--spacing-sm)` |
| spacing (gap) | `var(--spacing-xxs)` |
+| spacing (sections) | `var(--spacing-md)` |
| ring | `var(--ring-color)` |
## Theme gaps
@@ -103,17 +179,25 @@ const selected = ref(new Date(2026, 9, 8))
## Accessibility (WCAG 2.1 AA)
-- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface-raised)]`
-- Keyboard map: `Tab` enters the grid at the focused day; `Arrow` keys move focus one day (left/right) or one week (up/down) with roving tabindex; `Enter`/`Space` selects the focused day; `PageUp`/`PageDown` navigate to the previous/next month; previous/next nav buttons are reachable by `Tab`.
-- ARIA: grid uses `role="grid"` with `role="row"` and `role="gridcell"` descendants; selected cells set `aria-selected="true"`; the current date sets `aria-current="date"`; navigation buttons carry `aria-label` (previous month / next month); decorative chevron icons are `aria-hidden="true"`.
+- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]` on the trigger and `focus-visible:ring-offset-[var(--bg-surface-raised)]` inside the popover.
+- Keyboard map: the trigger toggles the popover with `Enter`/`Space` and exposes `aria-haspopup`/`aria-expanded`; `Escape` and click-outside close it and return focus to the trigger; focus is trapped in the open popover. In the grid, `Tab` enters at the focused day, `Arrow` keys move focus (paging the visible window when crossing its first/last month), `Enter`/`Space` selects, `PageUp`/`PageDown` change month. Apply, Clear, presets, fields, and the timezone control are all reachable by `Tab`.
+- ARIA: each month grid uses `role="grid"` with `role="row"` and `role="gridcell"` descendants; selected cells set `aria-selected="true"`; the current date sets `aria-current="date"`; navigation buttons carry `aria-label`; the popover is labelled by the trigger; decorative icons are `aria-hidden="true"`.
- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including the disabled state.
-- `motion-reduce:transition-none` on animated states.
-- Touch target ≥40×40 px (or justified deviation): day cells use a 36×36 px hit area matching the Figma `--size-9` token; this is a justified deviation for dense month-grid layouts.
+- `motion-reduce:animate-none` on the popover and `motion-reduce:transition-none` on animated states.
+- Touch target ≥40×40 px (or justified deviation): day cells use a 32×32 px (`small`), 36×36 px (`medium`), or 40×40 px (`large`) hit area; `small`/`medium` are a justified deviation for dense month-grid layouts, `large` meets the 40 px target.
## Stories (Storybook)
-- Default — single mode with a preselected date.
-- Range — range mode showing a selected start/end with the in-range band. Justified: range selection is a distinct, mutually-exclusive mode (`mode="range"`) with its own visual state (`data-band`) that the Default single-mode story cannot demonstrate.
+- Default — range picker with a preselected range (trigger + popover). Justified: shows the baseline trigger→popover→grid flow.
+- Sizes — composite story rendering every `size` value side-by-side (canonical, the component declares a `size` prop).
+- Single — single-date mode (`mode="single"`). Justified: single is a distinct, mutually-exclusive mode the range default cannot show.
+- MultiMonth — two months side-by-side (`number-of-months="2"`). Justified: a structural layout axis (shared nav, band across a month boundary) no other story exercises.
+- Horizontal — `horizontal` layout with fields beside the calendar. Justified: a distinct layout the default vertical story cannot show.
+- WithPresets — a `presets` rail of shortcuts. Justified: the data-driven preset behavior (one-click range + active state) cannot be shown without it.
+- WithTime — `show-time` Start/End time fields. Justified: the time-field round-trip is a distinct capability.
+- WithTimezone — `show-timezone` selector. Justified: the timezone control is a distinct capability.
+- SelectPeriod — `period` relative-time mode (preset list + parsed text input). Justified: the relative-time parsing mode is mutually exclusive with the absolute calendar flow.
+- Clearable — `clearable` trigger that empties the committed selection. Justified: the clear affordance and disabled-when-empty behavior cannot be shown without it.
## Constraints — DO NOT
@@ -134,6 +218,7 @@ const selected = ref(new Date(2026, 9, 8))
- Do not duplicate the `## Usage` block from the spec inside the Storybook story body. The block is injected once into `parameters.docs.description.component` by the storybook-write skill; copy it nowhere else.
- Do not edit `.claude/docs/DESIGN.md`, `.claude/docs/COMPONENT_REQUIREMENTS.md`, or `.claude/docs/PRIMEVUE_ABSTRACTION.md`.
- Do not edit the root `package.json` or `.github/workflows/*`.
+- Do not export composition sub-components without attaching them to the root compound (`index.ts` via `Object.assign`; vue-tsc generates `index.d.ts` — never hand-write it); the root export points at `index.ts`, and a standalone `./-root` export points at the root `.vue` (tree-shaking). Do not invent overlay part names (`Trigger` / `Content`) on a component with no `data-state=open|closed`, and do not collapse a slot-shaped concern into a config-array prop. See `.claude/rules/compound-api.md`.
- Do not change `structure` after `status: approved`. To change structure, bump `spec_version` and re-author the spec.
- Do not create files outside the paths declared by your task (the orchestrator tells you exactly which files to write).
- Do not run `git` commands, `pnpm install`, or any command that changes the lockfile.
diff --git a/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js b/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js
index 593213412..be5b62dbd 100644
--- a/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js
+++ b/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js
@@ -2,33 +2,29 @@ import { ref } from 'vue'
import Calendar from '@aziontech/webkit/calendar'
-const SINGLE_SOURCE = [
- '',
- '',
- '',
- ' ',
- ''
-].join('\n')
-
-const RANGE_SOURCE = [
- '',
- '',
- '',
- ' ',
- ''
-].join('\n')
+const sfc = (body) =>
+ ['', '', '', body, ''].join(
+ '\n'
+ )
+
+const DEFAULT_SOURCE = sfc(' ')
+const SIZES_SOURCE = sfc(
+ [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('\n')
+)
+const SINGLE_SOURCE = sfc(' ')
+const MULTI_MONTH_SOURCE = sfc(' ')
+const HORIZONTAL_SOURCE = sfc(' ')
+const PRESETS_SOURCE = sfc(' ')
+const TIME_SOURCE = sfc(' ')
+const TIMEZONE_SOURCE = sfc(' ')
+const PERIOD_SOURCE = sfc(' ')
+const CLEARABLE_SOURCE = sfc(' ')
/** @type {import('@storybook/vue3').Meta} */
const meta = {
@@ -49,7 +45,7 @@ const meta = {
docs: {
description: {
component:
- 'Inline date picker that renders a month grid for selecting a single date or a date range. Unlike a popover-anchored datepicker, Calendar is always-visible (no overlay, no positioning) and is the standalone surface a consumer embeds in a form, panel, or popover body. Date math uses the native `Date` API only — no date library.'
+ 'Date-range picker. A trigger button opens a popover that holds a one-click presets rail, one or more month grids, Start/End date + time fields, an optional timezone selector, and an Apply action; it also offers a "Select Period" mode whose text input parses relative spans (`45m`, `12 hours`, `last month`, `yesterday`, `1/1 - 1/2`). Selection is staged in a draft inside the popover and committed to `v-model` only on Apply (or immediately when `show-apply` is false). Date math uses the native `Date` API only — no date library.'
},
canvas: {
sourceState: 'shown'
@@ -59,100 +55,360 @@ const meta = {
argTypes: {
modelValue: {
control: false,
- description:
- 'Selected value for v-model. A Date (or null) in single mode; a range object in range mode.',
+ description: 'Committed selection for v-model (Date or range).',
table: { category: 'props', type: { summary: 'Date | null | CalendarRange' } }
},
mode: {
control: 'inline-radio',
options: ['single', 'range'],
- description: 'Selection mode. Single picks one date; range picks a start and end date.',
+ description: 'Selection mode.',
table: {
category: 'props',
type: { summary: "'single' | 'range'" },
- defaultValue: { summary: "'single'" }
+ defaultValue: { summary: "'range'" }
+ }
+ },
+ numberOfMonths: {
+ control: { type: 'number', min: 1, max: 3 },
+ description: 'Number of month grids rendered side-by-side.',
+ table: { category: 'props', type: { summary: 'number' }, defaultValue: { summary: '1' } }
+ },
+ size: {
+ control: 'inline-radio',
+ options: ['small', 'medium', 'large'],
+ description: 'Size token; affects the trigger, day-cell hit-area, and typography.',
+ table: {
+ category: 'props',
+ type: { summary: "'small' | 'medium' | 'large'" },
+ defaultValue: { summary: "'medium'" }
}
},
min: {
control: false,
- description: 'Earliest selectable date; earlier days render disabled.',
+ description: 'Earliest selectable date.',
table: { category: 'props', type: { summary: 'Date' } }
},
max: {
control: false,
- description: 'Latest selectable date; later days render disabled.',
+ description: 'Latest selectable date.',
table: { category: 'props', type: { summary: 'Date' } }
},
disabled: {
control: 'boolean',
- description: 'Disables the whole grid and navigation, applying disabled tokens.',
+ description: 'Disables the trigger, grid, and all controls.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
+ open: {
+ control: false,
+ description: 'Controlled open state of the popover (v-model:open).',
+ table: { category: 'props', type: { summary: 'boolean' } }
+ },
+ placeholder: {
+ control: 'text',
+ description: 'Trigger text shown when there is no selection.',
+ table: {
+ category: 'props',
+ type: { summary: 'string' },
+ defaultValue: { summary: "'Select date range'" }
+ }
+ },
+ presets: {
+ control: false,
+ description: 'Data-driven shortcuts rendered in the presets rail.',
+ table: { category: 'props', type: { summary: 'CalendarPresetItem[]' } }
+ },
+ showTime: {
+ control: 'boolean',
+ description: 'Shows Start/End time fields alongside the date fields.',
table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
},
- showHeader: {
+ showTimezone: {
control: 'boolean',
- description: 'Shows the month/year label and previous/next month navigation.',
+ description: 'Shows the timezone selector below the fields.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
+ timezone: {
+ control: 'text',
+ description: 'Selected IANA timezone for display formatting (v-model:timezone).',
+ table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } }
+ },
+ timezones: {
+ control: false,
+ description: 'Timezone options for the selector.',
+ table: { category: 'props', type: { summary: 'string[]' } }
+ },
+ horizontal: {
+ control: 'boolean',
+ description: 'Lays the fields/apply column beside the calendar instead of below it.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
+ clearable: {
+ control: 'boolean',
+ description: 'Shows a clear control on the trigger that empties the committed selection.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
+ showApply: {
+ control: 'boolean',
+ description: 'Stages edits in a draft and requires Apply to commit; when false, commits immediately.',
table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'true' } }
},
+ period: {
+ control: 'boolean',
+ description: 'Enables the Select Period relative-time mode.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
+ split: {
+ control: 'boolean',
+ description: 'Renders the trigger as a split control with a separate chevron affordance.',
+ table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
+ },
'onUpdate:modelValue': {
action: 'update:modelValue',
- description: 'Emitted when the selection changes.',
+ description: 'Emitted when the selection is committed.',
table: { category: 'events', type: { summary: 'Date | null | CalendarRange' } }
},
+ 'onUpdate:open': {
+ action: 'update:open',
+ description: 'Emitted when the popover opens or closes.',
+ table: { category: 'events', type: { summary: 'boolean' } }
+ },
+ 'onUpdate:timezone': {
+ action: 'update:timezone',
+ description: 'Emitted when the timezone selection changes.',
+ table: { category: 'events', type: { summary: 'string' } }
+ },
onMonthChange: {
action: 'month-change',
- description: 'Emitted when the visible month changes via navigation or keyboard paging.',
+ description: 'Emitted when the first visible month changes.',
table: { category: 'events', type: { summary: 'CalendarMonth' } }
+ },
+ onApply: {
+ action: 'apply',
+ description: 'Emitted when the draft selection is committed via Apply.',
+ table: { category: 'events', type: { summary: 'Date | null | CalendarRange' } }
}
},
args: {
- mode: 'single',
+ mode: 'range',
+ numberOfMonths: 1,
+ size: 'medium',
disabled: false,
- showHeader: true
+ showTime: false,
+ showTimezone: false,
+ horizontal: false,
+ clearable: false,
+ showApply: true,
+ period: false,
+ split: false
}
}
export default meta
+const handlers = (args) => {
+ const { onUpdateModelValue, onUpdateOpen, onUpdateTimezone, onMonthChange, onApply, ...props } =
+ args
+ return { props, onUpdateModelValue, onUpdateOpen, onUpdateTimezone, onMonthChange, onApply }
+}
+
+const LISTENERS =
+ '@update:modelValue="onUpdateModelValue" @update:open="onUpdateOpen" @update:timezone="onUpdateTimezone" @month-change="onMonthChange" @apply="onApply"'
+
/** @type {import('@storybook/vue3').StoryObj} */
export const Default = {
render: (args) => ({
components: { Calendar },
setup() {
- const { onUpdateModelValue, onMonthChange, ...props } = args
- const selected = ref(new Date(2026, 9, 8))
+ const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: DEFAULT_SOURCE },
+ description: { story: 'Range picker — the trigger opens a popover with the calendar grid and Apply.' }
+ }
+ }
+}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const Sizes = {
+ render: () => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+ return { range }
+ },
+ template: `
+
+
+
+
`
+ }),
+ parameters: {
+ docs: {
+ controls: { disable: true },
+ source: { code: SIZES_SOURCE },
+ description: { story: 'The three trigger sizes — `small`, `medium`, and `large`.' }
+ }
+ }
+}
- return { props, selected, onUpdateModelValue, onMonthChange }
+/** @type {import('@storybook/vue3').StoryObj} */
+export const Single = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const date = ref(new Date(2026, 9, 8))
+ return { ...handlers(args), date }
},
- template:
- ''
+ template: ``
}),
parameters: {
docs: {
source: { code: SINGLE_SOURCE },
- description: { story: 'Single-mode calendar with a preselected date.' }
+ description: { story: 'Single-date mode picks one day instead of a range.' }
+ }
+ }
+}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const MultiMonth = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({ start: new Date(2026, 9, 26), end: new Date(2026, 10, 8) })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: MULTI_MONTH_SOURCE },
+ description: {
+ story: 'Two months side-by-side; one shared nav pages both and the range band spans the boundary.'
+ }
}
}
}
/** @type {import('@storybook/vue3').StoryObj} */
-export const Range = {
+export const Horizontal = {
render: (args) => ({
components: { Calendar },
setup() {
- const { onUpdateModelValue, onMonthChange, ...props } = args
- const selected = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+ const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: HORIZONTAL_SOURCE },
+ description: { story: 'Horizontal layout places the Start/End fields and Apply beside the calendar.' }
+ }
+ }
+}
- return { props, selected, onUpdateModelValue, onMonthChange }
+/** @type {import('@storybook/vue3').StoryObj} */
+export const WithPresets = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({ start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) })
+ const presets = [
+ { label: 'Last 7 days', value: { start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) } },
+ { label: 'Last 30 days', value: { start: new Date(2026, 8, 20), end: new Date(2026, 9, 19) } }
+ ]
+ return { ...handlers(args), range, presets }
},
- template:
- ''
+ template: ``
}),
parameters: {
docs: {
- source: { code: RANGE_SOURCE },
+ source: { code: PRESETS_SOURCE },
+ description: { story: 'A presets rail of one-click shortcuts that stage the range in the draft.' }
+ }
+ }
+}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const WithTime = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({
+ start: new Date(2026, 9, 8, 0, 0),
+ end: new Date(2026, 9, 19, 23, 59)
+ })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: TIME_SOURCE },
+ description: { story: 'Start/End time fields round-trip the hours and minutes of each endpoint.' }
+ }
+ }
+}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const WithTimezone = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({
+ start: new Date(2026, 9, 8, 0, 0),
+ end: new Date(2026, 9, 19, 23, 59)
+ })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: TIMEZONE_SOURCE },
+ description: { story: 'A timezone selector (IANA zones) labels the selection; defaults to the local zone.' }
+ }
+ }
+}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const SelectPeriod = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref(null)
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: PERIOD_SOURCE },
description: {
- story: 'Range mode showing a selected start and end date with the in-range band between them.'
+ story: 'Select Period mode: a relative-preset list plus a text input that parses spans like `45m` or `last month`.'
}
}
}
}
+
+/** @type {import('@storybook/vue3').StoryObj} */
+export const Clearable = {
+ render: (args) => ({
+ components: { Calendar },
+ setup() {
+ const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) })
+ return { ...handlers(args), range }
+ },
+ template: ``
+ }),
+ parameters: {
+ docs: {
+ source: { code: CLEARABLE_SOURCE },
+ description: { story: 'A clear control on the trigger empties the committed selection when present.' }
+ }
+ }
+}
diff --git a/packages/webkit/package.json b/packages/webkit/package.json
index c275c5a9e..26cb51f6b 100644
--- a/packages/webkit/package.json
+++ b/packages/webkit/package.json
@@ -125,7 +125,10 @@
"./toast-close": "./src/components/feedback/toast/toast-close/toast-close.vue",
"./skeleton": "./src/components/feedback/skeleton/skeleton.vue",
"./progress-bar": "./src/components/feedback/progress-bar/progress-bar.vue",
- "./calendar": "./src/components/inputs/calendar/calendar.vue",
+ "./calendar": "./src/components/inputs/calendar/index.ts",
+ "./calendar-root": "./src/components/inputs/calendar/calendar.vue",
+ "./calendar-preset": "./src/components/inputs/calendar/calendar-preset/calendar-preset.vue",
+ "./calendar-clear": "./src/components/inputs/calendar/calendar-clear/calendar-clear.vue",
"./input-text": "./src/components/inputs/input-text/input-text.vue",
"./input-number": "./src/components/inputs/input-number/input-number.vue",
"./field-text": "./src/components/inputs/field-text/field-text.vue",
diff --git a/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue b/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue
new file mode 100644
index 000000000..d4d6c9e69
--- /dev/null
+++ b/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
diff --git a/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue b/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue
new file mode 100644
index 000000000..8f5d7644c
--- /dev/null
+++ b/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue
@@ -0,0 +1,158 @@
+
+
+
+