@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 +6 -0
- package/README.md +3 -1
- package/dist/components/Box.d.ts +26 -0
- package/dist/components/Box.js +6 -2
- package/dist/components/Stepper.d.ts +2 -66
- package/dist/components/Stepper.js +18 -5
- package/dist/components/box/external-link.d.ts +20 -0
- package/dist/components/box/external-link.js +37 -0
- package/dist/components/stepper/stepper-props.d.ts +78 -0
- package/dist/components/stepper/stepper-props.js +1 -0
- package/dist/components/stepper/use-stepper-commit.d.ts +22 -0
- package/dist/components/stepper/use-stepper-commit.js +66 -0
- package/dist/css/css-box-link.d.ts +3 -0
- package/dist/css/css-box-link.js +41 -2
- package/dist/styles.css +24 -0
- package/package.json +1 -1
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"
|
package/dist/components/Box.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/components/Box.js
CHANGED
|
@@ -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
|
-
|
|
2
|
-
export type StepperWidth
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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: () =>
|
|
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[];
|
package/dist/css/css-box-link.js
CHANGED
|
@@ -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
|