@scalewing/react 1.53.0 → 1.54.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,11 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.54.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4c2d7cd: `Box` `external` and `newTabLabel` (opt-in, `as="a"` only): a link that opens in a new tab, `target="_blank"` with `noopener noreferrer` added to `rel`, a decorative arrow (↗) after its words drawn with borders in the link's colour, and `newTabLabel` (your words, such as "(opens in a new tab)", required with `external`) read after its name. New class `sw-link-external-mark`. `Stepper` `onCommit` and `commitDelay` (opt-in): the finished count once per entry, on blur, on Enter and `commitDelay` ms (default 600) after the last − / + press or step key, never per typed digit and never for an unchanged count; `onChange` is unchanged. Requests: `docs/requests/futmas-external-link.md`, `docs/requests/futmas-stepper-commit.md`.
8
+
3
9
  ## 1.53.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -106,7 +106,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
106
106
 
107
107
  `AppHeader` is the page's top chrome, sticky by default (`sticky={false}` leaves it in flow). Its default `appearance="glass"` is a rounded frosted card. `appearance="bar"` (`AppHeaderAppearance`, `appHeaderAppearances`) is a full-width band of the solid surface with the border hairline under it: no blur, no rounding, no side or top border, the same under Reduce Transparency, and the hairline kept in forced colors. Set it against the page's edges (no side gutter round it) and put the gutter inside it. A `ref` reaches its `header` element. An unknown `appearance` throws a `RangeError`.
108
108
 
