@scalewing/react 1.9.0 → 1.10.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
@@ -1,5 +1,18 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 964e461: New `CalendarButton`: an icon-only `Button` that opens the `DateField` calendar dialog for a date the page already shows, such as a day heading with its own previous and next buttons (`docs/requests/teisoro-calendar-button.md`).
8
+
9
+ - Props: `label`, `value` (`YYYY-MM-DD`, never empty), `onChange`, and optional `min`, `max`, `disabled`, `locale`, `weekStartsOn`, `id`, `labels` (the calendar's words `previousMonth`, `nextMonth`, `month`, `year`, `today`, and `nameSeparator`), `size` (`xs`, `sm`, `md`; default `md`), and `variant` (a Button variant; default `ghost`). A `ref` reaches the `<button>`. New types `CalendarButtonProps` and `CalendarButtonLabels`.
10
+ - The accessible name is `label`, `labels.nameSeparator` (default `", "`, overridable per locale, such as `"、"` in Japanese), then the spoken date ("Choose survey day, Tuesday, September 22, 2026"), with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open. Enter, Space, or a press opens the calendar on `value` (a `value` outside `min`/`max` is kept, as on `DateField`, and the calendar opens on the nearest allowed day); choosing a day calls `onChange`, closes, and returns focus to the button; Escape and a press outside close without a change.
11
+ - An empty or invalid `value`, invalid bounds, locale, week start, or calendar words throw a `RangeError` with `DateField`'s messages. Two errors are new: `label must be non-empty text` and `labels.nameSeparator must be non-empty text`.
12
+ - New generated class `sw-calendar-button`: square at every Button size, and at least the 44 px md control height on a coarse pointer. The calendar reuses `sw-date-field-calendar` and its existing stacking layer.
13
+
14
+ No new dependencies.
15
+
3
16
  ## 1.9.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  BarChart,
13
13
  Box,
14
14
  Button,
15
+ CalendarButton,
15
16
  Card,
16
17
  Checkbox,
17
18
  RadioGroup,
@@ -170,6 +171,29 @@ People type the date into a text entry in the locale's numeric order (`MM/DD/YYY
170
171
  />
171
172
  ```
172
173
 
174
+ `CalendarButton` is an icon-only `Button` that opens the same calendar dialog for a date the page already shows, such as a day heading with its own previous and next steps: `‹ Tuesday, September 22, 2026 [calendar] ›`. It has no text entry and no empty value. Supply `label` (what pressing it does, such as "Choose survey day"), a `YYYY-MM-DD` `value`, and `onChange`; optional `min`, `max`, `disabled`, `locale`, and `weekStartsOn` behave as on `DateField`, and `id` and a `ref` reach the `<button>`. `labels` takes the calendar's words (`previousMonth`, `nextMonth`, `month`, `year`, `today`) and `nameSeparator`. The button's accessible name is `label`, `labels.nameSeparator` (default `", "`), then the spoken date (`"Choose survey day, Tuesday, September 22, 2026"`); pass the locale's own pause where a comma does not fit, such as `"、"` in Japanese. It comes with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open; the calendar dialog is named by `label`. Enter, Space, or a press opens it on `value`. As on `DateField`, a `value` outside `min`/`max` is kept rather than refused, and the calendar then opens on the nearest allowed day with `value` shown selected and disabled; a button has no invalid state, so the page that shows the date flags it if it must. Choosing a day or **Today** calls `onChange` with the new date only when it changed, closes, and returns focus to the button; Escape closes without a change and returns focus; a press outside closes without a change and leaves focus where it landed. An empty or malformed `value`, `min`, or `max`, inverted bounds, a bad `locale` or `weekStartsOn`, an empty `label`, or an empty label word throws a `RangeError`. `size` (`xs`, `sm`, `md`; default `md`) and `variant` (default `ghost`) follow `Button`; the button is square at every size, and on a coarse pointer it is at least 44 px at every size. Place it beside the heading, not inside it, so the heading's text stays only the date.
175
+
176
+ ```tsx
177
+ <Inline gap={1}>
178
+ <Button aria-label="Previous day" variant="ghost" onPress={previousDay}>
179
+ ‹
180
+ </Button>
181
+ <Text as="h2" variant="title">
182
+ {spokenSurveyDay}
183
+ </Text>
184
+ <CalendarButton
185
+ label="Choose survey day"
186
+ value={surveyDay}
187
+ onChange={setSurveyDay}
188
+ min="2026-09-01"
189
+ max="2026-09-30"
190
+ />
191
+ <Button aria-label="Next day" variant="ghost" onPress={nextDay}>
192
+ ›
193
+ </Button>
194
+ </Inline>
195
+ ```
196
+
173
197
  ```tsx
174
198
  <Switch
175
199
  label="Active habitats only"
@@ -0,0 +1,19 @@
1
+ import { type DateFieldLabels } from './date-field-labels.js';
2
+ /**
3
+ * CalendarButton's own words: the calendar's words from DateFieldLabels,
4
+ * plus the text between its label and the spoken date in its name.
5
+ */
6
+ export type CalendarButtonLabels = Pick<DateFieldLabels, 'previousMonth' | 'nextMonth' | 'month' | 'year' | 'today'> & {
7
+ /**
8
+ * Joins the label and the spoken date in the button's name. Defaults to
9
+ * ", "; pass the locale's own pause, such as "、" in Japanese.
10
+ */
11
+ nameSeparator: string;
12
+ };
13
+ export declare const defaultNameSeparator = ", ";
14
+ export type ResolvedCalendarButtonLabels = {
15
+ calendar: DateFieldLabels;
16
+ nameSeparator: string;
17
+ };
18
+ /** Consumer words over the English defaults; empty words fail closed. */
19
+ export declare function resolveCalendarButtonLabels(labels?: Partial<CalendarButtonLabels>): ResolvedCalendarButtonLabels;
@@ -0,0 +1,10 @@
1
+ import { resolveDateFieldLabels, } from './date-field-labels.js';
2
+ export const defaultNameSeparator = ', ';
3
+ /** Consumer words over the English defaults; empty words fail closed. */
4
+ export function resolveCalendarButtonLabels(labels = {}) {
5
+ const { nameSeparator = defaultNameSeparator, ...calendar } = labels;
6
+ // Only an empty string is refused: a lone space is a valid separator.
7
+ if (typeof nameSeparator !== 'string' || nameSeparator === '')
8
+ throw new RangeError('labels.nameSeparator must be non-empty text');
9
+ return { calendar: resolveDateFieldLabels(calendar), nameSeparator };
10
+ }
@@ -10,3 +10,10 @@ export declare function monthNames(locale: string): string[];
10
10
  export declare function monthTitle(month: CalendarMonth, locale: string): string;
11
11
  /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
12
12
  export declare function formatDateLabel(value: string, locale: string): string;
13
+ /**
14
+ * An icon-only calendar trigger's name: its purpose, a separator, then the
15
+ * date it holds spoken in full, such as "Choose survey day, Tuesday,
16
+ * September 22, 2026". `Intl.ListFormat` has no neutral join (Spanish unit
17
+ * lists add "y", Chinese adds nothing), so the separator is the caller's.
18
+ */
19
+ export declare function calendarTriggerName(purpose: string, value: string, locale: string, separator: string): string;
@@ -30,3 +30,12 @@ export function formatDateLabel(value, locale) {
30
30
  return '';
31
31
  return dateFormatter(locale, { dateStyle: 'full' }).format(formatterDate(parts));
32
32
  }
33
+ /**
34
+ * An icon-only calendar trigger's name: its purpose, a separator, then the
35
+ * date it holds spoken in full, such as "Choose survey day, Tuesday,
36
+ * September 22, 2026". `Intl.ListFormat` has no neutral join (Spanish unit
37
+ * lists add "y", Chinese adds nothing), so the separator is the caller's.
38
+ */
39
+ export function calendarTriggerName(purpose, value, locale, separator) {
40
+ return `${purpose}${separator}${formatDateLabel(value, locale)}`;
41
+ }
@@ -5,6 +5,8 @@ export type CalendarMonth = {
5
5
  };
6
6
  /** 0 is Sunday, 1 is Monday, matching `Date.prototype.getDay`. */
7
7
  export type WeekStart = 0 | 1;
8
+ /** Fails closed on a first weekday other than Sunday or Monday. */
9
+ export declare function assertWeekStart(weekStartsOn: number): void;
8
10
  export type CalendarCell = {
9
11
  value: string;
10
12
  day: number;
@@ -1,4 +1,9 @@
1
1
  import { daysInMonth, formatDateOnly, isValidDateParts, parseDateOnly, } from './date-only.js';
2
+ /** Fails closed on a first weekday other than Sunday or Monday. */
3
+ export function assertWeekStart(weekStartsOn) {
4
+ if (weekStartsOn !== 0 && weekStartsOn !== 1)
5
+ throw new RangeError('weekStartsOn must be 0 or 1');
6
+ }
2
7
  /*
3
8
  * Day arithmetic runs on a proleptic Gregorian day count, so it never passes
4
9
  * through a Date, a time zone, or a daylight-saving jump.
@@ -0,0 +1,43 @@
1
+ import { type CalendarButtonLabels } from '../calendar-button-labels.js';
2
+ import { type WeekStart } from '../calendar-month.js';
3
+ import { type ButtonSize, type ButtonVariant } from './Button.js';
4
+ export type { CalendarButtonLabels };
5
+ export type CalendarButtonProps = {
6
+ /**
7
+ * What pressing it does, such as "Choose survey day". The button's name
8
+ * is this label, `labels.nameSeparator`, then `value` spoken in full; the
9
+ * calendar is named by the label.
10
+ */
11
+ label: string;
12
+ /**
13
+ * Calendar date `YYYY-MM-DD`. The button always holds a date. A value
14
+ * outside `min`/`max` stays the value, as on DateField; the calendar then
15
+ * opens on the nearest allowed day.
16
+ */
17
+ value: string;
18
+ /** Receives the chosen `YYYY-MM-DD`, never converted through UTC. */
19
+ onChange: (value: string) => void;
20
+ min?: string;
21
+ max?: string;
22
+ disabled?: boolean;
23
+ /** The `<button>` element's id. */
24
+ id?: string;
25
+ /**
26
+ * BCP 47 tag for the spoken date and the calendar's month and weekday
27
+ * names. Defaults to the nearest `lang` attribute, then en-US.
28
+ */
29
+ locale?: string;
30
+ /** First column of the calendar: 0 Sunday (default) or 1 Monday. */
31
+ weekStartsOn?: WeekStart;
32
+ /** The calendar's words and the name separator; English by default. */
33
+ labels?: Partial<CalendarButtonLabels>;
34
+ /** A Button size; a coarse pointer always gets a 44 px target. */
35
+ size?: ButtonSize;
36
+ variant?: ButtonVariant;
37
+ };
38
+ /**
39
+ * An icon-only button that opens DateField's calendar dialog to pick a
40
+ * date for something the page already shows, such as a day heading. It has
41
+ * no text entry and no empty value. The ref reaches the `<button>`.
42
+ */
43
+ export declare const CalendarButton: import("react").ForwardRefExoticComponent<CalendarButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useId } from 'react';
4
+ import { resolveCalendarButtonLabels, } from '../calendar-button-labels.js';
5
+ import { calendarTriggerName } from '../calendar-labels.js';
6
+ import { assertWeekStart } from '../calendar-month.js';
7
+ import { assertDateBounds, assertDateOnly } from '../date-only.js';
8
+ import { Button } from './Button.js';
9
+ import { CalendarDialog } from './date-field/CalendarDialog.js';
10
+ import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
11
+ import { useCalendarPopup } from './date-field/use-calendar-popup.js';
12
+ import { useLangLocale } from './date-field/use-lang-locale.js';
13
+ function assertLabel(label) {
14
+ if (typeof label !== 'string' || label.trim() === '')
15
+ throw new RangeError('label must be non-empty text');
16
+ }
17
+ /** Sets a forwarded ref, callback or object, to the same node. */
18
+ function setForwardedRef(ref, node) {
19
+ if (typeof ref === 'function')
20
+ ref(node);
21
+ else if (ref)
22
+ ref.current = node;
23
+ }
24
+ /**
25
+ * An icon-only button that opens DateField's calendar dialog to pick a
26
+ * date for something the page already shows, such as a day heading. It has
27
+ * no text entry and no empty value. The ref reaches the `<button>`.
28
+ */
29
+ export const CalendarButton = forwardRef(function CalendarButton({ label, value, onChange, min, max, disabled = false, id, locale, weekStartsOn = 0, labels, size = 'md', variant = 'ghost', }, ref) {
30
+ assertLabel(label);
31
+ assertDateOnly('value', value);
32
+ assertDateBounds(value, min, max);
33
+ assertWeekStart(weekStartsOn);
34
+ const words = resolveCalendarButtonLabels(labels);
35
+ const { buttonRef, shown, toggle, trigger, dialog } = useCalendarPopup({
36
+ value,
37
+ disabled,
38
+ onChange,
39
+ });
40
+ const resolvedLocale = useLangLocale(locale, buttonRef);
41
+ const labelId = useId();
42
+ const setButton = useCallback((node) => {
43
+ buttonRef.current = node;
44
+ setForwardedRef(ref, node);
45
+ }, [buttonRef, ref]);
46
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { ...trigger, className: "sw-calendar-button", disabled: disabled, id: id, onPress: toggle, ref: setButton, size: size, variant: variant, children: [_jsx("span", { className: "sw-sr-only", children: calendarTriggerName(label, value, resolvedLocale, words.nameSeparator) }), _jsx(DateFieldGlyph, { name: "calendar" })] }), shown ? (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, id: labelId, children: label }), _jsx(CalendarDialog, { ...dialog, anchorRef: buttonRef, buttonRef: buttonRef, labelId: labelId, labels: words.calendar, locale: resolvedLocale, max: max, min: min, required: true, value: value, weekStartsOn: weekStartsOn })] })) : null] }));
47
+ });
@@ -1,22 +1,22 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useId, useMemo, useRef } from 'react';
4
+ import { assertWeekStart } from '../calendar-month.js';
4
5
  import { resolveDateFieldLabels, } from '../date-field-labels.js';
