{
  "$schema": "https://mian.ro/schema/v2/registry.json",
  "schemaVersion": "2.0.0",
  "generatedAt": "2026-10-02T13:14:06.957Z",
  "id": "calendar",
  "name": "Calendar",
  "category": "Forms",
  "description": "A month grid for picking a date, on its own or in a popover as a date field. Full keyboard support and locale-aware names.",
  "tags": [
    "calendar",
    "date picker",
    "datepicker",
    "date field",
    "month",
    "day",
    "schedule"
  ],
  "requiresJs": true,
  "license": "LicenseRef-MIAN-1.0",
  "composition": {
    "role": null,
    "intents": [
      "date picker",
      "booking calendar",
      "date range picker",
      "check-in check-out",
      "choose a day"
    ],
    "prominence": null,
    "density": null,
    "worksAfter": [],
    "worksBefore": [],
    "alternatives": [],
    "avoid": null
  },
  "url": "https://mian.ro/components/calendar",
  "markdownUrl": "https://mian.ro/ai/components/calendar.md",
  "classes": [
    {
      "name": "mn-calendar",
      "description": "The calendar. mian.js renders the month grid inside it."
    },
    {
      "name": "data-value=\"YYYY-MM-DD\"",
      "description": "Selected date."
    },
    {
      "name": "data-min · data-max",
      "description": "Earliest and latest selectable date."
    },
    {
      "name": "data-week-start=\"0–6\"",
      "description": "First day of the week (0 = Sunday, default 1 = Monday)."
    },
    {
      "name": "data-mn-for=\"#input\"",
      "description": "Keeps an input in sync; inside a [popover] the calendar closes after a choice."
    },
    {
      "name": "data-range",
      "description": "Pick a range. data-value=\"YYYY-MM-DD/YYYY-MM-DD\"; data-mn-start / data-mn-end keep two inputs in sync; mn:change carries { start, end, nights }."
    }
  ],
  "accessibility": [
    "The grid is a table with role=\"grid\", column headers with full weekday names (abbr) and a live month title.",
    "Only one day is in the tab order. Arrow keys move by day and week, Page Up/Down by month (Shift: year), Home/End to the week’s edges, Enter or Space selects.",
    "Each day button has a full date as its accessible name; today is aria-current=\"date\", the chosen day aria-pressed=\"true\".",
    "Emits mn:change with { value } as YYYY-MM-DD."
  ],
  "examples": [
    {
      "title": "Calendar",
      "description": null,
      "html": "<div class=\"mn-calendar\" data-value=\"2026-09-25\" aria-label=\"Choose a date\"></div>"
    },
    {
      "title": "Date field",
      "description": "An input with a calendar in a popover. Typing a date also works.",
      "html": "<div class=\"mn-field\" style=\"max-width:16rem\">\n  <label class=\"mn-label\" for=\"date-1\">Start date</label>\n  <div class=\"mn-input-group\">\n    <input class=\"mn-input\" id=\"date-1\" placeholder=\"YYYY-MM-DD\" inputmode=\"numeric\" value=\"2026-10-02\">\n    <button class=\"mn-btn mn-btn-ghost mn-btn-icon mn-btn-sm\" type=\"button\" popovertarget=\"date-1-cal\" aria-label=\"Choose date\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M3 10h18M8 3v4M16 3v4\"/></svg></button>\n  </div>\n</div>\n<div class=\"mn-popover\" id=\"date-1-cal\" popover data-placement=\"bottom-end\"><div class=\"mn-calendar\" data-mn-for=\"#date-1\" data-min=\"2026-09-01\"></div></div>"
    },
    {
      "title": "Date range",
      "description": "Check-in and check-out: the first choice starts the range, the second ends it. Hover previews it.",
      "html": "<div class=\"mn-stack\" style=\"max-width:20rem;--mn-gap:.75rem\">\n  <div class=\"mn-form-row\"><div class=\"mn-field\"><label class=\"mn-label\" for=\"stay-in\">Check-in</label><input class=\"mn-input\" id=\"stay-in\" value=\"2026-10-09\" readonly></div><div class=\"mn-field\"><label class=\"mn-label\" for=\"stay-out\">Check-out</label><input class=\"mn-input\" id=\"stay-out\" value=\"2026-10-12\" readonly></div></div>\n  <div class=\"mn-calendar\" data-range data-value=\"2026-10-09/2026-10-12\" data-min=\"2026-09-25\" data-mn-start=\"#stay-in\" data-mn-end=\"#stay-out\" aria-label=\"Choose your dates\"></div>\n</div>"
    }
  ],
  "css": ".mn-calendar {\n  --_cell: 2.25rem;\n  display: inline-grid; gap: .5rem; max-width: 100%;\n  padding: .75rem;\n  border: var(--mn-border-w) solid var(--mn-border); border-radius: var(--mn-radius-box);\n  background: var(--mn-card); color: var(--mn-fg);\n  font: 400 var(--mn-text-sm)/1 var(--mn-font);\n}\n.mn-popover .mn-calendar { border: 0; padding: .25rem; background: none; }\n.mn-calendar-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }\n.mn-calendar-title { font-weight: 600; text-align: center; flex: 1; }\n.mn-calendar-nav {\n  display: grid; place-items: center; width: 2rem; height: 2rem; flex: none;\n  border: 0; border-radius: var(--mn-radius-field); background: none; color: var(--mn-muted); cursor: pointer;\n}\n.mn-calendar-nav:hover { background: var(--mn-hover); color: var(--mn-fg); }\n.mn-calendar-nav:focus-visible { outline: none; box-shadow: var(--mn-focus); }\n.mn-calendar-nav svg { width: 1rem; height: 1rem; }\n.mn-calendar-grid { border-collapse: collapse; }\n.mn-calendar-grid th { width: var(--_cell); height: 1.75rem; padding: 0; font-size: .6875rem; font-weight: 500; color: var(--mn-muted); text-align: center; }\n.mn-calendar-grid td { padding: 1px; text-align: center; }\n.mn-calendar-day {\n  width: var(--_cell); height: var(--_cell); padding: 0;\n  border: 0; border-radius: min(var(--mn-radius-field), 999px); background: none;\n  color: inherit; font: inherit; font-variant-numeric: tabular-nums; cursor: pointer;\n  transition: background-color var(--mn-duration) var(--mn-ease), color var(--mn-duration) var(--mn-ease);\n}\n.mn-calendar-day:hover { background: var(--mn-hover); }\n.mn-calendar-day[data-outside] { color: var(--mn-muted); }\n.mn-calendar-day[aria-current=\"date\"] { font-weight: 700; box-shadow: inset 0 0 0 1px var(--mn-input); }\n.mn-calendar-day[aria-pressed=\"true\"] { background: var(--mn-primary); color: var(--mn-primary-fg); box-shadow: none; }\n.mn-calendar-day[data-in-range] { border-radius: 0; background: color-mix(in oklab, var(--mn-primary) 14%, transparent); color: var(--mn-fg); }\n.mn-calendar-day[data-range-start], .mn-calendar-day[data-range-end] { background: var(--mn-primary); color: var(--mn-primary-fg); box-shadow: none; }\n.mn-calendar-day[data-range-start]:not([data-range-end]) { border-start-end-radius: 0; border-end-end-radius: 0; }\n.mn-calendar-day[data-range-end]:not([data-range-start]) { border-start-start-radius: 0; border-end-start-radius: 0; }\n.mn-calendar-day:disabled { opacity: .35; cursor: not-allowed; background: none; }\n.mn-calendar-day:focus-visible { outline: none; box-shadow: var(--mn-focus); }\n@media (max-width: 22rem) { .mn-calendar { --_cell: 2rem; padding: .5rem; } }",
  "js": "/* <div class=\"mn-calendar\" data-value=\"2026-09-25\" data-min data-max\n        data-week-start=\"1\" data-mn-for=\"#input\">\n   Renders a month grid. Arrow keys move by day and week, Page Up/Down by\n   month (with Shift, by year), Home/End to the week's edges, Enter selects.\n   Emits mn:change { value: 'YYYY-MM-DD' }. With data-mn-for it keeps an\n   input in sync, and inside a popover it closes itself after a choice.\n   With data-range it picks a range: data-value=\"YYYY-MM-DD/YYYY-MM-DD\",\n   data-mn-start / data-mn-end keep two inputs in sync, and mn:change carries\n   { start, end, nights }. */\nregister('calendar', (root) => {\n  each(root, '.mn-calendar', 'Cal', (cal) => {\n    const lang = cal.closest('[lang]')?.lang || navigator.language || 'en';\n    const weekStart = Number(cal.dataset.weekStart ?? 1);\n    const pad = (n) => String(n).padStart(2, '0');\n    const iso = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\n    const parse = (s) => { const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(s || ''); if (!m) return null; const d = new Date(+m[1], +m[2] - 1, +m[3]); return d.getMonth() === +m[2] - 1 ? d : null; };\n    const input = cal.dataset.mnFor ? document.querySelector(cal.dataset.mnFor) : null;\n    const min = parse(cal.dataset.min), max = parse(cal.dataset.max);\n    const allowed = (d) => !((min && d < min) || (max && d > max));\n    const range = cal.hasAttribute('data-range');\n    const startInput = cal.dataset.mnStart ? document.querySelector(cal.dataset.mnStart) : null;\n    const endInput = cal.dataset.mnEnd ? document.querySelector(cal.dataset.mnEnd) : null;\n    const [rv0, rv1] = (cal.dataset.value || '').split('/');\n    let rStart = range ? parse(rv0) || parse(startInput?.value) : null;\n    let rEnd = range ? parse(rv1) || parse(endInput?.value) : null;\n    let hover = null;\n    let value = range ? rStart : parse(cal.dataset.value) || parse(input?.value);\n    const now = new Date();\n    let focus = value ? new Date(value) : new Date(now.getFullYear(), now.getMonth(), now.getDate());\n    const today = iso(now);\n    const fmtMonth = new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric' });\n    const fmtDay = new Intl.DateTimeFormat(lang, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });\n    const fmtWd = new Intl.DateTimeFormat(lang, { weekday: 'short' });\n    const fmtWdLong = new Intl.DateTimeFormat(lang, { weekday: 'long' });\n\n    cal.textContent = '';\n    const head = document.createElement('div'); head.className = 'mn-calendar-head';\n    const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'mn-calendar-nav'; prev.innerHTML = ICON_PREV; prev.setAttribute('aria-label', 'Previous month');\n    const next = document.createElement('button'); next.type = 'button'; next.className = 'mn-calendar-nav'; next.innerHTML = ICON_NEXT; next.setAttribute('aria-label', 'Next month');\n    const title = document.createElement('span'); title.className = 'mn-calendar-title'; title.setAttribute('aria-live', 'polite'); id(title, 'mn-cal-title');\n    head.append(prev, title, next);\n    const table = document.createElement('table'); table.className = 'mn-calendar-grid'; table.setAttribute('role', 'grid'); table.setAttribute('aria-labelledby', title.id);\n    const thead = table.createTHead().insertRow();\n    for (let j = 0; j < 7; j++) {\n      const day = new Date(2024, 0, 7 + ((weekStart + j) % 7));   // 7 Jan 2024 is a Sunday\n      const th = document.createElement('th'); th.scope = 'col'; th.abbr = fmtWdLong.format(day); th.textContent = fmtWd.format(day).slice(0, 2);\n      thead.appendChild(th);\n    }\n    const tbody = table.createTBody();\n    cal.append(head, table);\n\n    const render = (moveFocus) => {\n      title.textContent = fmtMonth.format(focus);\n      const first = new Date(focus.getFullYear(), focus.getMonth(), 1);\n      const start = new Date(first); start.setDate(1 - ((first.getDay() - weekStart + 7) % 7));\n      tbody.textContent = '';\n      for (let w = 0; w < 6; w++) {\n        const tr = tbody.insertRow();\n        for (let j = 0; j < 7; j++) {\n          const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + j);\n          const b = document.createElement('button');\n          b.type = 'button'; b.className = 'mn-calendar-day'; b.textContent = d.getDate(); b.dataset.date = iso(d);\n          b.setAttribute('aria-label', fmtDay.format(d));\n          if (d.getMonth() !== focus.getMonth()) b.dataset.outside = '';\n          if (iso(d) === today) b.setAttribute('aria-current', 'date');\n          if (range) {\n            const k = iso(d), last = rEnd || hover;\n            const isStart = rStart && k === iso(rStart), isEnd = rEnd && k === iso(rEnd);\n            b.setAttribute('aria-pressed', String(Boolean(isStart || isEnd)));\n            if (isStart) b.dataset.rangeStart = '';\n            if (isEnd || (!rEnd && hover && rStart && k === iso(hover) && hover > rStart)) b.dataset.rangeEnd = '';\n            if (rStart && last && d > rStart && d < last) b.dataset.inRange = '';\n          } else b.setAttribute('aria-pressed', String(Boolean(value) && iso(d) === iso(value)));\n          if (!allowed(d)) b.disabled = true;\n          b.tabIndex = iso(d) === iso(focus) ? 0 : -1;\n          tr.insertCell().appendChild(b);\n        }\n      }\n      prev.disabled = Boolean(min) && new Date(focus.getFullYear(), focus.getMonth(), 0) < min;\n      next.disabled = Boolean(max) && new Date(focus.getFullYear(), focus.getMonth() + 1, 1) > max;\n      if (moveFocus) tbody.querySelector('[tabindex=\"0\"]')?.focus();\n    };\n    const move = (d, moveFocus = true) => { focus = d; render(moveFocus); };\n    const addMonths = (d, n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1); t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };\n    const selectRange = (d) => {\n      if (!allowed(d)) return;\n      focus = new Date(d);\n      if (!rStart || rEnd || d < rStart) { rStart = d; rEnd = null; }\n      else rEnd = d;\n      hover = null;\n      cal.dataset.value = rEnd ? `${iso(rStart)}/${iso(rEnd)}` : iso(rStart);\n      render(!cal.closest('[popover]') || !rEnd);\n      const set = (el, v) => { if (el) { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); } };\n      set(startInput, iso(rStart)); set(endInput, rEnd ? iso(rEnd) : '');\n      if (rEnd) {\n        const nights = Math.round((rEnd - rStart) / 864e5);\n        emit(cal, 'change', { start: iso(rStart), end: iso(rEnd), nights });\n        announce(`${fmtDay.format(rStart)} to ${fmtDay.format(rEnd)}, ${nights} night${nights === 1 ? '' : 's'}`);\n        const pop = cal.closest('[popover]');\n        if (pop?.matches(':popover-open')) { pop.hidePopover(); (startInput || endInput)?.focus(); }\n      }\n    };\n    const select = (d) => {\n      if (range) return selectRange(d);\n      if (!allowed(d)) return;\n      value = d; focus = new Date(d);\n      cal.dataset.value = iso(d);\n      render(!cal.closest('[popover]'));\n      if (input) { input.value = iso(d); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }\n      emit(cal, 'change', { value: iso(d) });\n      const pop = cal.closest('[popover]');\n      if (pop?.matches(':popover-open')) { pop.hidePopover(); input?.focus(); }\n    };\n\n    prev.addEventListener('click', () => move(addMonths(focus, -1), false));\n    next.addEventListener('click', () => move(addMonths(focus, 1), false));\n    tbody.addEventListener('click', (e) => { const b = e.target.closest('.mn-calendar-day'); if (b && !b.disabled) select(parse(b.dataset.date)); });\n    if (range) tbody.addEventListener('mouseover', (e) => {\n      const b = e.target.closest('.mn-calendar-day'); if (!b || !rStart || rEnd) return;\n      const d = parse(b.dataset.date); if (hover && iso(d) === iso(hover)) return;\n      hover = d; render(false);\n    });\n    tbody.addEventListener('keydown', (e) => {\n      const d = new Date(focus);\n      const dow = (d.getDay() - weekStart + 7) % 7;\n      switch (e.key) {\n        case 'ArrowLeft': d.setDate(d.getDate() - 1); break;\n        case 'ArrowRight': d.setDate(d.getDate() + 1); break;\n        case 'ArrowUp': d.setDate(d.getDate() - 7); break;\n        case 'ArrowDown': d.setDate(d.getDate() + 7); break;\n        case 'Home': d.setDate(d.getDate() - dow); break;\n        case 'End': d.setDate(d.getDate() + 6 - dow); break;\n        case 'PageUp': move(addMonths(focus, e.shiftKey ? -12 : -1)); e.preventDefault(); return;\n        case 'PageDown': move(addMonths(focus, e.shiftKey ? 12 : 1)); e.preventDefault(); return;\n        case 'Enter': case ' ': select(focus); e.preventDefault(); return;\n        default: return;\n      }\n      e.preventDefault();\n      move(d);\n    });\n    input?.addEventListener('change', () => { const d = parse(input.value); if (d && (!value || iso(d) !== iso(value))) { value = d; focus = new Date(d); render(false); } });\n    render(false);\n  });\n});",
  "install": {
    "css": "https://mian.ro/dist/mian.css",
    "js": "https://mian.ro/dist/mian.js",
    "registryItem": "https://mian.ro/r/calendar.json"
  }
}