109
- `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. They also take `lg` (64rem), for content that fits only from a laptop up, such as a row of labelled destinations that a tablet shows as glyphs (name each glyph-only control, for example with `Tooltip relationship="label"`); the layout props keep `md`. `breakpointQuery('below', 'lg')` returns the query a hide class uses (`not all and (min-width: 64rem)`), for an app whose script must follow the same width. An unknown breakpoint throws a `RangeError`. 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. `Box as="a" link="quiet"` (opt-in; `link="accent"`, the default, is the canvas link) is a title that opens something, such as a table row's title: it keeps the surrounding text's colour at weight 600 (`sw-link-quiet`) and is underlined on hover and keyboard focus. Its weight is the non-colour cue WCAG 1.4.1 asks for (axe's `link-in-text-block` passes it on colour, the same colour and background as its text, or on its weight; the weight is what keeps it visibly a link), so use it in text lighter than 600. In text already at 600 the quiet look has no cue: an accent link there needs at least 3:1 contrast against the text. `link` on a box that is not `as="a"` throws a `TypeError`, an unknown value a `RangeError`. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Steps` is a one-page vertical stepper: an `ol` of `Step` panels in order, numbered by their place, a line joining each marker to the next (in the success color below a done step). A `Step` takes `status` (`'upcoming' | 'current' | 'done' | 'error' | 'skipped'`) and `title` (a heading at the list's `headingLevel`, 2 by default, or the step's own). The current step is open with its `children` and has `aria-current="step"`; an error step stays open with a "!" marker. A done step folds to one line: a check, the title, its `summary` (truncated) and, with `onEdit` and `editLabel`, a ghost small `Button` at the end named by its label and the title ("Edit The site"). A step ahead is greyed with its number in a ring; a skipped step is greyed with its `skippedReason` after the title (pass your own separator, such as "· not needed"). A folded step's heading ends in its status word for assistive technology (visually hidden; English by default, `labels` on `Steps` takes your words): its name is one visually hidden line, the title, a comma and the word ("The site, Done"), and the drawn title is `aria-hidden`, because a browser lays a hidden box out as a block and would put a space before the comma ("The site , Done"). The step's region and its Edit button are still named by the drawn title. A current step, and a skipped step showing its reason, draw and read their words as they are. The heading's text content now holds the title twice (drawn, then in the spoken line), so a test reading a folded heading's `textContent` or finding its title with a pattern (`getByText(/The site/)`) finds both: query headings by role and name (`getByRole('heading', { name: 'The site, Done' })`). Copying a folded heading, or finding its title in the page, meets the words twice too. The comma after the title is Scalewing's, as before; the word is yours. When a step becomes current from a folded state (upcoming, done or skipped), focus moves to its heading, a programmatic focus target; if several do in one update, the first in the list takes it. A first render, a move between current and error, and a step that turns to error leave focus alone: after a refused submit, move focus to the first invalid field yourself, as with `Field`. An error step has no `aria-current`; its heading's status word says it needs attention. Scalewing holds no order rules: the consumer sets each step's status. `Steps open` (off by default) keeps every step open at once, for a form whose sections are all filled on one page: each step shows its `children` whatever its status, inside a `section` named by its title (a region, class `sw-steps-region`), every title at the open size; the marker still follows the status (a number to fill, a check when done, "!" in error, a grey ring and muted title when greyed, such as after a blocking step), no step is `aria-current`, and no step takes focus when its status changes. `titleDetail` (opt-in) is muted words after an open step's title, on its line, such as "Optional" on a step a person may skip, or "Wetlands done · Woodlands empty" on a step with a part per habitat: the label size at the body's weight in the muted colour, a space-2 after the title on its baseline, or on a line of their own under the title where they do not fit beside it (a long title on a phone), never past the panel (a long word breaks). They are drawn while the step is open (current or error, or every step of an open list, with fields or without) and stay as the step turns done in an open list; a folded step draws its summary or reason instead. They sit outside the heading, so the heading's name stays the title (and its status word) and a heading list reads the titles alone; an open list's region keeps the title as its name and is described by the words, read as a person enters it, and in a stepped list they follow the heading in reading order. Write your own separators inside the words; Scalewing draws only the gap after the title. The words share a skipped step's reason's muted colour, not its size: a reason is part of the title, these are a note beside it. Give a step its `titleDetail` from its first render: setting or clearing it later moves the heading into or out of the title line, which remounts it (and drops its focus if it has it). A blank `titleDetail` throws a `RangeError`. Pass the `Step`s as direct children (a Fragment around several would share one number). A `Step` outside `Steps`, a Fragment child, a blank title or reason, an unknown status or heading level, `onEdit` without `editLabel`, or a blank status word throws a `RangeError`.
109
+ `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. They also take `lg` (64rem), for content that fits only from a laptop up, such as a row of labelled destinations that a tablet shows as glyphs (name each glyph-only control, for example with `Tooltip relationship="label"`); the layout props keep `md`. `breakpointQuery('below', 'lg')` returns the query a hide class uses (`not all and (min-width: 64rem)`), for an app whose script must follow the same width. An unknown breakpoint throws a `RangeError`. 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. `Box as="a" link="quiet"` (opt-in; `link="accent"`, the default, is the canvas link) is a title that opens something, such as a table row's title: it keeps the surrounding text's colour at weight 600 (`sw-link-quiet`) and is underlined on hover and keyboard focus. Its weight is the non-colour cue WCAG 1.4.1 asks for (axe's `link-in-text-block` passes it on colour, the same colour and background as its text, or on its weight; the weight is what keeps it visibly a link), so use it in text lighter than 600. In text already at 600 the quiet look has no cue: an accent link there needs at least 3:1 contrast against the text. `link` on a box that is not `as="a"` throws a `TypeError`, an unknown value a `RangeError`. `Box as="a" external` (opt-in) opens the address in a new tab: it sets `target="_blank"` (always), adds `noopener noreferrer` to your `rel` words (each once), draws a decorative arrow (↗, `sw-link-external-mark`, `aria-hidden`) right after the words with no space, so it never wraps alone, and reads `newTabLabel` after the link's name from a visually hidden span ("Check the marsh map (opens in a new tab)"). The arrow is drawn with borders in the link's colour (accent, the hover colour, a quiet link's), so forced colours keep it, and is mirrored in right-to-left text. `newTabLabel` is your words in your language; it is required with `external` (blank throws a `RangeError`) and refused without it (`TypeError`); `external` on a box that is not `as="a"` throws a `TypeError`. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Steps` is a one-page vertical stepper: an `ol` of `Step` panels in order, numbered by their place, a line joining each marker to the next (in the success color below a done step). A `Step` takes `status` (`'upcoming' | 'current' | 'done' | 'error' | 'skipped'`) and `title` (a heading at the list's `headingLevel`, 2 by default, or the step's own). The current step is open with its `children` and has `aria-current="step"`; an error step stays open with a "!" marker. A done step folds to one line: a check, the title, its `summary` (truncated) and, with `onEdit` and `editLabel`, a ghost small `Button` at the end named by its label and the title ("Edit The site"). A step ahead is greyed with its number in a ring; a skipped step is greyed with its `skippedReason` after the title (pass your own separator, such as "· not needed"). A folded step's heading ends in its status word for assistive technology (visually hidden; English by default, `labels` on `Steps` takes your words): its name is one visually hidden line, the title, a comma and the word ("The site, Done"), and the drawn title is `aria-hidden`, because a browser lays a hidden box out as a block and would put a space before the comma ("The site , Done"). The step's region and its Edit button are still named by the drawn title. A current step, and a skipped step showing its reason, draw and read their words as they are. The heading's text content now holds the title twice (drawn, then in the spoken line), so a test reading a folded heading's `textContent` or finding its title with a pattern (`getByText(/The site/)`) finds both: query headings by role and name (`getByRole('heading', { name: 'The site, Done' })`). Copying a folded heading, or finding its title in the page, meets the words twice too. The comma after the title is Scalewing's, as before; the word is yours. When a step becomes current from a folded state (upcoming, done or skipped), focus moves to its heading, a programmatic focus target; if several do in one update, the first in the list takes it. A first render, a move between current and error, and a step that turns to error leave focus alone: after a refused submit, move focus to the first invalid field yourself, as with `Field`. An error step has no `aria-current`; its heading's status word says it needs attention. Scalewing holds no order rules: the consumer sets each step's status. `Steps open` (off by default) keeps every step open at once, for a form whose sections are all filled on one page: each step shows its `children` whatever its status, inside a `section` named by its title (a region, class `sw-steps-region`), every title at the open size; the marker still follows the status (a number to fill, a check when done, "!" in error, a grey ring and muted title when greyed, such as after a blocking step), no step is `aria-current`, and no step takes focus when its status changes. `titleDetail` (opt-in) is muted words after an open step's title, on its line, such as "Optional" on a step a person may skip, or "Wetlands done · Woodlands empty" on a step with a part per habitat: the label size at the body's weight in the muted colour, a space-2 after the title on its baseline, or on a line of their own under the title where they do not fit beside it (a long title on a phone), never past the panel (a long word breaks). They are drawn while the step is open (current or error, or every step of an open list, with fields or without) and stay as the step turns done in an open list; a folded step draws its summary or reason instead. They sit outside the heading, so the heading's name stays the title (and its status word) and a heading list reads the titles alone; an open list's region keeps the title as its name and is described by the words, read as a person enters it, and in a stepped list they follow the heading in reading order. Write your own separators inside the words; Scalewing draws only the gap after the title. The words share a skipped step's reason's muted colour, not its size: a reason is part of the title, these are a note beside it. Give a step its `titleDetail` from its first render: setting or clearing it later moves the heading into or out of the title line, which remounts it (and drops its focus if it has it). A blank `titleDetail` throws a `RangeError`. Pass the `Step`s as direct children (a Fragment around several would share one number). A `Step` outside `Steps`, a Fragment child, a blank title or reason, an unknown status or heading level, `onEdit` without `editLabel`, or a blank status word throws a `RangeError`.
110
110
 
