@scalewing/react 1.44.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +51 -2
  3. package/dist/components/Article.d.ts +19 -2
  4. package/dist/components/Article.js +9 -5
  5. package/dist/components/Button.d.ts +14 -1
  6. package/dist/components/Button.js +5 -2
  7. package/dist/components/FileField.d.ts +18 -2
  8. package/dist/components/FileField.js +26 -22
  9. package/dist/components/ListGroup.d.ts +8 -3
  10. package/dist/components/ListGroup.js +13 -5
  11. package/dist/components/ListRow.d.ts +11 -3
  12. package/dist/components/ListRow.js +6 -4
  13. package/dist/components/Select.d.ts +8 -5
  14. package/dist/components/Select.js +7 -4
  15. package/dist/components/file-field/ChosenFile.d.ts +45 -0
  16. package/dist/components/file-field/ChosenFile.js +48 -0
  17. package/dist/components/file-field/file-field-checks.d.ts +5 -2
  18. package/dist/components/file-field/file-field-checks.js +7 -2
  19. package/dist/components/list/ListRowContent.d.ts +5 -1
  20. package/dist/components/list/ListRowContent.js +4 -2
  21. package/dist/components/select/select-naming.d.ts +34 -0
  22. package/dist/components/select/select-naming.js +22 -0
  23. package/dist/components/steps/Step.d.ts +10 -1
  24. package/dist/components/steps/Step.js +15 -19
  25. package/dist/components/steps/StepTitleLine.d.ts +19 -0
  26. package/dist/components/steps/StepTitleLine.js +13 -0
  27. package/dist/components/steps/StepTitleWords.d.ts +27 -0
  28. package/dist/components/steps/StepTitleWords.js +21 -0
  29. package/dist/components/steps/assert-step.d.ts +7 -0
  30. package/dist/components/steps/assert-step.js +19 -0
  31. package/dist/css/css-button-width.d.ts +11 -0
  32. package/dist/css/css-button-width.js +36 -0
  33. package/dist/css/css-button.js +3 -0
  34. package/dist/css/css-file-field-compact.d.ts +2 -0
  35. package/dist/css/css-file-field-compact.js +106 -0
  36. package/dist/css/css-file-field.js +4 -0
  37. package/dist/css/css-list-tiles.d.ts +6 -0
  38. package/dist/css/css-list-tiles.js +50 -0
  39. package/dist/css/css-list.js +5 -1
  40. package/dist/css/css-steps-title-detail.d.ts +2 -0
  41. package/dist/css/css-steps-title-detail.js +36 -0
  42. package/dist/css/css-steps.d.ts +2 -2
  43. package/dist/css/css-steps.js +6 -2
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.js +2 -0
  46. package/dist/list-options.d.ts +19 -11
  47. package/dist/list-options.js +27 -11
  48. package/dist/styles.css +134 -0
  49. package/package.json +1 -1
@@ -5,16 +5,19 @@ import { cx } from '../class-names.js';
5
5
  import { selectedSelectIndex } from '../select-list.js';
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
7
  import { assertRequiredMark, FieldLabelText, } from './FieldLabelText.js';
8
+ import { assertSelectNaming, } from './select/select-naming.js';
8
9
  import { SelectListbox } from './select/SelectListbox.js';
9
10
  import { SelectValue } from './select/SelectValue.js';
10
11
  import { useSelectList } from './select/use-select-list.js';
11
12
  import { Stack } from './Stack.js';
12
13
  import { tabStop } from '../tab-stop.js';
13
- export function Select({ action, label, labelVisuallyHidden = false, onChange, options, size = 'md', value, placeholder, required = false, requiredMark = 'visible', error, width = 'content', }) {
14
- assertRequiredMark(requiredMark);
14
+ export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size = 'md', value, placeholder, required = false, requiredMark, error, width = 'content', }) {
15
+ assertSelectNaming({ label, labelVisuallyHidden, labelledBy, requiredMark });
16
+ assertRequiredMark(requiredMark ?? 'visible');
15
17
  if (placeholder !== undefined && !placeholder.trim())
16
18
  throw new RangeError('placeholder must not be empty');
17
- const labelId = useId();
19
+ const ownLabelId = useId();
20
+ const labelId = labelledBy ?? ownLabelId;
18
21
  const triggerId = useId();
19
22
  const listId = useId();
20
23
  const optionIdPrefix = useId();
@@ -24,7 +27,7 @@ export function Select({ action, label, labelVisuallyHidden = false, onChange, o
24
27
  const showsPlaceholder = placeholder !== undefined &&
25
28
  !options.some((option) => option.value === value);
26
29
  const { commit, highlight, onTriggerKeyDown, open, openList, setHighlight, setOpen, } = useSelectList({ action, onChange, options, rootRef, value });
27
- return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: labelVisuallyHidden ? 0 : 1, children: [_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark, size: size, visuallyHidden: labelVisuallyHidden }) }), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
30
+ return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: label === undefined || labelVisuallyHidden ? 0 : 1, children: [label === undefined ? null : (_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark ?? 'visible', size: size, visuallyHidden: labelVisuallyHidden ?? false }) })), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
28
31
  const next = event.relatedTarget;
