@scalewing/react 1.8.0 → 1.9.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +69 -2
  3. package/dist/anchored-position.d.ts +28 -0
  4. package/dist/anchored-position.js +17 -0
  5. package/dist/calendar-keys.d.ts +19 -0
  6. package/dist/calendar-keys.js +39 -0
  7. package/dist/calendar-labels.d.ts +12 -0
  8. package/dist/calendar-labels.js +32 -0
  9. package/dist/calendar-month.d.ts +36 -0
  10. package/dist/calendar-month.js +111 -0
  11. package/dist/calendar-years.d.ts +14 -0
  12. package/dist/calendar-years.js +20 -0
  13. package/dist/components/Accordion.d.ts +6 -1
  14. package/dist/components/Accordion.js +12 -3
  15. package/dist/components/ActionBar.d.ts +30 -0
  16. package/dist/components/ActionBar.js +13 -0
  17. package/dist/components/Box.d.ts +7 -3
  18. package/dist/components/Box.js +2 -2
  19. package/dist/components/DateField.d.ts +15 -1
  20. package/dist/components/DateField.js +58 -18
  21. package/dist/components/Field.d.ts +5 -1
  22. package/dist/components/Field.js +16 -4
  23. package/dist/components/FieldAdornment.d.ts +20 -0
  24. package/dist/components/FieldAdornment.js +34 -0
  25. package/dist/components/Grid.d.ts +4 -1
  26. package/dist/components/Grid.js +2 -2
  27. package/dist/components/Progress.d.ts +3 -1
  28. package/dist/components/Progress.js +2 -2
  29. package/dist/components/date-field/CalendarDialog.d.ts +26 -0
  30. package/dist/components/date-field/CalendarDialog.js +93 -0
  31. package/dist/components/date-field/CalendarGrid.d.ts +23 -0
  32. package/dist/components/date-field/CalendarGrid.js +36 -0
  33. package/dist/components/date-field/CalendarHeader.d.ts +18 -0
  34. package/dist/components/date-field/CalendarHeader.js +33 -0
  35. package/dist/components/date-field/DateFieldGlyph.d.ts +9 -0
  36. package/dist/components/date-field/DateFieldGlyph.js +15 -0
  37. package/dist/components/date-field/use-anchored-popover.d.ts +8 -0
  38. package/dist/components/date-field/use-anchored-popover.js +61 -0
  39. package/dist/components/date-field/use-date-entry.d.ts +20 -0
  40. package/dist/components/date-field/use-date-entry.js +61 -0
  41. package/dist/components/date-field/use-lang-locale.d.ts +7 -0
  42. package/dist/components/date-field/use-lang-locale.js +17 -0
  43. package/dist/css/css-accordion.js +55 -8
  44. package/dist/css/css-action-bar.d.ts +4 -0
  45. package/dist/css/css-action-bar.js +99 -0
  46. package/dist/css/css-action-menu.js +2 -1
  47. package/dist/css/css-box-border.d.ts +5 -0
  48. package/dist/css/css-box-border.js +24 -0
  49. package/dist/css/css-chrome.js +2 -1
  50. package/dist/css/css-components.js +10 -6
  51. package/dist/css/css-data.js +2 -1
  52. package/dist/css/css-date-field.js +241 -11
  53. package/dist/css/css-denomination-grid.js +2 -1
  54. package/dist/css/css-document.d.ts +2 -0
  55. package/dist/css/css-document.js +2 -1
  56. package/dist/css/css-field.js +61 -1
  57. package/dist/css/css-select.js +2 -1
  58. package/dist/css/css-tooltip.js +2 -1
  59. package/dist/css/stacking.d.ts +10 -0
  60. package/dist/css/stacking.js +47 -0
  61. package/dist/css/stylesheet.js +4 -0
  62. package/dist/date-entry-validity.d.ts +10 -0
  63. package/dist/date-entry-validity.js +16 -0
  64. package/dist/date-entry.d.ts +32 -0
  65. package/dist/date-entry.js +106 -0
  66. package/dist/date-field-labels.d.ts +38 -0
  67. package/dist/date-field-labels.js +22 -0
  68. package/dist/date-field-locale.d.ts +8 -0
  69. package/dist/date-field-locale.js +27 -0
  70. package/dist/date-formatters.d.ts +13 -0
  71. package/dist/date-formatters.js +28 -0
  72. package/dist/date-only.d.ts +23 -0
  73. package/dist/date-only.js +63 -9
  74. package/dist/index.d.ts +5 -3
  75. package/dist/index.js +3 -2
  76. package/dist/styles.css +388 -21
  77. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a4a30e8: `DateField` draws its own calendar instead of using the browser's `<input type="date">`, so it looks the same in every browser, follows Scalewing's tokens, and shows up in screenshots. The props, the date-only `YYYY-MM-DD` contract, and the `RangeError` guards are unchanged.