111
111
  ```tsx
112
112
  <Steps aria-label="Heron survey">
@@ -257,6 +257,8 @@ A chosen photo can show itself: `value.preview` is the address of a small image
257
257
 
258
258
  `Stepper` is a whole-number count changed with − and + or typed. The count is a native text input with `role="spinbutton"` and `inputmode="numeric"`, named by `label` (visible above it, or `labelVisuallyHidden` where table headers say it) and the one tab stop: ArrowUp and ArrowDown step it by `step` (default 1), Home goes to `min` and End to a finite `max` (a range below zero takes `inputmode="text"`, since a phone's numeric pad has no minus key). The − and + buttons are pointer targets outside the tab order, named by `decrementLabel` and `incrementLabel` (your copy), and a press on one leaves focus where it was; the count and both buttons sit in a `group` named by the label. − is disabled at `min` (default 0) and + at `max` (no upper bound when left out). `value` is a count or `null` for an empty box, which the buttons treat as `min` and `placeholder` can fill. Typed text is refused unless it is digits (with a leading minus when `min` is below zero), so `onChange` only receives a whole number or `null`; a typed count is reported as typed even outside the bounds, so say why in `error` (a polite message, `aria-invalid` and the danger border, as `Field`'s), and the buttons then move it back inside them. `aria-describedby` takes ids of elements that describe the count, such as the value it adds up to, read before an `error`'s message. `changed` takes `Field`'s changed look; `readOnly` keeps the count focusable and hides the buttons in their own room; `disabled` stops all three. `limitAppearance="plain"` (default `"faded"`) keeps − at `min` and + at `max` in their usual look instead of faded, for a count sheet where a faded − reads as broken; they are still disabled, a disabled or read-only Stepper is unchanged, forced colors still grey them, and an unknown value throws a `RangeError`. The count is a few digits wide and keeps that width (`width="fixed"`), or fills its container (`width="full"`, for a table cell). `unit` (opt-in) is a short muted word after the count inside its frame, such as "min" or "players": the frame (`sw-stepper-count`) draws the control surface, focus ring and the invalid, changed and disabled states, a press on the word focuses the count, and the count keeps its few-digit width with its digits against the word, so the frame never follows it. The word (`sw-stepper-unit`) is hidden from assistive technology; the count is read with it (`aria-valuetext` "90 min", none while the box is empty), and a `valueText` you pass wins. The default joins the count and the word as given, so a word that changes with the count ("players") reads "1 players": pass `valueText` in your language's right form ("1 player", "11 players"). A blank `unit` throws a `RangeError`. Bounds that are not whole numbers, `min` above `max`, or a `step` that is not above zero throw a `RangeError`.
259
259
 
260
+ `Stepper` `onCommit` (opt-in) is the finished count once per entry, for a page that saves as you go: it fires on blur, on Enter (a form round the count still submits), and `commitDelay` ms (default 600) after the last − / + press or step key (ArrowUp, ArrowDown, Home, End), each press restarting the wait; blur or Enter while it waits commits at once. Typed digits never commit by themselves. It gives the count you hold at that moment, `null` or outside the bounds included as `onChange` reports them, so check it before saving. It does not fire when that count is the one last committed, or, before the first commit, the one held before the first edit; a count you set while nothing waits (a server's answer) becomes the new baseline. A commit still waiting when the Stepper unmounts is made then. `onChange` is unchanged. A `commitDelay` that is negative or not finite throws a `RangeError`.
261
+
260
262
  ```tsx
261
263
  <Stepper
262
264
  label="Herons"
@@ -24,6 +24,19 @@ export type BoxProps = SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAt
24
24
  * text lighter than 600. Only for `as="a"`; an unknown value throws.
25
25
  */
26
26
  link?: BoxLink;
27
+ /**
28
+ * A `Box as="a"` that opens in a new tab: it sets `target="_blank"`,
29
+ * adds `noopener noreferrer` to `rel`, draws a decorative arrow (↗)
30
+ * after its words and reads `newTabLabel` after its name. Only for
31
+ * `as="a"`; it needs `newTabLabel`.
32
+ */
33
+ external?: boolean;
34
+ /**
35
+ * The hidden words read after an external link's name, in your
36
+ * language, such as "(opens in a new tab)". Required with `external`,
37
+ * refused without it.
38
+ */
39
+ newTabLabel?: string;
27
40
  };
28
41
  export type BoxBorder = BoxBorderValue;
29
42
  export type BoxLink = BoxLinkAppearance;
@@ -45,4 +58,17 @@ export declare const Box: import("react").ForwardRefExoticComponent<SpacingProps
45
58
  * text lighter than 600. Only for `as="a"`; an unknown value throws.
46
59
  */
47
60
  link?: BoxLink;