5
6
  import { dateEntryPattern, dateEntryPlaceholder } from '../date-entry.js';
6
7
  import { dateEntryValidity } from '../date-entry-validity.js';
7
- import { assertDateBounds, isOutsideDateRange, todayDateOnly, } from '../date-only.js';
8
+ import { assertDateBounds, isOutsideDateRange } from '../date-only.js';
8
9
  import { CalendarDialog } from './date-field/CalendarDialog.js';
9
10
  import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
11
+ import { useCalendarPopup } from './date-field/use-calendar-popup.js';
10
12
  import { useDateEntry } from './date-field/use-date-entry.js';
11
13
  import { useLangLocale } from './date-field/use-lang-locale.js';
12
14
  export function DateField({ label, value, onChange, min, max, disabled = false, required = false, description, error, locale, weekStartsOn = 0, labels, }) {
13
15
  assertDateBounds(value, min, max);
14
- if (weekStartsOn !== 0 && weekStartsOn !== 1)
15
- throw new RangeError('weekStartsOn must be 0 or 1');
16
+ assertWeekStart(weekStartsOn);
16
17
  const words = resolveDateFieldLabels(labels);
17
18
  const rootRef = useRef(null);
18
19
  const controlRef = useRef(null);
19
- const buttonRef = useRef(null);
20
20
  const inputRef = useRef(null);
21
21
  const resolvedLocale = useLangLocale(locale, rootRef);
22
22
  const pattern = useMemo(() => dateEntryPattern(resolvedLocale), [resolvedLocale]);
@@ -29,14 +29,14 @@ export function DateField({ label, value, onChange, min, max, disabled = false,
29
29
  useEffect(() => {
30
30
  inputRef.current?.setCustomValidity(validity);
31
31
  }, [validity]);
32
- const [open, setOpen] = useState(false);
33
- // Disabling the field closes its calendar for good, not just while disabled.
34
- if (disabled && open)
35
- setOpen(false);
36
- const shown = open && !disabled;
32
+ const { buttonRef, shown, toggle, trigger, dialog } = useCalendarPopup({
33
+ value,
34
+ disabled,
35
+ onChange,
36
+ beforeSelect: entry.reset,
37
+ });
37
38
  const inputId = useId();
38
39
  const labelId = useId();
39
- const dialogId = useId();
40
40
  const descriptionId = useId();
41
41
  const entryErrorId = useId();
42
42
  const errorId = useId();
@@ -48,17 +48,6 @@ export function DateField({ label, value, onChange, min, max, disabled = false,
48
48
  .filter((id) => id !== null)
49
49
  .join(' ');
50
50
  const invalid = Boolean(error) || entry.invalid || isOutsideDateRange(value, min, max);
51
- function close(restoreFocus) {
52
- setOpen(false);
53
- if (restoreFocus)
54
- buttonRef.current?.focus();
55
- }
56
- function select(next) {
57
- entry.reset();
58
- close(true);
59
- if (next !== value)
60
- onChange(next);
61
- }
62
51
  return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: label }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
63
52
  if (event.key === 'Enter')
64
53
  entry.check();
@@ -66,5 +55,5 @@ export function DateField({ label, value, onChange, min, max, disabled = false,
66
55
  day: words.dayPlaceholder,
67
56
  month: words.monthPlaceholder,
68
57
  year: words.yearPlaceholder,
69
- }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { "aria-controls": shown ? dialogId : undefined, "aria-expanded": shown, "aria-haspopup": "dialog", "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: () => (shown ? close(true) : setOpen(true)), ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, entry.invalid ? (_jsx("span", { className: "sw-date-field-error", id: entryErrorId, children: words.invalidEntry })) : null, error ? (_jsx("span", { className: "sw-date-field-error", id: errorId, children: error })) : null, shown ? (_jsx(CalendarDialog, { anchorRef: controlRef, buttonRef: buttonRef, id: dialogId, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, onClose: close, onSelect: select, required: required, today: todayDateOnly(), value: value, weekStartsOn: weekStartsOn })) : null] }));
58
+ }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { ...trigger, "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: toggle, ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, entry.invalid ? (_jsx("span", { className: "sw-date-field-error", id: entryErrorId, children: words.invalidEntry })) : null, error ? (_jsx("span", { className: "sw-date-field-error", id: errorId, children: error })) : null, shown ? (_jsx(CalendarDialog, { ...dialog, buttonRef: buttonRef, anchorRef: controlRef, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, required: required, value: value, weekStartsOn: weekStartsOn })) : null] }));
70
59
  }
@@ -1,8 +1,8 @@
1
1
  export type DateFieldGlyphName = 'calendar' | 'previous' | 'next';
2
2
  /**
3
- * Private control chrome for DateField. Stroke, size, and color come from
4
- * the generated `sw-date-field-glyph` rule; the chevrons mirror in
5
- * right-to-left text.
3
+ * Private control chrome for DateField and CalendarButton. Stroke, size,
4
+ * and color come from the generated `sw-date-field-glyph` rule; the
5
+ * chevrons mirror in right-to-left text.
6
6
  */
7
7
  export declare function DateFieldGlyph({ name }: {
8
8
  name: DateFieldGlyphName;
@@ -6,9 +6,9 @@ const PATHS = {
6
6
  next: 'M9.5 6l6 6-6 6',
7
7
  };
8
8
  /**
9
- * Private control chrome for DateField. Stroke, size, and color come from
10
- * the generated `sw-date-field-glyph` rule; the chevrons mirror in
11
- * right-to-left text.
9
+ * Private control chrome for DateField and CalendarButton. Stroke, size,
10
+ * and color come from the generated `sw-date-field-glyph` rule; the
11
+ * chevrons mirror in right-to-left text.
12
12
  */
13
13
  export function DateFieldGlyph({ name }) {
14
14
  return (_jsx("svg", { "aria-hidden": "true", className: cx('sw-date-field-glyph', name !== 'calendar' && 'sw-date-field-glyph-directional'), focusable: "false", viewBox: "0 0 24 24", children: _jsx("path", { d: PATHS[name] }) }));
@@ -0,0 +1,36 @@
1
+ import { type RefObject } from 'react';
2
+ export type CalendarPopupOptions = {
3
+ value: string;
4
+ disabled: boolean;
5
+ onChange: (value: string) => void;
6
+ /** Runs before a chosen date closes the calendar, such as a text reset. */
7
+ beforeSelect?: () => void;
8
+ };
9
+ export type CalendarPopup = {
10
+ /** The button that toggles the calendar and takes focus back. */
11
+ buttonRef: RefObject<HTMLButtonElement | null>;
12
+ shown: boolean;
13
+ toggle: () => void;
14
+ /** ARIA for the toggling button, as the WAI-ARIA date picker asks. */
15
+ trigger: {
16
+ 'aria-controls': string | undefined;
17
+ 'aria-expanded': boolean;
18
+ 'aria-haspopup': 'dialog';
19
+ };
20
+ /**
21
+ * CalendarDialog props that belong to the open and close cycle; pass
22
+ * `buttonRef` beside them.
23
+ */
24
+ dialog: {
25
+ id: string;
26
+ onClose: (restoreFocus: boolean) => void;
27
+ onSelect: (value: string) => void;
28
+ today: string;
29
+ };
30
+ };
31
+ /**
32
+ * The open state of a calendar dialog toggled by one button, shared by
33
+ * DateField and CalendarButton. Disabling closes it for good; choosing a
34
+ * date closes it, returns focus to the button, and reports a changed date.
35
+ */
36
+ export declare function useCalendarPopup({ value, disabled, onChange, beforeSelect, }: CalendarPopupOptions): CalendarPopup;
@@ -0,0 +1,43 @@
1
+ import { useId, useRef, useState } from 'react';
2
+ import { todayDateOnly } from '../../date-only.js';
3
+ /**
4
+ * The open state of a calendar dialog toggled by one button, shared by
5
+ * DateField and CalendarButton. Disabling closes it for good; choosing a
6
+ * date closes it, returns focus to the button, and reports a changed date.
7
+ */
8
+ export function useCalendarPopup({ value, disabled, onChange, beforeSelect, }) {
9
+ const buttonRef = useRef(null);
10
+ const [open, setOpen] = useState(false);
11
+ // Disabling closes the calendar for good, not just while disabled.
12
+ if (disabled && open)
13
+ setOpen(false);
14
+ const shown = open && !disabled;
15
+ const id = useId();
16
+ function close(restoreFocus) {
17
+ setOpen(false);
18
+ if (restoreFocus)
19
+ buttonRef.current?.focus();
20
+ }
21
+ function select(next) {
22
+ beforeSelect?.();
23
+ close(true);
24
+ if (next !== value)
25
+ onChange(next);
26
+ }
27
+ return {
28
+ buttonRef,
29
+ shown,
30
+ toggle: () => (shown ? close(true) : setOpen(true)),
31
+ trigger: {
32
+ 'aria-controls': shown ? id : undefined,
33
+ 'aria-expanded': shown,
34
+ 'aria-haspopup': 'dialog',
35
+ },
36
+ dialog: {
37
+ id,
38
+ onClose: close,
39
+ onSelect: select,
40
+ today: todayDateOnly(),
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,2 @@
1
+ export declare function cssCalendarButtonClasses(): string;
2
+ export declare function calendarButtonClassCatalog(): string[];
@@ -0,0 +1,33 @@
1
+ import { buttonSizes } from '@scalewing/tokens';
2
+ /* The comfortable touch target: the md control height, 44 px, both ways. */
3
+ const touchTarget = 'var(--sw-control-md-min-height)';
4
+ /* Square at every Button size: as wide as the size's control height. */
5
+ function squareRules() {
6
+ return buttonSizes
7
+ .map((size) => `.sw-button-${size}.sw-calendar-button { min-width: var(--sw-control-${size}-min-height); }`)
8
+ .join('\n');
9
+ }
10
+ export function cssCalendarButtonClasses() {
11
+ return `.sw-button.sw-calendar-button {
12
+ flex: none;
13
+ padding-inline: 0;
14
+ }
15
+
16
+ ${squareRules()}
17
+
18
+ .sw-calendar-button:not(.sw-button-md) .sw-date-field-glyph {
19
+ height: var(--sw-space-4);
20
+ width: var(--sw-space-4);
21
+ }
22
+
23
+ /* A coarse pointer gets the full touch target at the smaller sizes too. */
24
+ @media (pointer: coarse) {
25
+ .sw-button.sw-calendar-button {
26
+ min-height: ${touchTarget};
27
+ min-width: ${touchTarget};
28
+ }
29
+ }`;
30
+ }
31
+ export function calendarButtonClassCatalog() {
32
+ return ['sw-calendar-button'];
33
+ }
@@ -4,6 +4,7 @@ import { cssActionBarClasses } from './css-action-bar.js';
4
4
  import { cssActionMenuClasses } from './css-action-menu.js';
5
5
  import { cssAccordionClasses } from './css-accordion.js';
6
6
  import { cssButtonClasses } from './css-button.js';
7
+ import { cssCalendarButtonClasses } from './css-calendar-button.js';
7
8
  import { cssChartClasses } from './css-chart.js';
8
9
  import { cssCheckboxClasses } from './css-checkbox.js';
9
10
  import { cssRadioGroupClasses } from './css-radio-group.js';
@@ -133,6 +134,8 @@ ${cssDataClasses()}
133
134
 
134
135
  ${cssDateFieldClasses()}
135
136
 
137
+ ${cssCalendarButtonClasses()}
138
+
136
139
  ${cssChartClasses()}
137
140
 
138
141
  ${cssChromeClasses()}
@@ -13,7 +13,8 @@
13
13
  * 4. `topChrome`: the sticky AppHeader, over a lifted surface that scrolls up
14
14
  * under it (the header is the page's way back, and a popup opens downward).
15
15
  * 5. `popup`: a popup outside any lifted surface (Select list, ActionMenu
16
- * list, Tooltip, DateField calendar) over everything in the page.
16
+ * list, Tooltip, the DateField and CalendarButton calendar) over
17
+ * everything in the page.
17
18
  *
18
19
  * Dialog and Toast sit on the browser's top layer, above all of these.
19
20
  */
@@ -31,7 +32,8 @@ const openPopups = [
31
32
  '.sw-select-list',
32
33
  '.sw-action-menu-list:not([hidden])',
33
34
  '.sw-tooltip:not([hidden])',
34
- // Rendered only while open; on the top layer where the popover API exists.
35
+ // DateField and CalendarButton share it. Rendered only while open; on the
36
+ // top layer where the popover API exists.
35
37
  '.sw-date-field-calendar',
36
38
  ].join(', ');
37
39
  const popupHosts = ['.sw-card-glass', '.sw-accordion'];
@@ -4,6 +4,7 @@ import { actionMenuClassCatalog } from './css-action-menu.js';
4
4
  import { accordionClassCatalog } from './css-accordion.js';
5
5
  import { switchClassCatalog } from './css-switch.js';
6
6
  import { dateFieldClassCatalog } from './css-date-field.js';
7
+ import { calendarButtonClassCatalog } from './css-calendar-button.js';
7
8
  import { checkboxClassCatalog } from './css-checkbox.js';
8
9
  import { radioGroupClassCatalog } from './css-radio-group.js';
9
10
  import { spinnerClassCatalog } from './css-spinner.js';
@@ -57,6 +58,7 @@ export function utilityClassCatalog() {
57
58
  ...actionBarClassCatalog(),
58
59
  ...switchClassCatalog(),
59
60
  ...dateFieldClassCatalog(),
61
+ ...calendarButtonClassCatalog(),
60
62
  ...checkboxClassCatalog(),
61
63
  ...radioGroupClassCatalog(),
62
64
  ...spinnerClassCatalog(),
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { Accordion } from './components/Accordion.js';
4
4
  export type { AccordionProps, AccordionSize } from './components/Accordion.js';
5
5
  export { Switch, type SwitchProps } from './components/Switch.js';
6
6
  export { DateField, type DateFieldLabels, type DateFieldProps, type WeekStart, } from './components/DateField.js';
7
+ export { CalendarButton, type CalendarButtonLabels, type CalendarButtonProps, } from './components/CalendarButton.js';
7
8
  export { Checkbox, type CheckboxProps } from './components/Checkbox.js';
8
9
  export { Spinner, type SpinnerProps, type SpinnerSize, } from './components/Spinner.js';
9
10
  export { Progress, type ProgressProps, type ProgressTone, } from './components/Progress.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { ActionBar } from './components/ActionBar.js';
3
3
  export { Accordion } from './components/Accordion.js';
4
4
  export { Switch } from './components/Switch.js';
5
5
  export { DateField, } from './components/DateField.js';
6
+ export { CalendarButton, } from './components/CalendarButton.js';
6
7
  export { Checkbox } from './components/Checkbox.js';
7
8
  export { Spinner, } from './components/Spinner.js';
8
9
  export { Progress, } from './components/Progress.js';
package/dist/styles.css CHANGED
@@ -1544,6 +1544,28 @@ body {
1544
1544
  }
1545
1545
  }
1546
1546
 
1547
+ .sw-button.sw-calendar-button {
1548
+ flex: none;
1549
+ padding-inline: 0;
1550
+ }
1551
+
1552
+ .sw-button-xs.sw-calendar-button { min-width: var(--sw-control-xs-min-height); }
1553
+ .sw-button-sm.sw-calendar-button { min-width: var(--sw-control-sm-min-height); }
1554
+ .sw-button-md.sw-calendar-button { min-width: var(--sw-control-md-min-height); }
1555
+
1556
+ .sw-calendar-button:not(.sw-button-md) .sw-date-field-glyph {
1557
+ height: var(--sw-space-4);
1558
+ width: var(--sw-space-4);
1559
+ }
1560
+
1561
+ /* A coarse pointer gets the full touch target at the smaller sizes too. */
1562
+ @media (pointer: coarse) {
1563
+ .sw-button.sw-calendar-button {
1564
+ min-height: var(--sw-control-md-min-height);
1565
+ min-width: var(--sw-control-md-min-height);
1566
+ }
1567
+ }
1568
+
1547
1569
  .sw-bar-chart {
1548
1570
  display: grid;
1549
1571
  gap: var(--sw-space-2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",