@softize/opus 31.0.0 → 31.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 31.1.0 — 2026-10-22
11
+
12
+ - `DateInput` reúne digitação com máscara brasileira e seleção no calendário. Valores válidos usam datas civis `AAAA-MM-DD`, sem conversão de fuso. `ActionForm` reconhece `widget: 'date'`; entradas incompletas continuam visíveis para validação pelo contrato.
13
+ - `ActionList` e `ActionFilterBar` aceitam `periodControl`, respeitando a posição e a composição dos filtros. Com esse controle, presets do contrato e do estado da lista deixam de sobrescrever o intervalo fornecido pelo consumidor. `CalendarMonthView` oferece uma grade mensal para `ActionList.views`, com agrupamento por data e navegação por teclado; consulta, erro e carregamento permanecem na lista.
14
+ - `Calendar` apresenta mês e ano como `Setembro/2026` por padrão, preservando formatters customizados. A raiz mantém sua identidade entre renderizações para conservar o foco e o primeiro clique de navegação.
15
+ - Dias selecionados e extremidades de intervalos usam o par semântico do botão primário sólido, corrigindo contraste no hover em tema escuro. O comportamento também se aplica aos calendários dos filtros existentes.
16
+
10
17
  ## 31.0.0 — 2026-10-20
11
18
 
12
19
  - `PageBack` aceita `onBack` e `disabled` para retorno controlado por uma pilha. `href` continua disponível para destinos navegáveis; `PageBackProps` é uma união que impede combinar as duas formas.
package/bin/lib/copy.mjs CHANGED
@@ -278,6 +278,7 @@ const JSX_PROP_ROLES = new Map([
278
278
  ]),
279
279
  ],
280
280
  ["Input", new Map([["placeholder", "placeholder"]])],
281
+ ["DateInput", new Map([["placeholder", "placeholder"]])],
281
282
  ["InputGroupInput", new Map([["placeholder", "placeholder"]])],
282
283
  ["InputGroupTextarea", new Map([["placeholder", "placeholder"]])],
283
284
  // A ajuda vive num tooltip (portal): não herda transformação nem classe do label.