61
+ /**
62
+ * A `Box as="a"` that opens in a new tab: it sets `target="_blank"`,
63
+ * adds `noopener noreferrer` to `rel`, draws a decorative arrow (↗)
64
+ * after its words and reads `newTabLabel` after its name. Only for
65
+ * `as="a"`; it needs `newTabLabel`.
66
+ */
67
+ external?: boolean;
68
+ /**
69
+ * The hidden words read after an external link's name, in your
70
+ * language, such as "(opens in a new tab)". Required with `external`,
71
+ * refused without it.
72
+ */
73
+ newTabLabel?: string;
48
74
  } & import("react").RefAttributes<HTMLElement>>;
@@ -5,10 +5,14 @@ import { boxBorderClass } from '../css/css-box-border.js';
5
5
  import { assertBoxLink, boxLinkClass, } from '../css/css-box-link.js';
6
6
  import { columnSpanClassNames, } from '../column-span-classes.js';
7
7
  import { spacingClassNames } from '../spacing-classes.js';
8
+ import { assertBoxExternal, externalLinkContent, externalLinkRel, } from './box/external-link.js';
8
9
  import { tabStop } from '../tab-stop.js';
9
10
  import { visibilityClassNames, } from '../visibility-classes.js';
10
- export const Box = forwardRef(function Box({ as = 'div', background, border, className, columnSpan, hideBelow, hideFrom, link, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, style, tabIndex, ...rest }, ref) {
11
+ export const Box = forwardRef(function Box({ as = 'div', background, border, children, className, columnSpan, external, hideBelow, hideFrom, link, newTabLabel, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, rel, style, tabIndex, target, ...rest }, ref) {
11
12
  assertBoxLink(link, as);
13
+ assertBoxExternal({ as, external, newTabLabel });
14
+ // assertBoxExternal has made sure an external link has its words.
15
+ const opensNewTab = external === true && newTabLabel !== undefined;
12
16
  const Component = as;
13
17
  // A link with a destination is a tab stop in Safari too (see tabStop).
14
18
  const linkTabStop = as === 'a' && rest.href !== undefined ? tabStop : undefined;
@@ -26,5 +30,5 @@ export const Box = forwardRef(function Box({ as = 'div', background, border, cla
26
30
  : undefined,
27
31
  borderRadius: radius ? `var(--sw-radius-${radius})` : undefined,
28
32
  ...style,
29
- }, tabIndex: tabIndex ?? linkTabStop, ...rest }));
33
+ }, tabIndex: tabIndex ?? linkTabStop, ...rest, rel: opensNewTab ? externalLinkRel(rel) : rel, target: opensNewTab ? '_blank' : target, children: opensNewTab ? externalLinkContent(children, newTabLabel) : children }));
30
34
  });
@@ -1,69 +1,5 @@
1
- /** `fixed` sizes the count for a few digits; `full` fills its container, such as a table cell. */
2
- export type StepperWidth = 'fixed' | 'full';
3
- export declare const stepperLimitAppearances: readonly ["faded", "plain"];
4
- /**
5
- * How − looks at `min` and + at `max`, where a press cannot move the count:
6
- * `faded` (the default) fades the button as any disabled control; `plain`
7
- * keeps its usual look. Either way the button is disabled.
8
- */
9
- export type StepperLimitAppearance = (typeof stepperLimitAppearances)[number];
10
- export type StepperProps = {
11
- /** Names the count. Keep it visible unless headers around it say it (`labelVisuallyHidden`). */
12
- label: string;
13
- labelVisuallyHidden?: boolean;
14
- /** The count, or `null` for an empty box (none). */
15
- value: number | null;
16
- /** A whole count, or `null` when the box is emptied. Typed counts are reported as typed, even outside the bounds. */
17
- onChange: (value: number | null) => void;
18
- /** The lowest count − reaches; defaults to 0. */
19
- min?: number;
20
- /** The highest count + reaches; no upper bound when left out. */
21
- max?: number;
22
- /** How much one press, or one arrow key, moves the count; defaults to 1. */
23
- step?: number;
24
- /** The − button's name, such as "One less". */
25
- decrementLabel: string;
26
- /** The + button's name, such as "One more". */
27
- incrementLabel: string;
28
- /**
29
- * A message under the control, as `Field`'s: a polite live region linked by
30
- * `aria-describedby`, with `aria-invalid` and the danger border. An empty
31
- * string is no error.
32
- */
33
- error?: string;
34
- /** `Field`'s changed look (the accent border and tint) for a count someone changed, or a suggestion. `error` wins. */
35
- changed?: boolean;
36
- disabled?: boolean;
37
- /** Readable and focusable, never changed: the buttons are hidden and keep their room. */
38
- readOnly?: boolean;
39
- /**
40
- * Says the count in words for assistive technology (`aria-valuetext`),
41
- * such as "12 straps". It wins over the count and `unit` read together.
42
- */
43
- valueText?: string;
44
- /**
45
- * A short muted word after the count, inside its frame, such as "min".
46
- * Without `valueText` the count is read with it ("90 min"), word for
47
- * word: a unit that changes with the count ("players") reads "1 players",
48
- * so pass `valueText` with the right form ("1 player"). Not blank.
49
- */
50
- unit?: string;
51
- /**
52
- * Ids of elements that describe the count, such as the value it adds up
53
- * to; read before an `error`'s message.
54
- */
55
- 'aria-describedby'?: string;
56
- /** Shown in an empty box, such as "0". */
57
- placeholder?: string;
58
- name?: string;
59
- width?: StepperWidth;
60
- /**
61
- * `plain` keeps − at `min` and + at `max` in their usual look instead of
62
- * fading them (`faded`, the default). The button is still disabled, and a
63
- * disabled or read-only Stepper is unchanged.
64
- */
65
- limitAppearance?: StepperLimitAppearance;
66
- };
1
+ import { type StepperProps } from './stepper/stepper-props.js';
2
+ export { stepperLimitAppearances, type StepperLimitAppearance, type StepperProps, type StepperWidth, } from './stepper/stepper-props.js';
67
3
  /**
68
4
  * A whole-number count between − and + buttons that can also be typed: one
69
5
  * tab stop (the count, a spin button that ArrowUp and ArrowDown step), with
@@ -6,7 +6,9 @@ import { assertStepperBounds, assertStepperUnit, readStepperEntry, stepperMoves,
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
7
  import { FieldLabelText } from './FieldLabelText.js';
8
8
  import { StepperCountFrame } from './stepper/StepperCountFrame.js';
9
- export const stepperLimitAppearances = ['faded', 'plain'];
9
+ import { stepperLimitAppearances, } from './stepper/stepper-props.js';
10
+ import { assertStepperCommitDelay, defaultStepperCommitDelay, useStepperCommit, } from './stepper/use-stepper-commit.js';
11
+ export { stepperLimitAppearances, } from './stepper/stepper-props.js';
10
12
  /** Keeps focus where it was (the count, or nowhere) when a button is pressed. */
