@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.
- package/CHANGELOG.md +140 -0
- package/README.md +69 -2
- package/dist/anchored-position.d.ts +28 -0
- package/dist/anchored-position.js +17 -0
- package/dist/calendar-keys.d.ts +19 -0
- package/dist/calendar-keys.js +39 -0
- package/dist/calendar-labels.d.ts +12 -0
- package/dist/calendar-labels.js +32 -0
- package/dist/calendar-month.d.ts +36 -0
- package/dist/calendar-month.js +111 -0
- package/dist/calendar-years.d.ts +14 -0
- package/dist/calendar-years.js +20 -0
- package/dist/components/Accordion.d.ts +6 -1
- package/dist/components/Accordion.js +12 -3
- package/dist/components/ActionBar.d.ts +30 -0
- package/dist/components/ActionBar.js +13 -0
- package/dist/components/Box.d.ts +7 -3
- package/dist/components/Box.js +2 -2
- package/dist/components/DateField.d.ts +15 -1
- package/dist/components/DateField.js +58 -18
- package/dist/components/Field.d.ts +5 -1
- package/dist/components/Field.js +16 -4
- package/dist/components/FieldAdornment.d.ts +20 -0
- package/dist/components/FieldAdornment.js +34 -0
- package/dist/components/Grid.d.ts +4 -1
- package/dist/components/Grid.js +2 -2
- package/dist/components/Progress.d.ts +3 -1
- package/dist/components/Progress.js +2 -2
- package/dist/components/date-field/CalendarDialog.d.ts +26 -0
- package/dist/components/date-field/CalendarDialog.js +93 -0
- package/dist/components/date-field/CalendarGrid.d.ts +23 -0
- package/dist/components/date-field/CalendarGrid.js +36 -0
- package/dist/components/date-field/CalendarHeader.d.ts +18 -0
- package/dist/components/date-field/CalendarHeader.js +33 -0
- package/dist/components/date-field/DateFieldGlyph.d.ts +9 -0
- package/dist/components/date-field/DateFieldGlyph.js +15 -0
- package/dist/components/date-field/use-anchored-popover.d.ts +8 -0
- package/dist/components/date-field/use-anchored-popover.js +61 -0
- package/dist/components/date-field/use-date-entry.d.ts +20 -0
- package/dist/components/date-field/use-date-entry.js +61 -0
- package/dist/components/date-field/use-lang-locale.d.ts +7 -0
- package/dist/components/date-field/use-lang-locale.js +17 -0
- package/dist/css/css-accordion.js +55 -8
- package/dist/css/css-action-bar.d.ts +4 -0
- package/dist/css/css-action-bar.js +99 -0
- package/dist/css/css-action-menu.js +2 -1
- package/dist/css/css-box-border.d.ts +5 -0
- package/dist/css/css-box-border.js +24 -0
- package/dist/css/css-chrome.js +2 -1
- package/dist/css/css-components.js +10 -6
- package/dist/css/css-data.js +2 -1
- package/dist/css/css-date-field.js +241 -11
- package/dist/css/css-denomination-grid.js +2 -1
- package/dist/css/css-document.d.ts +2 -0
- package/dist/css/css-document.js +2 -1
- package/dist/css/css-field.js +61 -1
- package/dist/css/css-select.js +2 -1
- package/dist/css/css-tooltip.js +2 -1
- package/dist/css/stacking.d.ts +10 -0
- package/dist/css/stacking.js +47 -0
- package/dist/css/stylesheet.js +4 -0
- package/dist/date-entry-validity.d.ts +10 -0
- package/dist/date-entry-validity.js +16 -0
- package/dist/date-entry.d.ts +32 -0
- package/dist/date-entry.js +106 -0
- package/dist/date-field-labels.d.ts +38 -0
- package/dist/date-field-labels.js +22 -0
- package/dist/date-field-locale.d.ts +8 -0
- package/dist/date-field-locale.js +27 -0
- package/dist/date-formatters.d.ts +13 -0
- package/dist/date-formatters.js +28 -0
- package/dist/date-only.d.ts +23 -0
- package/dist/date-only.js +63 -9
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/styles.css +388 -21
- 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
|
|
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
|
-
|
|
8
|
-
|
|
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: [
|
|
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
|
}
|