@@ -397,6 +398,7 @@ const JSX_PROP_CLASS = new Map([
397
398
  ],
398
399
  ["CommandInput", new Map([["placeholder", "className"]])],
399
400
  ["Input", new Map([["placeholder", "className"]])],
401
+ ["DateInput", new Map([["placeholder", "className"]])],
400
402
  ["InputGroupInput", new Map([["placeholder", "className"]])],
401
403
  ["InputGroupTextarea", new Map([["placeholder", "className"]])],
402
404
  [
@@ -431,6 +433,7 @@ const JSX_PROP_STYLE = new Map([
431
433
  ],
432
434
  ["CommandInput", new Map([["placeholder", "style"]])],
433
435
  ["Input", new Map([["placeholder", "style"]])],
436
+ ["DateInput", new Map([["placeholder", "style"]])],
434
437
  ["InputGroupInput", new Map([["placeholder", "style"]])],
435
438
  ["InputGroupTextarea", new Map([["placeholder", "style"]])],
436
439
  [
@@ -0,0 +1,28 @@
1
+ # ADR 0066 — Datas civis e calendário mensal na lista
2
+
3
+ - **Status:** aceita para consolidação; publicação é uma etapa separada.
4
+ - **Data:** 2026-10-22.
5
+
6
+ ## Contexto
7
+
8
+ A gestão de expediente precisa escolher datas sem conversão de fuso e consultar um mês inteiro junto dos filtros de unidade e equipe. O input nativo varia entre navegadores, e uma grade implementada em cada consumidor repetiria navegação e acessibilidade. A experimentação no consumidor Grand Brasil validou a composição antes da consolidação desta API.
9
+
10
+ ## Decisão
11
+
12
+ `DateInput` compõe Input, Popover e Calendar. Aceita digitação com máscara `DD/MM/AAAA`, colagem ISO e seleção por calendário; datas válidas são emitidas como `AAAA-MM-DD`. Entradas incompletas ou impossíveis permanecem visíveis e são emitidas sem convertê-las numa data válida, para o contrato poder rejeitá-las. O campo recebe foco; o ícone é um adorno. Clique abre o picker sem impedir digitação, Alt+Seta para baixo transfere o foco, e Escape fecha somente o popover. O renderer de `ActionForm` reconhece `widget: 'date'`, preservando controle, reset, validação e acessibilidade do formulário.
13
+
14
+ `ActionList` e `ActionFilterBar` recebem `periodControl?: ReactNode`. A extensão substitui o seletor padrão no mesmo espaço, respeitando `periodPosition` e a composição com `period.basisFilter`. O consumidor controla o valor temporal e seu estado navegável. Com periodControl, ActionList ignora o preset padrão e os valores period/from/to do estado; somente o input fornecido pelo consumidor define o intervalo. O helper listStateToInput aceita applyPeriod: false para reproduzir esse recorte em detalhes roteados. Sem a propriedade, a API anterior mantém seu comportamento.
15
+
16
+ `CalendarMonthView<TItem>` é uma visualização, consumida por `ActionList.views`. Recebe mês civil, itens, extração de data, conteúdo, nome acessível e ação por dia. Gera todas as datas do mês e agrupa os itens. A navegação usa setas, Home e End, com uma única parada de Tab. Consulta, filtros, loading, erro e vazio continuam pertencendo à lista. Regras de expediente e feriados ficam no domínio consumidor. A grade é separada do DayPicker por representar uma coleção com conteúdo e ação próprios em cada dia, sem modelo de seleção de datas. Adaptar os botões e o estado de seleção do DayPicker duplicaria essa semântica; a exceção preserva teclado e roles de grid sem copiar a primitiva. A primeira versão usa cabeçalhos em pt-BR e semanas iniciadas no domingo, explicitados na documentação.
17
+
18
+ O Calendar compartilhado apresenta o mês com inicial maiúscula e barra antes do ano, preservando os formatters customizados. A raiz estável evita perder foco ao renderizar novamente. Dias selecionados usam o par de cores do botão primário sólido, incluindo as extremidades de intervalos.
19
+
20
+ ## Alternativas e consequências
21
+
22
+ O input nativo foi experimentado e descartado pela inconsistência com os outros campos. Converter datas civis para timestamps criaria dependência de fuso sem necessidade. Copiar a barra de filtros ou inserir um calendário com consulta própria duplicaria responsabilidades já cobertas por ActionList.
23
+
24
+ As extensões são aditivas. A mudança de legenda e de contraste alcança todos os consumidores do Calendar, portanto inclui regressão do modo de intervalo e dos formatters. Mês inválido em CalendarMonthView é erro de programação; consumidores validam sua entrada antes de renderizar.
25
+
26
+ ## Verificação
27
+
28
+ Testes de DateInput cobrem máscara, cursor, teclado, datas inválidas, reset e integração com ActionForm. Testes da lista cobrem posicionamento do período e grade com navegação. Testes de Calendar cobrem identidade da raiz, formatter e seleção. Os gates da release incluem tipagem, suíte completa, Chromium e instalação de um consumidor a partir do tarball.
@@ -24,3 +24,5 @@ estar documentado na superfície correspondente, sem exigir a reconstrução des
24
24
  - [ADR 0064 — ActionList apresenta itens e conecta fontes externas](0064-action-list-com-itens-e-fontes-externas.md).
25
25
 
26
26
  - [ADR 0065 — Presentation compõe seções declaradas](0065-presentation-composes-settings-sections.md).
27
+
28
+ - [ADR 0066 — Datas civis e calendário mensal na lista](0066-civil-date-input-and-calendar-list.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "31.0.0",
3
+ "version": "31.1.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/core/types.ts CHANGED
@@ -598,6 +598,7 @@ export type OptionsSpec =
598
598
 
599
599
  /** Overrides declarativos nativos reconhecidos pelo ActionForm. */
600
600
  export type BuiltInFieldWidget =
601
+ | 'date'
601
602
  | 'password'
602
603
  | 'textarea'
603
604
  | 'code'
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createContext, useContext, useEffect, useId } from "react";
21
- import type { UseFormReturn } from "react-hook-form";
21
+ import { Controller, type UseFormReturn } from "react-hook-form";
22
22
  import {
23
23
  getLogicalType,
24
24
  type FieldWidget,
@@ -38,6 +38,7 @@ import { toast } from "../primitives/sonner.tsx";
38
38
  import { Button, type ButtonVariant } from "../primitives/button.tsx";
39
39
  import { ButtonGroup } from "../primitives/button-group.tsx";
40
40
  import { Input } from "../primitives/input.tsx";
41
+ import { DateInput } from "../primitives/date-input.tsx";
41
42
  import { Textarea } from "../primitives/textarea.tsx";
42
43
  import { Checkbox } from "../primitives/checkbox.tsx";
43
44
  import { IconPicker } from "../primitives/icon-picker.tsx";
@@ -680,6 +681,24 @@ export function ActionFormField({
680
681
  aria-describedby={describedBy}
681
682
  {...form.register(name)}
682
683
  />
684
+ ) : spec.widget === "date" ? (
685
+ <Controller
686
+ name={name}
687
+ control={form.control}
688
+ render={({ field }) => (
689
+ <DateInput
690
+ id={name}
691
+ name={field.name}
692
+ ref={field.ref}
693
+ value={(field.value as string | undefined) ?? ""}
694
+ onValueChange={field.onChange}
695
+ onBlur={field.onBlur}
696
+ placeholder={spec.placeholder}
697
+ aria-invalid={errorMessage !== undefined}
698
+ aria-describedby={describedBy}
699
+ />
700
+ )}
701
+ />
683
702
  ) : (
684
703
  <Input
685
704
  id={name}
@@ -0,0 +1,129 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/cn.ts";
3
+
4
+ export interface CalendarMonthViewProps<TItem> {
5
+ /** Mês civil no formato YYYY-MM. */
6
+ month: string;
7
+ items: readonly TItem[];
8
+ dateOf: (item: TItem) => string;
9
+ renderDay: (date: string, items: readonly TItem[]) => React.ReactNode;
10
+ dayLabel: (date: string, items: readonly TItem[]) => string;
11
+ onDayClick: (date: string, items: readonly TItem[]) => void;
12
+ label: string;
13
+ className?: string;
14
+ }
15
+
16
+ /** Grade mensal para ActionList.views. Consulta e estados continuam pertencendo à lista. */
17
+ export function CalendarMonthView<TItem>({
18
+ month,
19
+ items,
20
+ dateOf,
21
+ renderDay,
22
+ dayLabel,
23
+ onDayClick,
24
+ label,
25
+ className,
26
+ }: CalendarMonthViewProps<TItem>) {
27
+ const root = React.useRef<HTMLDivElement>(null);
28
+ const [focused, setFocused] = React.useState(`${month}-01`);
29
+ if (!/^[1-9]\d{3}-(?:0[1-9]|1[0-2])$/.test(month))
30
+ throw new RangeError("CalendarMonthView requires YYYY-MM");
31
+ const first = new Date(`${month}-01T00:00:00Z`);
32
+ const last = new Date(first);
33
+ last.setUTCMonth(last.getUTCMonth() + 1, 0);
34
+ const length = last.getUTCDate();
35
+ const offset = first.getUTCDay();
36
+ const rows = Math.ceil((offset + length) / 7);
37
+ const focusDate = focused.startsWith(`${month}-`) ? focused : `${month}-01`;
38
+ const grouped = new Map<string, TItem[]>();
39
+ for (const item of items) {
40
+ const date = dateOf(item);
41
+ const group = grouped.get(date) ?? [];
42
+ group.push(item);
43
+ grouped.set(date, group);
44
+ }
45
+ function move(event: React.KeyboardEvent, day: number) {
46
+ const weekday = (offset + day - 1) % 7;
47
+ const delta = {
48
+ ArrowLeft: -1,
49
+ ArrowRight: 1,
50
+ ArrowUp: -7,
51
+ ArrowDown: 7,
52
+ Home: -weekday,
53
+ End: 6 - weekday,
54
+ }[event.key];
55
+ if (delta === undefined) return;
56
+ event.preventDefault();
57
+ const next = Math.max(1, Math.min(length, day + delta));
58
+ const date = `${month}-${String(next).padStart(2, "0")}`;
59
+ setFocused(date);
60
+ root.current
61
+ ?.querySelector<HTMLButtonElement>(`[data-calendar-date="${date}"]`)
62
+ ?.focus();
63
+ }
64
+ return (
65
+ <div className={cn("overflow-x-auto", className)}>
66
+ <div
67
+ ref={root}
68
+ data-slot="calendar-month-view"
69
+ role="grid"
70
+ aria-label={label}
71
+ className="min-w-[35rem] overflow-hidden rounded-lg border"
72
+ >
73
+ <div role="row" className="grid grid-cols-7 border-b">
74
+ {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
75
+ <div
76
+ role="columnheader"
77
+ key={day}
78
+ className="p-2 text-center text-sm text-muted-foreground"
79
+ >
80
+ {day}
81
+ </div>
82
+ ))}
83
+ </div>
84
+ {Array.from({ length: rows }, (_, row) => (
85
+ <div
86
+ role="row"
87
+ key={row}
88
+ className="grid grid-cols-7 not-last:border-b"
89
+ >
90
+ {Array.from({ length: 7 }, (_, column) => {
91
+ const day = row * 7 + column - offset + 1;
92
+ if (day < 1 || day > length)
93
+ return (
94
+ <div
95
+ role="gridcell"
96
+ key={column}
97
+ className="bg-muted/20 not-last:border-r"
98
+ />
99
+ );
100
+ const date = `${month}-${String(day).padStart(2, "0")}`;
101
+ const entries = grouped.get(date) ?? [];
102
+ return (
103
+ <div
104
+ role="gridcell"
105
+ key={column}
106
+ className="min-w-0 not-last:border-r"
107
+ >
108
+ <button
109
+ type="button"
110
+ data-calendar-date={date}
111
+ tabIndex={date === focusDate ? 0 : -1}
112
+ aria-label={dayLabel(date, entries)}
113
+ onFocus={() => setFocused(date)}
114
+ onKeyDown={(event) => move(event, day)}
115
+ onClick={() => onDayClick(date, entries)}
116
+ className="flex min-h-28 w-full flex-col gap-2 p-3 text-left text-sm text-foreground hover:bg-muted/40 focus-visible:relative focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2"
117
+ >
118
+ <span className="font-medium">{day}</span>
119
+ {renderDay(date, entries)}
120
+ </button>
121
+ </div>
122
+ );
123
+ })}
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -315,6 +315,8 @@ export interface ActionFilterBarProps {
315
315
  refreshing?: boolean;
316
316
  /** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
317
317
  periodPosition?: "before" | "after" | { afterFilter: string };
318
+ /** Substitui o seletor de período; estado e input temporal ficam com o consumidor. */
319
+ periodControl?: ReactNode;
318
320
  /** Composição do período com uma dimensão temporal declarada como filtro inline. */
319
321
  period?: PeriodLayout;
320
322
  /** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
@@ -416,6 +418,8 @@ interface ActionListBaseProps<TInput, TItem> {
416
418
  advancedFilters?: AdvancedFiltersLayout;
417
419
  /** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
418
420
  periodPosition?: "before" | "after" | { afterFilter: string };
421
+ /** Substitui o seletor de período; estado e input temporal ficam com o consumidor. */
422
+ periodControl?: ReactNode;
419
423
  /** Composição do período com uma dimensão temporal declarada como filtro inline. */
420
424
  period?: PeriodLayout;
421
425
  /** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
@@ -1414,6 +1418,7 @@ export function ActionFilterBar({
1414
1418
  onRefresh,
1415
1419
  refreshing = false,
1416
1420
  periodPosition = "before",
1421
+ periodControl: customPeriodControl,
1417
1422
  period,
1418
1423
  controls,
1419
1424
  actions,
@@ -1433,8 +1438,9 @@ export function ActionFilterBar({
1433
1438
  periods[0]?.value ??
1434
1439
  null;
1435
1440
  const activePeriod = state.period ?? defaultPeriod;
1441
+ const hasPeriod = customPeriodControl != null || periods.length > 0;
1436
1442
  const periodBasisEntry =
1437
- periods.length > 0 && period !== undefined
1443
+ hasPeriod && period !== undefined
1438
1444
  ? inlineSpecs.find(([name]) => name === period.basisFilter)
1439
1445
  : undefined;
1440
1446
  const [advancedOpen, setAdvancedOpen] = useState(false);
@@ -1484,18 +1490,18 @@ export function ActionFilterBar({
1484
1490
  });
1485
1491
  const inlineControlCount =
1486
1492
  inlineSpecs.filter(([name]) => name !== periodBasisEntry?.[0]).length +
1487
- (periods.length > 0 ? 1 : 0) +
1493
+ (hasPeriod ? 1 : 0) +
1488
1494
  (hasSearch ? 1 : 0);
1489
1495
  const showInlineLabels = inlineControlCount > 1;
1490
- const searchAtActionsEnd =
1491
- hasSearch && (inlineSpecs.length > 0 || periods.length > 0);
1496
+ const searchAtActionsEnd = hasSearch && (inlineSpecs.length > 0 || hasPeriod);
1492
1497
  const periodAfterFilter =
1493
1498
  typeof periodPosition === "object" ? periodPosition.afterFilter : null;
1494
1499
  const anchoredPeriodExists = inlineSpecs.some(
1495
1500
  ([name]) => name === periodAfterFilter,
1496
1501
  );
1497
1502
  const periodField =
1498
- periods.length > 0 ? (
1503
+ customPeriodControl ??
1504
+ (periods.length > 0 ? (
1499
1505
  <PeriodControl
1500
1506
  periods={periods}
1501
1507
  period={activePeriod}
@@ -1518,7 +1524,7 @@ export function ActionFilterBar({
1518
1524
  })
1519
1525
  }
1520
1526
  />
1521
- ) : null;
1527
+ ) : null);
1522
1528
  const periodControl =
1523
1529
  periodField === null || periodBasisEntry !== undefined ? null : (
1524
1530
  <Labeled
@@ -1585,24 +1591,26 @@ export function ActionFilterBar({
1585
1591
  aria-hidden="true"
1586
1592
  />
1587
1593
  <div className="pl-2">
1588
- <PeriodControl
1589
- periods={periods}
1590
- period={activePeriod}
1591
- from={state.from}
1592
- to={state.to}
1593
- clearable={false}
1594
- embedded
1595
- onChange={(next) =>
1596
- onStateChange({
1597
- ...state,
1598
- ...next,
1599
- period:
1600
- next.period === defaultPeriod
1601
- ? null
1602
- : next.period,
1603
- })
1604
- }
1605
- />
1594
+ {customPeriodControl ?? (
1595
+ <PeriodControl
1596
+ periods={periods}
1597
+ period={activePeriod}
1598
+ from={state.from}
1599
+ to={state.to}
1600
+ clearable={false}
1601
+ embedded
1602
+ onChange={(next) =>
1603
+ onStateChange({
1604
+ ...state,
1605
+ ...next,
1606
+ period:
1607
+ next.period === defaultPeriod
1608
+ ? null
1609
+ : next.period,
1610
+ })
1611
+ }
1612
+ />
1613
+ )}
1606
1614
  </div>
1607
1615
  <Separator
1608
1616
  data-slot="action-filter-period-separator"
@@ -1831,6 +1839,7 @@ export function ActionList<TInput, TItem>({
1831
1839
  filterIcons,
1832
1840
  advancedFilters,
1833
1841
  periodPosition,
1842
+ periodControl,
1834
1843
  period,
1835
1844
  views,
1836
1845
  batch,
@@ -1966,6 +1975,7 @@ export function ActionList<TInput, TItem>({
1966
1975
  hasSearch ||
1967
1976
  filterSpecs.length > 0 ||
1968
1977
  periods.length > 0 ||
1978
+ periodControl != null ||
1969
1979
  hasSegment ||
1970
1980
  viewControls != null ||
1971
1981
  (title === undefined && toolbarActions != null) ||
@@ -1977,8 +1987,9 @@ export function ActionList<TInput, TItem>({
1977
1987
  input: input as Record<string, unknown>,
1978
1988
  filterDefaults,
1979
1989
  pageSize,
1990
+ applyPeriod: periodControl == null,
1980
1991
  }) as TInput,
1981
- [input, state, pageSize, filterDefaults, action],
1992
+ [input, state, pageSize, filterDefaults, action, periodControl],
1982
1993
  );
1983
1994
 
1984
1995
  const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
@@ -2262,6 +2273,7 @@ export function ActionList<TInput, TItem>({
2262
2273
  filterIcons={filterIcons}
2263
2274
  advancedFilters={advancedFilters}
2264
2275
  periodPosition={periodPosition}
2276
+ periodControl={periodControl}
2265
2277
  period={period}
2266
2278
  controls={
2267
2279
  <>
@@ -3283,10 +3295,13 @@ export function listStateToInput(
3283
3295
  input = {},
3284
3296
  filterDefaults,
3285
3297
  pageSize = 50,
3298
+ applyPeriod = true,
3286
3299
  }: {
3287
3300
  input?: Record<string, unknown>;
3288
3301
  filterDefaults?: Record<string, string>;
3289
3302
  pageSize?: number;
3303
+ /** False quando o consumidor fornece integralmente o intervalo temporal em input. */
3304
+ applyPeriod?: boolean;
3290
3305
  } = {},
3291
3306
  ): Record<string, unknown> {
3292
3307
  const sort = state.sort ?? action.sort?.default?.[0] ?? null;
@@ -3306,11 +3321,12 @@ export function listStateToInput(
3306
3321
  }
3307
3322
  // Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
3308
3323
  // range. state.period null = o default do contrato (sempre aplicado).
3309
- const effectivePeriod =
3310
- state.period ??
3311
- action.periods?.find((p) => p.default === true)?.value ??
3312
- action.periods?.[0]?.value ??
3313
- null;
3324
+ const effectivePeriod = applyPeriod
3325
+ ? (state.period ??
3326
+ action.periods?.find((p) => p.default === true)?.value ??
3327
+ action.periods?.[0]?.value ??
3328
+ null)
3329
+ : null;
3314
3330
  let range: { from: string; to: string } | null = null;
3315
3331
  if (
3316
3332
  effectivePeriod === "custom" &&
@@ -7,8 +7,10 @@ import {
7
7
  import { ptBR } from "date-fns/locale";
8
8
  import {
9
9
  DayPicker,
10
+ DateLib,
10
11
  getDefaultClassNames,
11
12
  type DayButton,
13
+ type RootProps,
12
14
  } from "react-day-picker";
13
15
 
14
16
  import { cn } from "../../lib/cn.ts";
@@ -16,7 +18,9 @@ import { Button, buttonVariants } from "./button.tsx";
16
18
 
17
19
  /** Rótulos acessíveis da navegação em pt-BR — o react-day-picker só traduz nomes de mês e
18
20
  * dia pelo `locale`; os aria-labels dos controles continuariam em inglês. */
19
- const LABELS_PT_BR: NonNullable<React.ComponentProps<typeof DayPicker>["labels"]> = {
21
+ const LABELS_PT_BR: NonNullable<
22
+ React.ComponentProps<typeof DayPicker>["labels"]
23
+ > = {
20
24
  labelPrevious: () => "Mês anterior",
21
25
  labelNext: () => "Próximo mês",
22
26
  labelMonthDropdown: () => "Escolher o mês",
@@ -34,6 +38,17 @@ const LABELS_PT_BR: NonNullable<React.ComponentProps<typeof DayPicker>["labels"]
34
38
  },
35
39
  };
36
40
 
41
+ function CalendarRoot({ className, rootRef, ...props }: RootProps) {
42
+ return (
43
+ <div
44
+ data-slot="calendar"
45
+ ref={rootRef}
46
+ className={cn(className)}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
37
52
  function Calendar({
38
53
  className,
39
54
  classNames,
@@ -63,17 +78,27 @@ function Calendar({
63
78
  )}
64
79
  captionLayout={captionLayout}
65
80
  formatters={{
81
+ formatCaption: (date, options, dateLib) => {
82
+ const caption = (
83
+ dateLib ??
84
+ new DateLib({
85
+ ...options,
86
+ locale: { ...ptBR, ...locale, ...options?.locale },
87
+ })
88
+ ).format(date, "LLLL/yyyy");
89
+ return (
90
+ caption.charAt(0).toLocaleUpperCase(locale.code) + caption.slice(1)
91
+ );
92
+ },
66
93
  // O dateLib já carrega o `locale` em vigor: o dropdown fala a mesma língua da legenda.
67
94
  formatMonthDropdown: (date, dateLib) =>
68
- dateLib?.format(date, "LLL") ?? date.toLocaleString("pt-BR", { month: "short" }),
95
+ dateLib?.format(date, "LLL") ??
96
+ date.toLocaleString("pt-BR", { month: "short" }),
69
97
  ...formatters,
70
98
  }}
71
99
  classNames={{
72
100
  root: cn("w-fit", defaultClassNames.root),
73
- months: cn(
74
- "relative flex flex-row gap-4",
75
- defaultClassNames.months,
76
- ),
101
+ months: cn("relative flex flex-row gap-4", defaultClassNames.months),
77
102
  month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
78
103
  nav: cn(
79
104
  "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
@@ -156,16 +181,7 @@ function Calendar({
156
181
  ...classNames,
157
182
  }}
158
183
  components={{
159
- Root: ({ className, rootRef, ...props }) => {
160
- return (
161
- <div
162
- data-slot="calendar"
163
- ref={rootRef}
164
- className={cn(className)}
165
- {...props}
166
- />
167
- );
168
- },
184
+ Root: CalendarRoot,
169
185
  Chevron: ({ className, orientation, ...props }) => {
170
186
  if (orientation === "left") {
171
187
  return (
@@ -219,7 +235,12 @@ function CalendarDayButton({
219
235
  return (
220
236
  <Button
221
237
  ref={ref}
222
- variant="ghost"
238
+ context={
239
+ modifiers.selected && !modifiers.range_middle ? "primary" : "neutral"
240
+ }
241
+ variant={
242
+ modifiers.selected && !modifiers.range_middle ? "solid" : "ghost"
243
+ }
223
244
  size="icon"
224
245
  data-day={day.date.toLocaleDateString()}
225
246
  data-selected-single={
@@ -232,7 +253,7 @@ function CalendarDayButton({
232
253
  data-range-end={modifiers.range_end}
233
254
  data-range-middle={modifiers.range_middle}
234
255
  className={cn(
235
- "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[0.1875rem] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
256
+ "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[0.1875rem] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
236
257
  defaultClassNames.day,
237
258
  className,
238
259
  )}
@@ -0,0 +1,276 @@
1
+ import * as React from "react";
2
+ import { CalendarDays } from "lucide-react";
3
+ import { Input, type InputProps } from "./input.tsx";
4
+ import { Calendar } from "./calendar.tsx";
5
+ import { Popover, PopoverAnchor, PopoverContent } from "./popover.tsx";
6
+
7
+ export interface DateInputProps extends Omit<
8
+ InputProps,
9
+ | "type"
10
+ | "value"
11
+ | "defaultValue"
12
+ | "onChange"
13
+ | "icon"
14
+ | "trailing"
15
+ | "min"
16
+ | "max"
17
+ | "step"
18
+ > {
19
+ /** Data civil ISO; durante a digitação, pode conter texto ainda inválido. */
20
+ value?: string;
21
+ defaultValue?: string;
22
+ onValueChange?: (value: string) => void;
23
+ }
24
+
25
+ function parseDate(value: string): Date | undefined {
26
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
27
+ if (!parts) return undefined;
28
+ const [, year, month, day] = parts.map(Number);
29
+ if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31)
30
+ return undefined;
31
+ const date = new Date(0);
32
+ date.setHours(0, 0, 0, 0);
33
+ date.setFullYear(year, month - 1, day);
34
+ return date.getFullYear() === year &&
35
+ date.getMonth() === month - 1 &&
36
+ date.getDate() === day
37
+ ? date
38
+ : undefined;
39
+ }
40
+
41
+ function serializeDate(date: Date): string {
42
+ return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
43
+ }
44
+
45
+ function valueFromText(text: string): string {
46
+ if (parseDate(text)) return text;
47
+ const parts =
48
+ /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text) ??
49
+ /^(\d{2})(\d{2})(\d{4})$/.exec(text);
50
+ if (!parts) return text;
51
+ const candidate = `${parts[3]}-${parts[2]}-${parts[1]}`;
52
+ return parseDate(candidate) ? candidate : text;
53
+ }
54
+
55
+ function maskDate(text: string): string {
56
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
57
+ const digits = (
58
+ iso ? `${iso[3]}${iso[2]}${iso[1]}` : text.replace(/\D/g, "")
59
+ ).slice(0, 8);
60
+ return (
61
+ digits.slice(0, 2) +
62
+ (digits.length >= 2 ? "/" : "") +
63
+ digits.slice(2, 4) +
64
+ (digits.length >= 4 ? "/" : "") +
65
+ digits.slice(4)
66
+ );
67
+ }
68
+
69
+ function positionAfterDigits(count: number): number {
70
+ return count + (count >= 2 ? 1 : 0) + (count >= 4 ? 1 : 0);
71
+ }
72
+
73
+ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
74
+ function DateInput(
75
+ {
76
+ value,
77
+ defaultValue = "",
78
+ onValueChange,
79
+ disabled,
80
+ readOnly,
81
+ name,
82
+ form,
83
+ placeholder = "DD/MM/AAAA",
84
+ onKeyDown,
85
+ ...props
86
+ },
87
+ ref,
88
+ ) {
89
+ const inputRef = React.useRef<HTMLInputElement>(null);
90
+ const calendarId = React.useId();
91
+ const fieldRef = React.useRef<HTMLDivElement>(null);
92
+ const restoreInputFocus = React.useRef(false);
93
+ React.useImperativeHandle(ref, () => inputRef.current!);
94
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
95
+ const currentValue = value ?? internalValue;
96
+ const selected = parseDate(currentValue);
97
+ const text = selected
98
+ ? currentValue.split("-").reverse().join("/")
99
+ : currentValue;
100
+ const [open, setOpen] = React.useState(false);
101
+ const openRef = React.useRef(open);
102
+ openRef.current = open;
103
+ const [focusCalendar, setFocusCalendar] = React.useState(false);
104
+ const [calendarSession, setCalendarSession] = React.useState(0);
105
+ const [caret, setCaret] = React.useState<{ position: number }>();
106
+ const [month, setMonth] = React.useState(() => selected ?? new Date());
107
+
108
+ React.useLayoutEffect(() => {
109
+ const input = inputRef.current;
110
+ if (caret && input && document.activeElement === input) {
111
+ input.setSelectionRange(caret.position, caret.position);
112
+ }
113
+ }, [caret]);
114
+
115
+ React.useEffect(() => {
116
+ const ownerForm = inputRef.current?.form;
117
+ if (value !== undefined || !ownerForm) return;
118
+ const reset = () => setInternalValue(defaultValue);
119
+ ownerForm.addEventListener("reset", reset);
120
+ return () => ownerForm.removeEventListener("reset", reset);
121
+ }, [value, defaultValue, form]);
122
+
123
+ const change = (next: string) => {
124
+ if (value === undefined) setInternalValue(next);
125
+ onValueChange?.(next);
126
+ };
127
+ const changeOpen = (next: boolean) => {
128
+ if (
129
+ next &&
130
+ (disabled || readOnly || inputRef.current?.matches(":disabled"))
131
+ )
132
+ return;
133
+ if (next) {
134
+ setMonth(selected ?? new Date());
135
+ if (!open) setCalendarSession((session) => session + 1);
136
+ }
137
+ setOpen(next);
138
+ };
139
+
140
+ return (
141
+ <Popover open={open && !disabled && !readOnly} onOpenChange={changeOpen}>
142
+ {name && (
143
+ <input
144
+ type="hidden"
145
+ name={name}
146
+ form={form}
147
+ value={currentValue}
148
+ disabled={disabled}
149
+ />
150
+ )}
151
+ <PopoverAnchor asChild>
152
+ <div
153
+ ref={fieldRef}
154
+ onClick={(event) => {
155
+ if (event.defaultPrevented) return;
156
+ if (
157
+ disabled ||
158
+ readOnly ||
159
+ inputRef.current?.matches(":disabled")
160
+ )
161
+ return;
162
+ inputRef.current?.focus();
163
+ setFocusCalendar(false);
164
+ changeOpen(true);
165
+ }}
166
+ >
167
+ <Input
168
+ {...props}
169
+ ref={inputRef}
170
+ form={form}
171
+ type="text"
172
+ inputMode="numeric"
173
+ value={text}
174
+ placeholder={placeholder}
175
+ disabled={disabled}
176
+ readOnly={readOnly}
177
+ role="combobox"
178
+ aria-expanded={open}
179
+ aria-haspopup="dialog"
180
+ aria-controls={open ? calendarId : undefined}
181
+ aria-keyshortcuts="Alt+ArrowDown"
182
+ onChange={(event) => {
183
+ const input = event.target;
184
+ let raw = input.value;
185
+ let cursor = input.selectionStart ?? raw.length;
186
+ const inputType = (event.nativeEvent as InputEvent).inputType;
187
+ // A barra pertence à máscara: apagar junto dela também remove o dígito adjacente,
188
+ // inclusive em teclados virtuais que não emitem keydown de Backspace ou Delete.
189
+ if (
190
+ raw.length === text.length - 1 &&
191
+ raw.replace(/\D/g, "") === text.replace(/\D/g, "")
192
+ ) {
193
+ if (inputType === "deleteContentBackward" && cursor > 0) {
194
+ raw = raw.slice(0, cursor - 1) + raw.slice(cursor);
195
+ cursor -= 1;
196
+ } else if (inputType === "deleteContentForward") {
197
+ raw = raw.slice(0, cursor) + raw.slice(cursor + 1);
198
+ }
199
+ }
200
+ const masked = maskDate(raw);
201
+ const digitCount = raw
202
+ .slice(0, cursor)
203
+ .replace(/\D/g, "").length;
204
+ setCaret({
205
+ position: Math.min(
206
+ masked.length,
207
+ positionAfterDigits(digitCount),
208
+ ),
209
+ });
210
+ change(valueFromText(masked));
211
+ }}
212
+ onKeyDown={(event) => {
213
+ onKeyDown?.(event);
214
+ if (event.defaultPrevented) return;
215
+ if (event.altKey && event.key === "ArrowDown") {
216
+ event.preventDefault();
217
+ setFocusCalendar(true);
218
+ changeOpen(true);
219
+ }
220
+ }}
221
+ trailing={<CalendarDays aria-hidden="true" className="size-4" />}
222
+ />
223
+ </div>
224
+ </PopoverAnchor>
225
+ <PopoverContent
226
+ id={calendarId}
227
+ className="w-auto p-0"
228
+ align="start"
229
+ aria-label="Escolher data"
230
+ onOpenAutoFocus={(event) => {
231
+ event.preventDefault();
232
+ if (focusCalendar) {
233
+ (event.target as HTMLElement)
234
+ .querySelector<HTMLButtonElement>('[data-day][tabindex="0"]')
235
+ ?.focus();
236
+ }
237
+ }}
238
+ onInteractOutside={(event) => {
239
+ if (fieldRef.current?.contains(event.target as Node))
240
+ event.preventDefault();
241
+ }}
242
+ onEscapeKeyDown={() => {
243
+ restoreInputFocus.current = true;
244
+ }}
245
+ onCloseAutoFocus={(event) => {
246
+ event.preventDefault();
247
+ // O fechamento anterior pode terminar depois de uma reabertura rápida.
248
+ if (restoreInputFocus.current && !openRef.current) {
249
+ inputRef.current?.focus();
250
+ }
251
+ restoreInputFocus.current = false;
252
+ }}
253
+ >
254
+ <Calendar
255
+ // DayPicker aplica autoFocus apenas na montagem, inclusive em reaberturas durante a animação.
256
+ key={`${calendarSession}-${focusCalendar}`}
257
+ mode="single"
258
+ required
259
+ autoFocus={focusCalendar}
260
+ startMonth={parseDate("0001-01-01")}
261
+ endMonth={parseDate("9999-12-01")}
262
+ month={month}
263
+ onMonthChange={setMonth}
264
+ selected={selected}
265
+ onSelect={(date) => {
266
+ if (inputRef.current?.matches(":disabled")) return;
267
+ change(serializeDate(date));
268
+ restoreInputFocus.current = true;
269
+ setOpen(false);
270
+ }}
271
+ />
272
+ </PopoverContent>
273
+ </Popover>
274
+ );
275
+ },
276
+ );
@@ -122,7 +122,11 @@ referência; itens estáticos permanecem como declarados no contrato.
122
122
 
123
123
  ## Widgets declarativos
124
124
 
125
- `FieldSpec.widget` aceita `password`, `textarea`, `code`, `lines`, `refItems`, `icon` e `toggle-group`.
125
+ `FieldSpec.widget` aceita `date`, `password`, `textarea`, `code`, `lines`, `refItems`, `icon` e `toggle-group`.
126
+ Para uma data civil, declare `widget: 'date'` e use uma string `YYYY-MM-DD` no schema e no
127
+ valor inicial. O formulário usa o [`DateInput`](/ui/date-input), com digitação em `DD/MM/AAAA`
128
+ e calendário em pt-BR. O valor válido enviado continua sendo a data civil, sem conversão para UTC.
129
+ Texto incompleto ou inválido chega ao schema para validação; use um schema de data civil.
126
130
  `password` usa um input de senha com `autocomplete="new-password"`, adequado a cadastro e redefinição.
127
131
  O literal canônico do grupo de escolhas é sempre kebab-case: `widget: 'toggle-group'`. Em enum ele
128
132
  renderiza seleção única; em array de enum, seleção múltipla. As opções seguem a mesma precedência e
@@ -568,6 +568,26 @@ render(
568
568
  );
569
569
  ```
570
570
 
571
+ ## Período controlado pelo consumidor
572
+
573
+ Use `periodControl` quando a coleção precisar de uma navegação temporal própria, como um mês de expediente. O controle ocupa o mesmo espaço do seletor padrão e respeita `periodPosition`, inclusive a composição com `period.basisFilter`.
574
+
575
+ Com uma action que recebe `month`, mantenha o valor no consumidor e envie-o no input:
576
+
577
+ ```tsx
578
+ const [month, setMonth] = useState("2026-11")
579
+
580
+ <ActionList
581
+ action={deliveryListContract}
582
+ input={{ month }}
583
+ periodControl={<Input type="month" value={month} onChange={(event) => setMonth(event.target.value)} />}
584
+ />
585
+ ```
586
+
587
+ Quando `periodControl` está presente, ActionList ignora os presets de `action.periods` e os campos `period`, `from` e `to` do estado da lista, inclusive os recuperados da URL. O intervalo vem do `input` do consumidor; não há filtro temporal oculto. Sem o controle personalizado, o comportamento padrão permanece. Ao hidratar detalhes pelo helper `listStateToInput`, passe `applyPeriod: false` para manter o mesmo recorte.
588
+
589
+ Para distribuir os resultados pelos dias, componha a extensão `views` com [CalendarMonthView](/ui/calendar-month-view). A lista continua responsável pela consulta e pelos estados assíncronos.
590
+
571
591
  ## Propriedades de ActionFilterBar
572
592
 
573
593
  | Propriedade | Tipo | Padrão | Descrição |
@@ -580,6 +600,7 @@ render(
580
600
  | `filterIcons` | `Record<string, ReactNode>` | | Associa um ícone leading ao controle de cada filtro. |
581
601
  | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento das seções; o Opus deriva a largura correspondente. |
582
602
  | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
603
+ | `periodControl` | `ReactNode` | — | Controle temporal do consumidor. Substitui o seletor padrão; em ActionList, também desativa a aplicação automática dos presets ao input. |
583
604
  | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
584
605
  | `onRefresh` | `() => Promise<void> \| void` | | Exibe a ação de recarregar e executa a consulta do consumidor. |
585
606
  | `refreshing` | `boolean` | `false` | Desabilita e anima a ação de recarregar durante a consulta. |
@@ -636,6 +657,7 @@ para fora de `toolbarActions`.
636
657
  | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
637
658
  | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
638
659
  | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
660
+ | `periodControl` | `ReactNode` | — | Controle temporal do consumidor. Substitui o seletor padrão; em ActionList, também desativa a aplicação automática dos presets ao input. |
639
661
  | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
640
662
  | `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
641
663
  | `beforeContent` | `ReactNode \| (input: TInput) => ReactNode` | | Conteúdo entre a toolbar e o corpo; a função recebe o mesmo input efetivo da consulta, para resumos que acompanham o recorte. |
@@ -0,0 +1,50 @@
1
+ ## Registros distribuídos pelos dias
2
+
3
+ Use `CalendarMonthView` para consultar uma agenda ou expediente com conteúdo em cada dia. Para escolher uma data num formulário, use [DateInput](/ui/date-input); para selecionar um intervalo, use [Calendar](/ui/calendar).
4
+
5
+ O exemplo mostra um registro no mês inteiro. Clique ou use o teclado para abrir um dia:
6
+
7
+ ```tsx preview
8
+ const [selected, setSelected] = useState("")
9
+ const entries = [{ date: "2026-11-02", title: "Expediente especial" }]
10
+
11
+ render(
12
+ <div className="space-y-3">
13
+ <CalendarMonthView
14
+ month="2026-11"
15
+ label="Agenda de novembro"
16
+ items={entries}
17
+ dateOf={(entry) => entry.date}
18
+ renderDay={(_date, items) => items.map((entry) => <span key={entry.title}>{entry.title}</span>)}
19
+ dayLabel={(date, items) => `${date}: ${items.map((entry) => entry.title).join(", ") || "Sem registros"}`}
20
+ onDayClick={(date) => setSelected(date)}
21
+ />
22
+ {selected && <p>Data selecionada: {selected}</p>}
23
+ </div>,
24
+ )
25
+ ```
26
+
27
+ A grade sempre mostra todos os dias do mês, mesmo sem itens. Ela agrupa os registros por `dateOf`, sem consultar dados ou calcular regras de expediente. Datas são strings civis; o componente não aplica o fuso do navegador.
28
+
29
+ ## Dentro de ActionList
30
+
31
+ Forneça a grade no renderer de `views`. A [ActionList](/ui/action-list) mantém consulta, filtros, carregamento, erro e vazio; use `paginate: false` no contrato para que uma página de resultados não esconda dias com registros. `periodControl` permite navegar pelo mês no espaço de período da lista, com o valor temporal controlado pelo consumidor.
32
+
33
+ ## Teclado e limites
34
+
35
+ Tab entra em um único dia. Setas esquerda e direita percorrem dias; cima e baixo avançam uma semana. Home e End levam ao início ou fim da semana visível. Enter e Espaço acionam o dia focado. A navegação fica dentro do mês: o consumidor fornece os controles para trocar de mês.
36
+
37
+ A semana começa no domingo, com cabeçalhos em pt-BR. Mês inválido é erro de programação; valide valores da URL antes de renderizar. O padrão não marca hoje automaticamente nem implementa seleção de datas ou PageUp/PageDown. A raiz da grade expõe `data-slot="calendar-month-view"`.
38
+
39
+ ## Propriedades de CalendarMonthView
40
+
41
+ | Propriedade | Tipo | Padrão | Descrição |
42
+ |---|---|---|---|
43
+ | `month` | `string` | Obrigatória | Mês civil `AAAA-MM`, de 1000-01 a 9999-12. |
44
+ | `items` | `readonly TItem[]` | Obrigatória | Registros do mês, fornecidos pela coleção. |
45
+ | `dateOf` | `(item: TItem) => string` | Obrigatória | Data civil `AAAA-MM-DD` de cada registro. |
46
+ | `renderDay` | `(date: string, items: readonly TItem[]) => ReactNode` | Obrigatória | Conteúdo do dia; evite controles interativos dentro do botão. |
47
+ | `dayLabel` | `(date: string, items: readonly TItem[]) => string` | Obrigatória | Nome acessível do botão, incluindo data e contexto útil. |
48
+ | `onDayClick` | `(date: string, items: readonly TItem[]) => void` | Obrigatória | Abre ou executa a ação do dia. |
49
+ | `label` | `string` | Obrigatória | Nome acessível da grade. |
50
+ | `className` | `string` | — | Classes do contêiner externo, que permite rolagem horizontal em telas estreitas. |
@@ -1,7 +1,8 @@
1
1
  ## Dia único
2
2
 
3
3
  Use `Calendar` quando a pessoa precisar escolher datas vendo o mês inteiro, como a data de uma
4
- entrega ou a janela de um relatório. Para abrir o calendário a partir de um campo, ancore-o em um
4
+ entrega ou a janela de um relatório. Para preencher uma data com digitação e calendário em um
5
+ campo, use [`DateInput`](/ui/date-input). Composições próprias podem ancorá-lo em um
5
6
  [`Popover`](/ui/popover).
6
7
 
7
8
  Com `mode="single"`, a seleção é uma `Date`, controlada por `selected` e `onSelect`. `defaultMonth`
@@ -23,6 +24,8 @@ render(
23
24
  O calendário usa pt-BR por padrão: os nomes de mês e de dia vêm do `locale` (`ptBR`, de
24
25
  `date-fns/locale`) e os controles de navegação têm nomes acessíveis em português. Passe outro
25
26
  `locale` para trocar o idioma; `labels` substitui nomes acessíveis individualmente.
27
+ O título usa mês com inicial maiúscula e barra antes do ano, como `Setembro/2026`.
28
+ Esse formato também aparece nos campos `DateInput`; `formatters.formatCaption` permite substituí-lo.
26
29
 
27
30
  ## Intervalo
28
31
 
@@ -181,3 +184,5 @@ no nome acessível do dia por `labels.labelDayButton`.
181
184
  | `className` | `string` | | Classes somadas ao botão do dia. |
182
185
 
183
186
  `CalendarDayButton` aceita também as props nativas de `<button>`, repassadas pelo `Calendar`.
187
+
188
+ Para consultar registros por dia, use [CalendarMonthView](/ui/calendar-month-view). A grade recebe um mês civil válido no formato `AAAA-MM`. Os cabeçalhos estão em pt-BR, e a semana começa no domingo. A consulta e os estados de carregamento e erro pertencem à ActionList.
@@ -0,0 +1,67 @@
1
+ ## Digitar ou escolher uma data
2
+
3
+ Use `DateInput` para preencher uma data civil, como uma entrega ou um feriado. O campo
4
+ exibe `DD/MM/AAAA` e abre o calendário em português ao clicar em qualquer parte do controle.
5
+ O clique mantém o foco na digitação. O ícone é decorativo e não adiciona uma parada de Tab;
6
+ o atalho `Alt` + seta para baixo leva o foco para o calendário.
7
+
8
+ ```tsx preview col md
9
+ function Demo() {
10
+ const [date, setDate] = useState("2026-11-02")
11
+ return (
12
+ <div className="grid gap-2">
13
+ <Label htmlFor="delivery-date">Data de entrega</Label>
14
+ <DateInput id="delivery-date" value={date} onValueChange={setDate} />
15
+ </div>
16
+ )
17
+ }
18
+ render(<Demo />)
19
+ ```
20
+
21
+ A máscara insere as barras durante a digitação: basta informar `02112026`. Também é possível
22
+ digitar `02/11/2026`, colar `2026-11-02` ou escolher o dia no
23
+ calendário. Uma data válida é emitida como `2026-11-02`; apagar o campo emite `''`. O componente
24
+ mantém o dia civil, sem transformá-lo em um instante UTC.
25
+
26
+ Texto incompleto ou uma data impossível permanecem no campo com a máscara, sem conversão para ISO.
27
+ Valide o valor antes de persistir: o componente não troca `31/02/2026` por um dia de março nem
28
+ preserva silenciosamente a data anterior. No `ActionForm`, o schema do contrato mantém essa
29
+ responsabilidade.
30
+
31
+ ## Formulário declarativo
32
+
33
+ Declare `widget: 'date'` no campo do contrato e use um schema de data civil. O
34
+ [`ActionForm`](/ui/action-form) compõe o `DateInput` com rótulo, hint, erro e foco de validação.
35
+
36
+ ```tsx
37
+ const fields = { date: { label: "Data", widget: "date" as const } }
38
+ const input = z.object({ date: z.string().date("Informe uma data válida.") })
39
+ ```
40
+
41
+ ## Teclado e estados
42
+
43
+ Com foco no campo, `Alt` + seta para baixo abre o calendário e leva o foco até ele. As setas navegam pelos dias;
44
+ selecionar uma data fecha o calendário e devolve o foco ao campo. `Escape` fecha o calendário
45
+ sem fechar o diálogo que o contém. É possível substituir trechos selecionados e apagar com
46
+ `Backspace` ou `Delete`, sem ficar preso às barras da máscara. `disabled` e `readOnly` impedem
47
+ a abertura pelo campo e pelo atalho.
48
+ Associe `Label.htmlFor` ao `id` e use `aria-describedby` para ajuda ou erro.
49
+
50
+ Para manter um mês inteiro visível, use [`Calendar`](/ui/calendar). Para um campo com estado
51
+ interno, forneça `defaultValue`; `value` e `onValueChange` tornam o componente controlado.
52
+
53
+ ## Propriedades de DateInput
54
+
55
+ | Propriedade | Tipo | Padrão | Efeito |
56
+ | --- | --- | --- | --- |
57
+ | `value` | `string` | — | Valor controlado: ISO quando válido, texto durante digitação inválida ou incompleta. |
58
+ | `defaultValue` | `string` | `''` | Valor inicial no modo não controlado; restaurado no reset do formulário nativo. |
59
+ | `onValueChange` | `(value: string) => void` | — | Recebe a data civil, o texto em edição ou `''`. |
60
+ | `name` | `string` | — | Envia o valor por um campo oculto no formulário nativo, sem converter para a apresentação brasileira. |
61
+ | `placeholder` | `string` | `DD/MM/AAAA` | Exemplo de formato no campo vazio. |
62
+ | `disabled`, `readOnly` | `boolean` | `false` | Impedem edição e abertura do calendário. |
63
+ | `id`, `ref`, `form`, `onBlur`, `aria-*` | Atributos do input | — | Identificação, foco, vínculo com formulário e descrição acessível do campo visível. |
64
+ | `className` | `string` | — | Classes da moldura do campo, seguindo o Input com adorno. |
65
+
66
+ O componente não aceita `type`, `onChange`, `icon`, `trailing`, `min`, `max` ou `step`.
67
+ O contrato define os limites de negócio; `onValueChange` substitui o evento de alteração nativo.
@@ -70,6 +70,9 @@ Associe `htmlFor` no `Label` ao `id` do campo para que o clique no rótulo mova
70
70
  `type` é repassado ao `<input>` nativo, permitindo que o navegador escolha teclado e validação
71
71
  adequados.
72
72
 
73
+ Para datas com digitação em formato brasileiro e calendário do Opus, use
74
+ [`DateInput`](/ui/date-input).
75
+
73
76
  ```tsx preview col md
74
77
  <Input type="email" placeholder="Ex.: joao@softize.com.br" />
75
78
  <Input type="password" placeholder="Token de serviço" />
@@ -67,6 +67,8 @@ import dockMd from "./content/dock.md?raw";
67
67
  import breadcrumbMd from "./content/breadcrumb.md?raw";
68
68
  import buttonMd from "./content/button.md?raw";
69
69
  import calendarMd from "./content/calendar.md?raw";
70
+ import dateInputMd from "./content/date-input.md?raw";
71
+ import calendarMonthViewMd from "./content/calendar-month-view.md?raw";
70
72
  import metricCardMd from "./content/metric-card.md?raw";
71
73
  import carouselMd from "./content/carousel.md?raw";
72
74
  import askMd from "./content/ask.md?raw";
@@ -152,9 +154,15 @@ export const iconScope: Record<string, unknown> = Object.fromEntries(
152
154
  );
153
155
 
154
156
  /** Página conceitual (Começar/Framework/Design/Intro): markdown direto (o H1/lead moram no md). */
155
- const doc = (content: string, codeCollapsible = true) => (): React.ReactElement => (
156
- <DocMarkdown content={content} scope={iconScope} codeCollapsible={codeCollapsible} />
157
- );
157
+ const doc =
158
+ (content: string, codeCollapsible = true) =>
159
+ (): React.ReactElement => (
160
+ <DocMarkdown
161
+ content={content}
162
+ scope={iconScope}
163
+ codeCollapsible={codeCollapsible}
164
+ />
165
+ );
158
166
 
159
167
  /**
160
168
  * Página de componente: o cabeçalho (título + badge de origem + whenToUse) vem do `meta` central
@@ -305,7 +313,11 @@ export const PROTOCOL_SECTIONS: DocSection[] = [
305
313
  searchText: mcpMd,
306
314
  render: doc(mcpMd),
307
315
  },
308
- { slug: "ui", title: "Interface com Opus ↗", render: () => <UiRedirect /> },
316
+ {
317
+ slug: "ui",
318
+ title: "Interface com Opus ↗",
319
+ render: () => <UiRedirect />,
320
+ },
309
321
  {
310
322
  slug: "documentation-portal",
311
323
  title: "Publicação de documentação",
@@ -585,6 +597,11 @@ export const UI_SECTIONS: DocSection[] = [
585
597
  actionFormDialogMd,
586
598
  ),
587
599
  },
600
+ {
601
+ slug: "calendar-month-view",
602
+ title: "CalendarMonthView",
603
+ render: comp("CalendarMonthView", "calendar-month-view", calendarMonthViewMd),
604
+ },
588
605
  {
589
606
  slug: "action-list",
590
607
  title: "Action List",
@@ -637,6 +654,11 @@ export const UI_SECTIONS: DocSection[] = [
637
654
  title: "Calendar",
638
655
  render: comp("Calendar", "calendar", calendarMd),
639
656
  },
657
+ {
658
+ slug: "date-input",
659
+ title: "DateInput",
660
+ render: comp("DateInput", "date-input", dateInputMd),
661
+ },
640
662
  {
641
663
  slug: "checkbox",
642
664
  title: "Checkbox",
@@ -890,8 +912,9 @@ export const DOC_SECTIONS: DocSection[] = [
890
912
  {
891
913
  pages: [
892
914
  ...["getting-started", "cycle", "upgrading"].map((slug) =>
893
- PROTOCOL_SECTIONS.flatMap((section) => section.groups.flatMap((group) => group.pages))
894
- .find((page) => page.slug === slug)!,
915
+ PROTOCOL_SECTIONS.flatMap((section) =>
916
+ section.groups.flatMap((group) => group.pages),
917
+ ).find((page) => page.slug === slug)!,
895
918
  ),
896
919
  {
897
920
  slug: "changelog",
@@ -902,7 +925,9 @@ export const DOC_SECTIONS: DocSection[] = [
902
925
  },
903
926
  {
904
927
  label: "Introdução ao SDK",
905
- pages: PROTOCOL_SECTIONS[0]!.groups[0]!.pages.filter((page) => page.slug !== "getting-started"),
928
+ pages: PROTOCOL_SECTIONS[0]!.groups[0]!.pages.filter(
929
+ (page) => page.slug !== "getting-started",
930
+ ),
906
931
  },
907
932
  ],
908
933
  },
@@ -913,8 +938,12 @@ export const DOC_SECTIONS: DocSection[] = [
913
938
  label: s.label === "Testes e evolução" ? "Testes" : s.label,
914
939
  groups: s.groups.map((g) => ({
915
940
  ...g,
916
- pages: g.pages.filter((p) => !["ui", "cycle", "upgrading"].includes(p.slug)),
941
+ pages: g.pages.filter(
942
+ (p) => !["ui", "cycle", "upgrading"].includes(p.slug),
943
+ ),
917
944
  })),
918
945
  })),
919
- ...UI_SECTIONS.map((section, index) => index === 0 ? { ...section, label: "Introdução à UI" } : section),
946
+ ...UI_SECTIONS.map((section, index) =>
947
+ index === 0 ? { ...section, label: "Introdução à UI" } : section,
948
+ ),
920
949
  ];
package/src/ui/meta.ts CHANGED
@@ -131,6 +131,12 @@ export const componentMeta = {
131
131
  whenToUse:
132
132
  "Receba texto em uma única linha com os atributos nativos de `input`. Use `icon` para identidade e `trailing` para uma ação no fim do campo; associe uma Label. Quando precisar combinar múltiplos adornos ou controles na mesma moldura, use InputGroup.",
133
133
  },
134
+ "date-input": {
135
+ name: "date-input",
136
+ ancestry: "opus",
137
+ whenToUse:
138
+ "Receba uma data por digitação em DD/MM/AAAA ou escolha no calendário. O valor válido permanece uma string civil AAAA-MM-DD, sem conversão de fuso horário. Associe uma Label; no ActionForm, declare widget: 'date'.",
139
+ },
134
140
  label: {
135
141
  name: "label",
136
142
  ancestry: "shadcn",
@@ -239,11 +245,16 @@ export const componentMeta = {
239
245
  whenToUse:
240
246
  "Mostre onde a pessoa está quando a página fica vários níveis abaixo da entrada da seção e permita voltar a um nível intermediário. Use BreadcrumbLink nos níveis navegáveis, BreadcrumbPage no nível atual, que não é clicável, BreadcrumbSeparator entre eles e BreadcrumbEllipsis para colapsar trilhas longas. Quando basta voltar ao nível imediatamente acima, use PageBack; para alternar painéis no mesmo nível, Tabs.",
241
247
  },
248
+ "calendar-month-view": {
249
+ name: "calendar-month-view",
250
+ ancestry: "opus",
251
+ whenToUse: "Consulte registros distribuídos pelos dias de um mês, como expediente e agenda. Use como visualização de ActionList para preservar consulta, filtros e estados da coleção.",
252
+ },
242
253
  calendar: {
243
254
  name: "calendar",
244
255
  ancestry: "shadcn",
245
256
  whenToUse:
246
- 'Permita escolher datas vendo o mês inteiro, como a data de uma entrega ou o período de um relatório. `mode` define a seleção — `single`, `multiple` ou `range` — e o formato de `selected` e `onSelect` (Date, Date[] ou { from, to }). `captionLayout="dropdown"` troca o título do mês por seletores de mês e ano, útil para chegar a um período distante; `numberOfMonths` mostra meses lado a lado e `disabled` impede a escolha de datas específicas. Os textos usam pt-BR por padrão. Para abrir o calendário a partir de um campo, ancore-o em um Popover.',
257
+ 'Permita escolher datas vendo o mês inteiro, como a data de uma entrega ou o período de um relatório. `mode` define a seleção — `single`, `multiple` ou `range` — e o formato de `selected` e `onSelect` (Date, Date[] ou { from, to }). `captionLayout="dropdown"` troca o título do mês por seletores de mês e ano, útil para chegar a um período distante; `numberOfMonths` mostra meses lado a lado e `disabled` impede a escolha de datas específicas. Os textos usam pt-BR por padrão. Para preencher uma data em um campo com digitação e calendário, use DateInput. Use Popover para composições próprias.',
247
258
  },
248
259
  carousel: {
249
260
  name: "carousel",
package/src/ui/react.tsx CHANGED
@@ -54,6 +54,10 @@ export type {
54
54
  } from "./components/primitives/control.ts";
55
55
 
56
56
  export { Input } from "./components/primitives/input.tsx";
57
+ export {
58
+ DateInput,
59
+ type DateInputProps,
60
+ } from "./components/primitives/date-input.tsx";
57
61
 
58
62
  export { Chat } from "./components/primitives/chat.tsx";
59
63
  export type {
@@ -652,3 +656,6 @@ export type {
652
656
  PresentationListContentAdapter,
653
657
  PresentationRowContentAdapter,
654
658
  } from "./components/patterns/presentation-sections.tsx";
659
+
660
+ export { CalendarMonthView } from "./components/patterns/list-calendar.tsx";
661
+ export type { CalendarMonthViewProps } from "./components/patterns/list-calendar.tsx";