11
13
  function keepFocus(event) {
12
14
  event.preventDefault();
@@ -16,10 +18,11 @@ function keepFocus(event) {
16
18
  * tab stop (the count, a spin button that ArrowUp and ArrowDown step), with
17
19
  * the buttons as pointer targets beside it. − stops at `min`, + at `max`.
18
20
  */
19
- export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden = false, value, onChange, min = 0, max = Number.POSITIVE_INFINITY, step = 1, decrementLabel, incrementLabel, error, changed = false, disabled = false, readOnly = false, valueText, unit, 'aria-describedby': describedBy, placeholder, name, width = 'fixed', limitAppearance = 'faded', }, ref) {
21
+ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden = false, value, onChange, onCommit, commitDelay = defaultStepperCommitDelay, min = 0, max = Number.POSITIVE_INFINITY, step = 1, decrementLabel, incrementLabel, error, changed = false, disabled = false, readOnly = false, valueText, unit, 'aria-describedby': describedBy, placeholder, name, width = 'fixed', limitAppearance = 'faded', }, ref) {
20
22
  const bounds = { min, max, step };
21
23
  assertStepperBounds(bounds);
22
24
  assertStepperUnit(unit);
25
+ assertStepperCommitDelay(commitDelay);
23
26
  if (!stepperLimitAppearances.includes(limitAppearance))
24
27
  throw new RangeError(`Unknown Stepper limitAppearance: ${String(limitAppearance)}`);
25
28
  if (value !== null && !Number.isSafeInteger(value))
@@ -28,6 +31,7 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
28
31
  const labelId = useId();
29
32
  const errorId = useId();
30
33
  const [draft, setDraft] = useState(null);
34
+ const commit = useStepperCommit(value, onCommit, commitDelay);
31
35
  const allowsNegative = min < 0;
32
36
  const moves = stepperMoves(value, bounds, disabled || readOnly);
33
37
  // The typed text stays while it means the count the consumer holds, so
@@ -42,6 +46,7 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
42
46
  if (next === null)
43
47
  return;
44
48
  setDraft(null);
49
+ commit.stepped();
45
50
  onChange(next);
46
51
  }
47
52
  function onType(event) {
@@ -52,8 +57,10 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
52
57
  return;
53
58
  setDraft(next);
54
59
  const count = entry.kind === 'count' ? entry.value : null;
55
- if (count !== value)
56
- onChange(count);
60
+ if (count === value)
61
+ return;
62
+ commit.edited();
63
+ onChange(count);
57
64
  }
58
65
  /** ArrowUp and ArrowDown step; Home and End go to min and a finite max. */
59
66
  function keyTarget(key) {
@@ -68,6 +75,9 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
68
75
  return undefined;
69
76
  }