8
+
9
+ - People type the date into a text entry in the locale's numeric order (`MM/DD/YYYY` for en-US, `DD/MM/YYYY` for es); ISO `YYYY-MM-DD` and eight bare digits also work. A keystroke commits once the entry's last field is at full width (a four-digit year, or two digits for a trailing day or month); a shorter last field commits on blur or Enter. Typed text stays when the parent keeps the old value. Text that is not a date keeps the last value and, after the person leaves the field, sets `aria-invalid` with a message. As with the native input, the entry blocks form submission while its text is not a date or its date is outside `min`/`max` (`setCustomValidity`).
10
+ - A calendar button opens a WAI-ARIA date picker dialog anchored under the field on the popover layer, with month and year selectors for jumping decades, Today, and Clear (only when not `required`). Arrows, Home/End, PageUp/PageDown, and Shift+PageUp/PageDown move focus; Enter or Space selects; Escape closes and returns focus to the button. Days outside `min`/`max` are `aria-disabled`, the month selector offers only months inside them, and padding past `0001-01-01` or `9999-12-31` is blank and inert.
11
+ - New optional props: `locale` (BCP 47; defaults to the nearest `lang` attribute, then `en-US`), `weekStartsOn` (`0` Sunday by default, or `1` Monday), and `labels` for the control's own words (English defaults), including `outOfRange`, the form validation message for a date outside `min`/`max`. New types `DateFieldLabels` and `WeekStart`. New generated `sw-date-field-*` classes for the control and calendar.
12
+ - Migration for tests: there is no `input[type=date]` any more. Find the entry by its label (`getByLabel('Hire date')` or `getByRole('textbox', { name: 'Hire date' })`) and `fill` it in the locale's order or in ISO, or open the calendar with the **Choose date** button. The input's value is now the locale's display text (`03/10/2024`), not `2024-03-10`; assert the serialized value from your own state.
13
+
14
+ No new dependencies.
15
+
16
+ - 4548fc2: `Accordion` takes `subtitle`, one muted caption line under the title, and
17
+ `size="sm"` for a quieter disclosure nested inside other content (a
18
+ label-size title, spacing step 3, `md` corners, and a header that is an `sm`
19
+ control of at least 32px instead of 44px). The header now draws a
20
+ token chevron at its inline end in place of the browser's `details` triangle;
21
+ it turns when the disclosure opens and holds still under reduced motion. See
22
+ `docs/requests/teisoro-accordion-summary.md`.
23
+
24
+ ```tsx
25
+ <Accordion
26
+ open={open}
27
+ onOpenChange={setOpen}
28
+ subtitle="Twelve sightings · Two nests"
29
+ title="Wetlands"
30
+ >
31
+ {children}
32
+ </Accordion>
33
+ ```
34
+
35
+ New generated classes `sw-accordion-sm`, `sw-accordion-heading` and
36
+ `sw-accordion-marker`, and a new exported type `AccordionSize`. The summary is
37
+ now a flex row instead of `display: list-item`, so the native marker is gone;
38
+ an app that styled `.sw-accordion-summary::marker` has nothing left to style.
39
+ Additive for the public API.
40
+
41
+ - 38e6f09: New `ActionBar`: a long page's actions and one short `status` line on a glass
42
+ bar that sticks to the bottom of the viewport while the content above it
43
+ scrolls, then rests in place after it (see
44
+ `docs/requests/teisoro-action-bar.md`). `stickyBelow="md"` sticks only below
45
+ the `md` breakpoint and keeps the bar in page flow from `md` up. The bar clears
46
+ the safe-area insets (with `viewport-fit=cover`), is solid under Reduce
47
+ Transparency, and on a phone puts the status on its own line with the actions
48
+ sharing the row under it. The status is a polite live region
49
+ (`role="status"`), always rendered so the first status is announced too; an
50
+ empty status takes no room.
51
+
52
+ ```tsx
53
+ <Stack gap={4}>
54
+ {longForm}
55
+ <ActionBar status="Draft saved at 5:00 PM" stickyBelow="md">
56
+ <Button variant="secondary" onPress={save}>
57
+ Save draft
58
+ </Button>
59
+ <Button onPress={finish}>Finish</Button>
60
+ </ActionBar>
61
+ </Stack>
62
+ ```
63
+
64
+ New generated classes `sw-action-bar`, `sw-action-bar-status`,
65
+ `sw-action-bar-actions`, `sw-action-bar-sticky` and
66
+ `sw-action-bar-sticky-below-md`; new exported types `ActionBarProps` and `Breakpoint` (the breakpoint names
67
+ `stickyBelow`, `hideBelow` and `columnsBelow` already take, today only
68
+ `'md'`). Additive.
69
+
70
+ Every generated `z-index` now comes from one stacking order
71
+ (`src/css/stacking.ts`): sticky table cells 1, the ActionBar 2, a glass `Card`
72
+ or `Accordion` holding an open popup 3 (was 1), the sticky `AppHeader` 4 (was
73
+ 2), and popups 10 (the `Select` list was 3). An open `Select`, `ActionMenu`
74
+ or `Tooltip` inside a glass surface now paints over a stuck ActionBar, and
75
+ still under the AppHeader. An app that layered its own element between these
76
+ values should check it against the new numbers.
77
+
78
+ - affd090: `Box` `border` accepts `'dashed'` as well as `true`: a hairline dashed border
79
+ in the `border` color token, for a space to fill in by hand such as a blank on
80
+ a printed form (see `docs/requests/teisoro-box-border-style.md`). Every
81
+ Box-based component takes it.
82
+
83
+ ```tsx
84
+ <Box border="dashed" padding={2} radius="sm">
85
+ {hint}
86
+ </Box>
87
+ ```
88
+
89
+ New generated classes `sw-border` and `sw-border-dashed` and a new exported
90
+ type `BoxBorder` (`boolean | 'dashed'`). `border` and `border={true}` look the
91
+ same as before, but the solid hairline is now the `sw-border` class instead of
92
+ an inline `style.border`; a consumer `style` still wins, and the prop still
93
+ wins over a Box-based component's own frame. A test that read
94
+ `element.style.border` should check the class instead. Additive for the
95
+ public API.
96
+
97
+ - 6a4c4ab: `Field` takes `prefix` and `suffix`: short text inside the control's frame
98
+ before or after the value, such as a currency sign or a unit (see
99
+ `docs/requests/teisoro-field-adornment.md`).
100
+
101
+ ```tsx
102
+ <Field label="Drop amount" prefix="$">
103
+ <input inputMode="decimal" name="drop" />
104
+ </Field>
105
+ ```
106
+
107
+ The text is not part of the value. The generated `sw-field-adorned` wrapper
108
+ draws the control frame and focus ring, `sw-field-prefix` and
109
+ `sw-field-suffix` are muted, and the input is named by its label plus the
110
+ adornment ("Drop amount $") through `aria-labelledby`. An input that names
111
+ itself with its own `aria-label` or `aria-labelledby` keeps that name, and the
112
+ adornment joins its `aria-describedby` instead. A press on the prefix, the
113
+ suffix or the frame focuses the input. Adornments need one
114
+ native `<input>` child; anything else throws a `TypeError`. Additive; an
115
+ unadorned `Field` renders as before.
116
+
117
+ - 988bd2c: `Grid` takes `align` (`start`, `center`, `end`, `stretch`): where each child
118
+ sits in the height of its row, through the same generated `sw-align-*` classes
119
+ and `Align` type as `Stack` and `Inline` (see the 2026-09-28 follow-up in
120
+ `docs/requests/teisoro-grid.md`). `align="end"` keeps a row of fields level
121
+ when one label wraps:
122
+
123
+ ```tsx
124
+ <Grid align="end" columns={2} gap={3}>
125
+ {fields}
126
+ </Grid>
127
+ ```
128
+
129
+ Additive; unset, no class is added and children stretch as before.
130
+
131
+ - c3d04a8: `Progress` takes `showCount` (default `true`). `showCount={false}` hides the
132
+ visible `value / max` count beside the label when the page shows its own count
133
+ caption, so the count appears once (see the 2026-09-28 follow-up in
134
+ `docs/requests/teisoro-progress.md`). The progress bar still exposes its value
135
+ and maximum to assistive tech.
136
+
137
+ ```tsx
138
+ <Progress label="Registers closed" max={2} showCount={false} value={0} />
139
+ ```
140
+
141
+ Additive; the count shows as before when the prop is left out.
142
+
3
143
  ## 1.8.0