29
32
  if (next instanceof Node && rootRef.current?.contains(next)) {
30
33
  return;
@@ -0,0 +1,45 @@
1
+ import { type ReactNode, type Ref } from 'react';
2
+ import type { FileFieldValue } from '../FileField.js';
3
+ /** The ids a chosen file's parts take, from the field's. */
4
+ export type ChosenFileIds = {
5
+ label: string;
6
+ button: string;
7
+ name: string;
8
+ detail: string;
9
+ remove: string;
10
+ };
11
+ /** The change button's naming and description, as the field wires them. */
12
+ export type ChosenFileNaming = {
13
+ 'aria-labelledby': string;
14
+ 'aria-describedby': string | undefined;
15
+ id: string;
16
+ };
17
+ /** Remove beside a chosen file: its words and what it does. */
18
+ export type ChosenFileRemove = {
19
+ label: string;
20
+ onRemove: () => void;
21
+ };
22
+ type ChosenFileProps = {
23
+ value: FileFieldValue;
24
+ icon: ReactNode;
25
+ ids: ChosenFileIds;
26
+ naming: ChosenFileNaming;
27
+ changeLabel: string;
28
+ remove: ChosenFileRemove | null;
29
+ disabled: boolean;
30
+ onChange: () => void;
31
+ buttonRef: Ref<HTMLButtonElement>;
32
+ };
33
+ /**
34
+ * The chosen file in the full field: its mark, its name over its detail,
35
+ * then the change button (and Remove after it) at the row's end.
36
+ */
37
+ export declare function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
38
+ /**
39
+ * The chosen file in a compact field: one row at a text field's height.
40
+ * The mark and the name (cut to one line) are the change button, named by
41
+ * the field's label and the change words, described by the name and the
42
+ * detail (which is not drawn); Remove, quiet, ends the row.
43
+ */
44
+ export declare function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
45
+ export {};
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { tabStop } from '../../tab-stop.js';
4
+ import { Button } from '../Button.js';
5
+ /** Whether the consumer passed a mark to draw (not empty JSX). */
6
+ function hasMark(icon) {
7
+ return (icon !== undefined &&
8
+ icon !== null &&
9
+ icon !== '' &&
10
+ typeof icon !== 'boolean');
11
+ }
12
+ /** Before a chosen file's name: its photo's thumbnail, else the consumer's mark. */
13
+ function ChosenMark({ icon, preview }) {
14
+ if (preview !== undefined)
15
+ return _jsx("img", { alt: "", className: "sw-file-field-preview", src: preview });
16
+ if (!hasMark(icon))
17
+ return null;
18
+ return (_jsx("span", { "aria-hidden": "true", className: "sw-file-field-icon", children: icon }));
19
+ }
20
+ /**
21
+ * Remove: a quiet button named by its words and the field's label
22
+ * ("Remove Cover photo"), so two fields' Removes are told apart. Disabled,
23
+ * the neutral one greys as the change button does; the ghost one, which
24
+ * has no fill, fades as the compact row's pick button does.
25
+ */
26
+ function RemoveButton({ ids, remove, disabled, variant, }) {
27
+ return (_jsx(Button
28
+ // Named by its own words, then the label: the self-reference is
29
+ // deliberate and needs the id below on the button itself.
30
+ , { "aria-labelledby": `${ids.remove} ${ids.label}`, className: "sw-file-field-remove", disabled: disabled, disabledAppearance: variant === 'neutral' ? 'neutral' : 'faded', id: ids.remove, onPress: remove.onRemove, size: variant === 'ghost' ? 'sm' : 'md', variant: variant, children: remove.label }));
31
+ }
32
+ /**
33
+ * The chosen file in the full field: its mark, its name over its detail,
34
+ * then the change button (and Remove after it) at the row's end.
35
+ */
36
+ export function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
37
+ const change = (_jsx(Button, { ...naming, disabled: disabled, disabledAppearance: "neutral", onPress: onChange, ref: buttonRef, variant: "neutral", children: changeLabel }));
38
+ return (_jsxs(_Fragment, { children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsxs("span", { className: "sw-file-field-file", children: [_jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-file-field-detail", id: ids.detail, children: value.detail })) : null] }), remove === null ? (change) : (_jsxs("span", { className: "sw-file-field-actions", children: [change, _jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "neutral" })] }))] }));
39
+ }
40
+ /**
41
+ * The chosen file in a compact field: one row at a text field's height.
42
+ * The mark and the name (cut to one line) are the change button, named by
43
+ * the field's label and the change words, described by the name and the
44
+ * detail (which is not drawn); Remove, quiet, ends the row.
45
+ */
46
+ export function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
47
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { ...naming, className: "sw-file-field-pick", disabled: disabled, onClick: onChange, ref: buttonRef, tabIndex: tabStop, type: "button", children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-sr-only", id: ids.detail, children: value.detail })) : null, _jsx("span", { className: "sw-sr-only", id: `${ids.button}-words`, children: changeLabel })] }), remove === null ? null : (_jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "ghost" }))] }));
48
+ }
@@ -1,8 +1,9 @@
1
1
  import type { FileFieldValue } from '../FileField.js';
2
2
  /**
3
3
  * Fails closed on a FileField it cannot name or show: an empty label,
4
- * choose or change words, a blank drop hint, or a chosen file with no name,
5
- * a blank detail or a blank preview address.
4
+ * choose or change words, a blank drop hint, `onRemove` without its
5
+ * words (or words without it), blank Remove words, or a chosen file with
6
+ * no name, a blank detail or a blank preview address.
6
7
  */
7
8
  export declare function assertFileField(props: {
8
9
  label: string;
@@ -10,4 +11,6 @@ export declare function assertFileField(props: {
10
11
  changeLabel: string;
11
12
  dropLabel: string | undefined;
12
13
  value: FileFieldValue | null;
14
+ onRemove: (() => void) | undefined;
15
+ removeLabel: string | undefined;
13
16
  }): void;
@@ -3,8 +3,9 @@ function isBlank(text) {
3
3
  }
4
4
  /**
5
5
  * Fails closed on a FileField it cannot name or show: an empty label,
6
- * choose or change words, a blank drop hint, or a chosen file with no name,
7
- * a blank detail or a blank preview address.
6
+ * choose or change words, a blank drop hint, `onRemove` without its
7
+ * words (or words without it), blank Remove words, or a chosen file with
8
+ * no name, a blank detail or a blank preview address.
8
9
  */
9
10
  export function assertFileField(props) {
10
11
  for (const key of ['label', 'chooseLabel', 'changeLabel'])
@@ -12,6 +13,10 @@ export function assertFileField(props) {
12
13
  throw new RangeError(`FileField ${key} must be non-empty text`);
13
14
  if (props.dropLabel !== undefined && isBlank(props.dropLabel))
14
15
  throw new RangeError('FileField dropLabel must be non-empty text');
16
+ if ((props.onRemove === undefined) !== (props.removeLabel === undefined))
17
+ throw new RangeError('FileField onRemove and removeLabel go together');
18
+ if (props.removeLabel !== undefined && isBlank(props.removeLabel))
19
+ throw new RangeError('FileField removeLabel must be non-empty text');
15
20
  if (props.value === null)
16
21
  return;
17
22
  if (isBlank(props.value.name))
@@ -5,6 +5,8 @@ export type ListRowContentProps = {
5
5
  /** Prefixes the ids that name and describe a pressable row. */
6
6
  id: string;
7
7
  title: string;
8
+ /** The title's language when it is not the page's; on its words only. */
9
+ titleLang?: string;
8
10
  detail?: string;
9
11
  value?: string;
10
12
  valueDetail?: string;
@@ -25,5 +27,7 @@ export type ListRowContentProps = {
25
27
  * (with the unread dot and its hidden words) over its detail, the value
26
28
  * over its detail, the tag, then the chevron or check. A truncated row
27
29
  * sets its title's words in their own box, so the "…" never hides the dot.
30
+ * `titleLang` goes on the title's box, which holds its words and the
31
+ * wordless dot only; the unread words after it stay in the page's language.
28
32
  */
29
- export declare function ListRowContent({ id, title, detail, value, valueDetail, valueTone, valueBadge, leading, tone, titleAppearance, accessory, unreadLabel, tag, truncate, }: ListRowContentProps): import("react").JSX.Element;
33
+ export declare function ListRowContent({ id, title, titleLang, detail, value, valueDetail, valueTone, valueBadge, leading, tone, titleAppearance, accessory, unreadLabel, tag, truncate, }: ListRowContentProps): import("react").JSX.Element;
@@ -8,7 +8,9 @@ import { hasSlot } from '../slot.js';
8
8
  * (with the unread dot and its hidden words) over its detail, the value
9
9
  * over its detail, the tag, then the chevron or check. A truncated row
10
10
  * sets its title's words in their own box, so the "…" never hides the dot.
11
+ * `titleLang` goes on the title's box, which holds its words and the
12
+ * wordless dot only; the unread words after it stay in the page's language.
11
13
  */
12
- export function ListRowContent({ id, title, detail, value, valueDetail, valueTone, valueBadge, leading, tone, titleAppearance, accessory, unreadLabel, tag, truncate, }) {
13
- return (_jsxs(_Fragment, { children: [!hasSlot(leading) ? null : (_jsx("span", { "aria-hidden": "true", className: "sw-list-row-leading", children: leading })), _jsxs("span", { className: "sw-list-row-label", children: [_jsxs("span", { className: cx('sw-list-row-title', titleAppearance === 'plain' && 'sw-list-row-title-plain', tone === 'danger' && 'sw-list-row-title-danger'), id: `${id}-title`, children: [truncate ? (_jsx("span", { className: "sw-list-row-title-text", title: title, children: title })) : (title), unreadLabel === undefined ? null : (_jsx("span", { "aria-hidden": "true", className: "sw-list-row-unread" }))] }), unreadLabel === undefined ? null : (_jsx("span", { className: "sw-sr-only", id: `${id}-unread`, children: unreadLabel })), detail ? (_jsx("span", { className: "sw-list-row-detail", id: `${id}-detail`, title: truncate ? detail : undefined, children: detail })) : null] }), value === undefined ? null : (_jsxs("span", { className: "sw-list-row-end", children: [_jsx("span", { className: cx('sw-list-row-value', valueTone && `sw-list-row-value-${valueTone}`), id: `${id}-value`, children: valueBadge ? _jsx(Badge, { tone: valueBadge, children: value }) : value }), valueDetail ? (_jsx("span", { className: "sw-list-row-value-detail", id: `${id}-value-detail`, children: valueDetail })) : null] })), !hasSlot(tag) ? null : (_jsx("span", { className: "sw-list-row-tag", id: `${id}-tag`, children: tag })), _jsx(ListRowAccessoryMark, { kind: accessory })] }));
14
+ export function ListRowContent({ id, title, titleLang, detail, value, valueDetail, valueTone, valueBadge, leading, tone, titleAppearance, accessory, unreadLabel, tag, truncate, }) {
15
+ return (_jsxs(_Fragment, { children: [!hasSlot(leading) ? null : (_jsx("span", { "aria-hidden": "true", className: "sw-list-row-leading", children: leading })), _jsxs("span", { className: "sw-list-row-label", children: [_jsxs("span", { className: cx('sw-list-row-title', titleAppearance === 'plain' && 'sw-list-row-title-plain', tone === 'danger' && 'sw-list-row-title-danger'), id: `${id}-title`, lang: titleLang, children: [truncate ? (_jsx("span", { className: "sw-list-row-title-text", title: title, children: title })) : (title), unreadLabel === undefined ? null : (_jsx("span", { "aria-hidden": "true", className: "sw-list-row-unread" }))] }), unreadLabel === undefined ? null : (_jsx("span", { className: "sw-sr-only", id: `${id}-unread`, children: unreadLabel })), detail ? (_jsx("span", { className: "sw-list-row-detail", id: `${id}-detail`, title: truncate ? detail : undefined, children: detail })) : null] }), value === undefined ? null : (_jsxs("span", { className: "sw-list-row-end", children: [_jsx("span", { className: cx('sw-list-row-value', valueTone && `sw-list-row-value-${valueTone}`), id: `${id}-value`, children: valueBadge ? _jsx(Badge, { tone: valueBadge, children: value }) : value }), valueDetail ? (_jsx("span", { className: "sw-list-row-value-detail", id: `${id}-value-detail`, children: valueDetail })) : null] })), !hasSlot(tag) ? null : (_jsx("span", { className: "sw-list-row-tag", id: `${id}-tag`, children: tag })), _jsx(ListRowAccessoryMark, { kind: accessory })] }));
14
16
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How a Select is named: by its own `label` (drawn, or visually hidden),
3
+ * or by words the page already draws, through `aria-labelledby`.
4
+ */
5
+ export type SelectNaming = {
6
+ /** The select's own label, drawn above it as Field's. */
7
+ label: string;
8
+ /** Keeps the label for assistive technology only, for toolbar chrome. */
9
+ labelVisuallyHidden?: boolean;
10
+ 'aria-labelledby'?: undefined;
11
+ } | {
12
+ /**
13
+ * The id of words the page draws for this choice, such as a heading
14
+ * or a label beside it: the select draws no label of its own, so
15
+ * the name is drawn, and read, once.
16
+ */
17
+ 'aria-labelledby': string;
18
+ label?: undefined;
19
+ labelVisuallyHidden?: undefined;
20
+ /** No mark: the page draws the words, and any mark, itself. */
21
+ requiredMark?: undefined;
22
+ };
23
+ /**
24
+ * Fails closed on a Select with no name, or with both: a blank `label`
25
+ * without `aria-labelledby`, a blank `aria-labelledby`, the two together
26
+ * (one would silently hide the other), or `requiredMark` with
27
+ * `aria-labelledby` (there is no label to mark).
28
+ */
29
+ export declare function assertSelectNaming(naming: {
30
+ label: unknown;
31
+ labelVisuallyHidden: unknown;
32
+ labelledBy: unknown;
33
+ requiredMark: unknown;
34
+ }): void;
@@ -0,0 +1,22 @@
1
+ function isBlank(text) {
2
+ return typeof text !== 'string' || text.trim() === '';
3
+ }
4
+ /**
5
+ * Fails closed on a Select with no name, or with both: a blank `label`
6
+ * without `aria-labelledby`, a blank `aria-labelledby`, the two together
7
+ * (one would silently hide the other), or `requiredMark` with
8
+ * `aria-labelledby` (there is no label to mark).
9
+ */
10
+ export function assertSelectNaming(naming) {
11
+ if (naming.labelledBy === undefined) {
12
+ if (isBlank(naming.label))
13
+ throw new RangeError('Select needs a label or aria-labelledby');
14
+ return;
15
+ }
16
+ if (isBlank(naming.labelledBy))
17
+ throw new RangeError('Select aria-labelledby must be a non-empty id');
18
+ if (naming.label !== undefined || naming.labelVisuallyHidden !== undefined)
19
+ throw new RangeError('Select takes label (and labelVisuallyHidden) or aria-labelledby, not both');
20
+ if (naming.requiredMark !== undefined)
21
+ throw new RangeError('Select requiredMark needs label: with aria-labelledby the page draws the mark');
22
+ }
@@ -11,6 +11,14 @@ export type StepProps = {
11
11
  editLabel?: string;
12
12
  /** Muted words after a skipped step's title, such as "not needed". */
13
13
  skippedReason?: string;
14
+ /**
15
+ * Muted words after an open step's title, on its line, such as
16
+ * "Optional" or "Wetlands done · Woodlands empty". Drawn while the step
17
+ * is open (current or error, or any step of an open list) and outside
18
+ * the heading, so the heading and the region keep the title's name; an
19
+ * open list's region is described by them.
20
+ */
21
+ titleDetail?: string;
14
22
  /** Overrides the list's heading level for this step's title. */
15
23
  headingLevel?: StepHeadingLevel;
16
24
  /** The step's fields, shown while it is current or in error, or always in an open list. */
@@ -21,6 +29,7 @@ export type StepProps = {
21
29
  * and, by status, its fields (current, error) or one folded line (a done
22
30
  * step's summary and Edit; an upcoming or skipped step's title alone). In
23
31
  * an open list every step shows its fields, inside a region named by its
24
- * title.
32
+ * title. An open step's `titleDetail` is muted words beside its title,
33
+ * outside the heading.
25
34
  */
26
35
  export declare function Step(props: StepProps): import("react").JSX.Element;
@@ -1,22 +1,13 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Children, useContext, useEffect, useId, useRef, } from 'react';
4
4
  import { cx } from '../../class-names.js';
5
5
  import { Button } from '../Button.js';
6
- import { isOpenStep, stepHeadingLevels, stepStatuses, stepStatusWord, stepTakesFocus, } from './step-status.js';
6
+ import { isOpenStep, stepStatusWord, stepTakesFocus, } from './step-status.js';
7
+ import { assertStep } from './assert-step.js';
7
8
  import { StepPlaceContext } from './steps-context.js';
8
- function assertStep(props, level) {
9
- if (!props.title.trim())
10
- throw new RangeError('Step title must not be empty');
11
- if (!stepStatuses.includes(props.status))
12
- throw new RangeError(`Unknown Step status: ${String(props.status)}`);
13
- if (!stepHeadingLevels.includes(level))
14
- throw new RangeError('Step headingLevel must be 2, 3 or 4');
15
- if (props.onEdit && !props.editLabel?.trim())
16
- throw new RangeError('Step onEdit needs an editLabel');
17
- if (props.skippedReason !== undefined && !props.skippedReason.trim())
18
- throw new RangeError('Step skippedReason must not be empty');
19
- }
9
+ import { StepTitleLine } from './StepTitleLine.js';
10
+ import { StepTitleWords } from './StepTitleWords.js';
20
11
  /**
21
12
  * Moves focus to the heading when the step becomes current from a folded
22
13
  * state, unless an earlier step of the list already took it in this commit
@@ -46,7 +37,8 @@ function useFocusWhenOpened(status, heading, focusClaimRef, open) {
46
37
  * and, by status, its fields (current, error) or one folded line (a done
47
38
  * step's summary and Edit; an upcoming or skipped step's title alone). In
48
39
  * an open list every step shows its fields, inside a region named by its
49
- * title.
40
+ * title. An open step's `titleDetail` is muted words beside its title,
41
+ * outside the heading.
50
42
  */
51
43
  export function Step(props) {
52
44
  const place = useContext(StepPlaceContext);
@@ -54,17 +46,21 @@ export function Step(props) {
54
46
  throw new RangeError('Step must be a child of Steps');
55
47
  const level = props.headingLevel ?? place.headingLevel;
56
48
  assertStep(props, level);
57
- const { status, title, summary, onEdit, editLabel, skippedReason, children } = props;
49
+ const { status, title, summary, onEdit, editLabel, skippedReason, titleDetail, children, } = props;
58
50
  const heading = useRef(null);
59
51
  const titleId = useId();
60
52
  const editId = useId();
53
+ const detailId = useId();
61
54
  useFocusWhenOpened(status, heading, place.focusClaimRef, place.open);
62
55
  const Heading = `h${level}`;
63
56
  const reason = status === 'skipped' ? skippedReason : undefined;
64
57
  const word = stepStatusWord(status, place.labels, reason !== undefined);
65
58
  const done = status === 'done';
66
- const header = (_jsxs("div", { className: "sw-steps-header", children: [_jsx("span", { "aria-hidden": "true", className: "sw-steps-marker", children: status === 'error' ? '!' : done ? null : place.number }), _jsxs(Heading, { ref: heading, className: "sw-steps-title", tabIndex: -1, children: [_jsx("span", { id: titleId, children: title }), reason ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-steps-reason", children: reason })] })) : null, word ? _jsxs("span", { className: "sw-sr-only", children: [", ", word] }) : null] }), done && summary ? (_jsx("span", { className: "sw-steps-summary", children: summary })) : null, done && onEdit ? (_jsx(Button, { "aria-labelledby": `${editId} ${titleId}`, className: "sw-steps-edit", id: editId, onPress: onEdit, size: "sm", variant: "ghost", children: editLabel })) : null] }));
67
- const shown = (place.open || isOpenStep(status)) && Children.toArray(children).length > 0;
59
+ const shownOpen = place.open || isOpenStep(status);
60
+ const detail = shownOpen ? titleDetail : undefined;
61
+ const headingElement = (_jsx(Heading, { ref: heading, className: "sw-steps-title", tabIndex: -1, children: word === null ? (_jsx(StepTitleWords, { reason: reason, title: title, titleId: titleId, word: null })) : (_jsx(StepTitleWords, { title: title, titleId: titleId, word: word })) }));
62
+ const header = (_jsxs("div", { className: "sw-steps-header", children: [_jsx("span", { "aria-hidden": "true", className: "sw-steps-marker", children: status === 'error' ? '!' : done ? null : place.number }), _jsx(StepTitleLine, { detail: detail, detailId: detailId, hasDetail: titleDetail !== undefined, children: headingElement }), done && summary ? (_jsx("span", { className: "sw-steps-summary", children: summary })) : null, done && onEdit ? (_jsx(Button, { "aria-labelledby": `${editId} ${titleId}`, className: "sw-steps-edit", id: editId, onPress: onEdit, size: "sm", variant: "ghost", children: editLabel })) : null] }));
63
+ const shown = shownOpen && Children.toArray(children).length > 0;
68
64
  const content = (_jsxs(_Fragment, { children: [header, shown ? _jsx("div", { className: "sw-steps-body", children: children }) : null] }));
69
- return (_jsx("li", { "aria-current": !place.open && status === 'current' ? 'step' : undefined, className: cx('sw-steps-item', `sw-steps-item-${status}`), children: place.open ? (_jsx("section", { "aria-labelledby": titleId, className: "sw-steps-region", children: content })) : (content) }));
65
+ return (_jsx("li", { "aria-current": !place.open && status === 'current' ? 'step' : undefined, className: cx('sw-steps-item', `sw-steps-item-${status}`), children: place.open ? (_jsx("section", { "aria-describedby": detail ? detailId : undefined, "aria-labelledby": titleId, className: "sw-steps-region", children: content })) : (content) }));
70
66
  }
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from 'react';
2
+ export type StepTitleLineProps = {
3
+ /** The step's heading. */
4
+ children: ReactNode;
5
+ /** Whether the step has `titleDetail` at all, open or folded. */
6
+ hasDetail: boolean;
7
+ /** The muted words to draw now (an open step's), or none. */
8
+ detail?: string;
9
+ /** Their id, which an open list's region is described by. */
10
+ detailId: string;
11
+ };
12
+ /**
13
+ * A step's heading and, after it on a line that wraps, its muted
14
+ * `titleDetail` words, outside the heading. The line is there whenever the
15
+ * step has words, drawn or not, so the heading keeps its element (and its
16
+ * focus) as the step opens and folds; a step without words has the bare
17
+ * heading, as before.
18
+ */
19
+ export declare function StepTitleLine({ children, hasDetail, detail, detailId, }: StepTitleLineProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A step's heading and, after it on a line that wraps, its muted
4
+ * `titleDetail` words, outside the heading. The line is there whenever the
5
+ * step has words, drawn or not, so the heading keeps its element (and its
6
+ * focus) as the step opens and folds; a step without words has the bare
7
+ * heading, as before.
8
+ */
9
+ export function StepTitleLine({ children, hasDetail, detail, detailId, }) {
10
+ if (!hasDetail)
11
+ return children;
12
+ return (_jsxs("div", { className: "sw-steps-title-line", children: [children, detail ? (_jsx("span", { className: "sw-steps-title-detail", id: detailId, children: detail })) : null] }));
13
+ }
@@ -0,0 +1,27 @@
1
+ export type StepTitleWordsProps = {
2
+ /** The id the step's region and its Edit button are named by. */
3
+ titleId: string;
4
+ title: string;
5
+ } & ({
6
+ /** The status word read after the title. */
7
+ word: string;
8
+ reason?: never;
9
+ } | {
10
+ word: null;
11
+ /** A skipped step's muted reason after its title. */
12
+ reason?: string;
13
+ });
14
+ /**
15
+ * The words inside a step's heading. Without a status word they are the
16
+ * title (and a skipped step's reason, which never comes with a word) as
17
+ * drawn. With one, the drawn title is hidden from assistive technology and
18
+ * one visually hidden line holds the whole spoken name ("The site, Done"):
19
+ * a hidden box is laid out as a block, so a browser computing the name
20
+ * puts a space before it ("The site , Done") when the title and the word
21
+ * sit in separate boxes.
22
+ *
23
+ * `aria-hidden` goes on the title's own box, never a wrapper: the region
24
+ * and the Edit button name themselves by its id, and Chrome reads a
25
+ * referenced box inside a hidden wrapper into the heading's name too.
26
+ */
27
+ export declare function StepTitleWords(props: StepTitleWordsProps): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The words inside a step's heading. Without a status word they are the
4
+ * title (and a skipped step's reason, which never comes with a word) as
5
+ * drawn. With one, the drawn title is hidden from assistive technology and
6
+ * one visually hidden line holds the whole spoken name ("The site, Done"):
7
+ * a hidden box is laid out as a block, so a browser computing the name
8
+ * puts a space before it ("The site , Done") when the title and the word
9
+ * sit in separate boxes.
10
+ *
11
+ * `aria-hidden` goes on the title's own box, never a wrapper: the region
12
+ * and the Edit button name themselves by its id, and Chrome reads a
13
+ * referenced box inside a hidden wrapper into the heading's name too.
14
+ */
15
+ export function StepTitleWords(props) {
16
+ const { titleId, title } = props;
17
+ if (props.word !== null)
18
+ return (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", id: titleId, children: title }), _jsx("span", { className: "sw-sr-only", children: `${title}, ${props.word}` })] }));
19
+ const { reason } = props;
20
+ return (_jsxs(_Fragment, { children: [_jsx("span", { id: titleId, children: title }), reason ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-steps-reason", children: reason })] })) : null] }));
21
+ }
@@ -0,0 +1,7 @@
1
+ import type { StepProps } from './Step.js';
2
+ import { type StepHeadingLevel } from './step-status.js';
3
+ /**
4
+ * Fails closed on a step it cannot draw: a blank title, reason or title
5
+ * detail, an unknown status or heading level, or an Edit with no words.
6
+ */
7
+ export declare function assertStep(props: StepProps, level: StepHeadingLevel): void;
@@ -0,0 +1,19 @@
1
+ import { stepHeadingLevels, stepStatuses, } from './step-status.js';
2
+ /**
3
+ * Fails closed on a step it cannot draw: a blank title, reason or title
4
+ * detail, an unknown status or heading level, or an Edit with no words.
5
+ */
6
+ export function assertStep(props, level) {
7
+ if (!props.title.trim())
8
+ throw new RangeError('Step title must not be empty');
9
+ if (!stepStatuses.includes(props.status))
10
+ throw new RangeError(`Unknown Step status: ${String(props.status)}`);
11
+ if (!stepHeadingLevels.includes(level))
12
+ throw new RangeError('Step headingLevel must be 2, 3 or 4');
13
+ if (props.onEdit && !props.editLabel?.trim())
14
+ throw new RangeError('Step onEdit needs an editLabel');
15
+ if (props.skippedReason !== undefined && !props.skippedReason.trim())
16
+ throw new RangeError('Step skippedReason must not be empty');
17
+ if (props.titleDetail !== undefined && !props.titleDetail.trim())
18
+ throw new RangeError('Step titleDetail must not be empty');
19
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * How wide a Button is: `content` (the default) is as wide as its words;
3
+ * `full` fills the container's inline size, and in an Inline row takes
4
+ * the space its siblings leave, such as an Edit beside a ••• menu.
5
+ */
6
+ export declare const buttonWidths: readonly ["content", "full"];
7
+ export type ButtonWidth = (typeof buttonWidths)[number];
8
+ /** The class a width adds; `content` adds none (`cx` drops `undefined`). */
9
+ export declare function buttonWidthClass(width: ButtonWidth): string | undefined;
10
+ export declare function cssButtonWidthClasses(): string;
11
+ export declare function buttonWidthClassCatalog(): string[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How wide a Button is: `content` (the default) is as wide as its words;
3
+ * `full` fills the container's inline size, and in an Inline row takes
4
+ * the space its siblings leave, such as an Edit beside a ••• menu.
5
+ */
6
+ export const buttonWidths = ['content', 'full'];
7
+ const full = 'sw-button-full';
8
+ /** The class a width adds; `content` adds none (`cx` drops `undefined`). */
9
+ export function buttonWidthClass(width) {
10
+ return width === 'full' ? full : undefined;
11
+ }
12
+ /*
13
+ * A full button fills a Stack, a Grid cell or any column. As a direct
14
+ * child of an Inline row it shares the row with its siblings, which keep
15
+ * their own width (a ••• or a second button), as a full Select does; in a
16
+ * wrapping row it grows from its words' width instead, so it moves to a
17
+ * line of its own rather than shrinking past them. Its words stay centred.
18
+ */
19
+ export function cssButtonWidthClasses() {
20
+ return `.${full} {
21
+ width: 100%;
22
+ }
23
+
24
+ .sw-inline > .${full} {
25
+ flex: 1 1 0;
26
+ min-width: 0;
27
+ }
28
+
29
+ .sw-inline.sw-wrap > .${full} {
30
+ flex: 1 1 auto;
31
+ width: auto;
32
+ }`;
33
+ }
34
+ export function buttonWidthClassCatalog() {
35
+ return [full];
36
+ }
@@ -1,6 +1,7 @@
1
1
  import { buttonSizes, buttonVariants, controlScale, typographyVariants, } from '@scalewing/tokens';
2
2
  import { breakpointQuery } from './breakpoints.js';
3
3
  import { buttonGroupAttachedClassCatalog, cssButtonGroupAttachedClasses, } from './css-button-group-attached.js';
4
+ import { buttonWidthClassCatalog, cssButtonWidthClasses, } from './css-button-width.js';
4
5
  import { quietFill } from './quiet-fill.js';
5
6
  const label = typographyVariants.label;
6
7
  /**
@@ -135,6 +136,7 @@ ${disabledNeutralRules()}
135
136
  ${pressedRules()}
136
137
  ${ariaDisabledForcedRules()}
137
138
  ${sizeRules()}
139
+ ${cssButtonWidthClasses()}
138
140
  ${groupRules()}
139
141
 
140
142
  ${cssButtonGroupAttachedClasses()}`;
@@ -162,6 +164,7 @@ export function buttonClassCatalog() {
162
164
  ...webButtonVariants.map((variant) => `sw-button-${variant}`),
163
165
  'sw-button-disabled-neutral',
164
166
  ...buttonSizes.map((size) => `sw-button-${size}`),
167
+ ...buttonWidthClassCatalog(),
165
168
  'sw-button-group',
166
169
  ...buttonGroupJustifies.map((name) => `sw-button-group-${name}`),
167
170
  ...buttonGroupAttachedClassCatalog(),
@@ -0,0 +1,2 @@
1
+ export declare function cssFileFieldCompactClasses(): string;
2
+ export declare function fileFieldCompactClassCatalog(): string[];