70
77
  function onKeyDown(event) {
78
+ // Enter finishes the entry; a form round the count still submits.
79
+ if (event.key === 'Enter')
80
+ commit.finish();
71
81
  const target = keyTarget(event.key);
72
82
  if (target === undefined)
73
83
  return;
@@ -83,5 +93,8 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
83
93
  .join(' ') || undefined, "aria-invalid": invalid || undefined, "aria-valuemax": Number.isFinite(max) ? max : undefined, "aria-valuemin": min, "aria-valuenow": value ?? undefined, "aria-valuetext": stepperValueText(value, unit, valueText), autoComplete: "off", className: "sw-stepper-input", disabled: disabled, id: inputId,
84
94
  // iOS's numeric pad has no minus key, so a range below zero takes
85
95
  // the text keyboard.
86
- inputMode: allowsNegative ? 'text' : 'numeric', name: name, onBlur: () => setDraft(null), onChange: onType, onKeyDown: onKeyDown, placeholder: placeholder, readOnly: readOnly, role: "spinbutton", type: "text", value: text }) }), _jsx("button", { "aria-controls": inputId, "aria-label": incrementLabel, className: "sw-stepper-button", disabled: moves.up === null, onClick: () => move(moves.up), onMouseDown: keepFocus, tabIndex: -1, type: "button", children: _jsx("span", { "aria-hidden": "true", children: "+" }) })] }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
96
+ inputMode: allowsNegative ? 'text' : 'numeric', name: name, onBlur: () => {
97
+ setDraft(null);
98
+ commit.finish();
99
+ }, onChange: onType, onKeyDown: onKeyDown, placeholder: placeholder, readOnly: readOnly, role: "spinbutton", type: "text", value: text }) }), _jsx("button", { "aria-controls": inputId, "aria-label": incrementLabel, className: "sw-stepper-button", disabled: moves.up === null, onClick: () => move(moves.up), onMouseDown: keepFocus, tabIndex: -1, type: "button", children: _jsx("span", { "aria-hidden": "true", children: "+" }) })] }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
87
100
  });
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from 'react';
2
+ /** What a `Box` passes to check and draw its external link. */
3
+ export type BoxExternalOptions = {
4
+ as: string;
5
+ external: boolean | undefined;
6
+ newTabLabel: string | undefined;
7
+ };
8
+ /**
9
+ * Refuses an external box that is not a link or has no words for its new
10
+ * tab, and new-tab words on a box that is not external.
11
+ */
12
+ export declare function assertBoxExternal({ as, external, newTabLabel, }: BoxExternalOptions): void;
13
+ /** The consumer's `rel` words with `noopener noreferrer`, each once. */
14
+ export declare function externalLinkRel(rel: string | undefined): string;
15
+ /**
16
+ * An external link's content: its words, the decorative new-tab mark
17
+ * right after them (no space, so it never wraps alone) and the hidden
18
+ * words read after the name, such as "(opens in a new tab)".
19
+ */
20
+ export declare function externalLinkContent(children: ReactNode, newTabLabel: string): ReactNode;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { externalLinkMarkClass } from '../../css/css-box-link.js';
3
+ /** The `rel` words a link that opens a new tab always carries. */
4
+ const newTabRelWords = ['noopener', 'noreferrer'];
5
+ /**
6
+ * Refuses an external box that is not a link or has no words for its new
7
+ * tab, and new-tab words on a box that is not external.
8
+ */
9
+ export function assertBoxExternal({ as, external, newTabLabel, }) {
10
+ if (external === undefined || external === false) {
11
+ if (newTabLabel !== undefined)
12
+ throw new TypeError('Box newTabLabel is for an external link');
13
+ return;
14
+ }
15
+ if (external !== true)
16
+ throw new RangeError('Box external must be a boolean');
17
+ if (as !== 'a')
18
+ throw new TypeError('Box external is for a link (as="a")');
19
+ if (typeof newTabLabel !== 'string' || newTabLabel.trim() === '')
20
+ throw new RangeError('Box external needs a non-empty newTabLabel');
21
+ }
22
+ /** The consumer's `rel` words with `noopener noreferrer`, each once. */
23
+ export function externalLinkRel(rel) {
24
+ const words = (rel ?? '').split(/\s+/).filter((word) => word !== '');
25
+ for (const word of newTabRelWords)
26
+ if (!words.includes(word))
27
+ words.push(word);
28
+ return words.join(' ');
29
+ }
30
+ /**
31
+ * An external link's content: its words, the decorative new-tab mark
32
+ * right after them (no space, so it never wraps alone) and the hidden
33
+ * words read after the name, such as "(opens in a new tab)".
34
+ */
35
+ export function externalLinkContent(children, newTabLabel) {
36
+ return (_jsxs(_Fragment, { children: [children, _jsx("span", { "aria-hidden": "true", className: externalLinkMarkClass }), _jsxs("span", { className: "sw-sr-only", children: [" ", newTabLabel] })] }));
37
+ }
@@ -0,0 +1,78 @@
1
+ /** `fixed` sizes the count for a few digits; `full` fills its container, such as a table cell. */
2
+ export type StepperWidth = 'fixed' | 'full';
3
+ export declare const stepperLimitAppearances: readonly ["faded", "plain"];
4
+ /**
5
+ * How − looks at `min` and + at `max`, where a press cannot move the count:
6
+ * `faded` (the default) fades the button as any disabled control; `plain`
7
+ * keeps its usual look. Either way the button is disabled.
8
+ */
9
+ export type StepperLimitAppearance = (typeof stepperLimitAppearances)[number];
10
+ export type StepperProps = {
11
+ /** Names the count. Keep it visible unless headers around it say it (`labelVisuallyHidden`). */
12
+ label: string;
13
+ labelVisuallyHidden?: boolean;
14
+ /** The count, or `null` for an empty box (none). */
15
+ value: number | null;
16
+ /** A whole count, or `null` when the box is emptied. Typed counts are reported as typed, even outside the bounds. */
17
+ onChange: (value: number | null) => void;
18
+ /**
19
+ * The finished count, once per entry, for a page that saves as you go:
20
+ * on blur, on Enter, and `commitDelay` ms after the last − / + press or
21
+ * step key (ArrowUp, ArrowDown, Home, End). Never per typed digit, and
22
+ * never when the count is the one last committed (or held before the
23
+ * first edit). It gives the count you hold then, `null` or outside the
24
+ * bounds included, as `onChange` reports typed counts. `onChange` is
25
+ * unchanged.
26
+ */
27
+ onCommit?: (value: number | null) => void;
28
+ /** How long − / + presses and step keys settle before `onCommit`, in ms; defaults to 600. */
29
+ commitDelay?: number;
30
+ /** The lowest count − reaches; defaults to 0. */
31
+ min?: number;
32
+ /** The highest count + reaches; no upper bound when left out. */
33
+ max?: number;
34
+ /** How much one press, or one arrow key, moves the count; defaults to 1. */
35
+ step?: number;
36
+ /** The − button's name, such as "One less". */
37
+ decrementLabel: string;
38
+ /** The + button's name, such as "One more". */
39
+ incrementLabel: string;
40
+ /**
41
+ * A message under the control, as `Field`'s: a polite live region linked by
42
+ * `aria-describedby`, with `aria-invalid` and the danger border. An empty
43
+ * string is no error.
44
+ */
45
+ error?: string;
46
+ /** `Field`'s changed look (the accent border and tint) for a count someone changed, or a suggestion. `error` wins. */
47
+ changed?: boolean;
48
+ disabled?: boolean;
49
+ /** Readable and focusable, never changed: the buttons are hidden and keep their room. */
50
+ readOnly?: boolean;
51
+ /**
52
+ * Says the count in words for assistive technology (`aria-valuetext`),
53
+ * such as "12 straps". It wins over the count and `unit` read together.
54
+ */
55
+ valueText?: string;
56
+ /**
57
+ * A short muted word after the count, inside its frame, such as "min".
58
+ * Without `valueText` the count is read with it ("90 min"), word for
59
+ * word: a unit that changes with the count ("players") reads "1 players",
60
+ * so pass `valueText` with the right form ("1 player"). Not blank.
61
+ */
62
+ unit?: string;
63
+ /**
64
+ * Ids of elements that describe the count, such as the value it adds up
65
+ * to; read before an `error`'s message.
66
+ */
67
+ 'aria-describedby'?: string;
68
+ /** Shown in an empty box, such as "0". */
69
+ placeholder?: string;
70
+ name?: string;
71
+ width?: StepperWidth;
72
+ /**
73
+ * `plain` keeps − at `min` and + at `max` in their usual look instead of
74
+ * fading them (`faded`, the default). The button is still disabled, and a
75
+ * disabled or read-only Stepper is unchanged.
76
+ */
77
+ limitAppearance?: StepperLimitAppearance;
78
+ };
@@ -0,0 +1 @@
1
+ export const stepperLimitAppearances = ['faded', 'plain'];
@@ -0,0 +1,22 @@
1
+ /** How long after the last − / + press or arrow key a count commits, in ms. */
2
+ export declare const defaultStepperCommitDelay = 600;
3
+ /** Refuses a commit delay that is not a finite number of zero or more ms. */
4
+ export declare function assertStepperCommitDelay(delay: number): void;
5
+ /** What a Stepper calls as its count is edited and finished. */
6
+ export type StepperCommit = {
7
+ /** A typed change: it waits for blur or Enter. */
8
+ edited: () => void;
9
+ /** A − / + press or a step key: it commits once the presses settle. */
10
+ stepped: () => void;
11
+ /** Blur or Enter: commits now. */
12
+ finish: () => void;
13
+ };
14
+ /**
15
+ * Turns a Stepper's edits into one `onCommit` per finished entry: on blur,
16
+ * on Enter, and `delay` ms after the last − / + press or step key. A
17
+ * commit gives the count the consumer holds then, and only when it differs
18
+ * from the last committed count (or the count before the first edit). A
19
+ * count the consumer changes while nothing is waiting becomes the new
20
+ * baseline. A commit still waiting when the Stepper unmounts is made then.
21
+ */
22
+ export declare function useStepperCommit(value: number | null, onCommit: ((value: number | null) => void) | undefined, delay: number): StepperCommit;
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef } from 'react';
2
+ /** How long after the last − / + press or arrow key a count commits, in ms. */
3
+ export const defaultStepperCommitDelay = 600;
4
+ /** Refuses a commit delay that is not a finite number of zero or more ms. */
5
+ export function assertStepperCommitDelay(delay) {
6
+ if (!Number.isFinite(delay) || delay < 0)
7
+ throw new RangeError('Stepper commitDelay must be a finite number of zero or more');
8
+ }
9
+ /**
10
+ * Turns a Stepper's edits into one `onCommit` per finished entry: on blur,
11
+ * on Enter, and `delay` ms after the last − / + press or step key. A
12
+ * commit gives the count the consumer holds then, and only when it differs
13
+ * from the last committed count (or the count before the first edit). A
14
+ * count the consumer changes while nothing is waiting becomes the new
15
+ * baseline. A commit still waiting when the Stepper unmounts is made then.
16
+ */
17
+ export function useStepperCommit(value, onCommit, delay) {
18
+ const latest = useRef({ value, onCommit });
19
+ const baseline = useRef(value);
20
+ const pending = useRef(false);
21
+ const timer = useRef(null);
22
+ useEffect(() => {
23
+ latest.current = { value, onCommit };
24
+ if (!pending.current)
25
+ baseline.current = value;
26
+ }, [value, onCommit]);
27
+ function clearTimer() {
28
+ if (timer.current === null)
29
+ return;
30
+ clearTimeout(timer.current);
31
+ timer.current = null;
32
+ }
33
+ function finish() {
34
+ clearTimer();
35
+ if (!pending.current)
36
+ return;
37
+ pending.current = false;
38
+ const { value: current, onCommit: commit } = latest.current;
39
+ if (current === baseline.current)
40
+ return;
41
+ baseline.current = current;
42
+ commit?.(current);
43
+ }
44
+ // The latest finish, for the timer and the unmount to call.
45
+ const finishRef = useRef(finish);
46
+ useEffect(() => {
47
+ finishRef.current = finish;
48
+ });
49
+ useEffect(() => () => finishRef.current(), []);
50
+ function edited() {
51
+ if (latest.current.onCommit === undefined)
52
+ return;
53
+ pending.current = true;
54
+ }
55
+ function stepped() {
56
+ if (latest.current.onCommit === undefined)
57
+ return;
58
+ pending.current = true;
59
+ clearTimer();
60
+ timer.current = setTimeout(() => {
61
+ timer.current = null;
62
+ finishRef.current();
63
+ }, delay);
64
+ }
65
+ return { edited, stepped, finish };
66
+ }
@@ -6,8 +6,11 @@
6
6
  */