4
144
 
5
145
  ### Minor Changes
package/README.md CHANGED
@@ -7,6 +7,7 @@ import '@scalewing/react/styles.css';
7
7
  import '@scalewing/react/palette/cerulean.css';
8
8
  import {
9
9
  Accordion,
10
+ ActionBar,
10
11
  ActionMenu,
11
12
  BarChart,
12
13
  Box,
@@ -44,10 +45,54 @@ import {
44
45
 
45
46
  Import the CSS once at the application entry. Do not copy it into your source tree. Optional: import one `@scalewing/react/palette/<id>.css` file after it, or set `data-palette` on the `data-theme` node. React apps can set `palette` on `ThemeProvider` instead.
46
47
 
48
+ `Box` `border` draws a hairline in the `border` color token: `border` (or `border={true}`) is solid, and `border="dashed"` is dashed (the generated `sw-border` and `sw-border-dashed` classes; a `style` you pass still wins), for a space to fill in by hand such as a blank on a printed form. It does not make the box look or act like an input. Every `Box`-based component takes it.
49
+
47
50
  `Box hideBelow="md"` hides a region under 48rem and `hideFrom="md"` hides it at 48rem and wider, so a product can swap a wide layout for a narrow one without its own stylesheet. Hidden regions leave the accessibility tree; do not use this to protect data. `Box as="a"` is a layout link. Use `Button` for press actions. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Select` is a labeled listbox menu when the open list must match the canvas.
48
51
 
49
52
  `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby`, appends a stable message ID, and replaces the hint with an alert when an error is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The consumer owns validation and localized messages.
50
53
 
54
+ `Field` `prefix` and `suffix` put short text such as `$` or `%` inside the frame of one native `<input>` child, before or after the value. The text is not part of the value; the input is named by its label plus the adornment (`"Drop amount $"`), unless it names itself with its own `aria-label` or `aria-labelledby`, in which case the adornment joins its description. A press anywhere on the frame focuses the input. Formatting the value stays with the consumer. Any other child throws a `TypeError`.
55
+
56
+ ```tsx
57
+ <Field label="Drop amount" prefix="$">
58
+ <input inputMode="decimal" name="drop" />
59
+ </Field>
60
+ ```
61
+
62
+ `Accordion` is a native `<details>` disclosure controlled by `open` and `onOpenChange`. A token chevron at the end of the header replaces the browser triangle and turns when it opens (it holds still under reduced motion). `subtitle` adds one muted caption line under the title, such as a summary of what the section holds; it wraps rather than truncating and is read after the title. `size="sm"` is a quieter disclosure nested inside other content: a label-size title, tighter padding, smaller corners and a header that is an `sm` control (at least 32px).
63
+
64
+ ```tsx
65
+ <Accordion
66
+ open={open}
67
+ onOpenChange={setOpen}
68
+ subtitle="Twelve sightings · Two nests"
69
+ title="Wetlands"
70
+ >
71
+ <Accordion
72
+ open={methodOpen}
73
+ onOpenChange={setMethodOpen}
74
+ size="sm"
75
+ title="How the count is taken"
76
+ >
77
+ {method}
78
+ </Accordion>
79
+ </Accordion>
80
+ ```
81
+
82
+ `ActionBar` keeps the actions of a long page on a glass bar stuck to the bottom of the viewport, with an optional one-line `status` (such as when the work was last saved). Put it last in the content it acts on: it stays stuck while that content scrolls by and then rests in its own place at the end. `stickyBelow="md"` sticks only on a phone and leaves the bar in page flow from `md` up. The bar clears `env(safe-area-inset-bottom)` (set `viewport-fit=cover` in the page's viewport meta for the inset to apply). Its children are the actions: on a phone they share one row under the status. The status is a polite live region (`role="status"`, kept in the page even while empty), so a new status such as "Draft saved at 5:00 PM" is announced; do not announce the same save a second time with your own notice. An open `Select`, `ActionMenu` or `Tooltip` paints over the bar, including inside a glass `Card` or `Accordion`.
83
+
84
+ ```tsx
85
+ <Stack gap={4}>
86
+ {longForm}
87
+ <ActionBar status="Draft saved at 5:00 PM" stickyBelow="md">
88
+ <Button variant="secondary" onPress={save}>
89
+ Save draft
90
+ </Button>
91
+ <Button onPress={finish}>Finish</Button>
92
+ </ActionBar>
93
+ </Stack>
94
+ ```
95
+
51
96
  `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape and choosing a command both return focus to the trigger (before the command runs, so a `Dialog` it opens hands focus back to the trigger on close), and outside interaction dismisses the menu.
52
97
 
53
98
  `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true.
@@ -60,7 +105,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
60
105
 
61
106
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
62
107
 
63
- `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`.
108
+ `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`. `showCount={false}` hides the visible `value / max` count when the page shows its own count caption, so the count appears once; the progress bar still exposes its value and maximum.
64
109
 
65
110
  `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger. Supply localized `content` and an existing trigger element with its own accessible name. It opens on hover or focus, closes on pointer leave, blur, Escape, or outside touch, and toggles on touch. Keep required instructions visible outside the tooltip.
66
111
 
@@ -87,11 +132,24 @@ Every `Box`-based component (`Box`, `Stack`, `Inline`, `Card`, `Grid`, …) take
87
132
 
88
133
  Values outside the catalog throw a `RangeError`. There is no per-breakpoint span and no arbitrary column template.
89
134
 
135
+ `Grid` `align` (`start`, `center`, `end`, `stretch`) sets where each child sits in the height of its row, with the same generated `sw-align-*` classes as `Stack` and `Inline`. Unset, children stretch. `align="end"` keeps a row of fields level when one label wraps:
136
+
137
+ ```tsx
138
+ <Grid align="end" columns={2} gap={3}>
139
+ <Field label="Nest height above the waterline">{heightInput}</Field>
140
+ <Field label="Eggs">{eggsInput}</Field>
141
+ </Grid>
142
+ ```
143
+
90
144
  `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that moves under the label on a phone. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start; the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
91
145
 
92
146
  `FilterChips` presents a long, wrapping single-choice filter set. Provide a localized group `label`, controlled `value`, `onChange`, and options with stable values and localized labels. An optional non-negative integer `count` renders as a tabular chicklet after the label and is part of the option's accessible name; a zero count quiets the chip until it is selected. Native radios provide arrow and Space navigation; disabled options are skipped.
93
147
 
94
- `DateField` is a controlled native date input. Supply `value`, `min`, and `max` as valid `YYYY-MM-DD` dates; use an empty `value` for a blank field. Its callback returns a date-only string or an empty string, without timezone conversion. Invalid serialized dates and inverted bounds throw; a value outside the bounds remains visible with invalid styling. For a blank required field, supply `error` when form validation runs to show the message and invalid styling. The browser chooses the calendar and localized display.
148
+ `DateField` is a controlled date-only field that draws its own calendar. Supply `value`, `min`, and `max` as valid `YYYY-MM-DD` dates; use an empty `value` for a blank field. Its callback returns a date-only string or an empty string, without timezone conversion. Invalid serialized dates and inverted bounds throw; a value outside the bounds remains visible with invalid styling. For a blank required field, supply `error` when form validation runs to show the message and invalid styling.
149
+
150
+ People type the date into a text entry in the locale's numeric order (`MM/DD/YYYY` for en-US, `DD/MM/YYYY` for es), with any non-digit separator, eight bare digits, or ISO `YYYY-MM-DD`. A keystroke calls `onChange` once the entry is complete, meaning its last field is at full width (four year digits, or two digits for a trailing day or month, as in ISO); otherwise the date commits when the person leaves the field or presses Enter, so typing `2024-03-10` never sends `2024-03-01` on the way. If the parent keeps the old value, the typed text stays on screen; any other outside change replaces it. Text that is not a date keeps the last value and, once the person leaves the field, sets `aria-invalid` and shows `labels.invalidEntry`. Like the native date input, the entry blocks form submission through `setCustomValidity`: `labels.invalidEntry` while its text is not a date, and `labels.outOfRange` while its date, typed or given, is outside `min`/`max`. The calendar button beside it opens a WAI-ARIA date picker dialog anchored under the field on the popover layer: the month grid shows today, the selected day, and days outside `min`/`max` as `aria-disabled` (padding before `0001-01-01` or after `9999-12-31` is blank and inert); arrows move by day and week, Home and End to the week's edges, PageUp and PageDown by month, Shift with them by year, Enter or Space selects and closes, and Escape closes and returns focus to the button. Month and year selectors in the header jump decades (the year list spans 120 years back and 20 ahead of today and the shown month, cut to `min` and `max`; the month list offers only months with a day inside them). A new `value`, `min`, or `max` while the calendar is open moves it to the new value or back inside the bounds. **Today** selects today; **Clear** appears only when the field is not `required`.
151
+
152
+ `locale` (BCP 47) sets month and weekday names, spoken dates, and the typed order; it defaults to the nearest `lang` attribute when the field mounts, then `en-US`, and a malformed tag throws. `weekStartsOn` is `0` (Sunday, the default) or `1` (Monday). `labels` overrides the control's own words, English by default: `chooseDate`, `previousMonth`, `nextMonth`, `month`, `year`, `today`, `clear`, `invalidEntry`, `outOfRange`, and the placeholder letters `dayPlaceholder`, `monthPlaceholder`, `yearPlaceholder`. The field label names only the text entry; the calendar button is named `labels.chooseDate` and described by the field label, so tests find the entry with `getByLabel` or `getByRole('textbox', { name })`.
95
153
 
96
154
  ```tsx
97
155
  <DateField
@@ -101,6 +159,15 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
101
159
  min="2024-01-01"
102
160
  max="2024-12-31"
103
161
  />
162
+
163
+ <DateField
164
+ label="Fecha del avistamiento"
165
+ locale="es"
166
+ weekStartsOn={1}
167
+ labels={{ chooseDate: 'Elegir fecha', today: 'Hoy', clear: 'Borrar' }}
168
+ value={fecha}
169
+ onChange={setFecha}
170
+ />
104
171
  ```
105
172
 
106
173
  ```tsx
@@ -0,0 +1,28 @@
1
+ export type AnchorRect = {
2
+ top: number;
3
+ bottom: number;
4
+ left: number;
5
+ right: number;
6
+ };
7
+ export type PopoverSize = {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export type Viewport = {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ export type AnchoredPosition = {
16
+ left: number;
17
+ top: number;
18
+ };
19
+ /**
20
+ * Where a fixed popover goes beside its anchor: below and aligned to the
21
+ * anchor's inline start (its end in right-to-left), above when it does not
22
+ * fit below but does above, and always kept inside the viewport by `inset`.
23
+ */
24
+ export declare function anchoredPosition(anchor: AnchorRect, size: PopoverSize, viewport: Viewport, { gap, inset, rtl }?: {
25
+ gap?: number | undefined;
26
+ inset?: number | undefined;
27
+ rtl?: boolean | undefined;
28
+ }): AnchoredPosition;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Where a fixed popover goes beside its anchor: below and aligned to the
3
+ * anchor's inline start (its end in right-to-left), above when it does not
4
+ * fit below but does above, and always kept inside the viewport by `inset`.
5
+ */
6
+ export function anchoredPosition(anchor, size, viewport, { gap = 0, inset = 0, rtl = false } = {}) {
7
+ const preferredLeft = rtl ? anchor.right - size.width : anchor.left;
8
+ const maxLeft = viewport.width - inset - size.width;
9
+ const left = Math.max(inset, Math.min(preferredLeft, maxLeft));
10
+ const below = anchor.bottom + gap;
11
+ const above = anchor.top - gap - size.height;
12
+ const fitsBelow = below + size.height <= viewport.height - inset;
13
+ const fitsAbove = above >= inset;
14
+ const top = fitsBelow || !fitsAbove ? below : above;
15
+ const maxTop = viewport.height - inset - size.height;
16
+ return { left, top: Math.max(inset, Math.min(top, maxTop)) };
17
+ }
@@ -0,0 +1,19 @@
1
+ import { type WeekStart } from './calendar-month.js';
2
+ export type CalendarKey = {
3
+ key: string;
4
+ shiftKey: boolean;
5
+ };
6
+ export type CalendarKeyContext = {
7
+ weekStartsOn: WeekStart;
8
+ min?: string;
9
+ max?: string;
10
+ /** Right-to-left grids swap the meaning of the horizontal arrows. */
11
+ rtl?: boolean;
12
+ };
13
+ /**
14
+ * The day a calendar grid key moves focus to, following the WAI-ARIA date
15
+ * picker dialog pattern: arrows by day and week, Home and End to the week's
16
+ * edges, PageUp and PageDown by month, and with Shift by year. The result
17
+ * stays inside `[min, max]`. Keys the grid does not own return null.
18
+ */
19
+ export declare function calendarKeyTarget(key: CalendarKey, focused: string, context: CalendarKeyContext): string | null;
@@ -0,0 +1,39 @@
1
+ import { addDays, addMonths, weekdayOf, } from './calendar-month.js';
2
+ import { clampDateOnly } from './date-only.js';
3
+ function unclampedTarget({ key, shiftKey }, focused, { weekStartsOn, rtl = false }) {
4
+ const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
5
+ const back = rtl ? 'ArrowRight' : 'ArrowLeft';
6
+ const column = (weekdayOf(focused) - weekStartsOn + 7) % 7;
7
+ switch (key) {
8
+ case forward:
9
+ return addDays(focused, 1);
10
+ case back:
11
+ return addDays(focused, -1);
12
+ case 'ArrowDown':
13
+ return addDays(focused, 7);
14
+ case 'ArrowUp':
15
+ return addDays(focused, -7);
16
+ case 'Home':
17
+ return addDays(focused, -column);
18
+ case 'End':
19
+ return addDays(focused, 6 - column);
20
+ case 'PageUp':
21
+ return addMonths(focused, shiftKey ? -12 : -1);
22
+ case 'PageDown':
23
+ return addMonths(focused, shiftKey ? 12 : 1);
24
+ default:
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * The day a calendar grid key moves focus to, following the WAI-ARIA date
30
+ * picker dialog pattern: arrows by day and week, Home and End to the week's
31
+ * edges, PageUp and PageDown by month, and with Shift by year. The result
32
+ * stays inside `[min, max]`. Keys the grid does not own return null.
33
+ */
34
+ export function calendarKeyTarget(key, focused, context) {
35
+ const target = unclampedTarget(key, focused, context);
36
+ return target === null
37
+ ? null
38
+ : clampDateOnly(target, context.min, context.max);
39
+ }
@@ -0,0 +1,12 @@
1
+ import { type CalendarMonth, type WeekStart } from './calendar-month.js';
2
+ export type WeekdayLabel = {
3
+ short: string;
4
+ long: string;
5
+ };
6
+ /** Weekday column headers in display order, named by `Intl` for `locale`. */
7
+ export declare function weekdayLabels(locale: string, weekStartsOn: WeekStart): WeekdayLabel[];
8
+ /** The twelve month names, January first, as `locale` writes them alone. */
9
+ export declare function monthNames(locale: string): string[];
10
+ export declare function monthTitle(month: CalendarMonth, locale: string): string;
11
+ /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
12
+ export declare function formatDateLabel(value: string, locale: string): string;
@@ -0,0 +1,32 @@
1
+ import { weekdayOrder, } from './calendar-month.js';
2
+ import { dateFormatter, formatterDate } from './date-formatters.js';
3
+ import { parseDateOnly } from './date-only.js';
4
+ /** A Sunday; any week works because only the weekday names are read. */
5
+ const REFERENCE_SUNDAY = { year: 2023, month: 1, day: 1 };
6
+ /** Weekday column headers in display order, named by `Intl` for `locale`. */
7
+ export function weekdayLabels(locale, weekStartsOn) {
8
+ const short = dateFormatter(locale, { weekday: 'short' });
9
+ const long = dateFormatter(locale, { weekday: 'long' });
10
+ return weekdayOrder(weekStartsOn).map((weekday) => {
11
+ const date = formatterDate({
12
+ ...REFERENCE_SUNDAY,
13
+ day: REFERENCE_SUNDAY.day + weekday,
14
+ });
15
+ return { short: short.format(date), long: long.format(date) };
16
+ });
17
+ }
18
+ /** The twelve month names, January first, as `locale` writes them alone. */
19
+ export function monthNames(locale) {
20
+ const format = dateFormatter(locale, { month: 'long' });
21
+ return Array.from({ length: 12 }, (_, index) => format.format(formatterDate({ year: 2023, month: index + 1, day: 1 })));
22
+ }
23
+ export function monthTitle(month, locale) {
24
+ return dateFormatter(locale, { month: 'long', year: 'numeric' }).format(formatterDate({ ...month, day: 1 }));
25
+ }
26
+ /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
27
+ export function formatDateLabel(value, locale) {
28
+ const parts = parseDateOnly(value);
29
+ if (!parts)
30
+ return '';
31
+ return dateFormatter(locale, { dateStyle: 'full' }).format(formatterDate(parts));
32
+ }
@@ -0,0 +1,36 @@
1
+ export type CalendarMonth = {
2
+ year: number;
3
+ /** 1–12. */
4
+ month: number;
5
+ };
6
+ /** 0 is Sunday, 1 is Monday, matching `Date.prototype.getDay`. */
7
+ export type WeekStart = 0 | 1;
8
+ export type CalendarCell = {
9
+ value: string;
10
+ day: number;
11
+ inMonth: boolean;
12
+ };
13
+ /** 0 is Sunday. */
14
+ export declare function weekdayOf(value: string): number;
15
+ export declare function addDays(value: string, delta: number): string;
16
+ /** Same day of the month `delta` months away, capped at that month's length. */
17
+ export declare function addMonths(value: string, delta: number): string;
18
+ export declare function monthOf(value: string): CalendarMonth;
19
+ export declare function shiftMonth(month: CalendarMonth, delta: number): CalendarMonth;
20
+ export declare function firstOfMonth(month: CalendarMonth): string;
21
+ export declare function lastOfMonth(month: CalendarMonth): string;
22
+ /** Whether any day of the month falls inside `[min, max]`. */
23
+ export declare function monthInRange(month: CalendarMonth, min?: string, max?: string): boolean;
24
+ /**
25
+ * The months of `year` with any day inside `[min, max]`, 1–12, for a month
26
+ * selector that only offers months a person can open.
27
+ */
28
+ export declare function monthsInRange(year: number, min?: string, max?: string): number[];
29
+ /**
30
+ * Six rows of seven cells covering the month, padded with the neighboring
31
+ * months so the grid never changes height. Padding before 0001-01-01 or
32
+ * after 9999-12-31 is `null`: no four-digit date exists there.
33
+ */
34
+ export declare function monthGrid(month: CalendarMonth, weekStartsOn: WeekStart): (CalendarCell | null)[][];
35
+ /** Weekday indexes in display order for the given week start. */
36
+ export declare function weekdayOrder(weekStartsOn: WeekStart): number[];
@@ -0,0 +1,111 @@
1
+ import { daysInMonth, formatDateOnly, isValidDateParts, parseDateOnly, } from './date-only.js';
2
+ /*
3
+ * Day arithmetic runs on a proleptic Gregorian day count, so it never passes
4
+ * through a Date, a time zone, or a daylight-saving jump.
5
+ * Algorithm: Howard Hinnant, "chrono-compatible low-level date algorithms".
6
+ */
7
+ function toDayNumber({ year, month, day }) {
8
+ const y = month <= 2 ? year - 1 : year;
9
+ const era = Math.floor(y / 400);
10
+ const yearOfEra = y - era * 400;
11
+ const monthIndex = (month + 9) % 12;
12
+ const dayOfYear = Math.floor((153 * monthIndex + 2) / 5) + day - 1;
13
+ const dayOfEra = yearOfEra * 365 +
14
+ Math.floor(yearOfEra / 4) -
15
+ Math.floor(yearOfEra / 100) +
16
+ dayOfYear;
17
+ return era * 146097 + dayOfEra - 719468;
18
+ }
19
+ function fromDayNumber(dayNumber) {
20
+ const z = dayNumber + 719468;
21
+ const era = Math.floor(z / 146097);
22
+ const dayOfEra = z - era * 146097;
23
+ const yearOfEra = Math.floor((dayOfEra -
24
+ Math.floor(dayOfEra / 1460) +
25
+ Math.floor(dayOfEra / 36524) -
26
+ Math.floor(dayOfEra / 146096)) /
27
+ 365);
28
+ const dayOfYear = dayOfEra -
29
+ (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
30
+ const monthIndex = Math.floor((5 * dayOfYear + 2) / 153);
31
+ const day = dayOfYear - Math.floor((153 * monthIndex + 2) / 5) + 1;
32
+ const month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9;
33
+ const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
34
+ return { year, month, day };
35
+ }
36
+ function requireDate(value) {
37
+ const parts = parseDateOnly(value);
38
+ if (!parts)
39
+ throw new RangeError('expected a valid YYYY-MM-DD date');
40
+ return parts;
41
+ }
42
+ /** 0 is Sunday. */
43
+ export function weekdayOf(value) {
44
+ // Day 0 of the count is 1970-01-01, a Thursday.
45
+ return (((toDayNumber(requireDate(value)) + 4) % 7) + 7) % 7;
46
+ }
47
+ export function addDays(value, delta) {
48
+ return formatDateOnly(fromDayNumber(toDayNumber(requireDate(value)) + delta));
49
+ }
50
+ /** Same day of the month `delta` months away, capped at that month's length. */
51
+ export function addMonths(value, delta) {
52
+ const parts = requireDate(value);
53
+ const month = shiftMonth(parts, delta);
54
+ return formatDateOnly({
55
+ ...month,
56
+ day: Math.min(parts.day, daysInMonth(month.year, month.month)),
57
+ });
58
+ }
59
+ export function monthOf(value) {
60
+ const parts = requireDate(value);
61
+ return { year: parts.year, month: parts.month };
62
+ }
63
+ export function shiftMonth(month, delta) {
64
+ const index = month.year * 12 + (month.month - 1) + delta;
65
+ return { year: Math.floor(index / 12), month: (index % 12) + 1 };
66
+ }
67
+ export function firstOfMonth(month) {
68
+ return formatDateOnly({ ...month, day: 1 });
69
+ }
70
+ export function lastOfMonth(month) {
71
+ return formatDateOnly({
72
+ ...month,
73
+ day: daysInMonth(month.year, month.month),
74
+ });
75
+ }
76
+ /** Whether any day of the month falls inside `[min, max]`. */
77
+ export function monthInRange(month, min, max) {
78
+ return ((min === undefined || lastOfMonth(month) >= min) &&
79
+ (max === undefined || firstOfMonth(month) <= max));
80
+ }
81
+ /**
82
+ * The months of `year` with any day inside `[min, max]`, 1–12, for a month
83
+ * selector that only offers months a person can open.
84
+ */
85
+ export function monthsInRange(year, min, max) {
86
+ return Array.from({ length: 12 }, (_, index) => index + 1).filter((month) => monthInRange({ year, month }, min, max));
87
+ }
88
+ /**
89
+ * Six rows of seven cells covering the month, padded with the neighboring
90
+ * months so the grid never changes height. Padding before 0001-01-01 or
91
+ * after 9999-12-31 is `null`: no four-digit date exists there.
92
+ */
93
+ export function monthGrid(month, weekStartsOn) {
94
+ const first = firstOfMonth(month);
95
+ const leading = (weekdayOf(first) - weekStartsOn + 7) % 7;
96
+ const start = toDayNumber(requireDate(first)) - leading;
97
+ return Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, column) => {
98
+ const parts = fromDayNumber(start + row * 7 + column);
99
+ if (!isValidDateParts(parts))
100
+ return null;
101
+ return {
102
+ value: formatDateOnly(parts),
103
+ day: parts.day,
104
+ inMonth: parts.year === month.year && parts.month === month.month,
105
+ };
106
+ }));
107
+ }
108
+ /** Weekday indexes in display order for the given week start. */
109
+ export function weekdayOrder(weekStartsOn) {
110
+ return Array.from({ length: 7 }, (_, index) => (index + weekStartsOn) % 7);
111
+ }
@@ -0,0 +1,14 @@
1
+ /** How far the year list reaches past today and the shown month by default. */
2
+ export declare const YEARS_BACK = 120;
3
+ export declare const YEARS_AHEAD = 20;
4
+ export type YearRange = {
5
+ first: number;
6
+ last: number;
7
+ };
8
+ /**
9
+ * The years a calendar's year selector offers: 120 years before and 20
10
+ * after both today and the shown year, cut to `min` and `max`. The shown
11
+ * year is always inside, so the selector can display it.
12
+ */
13
+ export declare function calendarYearRange(shownYear: number, todayYear: number, min?: string, max?: string): YearRange;
14
+ export declare function yearsIn({ first, last }: YearRange): number[];
@@ -0,0 +1,20 @@
1
+ import { parseDateOnly } from './date-only.js';
2
+ /** How far the year list reaches past today and the shown month by default. */
3
+ export const YEARS_BACK = 120;
4
+ export const YEARS_AHEAD = 20;
5
+ /**
6
+ * The years a calendar's year selector offers: 120 years before and 20
7
+ * after both today and the shown year, cut to `min` and `max`. The shown
8
+ * year is always inside, so the selector can display it.
9
+ */
10
+ export function calendarYearRange(shownYear, todayYear, min, max) {
11
+ const minYear = min === undefined ? 1 : (parseDateOnly(min)?.year ?? 1);
12
+ const maxYear = max === undefined ? 9999 : (parseDateOnly(max)?.year ?? 9999);
13
+ return {
14
+ first: Math.max(minYear, 1, Math.min(shownYear, todayYear) - YEARS_BACK),
15
+ last: Math.min(maxYear, 9999, Math.max(shownYear, todayYear) + YEARS_AHEAD),
16
+ };
17
+ }
18
+ export function yearsIn({ first, last }) {
19
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index);
20
+ }
@@ -1,8 +1,13 @@
1
1
  import { type DetailsHTMLAttributes, type ReactNode } from 'react';
2
+ export type AccordionSize = 'sm' | 'md';
2
3
  export type AccordionProps = Omit<DetailsHTMLAttributes<HTMLDetailsElement>, 'onToggle' | 'open' | 'title' | 'children'> & {
3
4
  open: boolean;
4
5
  onOpenChange: (open: boolean) => void;
5
6
  title: string;
7
+ /** One muted line under the title, such as a summary of the content. */
8
+ subtitle?: string;
9
+ /** `sm` is a quieter disclosure nested inside other content. */
10
+ size?: AccordionSize;
6
11
  children: ReactNode;
7
12
  };
8
- export declare function Accordion({ children, className, onOpenChange, open, title, ...rest }: AccordionProps): import("react").JSX.Element;
13
+ export declare function Accordion({ children, className, onOpenChange, open, size, subtitle, title, ...rest }: AccordionProps): import("react").JSX.Element;
@@ -4,11 +4,20 @@ import { cx } from '../class-names.js';
4
4
  import { spacingClassNames } from '../spacing-classes.js';
5
5
  import { Stack } from './Stack.js';
6
6
  import { Text } from './Text.js';
7
- export function Accordion({ children, className, onOpenChange, open, title, ...rest }) {
8
- return (_jsxs("details", { ...rest, className: cx('sw-accordion', className), open: open, onToggle: (event) => {
7
+ const spaceBySize = { sm: 3, md: 4 };
8
+ /* The sm header is an sm control: a step of block padding over its generated
9
+ min-height instead of the full inset, so it sits lower than a section. */
10
+ const summarySpacingBySize = {
11
+ sm: { paddingX: 3, paddingY: 1 },
12
+ md: { padding: 4 },
13
+ };
14
+ const titleVariantBySize = { sm: 'label', md: 'title' };
15
+ export function Accordion({ children, className, onOpenChange, open, size = 'md', subtitle, title, ...rest }) {
16
+ const space = spaceBySize[size];
17
+ return (_jsxs("details", { ...rest, className: cx('sw-accordion', size === 'sm' && 'sw-accordion-sm', className), open: open, onToggle: (event) => {
9
18
  const next = event.currentTarget.open;
10
19
  if (next !== open) {
11
20
  onOpenChange(next);
12
21
  }
13
- }, children: [_jsx("summary", { className: cx('sw-accordion-summary', ...spacingClassNames({ padding: 4 })), children: _jsx(Text, { as: "span", variant: "title", children: title }) }), _jsx(Stack, { gap: 4, padding: 4, paddingTop: 0, children: children })] }));
22
+ }, children: [_jsxs("summary", { className: cx('sw-accordion-summary', ...spacingClassNames(summarySpacingBySize[size])), children: [_jsxs("span", { className: "sw-accordion-heading", children: [_jsx(Text, { as: "span", variant: titleVariantBySize[size], children: title }), ' ', subtitle ? (_jsx(Text, { as: "span", color: "muted", variant: "caption", children: subtitle })) : null] }), _jsx("span", { "aria-hidden": "true", className: "sw-accordion-marker" })] }), _jsx(Stack, { gap: space, padding: space, paddingTop: 0, children: children })] }));
14
23
  }