{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"month-picker","type":"registry:ui","title":"Month Picker","description":"A month or a year picked in a field, for monthly and yearly reports, billing periods and card expiry.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json","https://dashral.com/r/field.json","https://dashral.com/r/popover.json"],"files":[{"path":"registry/dashral/month-picker.tsx","type":"registry:file","target":"components/dashral/month-picker.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\nimport { Button } from \"./button\"\nimport { PopoverRoot, PopoverContent, PopoverTrigger } from \"./popover\"\n\ntype MonthPickerView = \"month\" | \"year\"\n\ntype MonthPickerProps = FieldOwnProps & {\n  /** What it picks: a month of a year (the default), or a year. */\n  view?: MonthPickerView\n  /** Controlled value: the 1st of the month (or January 1st for a year). Omit it (and use defaultValue) for an uncontrolled picker. */\n  value?: Date\n  defaultValue?: Date\n  /** The 1st of the month picked (or January 1st of the year), or undefined when it's cleared. */\n  onValueChange?: (date: Date | undefined) => void\n  placeholder?: string\n  /** The earliest month (or year) that can be picked. */\n  min?: Date\n  /** The latest month (or year) that can be picked. */\n  max?: Date\n  /** BCP 47 locale for the month names and the label, e.g. \"id-ID\". */\n  locale?: string\n  /** How the button shows the value. A month and its year (\"March 2026\"), or the year, by default. */\n  formatOptions?: Intl.DateTimeFormatOptions\n  /** The whole picker can't be used. */\n  disabled?: boolean\n  required?: boolean\n  id?: string\n  /** The trigger's classes. */\n  className?: string\n  \"aria-label\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  classNames?: FieldClassNames & { trigger?: string; content?: string }\n}\n\n/** A month as a number that orders: 2026 * 12 + 2 for March 2026. */\nconst monthIndex = (date: Date) => date.getFullYear() * 12 + date.getMonth()\n/** Years shown at once in the year grid: the year in view sits in its third row, with the years around it. */\nconst PAGE = 12\nconst pageStart = (year: number) => year - 6\n\n/**\n * A month (or a year) picked in a field, in one tag: `<MonthPicker label=\"Report month\" />`. For monthly and yearly\n * reports, billing periods and card expiry, where a day picker asks for too much. The panel shows a year's months\n * (its year a button that opens the years); arrow keys move through the grid, and the value is the month's 1st.\n */\nfunction MonthPicker(props: MonthPickerProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <MonthPickerControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction MonthPickerControl({\n  view = \"month\",\n  value,\n  defaultValue,\n  onValueChange,\n  placeholder: placeholderProp,\n  min,\n  max,\n  locale: localeProp,\n  formatOptions: formatOptionsProp,\n  classNames,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: Omit<MonthPickerProps, keyof FieldOwnProps | \"required\">) {\n  const { t, locale: contextLocale, dir } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const placeholder = placeholderProp ?? t(view === \"year\" ? \"pickYear\" : \"pickMonth\")\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: rest.id, disabled: rest.disabled, \"aria-invalid\": rest[\"aria-invalid\"] })\n  const [open, setOpen] = React.useState(false)\n  const [internal, setInternal] = React.useState<Date | undefined>(defaultValue)\n  const current = value === undefined && onValueChange === undefined ? internal : value\n  const formatOptions = formatOptionsProp ?? (view === \"year\" ? { year: \"numeric\" } : { month: \"long\", year: \"numeric\" })\n\n  // What the panel shows: the months of a year, or a page of years. It opens on the value's year (or this year).\n  const [panel, setPanel] = React.useState<MonthPickerView>(view)\n  const [year, setYear] = React.useState(() => (current ?? new Date()).getFullYear())\n  const [yearPage, setYearPage] = React.useState(() => pageStart(year))\n\n  const today = new Date()\n  const monthNames = React.useMemo(() => {\n    const format = new Intl.DateTimeFormat(locale, { month: \"short\" })\n    return Array.from({ length: 12 }, (_, month) => format.format(new Date(2000, month, 1)))\n  }, [locale])\n  const longMonth = new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" })\n  const yearFormat = new Intl.DateTimeFormat(locale, { year: \"numeric\" })\n\n  const monthDisabled = (y: number, month: number) => {\n    const index = y * 12 + month\n    return (min !== undefined && index < monthIndex(min)) || (max !== undefined && index > monthIndex(max))\n  }\n  const yearDisabled = (y: number) => (min !== undefined && y < min.getFullYear()) || (max !== undefined && y > max.getFullYear())\n\n  function commit(date: Date) {\n    setInternal(date)\n    onValueChange?.(date)\n    setOpen(false)\n  }\n\n  function openChange(next: boolean) {\n    setOpen(next)\n    if (next) {\n      const start = (current ?? today).getFullYear()\n      setPanel(view)\n      setYear(start)\n      setYearPage(pageStart(start))\n    }\n  }\n\n  // The grid's buttons: arrow keys move between them (left and right swap in right-to-left), Home and End to the ends.\n  const grid = React.useRef<HTMLDivElement>(null)\n  function onGridKey(event: React.KeyboardEvent) {\n    const buttons = [...(grid.current?.querySelectorAll<HTMLButtonElement>(\"button:not(:disabled)\") ?? [])]\n    const at = buttons.indexOf(document.activeElement as HTMLButtonElement)\n    if (at < 0) return\n    const all = [...(grid.current?.querySelectorAll<HTMLButtonElement>(\"button\") ?? [])]\n    const position = all.indexOf(buttons[at]!)\n    const sideways = dir === \"rtl\" ? -1 : 1\n    const step =\n      event.key === \"ArrowRight\" ? sideways : event.key === \"ArrowLeft\" ? -sideways : event.key === \"ArrowDown\" ? 3 : event.key === \"ArrowUp\" ? -3 : 0\n    let target: HTMLButtonElement | undefined\n    if (step) {\n      // Skip a disabled cell on the way; stop at the grid's edge.\n      for (let next = position + step; next >= 0 && next < all.length; next += step) {\n        if (!all[next]!.disabled) {\n          target = all[next]\n          break\n        }\n      }\n    } else if (event.key === \"Home\") target = buttons[0]\n    else if (event.key === \"End\") target = buttons.at(-1)\n    if (!target) return\n    event.preventDefault()\n    target.focus()\n  }\n\n  const cell =\n    \"h-9 rounded-md px-2 text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary\"\n  const Prev = dir === \"rtl\" ? ChevronRightIcon : ChevronLeftIcon\n  const Next = dir === \"rtl\" ? ChevronLeftIcon : ChevronRightIcon\n\n  const header = (label: React.ReactNode, onPrev: () => void, onNext: () => void, prevLabel: string, nextLabel: string, prevOff: boolean, nextOff: boolean) => (\n    <div className=\"flex items-center justify-between gap-2 pb-2\">\n      <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" aria-label={prevLabel} onClick={onPrev} disabled={prevOff}>\n        <Prev aria-hidden />\n      </Button>\n      {label}\n      <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" aria-label={nextLabel} onClick={onNext} disabled={nextOff}>\n        <Next aria-hidden />\n      </Button>\n    </div>\n  )\n\n  const selectedYear = current?.getFullYear()\n  const selectedMonth = current ? monthIndex(current) : undefined\n\n  return (\n    <PopoverRoot open={open} onOpenChange={openChange}>\n      <PopoverTrigger asChild>\n        <Button\n          id={id}\n          variant=\"outline\"\n          disabled={disabled}\n          aria-label={ariaLabel}\n          aria-describedby={aria[\"aria-describedby\"]}\n          aria-invalid={aria[\"aria-invalid\"]}\n          className={cn(\n            \"w-full justify-start font-normal aria-invalid:border-destructive\",\n            // Looks like the other fields (Input, Select), not like a button.\n            \"border-input bg-transparent hover:bg-transparent focus-visible:border-ring focus-visible:ring-ring/15 motion-safe:enabled:not-aria-busy:active:scale-100\",\n            current ? \"hover:text-foreground\" : \"text-muted-foreground hover:text-muted-foreground\",\n            className,\n            classNames?.trigger\n          )}\n        >\n          <CalendarIcon aria-hidden />\n          {current ? new Intl.DateTimeFormat(locale, formatOptions).format(current) : placeholder}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className={cn(\"w-64 p-3\", classNames?.content)} align=\"start\">\n        {panel === \"month\"\n          ? header(\n              <Button type=\"button\" variant=\"ghost\" size=\"sm\" aria-label={`${t(\"chooseYear\")}: ${yearFormat.format(new Date(year, 0, 1))}`} onClick={() => { setYearPage(pageStart(year)); setPanel(\"year\") }}>\n                {yearFormat.format(new Date(year, 0, 1))}\n              </Button>,\n              () => setYear(year - 1),\n              () => setYear(year + 1),\n              t(\"previousYear\"),\n              t(\"nextYear\"),\n              min !== undefined && year <= min.getFullYear(),\n              max !== undefined && year >= max.getFullYear()\n            )\n          : header(\n              <span className=\"text-sm font-medium tabular-nums\">\n                {yearFormat.format(new Date(yearPage, 0, 1))} – {yearFormat.format(new Date(yearPage + PAGE - 1, 0, 1))}\n              </span>,\n              () => setYearPage(yearPage - PAGE),\n              () => setYearPage(yearPage + PAGE),\n              t(\"previousYears\"),\n              t(\"nextYears\"),\n              min !== undefined && yearPage <= min.getFullYear(),\n              max !== undefined && yearPage + PAGE - 1 >= max.getFullYear()\n            )}\n        <div ref={grid} role=\"group\" aria-label={panel === \"month\" ? yearFormat.format(new Date(year, 0, 1)) : t(\"chooseYear\")} onKeyDown={onGridKey} className=\"grid grid-cols-3 gap-1\">\n          {panel === \"month\"\n            ? monthNames.map((name, month) => {\n                const index = year * 12 + month\n                const isNow = index === monthIndex(today)\n                return (\n                  <button\n                    key={month}\n                    type=\"button\"\n                    aria-label={longMonth.format(new Date(year, month, 1))}\n                    aria-pressed={index === selectedMonth}\n                    aria-current={isNow ? \"date\" : undefined}\n                    disabled={monthDisabled(year, month)}\n                    // The month picked, or this month, takes the focus when the panel opens.\n                    autoFocus={index === selectedMonth || (selectedMonth === undefined && isNow)}\n                    onClick={() => commit(new Date(year, month, 1))}\n                    className={cn(cell, isNow && \"font-semibold\")}\n                  >\n                    {name}\n                  </button>\n                )\n              })\n            : Array.from({ length: PAGE }, (_, i) => yearPage + i).map((y) => {\n                const isNow = y === today.getFullYear()\n                return (\n                  <button\n                    key={y}\n                    type=\"button\"\n                    aria-pressed={view === \"year\" ? y === selectedYear : y === year}\n                    aria-current={isNow ? \"date\" : undefined}\n                    disabled={yearDisabled(y)}\n                    autoFocus={y === (view === \"year\" ? (selectedYear ?? today.getFullYear()) : year)}\n                    onClick={() => {\n                      // Picking a year: in a year picker it's the value; in a month picker it opens that year's months.\n                      if (view === \"year\") commit(new Date(y, 0, 1))\n                      else {\n                        setYear(y)\n                        setPanel(\"month\")\n                      }\n                    }}\n                    className={cn(cell, \"tabular-nums\", isNow && \"font-semibold\")}\n                  >\n                    {yearFormat.format(new Date(y, 0, 1))}\n                  </button>\n                )\n              })}\n        </div>\n      </PopoverContent>\n    </PopoverRoot>\n  )\n}\n\nexport { MonthPicker, type MonthPickerProps, type MonthPickerView }\n"}]}