7
7
  export declare const boxLinkAppearances: readonly ["accent", "quiet"];
8
8
  export type BoxLinkAppearance = (typeof boxLinkAppearances)[number];
9
+ /** The decorative new-tab mark after an external link's words. */
10
+ export declare const externalLinkMarkClass = "sw-link-external-mark";
9
11
  export declare function assertBoxLink(link: unknown, as: string): void;
10
12
  /** The generated class for a Box `link` prop, or none for the canvas link. */
11
13
  export declare function boxLinkClass(link: BoxLinkAppearance | undefined): string | undefined;
12
14
  export declare function cssBoxLinkClasses(): string;
15
+ export declare function cssExternalLinkMarkClasses(): string;
13
16
  export declare function boxLinkClassCatalog(): string[];
@@ -7,6 +7,8 @@ import { typographyVariants } from '@scalewing/tokens';
7
7
  */
8
8
  export const boxLinkAppearances = ['accent', 'quiet'];
9
9
  const quietLinkClass = 'sw-link-quiet';
10
+ /** The decorative new-tab mark after an external link's words. */
11
+ export const externalLinkMarkClass = 'sw-link-external-mark';
10
12
  export function assertBoxLink(link, as) {
11
13
  if (link === undefined)
12
14
  return;
@@ -43,8 +45,45 @@ ${quiet}:hover,
43
45
  ${quiet}:focus-visible {
44
46
  color: inherit;
45
47
  text-decoration-line: underline;
46
- }`;
48
+ }
49
+
50
+ ${cssExternalLinkMarkClasses()}`;
51
+ }
52
+ /*
53
+ * An external link's new-tab mark: an arrow to the inline end and up (↗),
54
+ * drawn with borders in the link's colour so forced colours keep it as the
55
+ * system link colour. Its head is the top and inline-end borders of a
56
+ * space-2 square; its shaft is one border on the square's diagonal. It
57
+ * follows the link's colour (accent, the hover text colour, a quiet link's
58
+ * inherited one) and is mirrored in right-to-left text. It sits a space-1
59
+ * after the last word with no space between them, so it never wraps alone.
60
+ */
61
+ export function cssExternalLinkMarkClasses() {
62
+ const mark = `.${externalLinkMarkClass}`;
63
+ return `${mark} {
64
+ border-right: 2px solid currentColor;
65
+ border-top: 2px solid currentColor;
66
+ box-sizing: border-box;
67
+ display: inline-block;
68
+ height: var(--sw-space-2);
69
+ margin-inline-start: var(--sw-space-1);
70
+ position: relative;
71
+ vertical-align: middle;
72
+ width: var(--sw-space-2);
73
+ }
74
+
75
+ ${mark}::after {
76
+ border-left: 2px solid currentColor;
77
+ content: '';
78
+ height: calc(var(--sw-space-2) * 1.25);
79
+ left: 50%;
80
+ position: absolute;
81
+ top: 50%;
82
+ transform: translate(-50%, -50%) rotate(45deg);
83
+ }
84
+
85
+ ${mark}:dir(rtl) { transform: scaleX(-1); }`;
47
86
  }
48
87
  export function boxLinkClassCatalog() {
49
- return [quietLinkClass];
88
+ return [quietLinkClass, externalLinkMarkClass];
50
89
  }
package/dist/styles.css CHANGED
@@ -666,6 +666,30 @@ body {
666
666
  text-decoration-line: underline;
667
667
  }
668
668
 
669
+ .sw-link-external-mark {
670
+ border-right: 2px solid currentColor;
671
+ border-top: 2px solid currentColor;
672
+ box-sizing: border-box;
673
+ display: inline-block;
674
+ height: var(--sw-space-2);
675
+ margin-inline-start: var(--sw-space-1);
676
+ position: relative;
677
+ vertical-align: middle;
678
+ width: var(--sw-space-2);
679
+ }
680
+
681
+ .sw-link-external-mark::after {
682
+ border-left: 2px solid currentColor;
683
+ content: '';
684
+ height: calc(var(--sw-space-2) * 1.25);
685
+ left: 50%;
686
+ position: absolute;
687
+ top: 50%;
688
+ transform: translate(-50%, -50%) rotate(45deg);
689
+ }
690
+
691
+ .sw-link-external-mark:dir(rtl) { transform: scaleX(-1); }
692
+
669
693
  /*
670
694
  * A programmatic focus target (tabindex="-1": a notice, a card or a heading
671
695
  * that a script focuses after a save) takes the accent ring past the ring
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",