@scalewing/react 1.2.0 → 1.4.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,26 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e3b4ddc: Add `StatTile`, one prominent figure with its label: a glyph slot, a tabular value with a `tone` (`accent`, `success`, `danger`, `warning`), an optional caption, and `emphasis="primary"` that fills the tile in the accent for the figure a page leads with (generated `sw-stat-tile`, `sw-stat-tile-primary`, `sw-stat-tile-glyph`, `sw-stat-tile-body`, `sw-stat-tile-label`, `sw-stat-tile-value`, `sw-stat-tile-caption`; the tone colours the value through `Text color`).
8
+ - 97e172a: Add `Tabs` and `TabPanel`: a `tablist` strip with an accent underline under the current tab, roving focus, ArrowLeft / ArrowRight / Home / End that move and select, sideways scrolling when the labels overflow (generated `sw-tabs`, `sw-tab`, `sw-tab-selected`), and a labelled `tabpanel` that hides itself while another tab is current.
9
+ - 98ffed7: Add `Badge tone="warning"`, outlined in the new `warning` colour like `success` and `danger` (generated `sw-badge-warning`), for the middle status a product shows between good and blocked.
10
+
11
+ ### Patch Changes
12
+
13
+ - aefecaf: `Table` scroll wrapper is keyboard reachable: the `sw-table-wrap` element is a `group` named after the table (`aria-label` / `aria-labelledby` mirrored) with `tabIndex={0}` and an accent focus ring, so a wide table with no focusable cell can be scrolled sideways from the keyboard and passes the axe `scrollable-region-focusable` rule.
14
+ - Updated dependencies [98ffed7]
15
+ - @scalewing/tokens@1.2.0
16
+
17
+ ## 1.3.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 3ce39ea: Add `ButtonGroup`, the action row of a form or dialog: buttons on one line from the `md` breakpoint up, aligned `start`, `end` (default) or `between`; below `md` they stack full width in source order (generated `sw-button-group` and `sw-button-group-*`).
22
+ - 3e20a6e: Add `SegmentedControl` `disabled`: the group reports `aria-disabled`, every segment is a disabled button, arrow keys and clicks are ignored, and the track fades to `--sw-disabled-opacity` (generated `sw-segmented-disabled`). The recorded choice stays visible, for an identity that can no longer change; a disabled control may omit `onChange`.
23
+
3
24
  ## 1.2.0
4
25
 
5
26
  ### Minor Changes
@@ -0,0 +1,14 @@
1
+ import { type BoxProps } from './Box.js';
2
+ export type ButtonGroupJustify = 'start' | 'end' | 'between';
3
+ export type ButtonGroupProps = BoxProps & {
4
+ /** Where the row sits from the `md` breakpoint up; below it every button stacks full width in source order. */
5
+ justify?: ButtonGroupJustify;
6
+ };
7
+ /**
8
+ * The action row of a form or dialog: buttons on one line, wrapping when
9
+ * they must, aligned to the end by default. On a phone the same buttons
10
+ * stack full width so each one is a whole-row target, in source order
11
+ * (put the primary action last, as the row already does). It is a `group`,
12
+ * so an `aria-label` names the row for assistive tech.
13
+ */
14
+ export declare function ButtonGroup({ className, justify, ...rest }: ButtonGroupProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from '../class-names.js';
3
+ import { Box } from './Box.js';
4
+ /**
5
+ * The action row of a form or dialog: buttons on one line, wrapping when
6
+ * they must, aligned to the end by default. On a phone the same buttons
7
+ * stack full width so each one is a whole-row target, in source order
8
+ * (put the primary action last, as the row already does). It is a `group`,
9
+ * so an `aria-label` names the row for assistive tech.
10
+ */
11
+ export function ButtonGroup({ className, justify = 'end', ...rest }) {
12
+ return (_jsx(Box, { className: cx('sw-button-group', `sw-button-group-${justify}`, className), role: "group", ...rest }));
13
+ }
@@ -17,9 +17,20 @@ type SegmentedLabel = {
17
17
  * width in a Stack, the width of its widest label in an Inline.
18
18
  */
19
19
  export type SegmentedControlVariant = 'compact' | 'filled';
20
- export type SegmentedControlProps = SegmentedLabel & {
21
- items: readonly SegmentedItem[];
20
+ /**
21
+ * A disabled control can never report a change, so it needs no `onChange`;
22
+ * a live one must have it.
23
+ */
24
+ type SegmentedChange = {
25
+ disabled?: false;
22
26
  onChange: (id: string) => void;
27
+ } | {
28
+ /** Keeps the current choice visible but inert, for an identity that can no longer change. */
29
+ disabled: true;
30
+ onChange?: (id: string) => void;
31
+ };
32
+ export type SegmentedControlProps = SegmentedLabel & SegmentedChange & {
33
+ items: readonly SegmentedItem[];
23
34
  value: string;
24
35
  variant?: SegmentedControlVariant;
25
36
  };
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef } from 'react';
3
3
  import { cx } from '../class-names.js';
4
- export const SegmentedControl = forwardRef(function SegmentedControl({ items, onChange, value, variant = 'compact', ...labelProps }, ref) {
4
+ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, items, onChange, value, variant = 'compact', ...labelProps }, ref) {
5
5
  function onKeyDown(event) {
6
- if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') {
6
+ if (disabled || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft')) {
7
7
  return;
8
8
  }
9
9
  const index = items.findIndex((item) => item.id === value);
@@ -14,14 +14,14 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ items, on
14
14
  const delta = event.key === 'ArrowRight' ? 1 : -1;
15
15
  const next = items[(index + delta + items.length) % items.length];
16
16
  if (next) {
17
- onChange(next.id);
17
+ onChange?.(next.id);
18
18
  }
19
19
  }
20
- return (_jsx("div", { ref: ref, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
20
+ return (_jsx("div", { ref: ref, "aria-disabled": disabled || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
21
21
  const selected = item.id === value;
22
- return (_jsx("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), onClick: () => {
22
+ return (_jsx("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
23
23
  if (!selected) {
24
- onChange(item.id);
24
+ onChange?.(item.id);
25
25
  }
26
26
  }, role: "radio", type: "button", children: item.label }, item.id));
27
27
  }) }));
@@ -0,0 +1,25 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type StatTileTone, statTileTones } from '../css/css-stat-tile.js';
3
+ export type { StatTileTone };
4
+ export { statTileTones };
5
+ export type StatTileEmphasis = 'default' | 'primary';
6
+ export type StatTileProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
7
+ /** A short name for the figure, set in the label variant. */
8
+ label: ReactNode;
9
+ /** The figure, already formatted by the consumer; tabular numerals. */
10
+ value: ReactNode;
11
+ /** The consumer's glyph (Lucide), shown in a tinted circle before the text. */
12
+ glyph?: ReactNode;
13
+ /** Colours the value through `Text color`: the sign of a variance, a count that needs attention. */
14
+ tone?: StatTileTone;
15
+ /** A second line under the value: a breakdown, a unit, a comparison. */
16
+ caption?: ReactNode;
17
+ /** `primary` fills the tile in the accent for the one figure a page leads with. */
18
+ emphasis?: StatTileEmphasis;
19
+ };
20
+ /**
21
+ * One prominent figure with its label: the tiles a report or a list opens
22
+ * with. Presentation only; the consumer formats the number and picks the
23
+ * glyph.
24
+ */
25
+ export declare function StatTile({ caption, className, emphasis, glyph, label, tone, value, ...rest }: StatTileProps): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../class-names.js';
3
+ import { statTileTones } from '../css/css-stat-tile.js';
4
+ import { Text } from './Text.js';
5
+ export { statTileTones };
6
+ /**
7
+ * One prominent figure with its label: the tiles a report or a list opens
8
+ * with. Presentation only; the consumer formats the number and picks the
9
+ * glyph.
10
+ */
11
+ export function StatTile({ caption, className, emphasis = 'default', glyph, label, tone = 'default', value, ...rest }) {
12
+ const primary = emphasis === 'primary';
13
+ return (_jsxs("section", { className: cx('sw-stat-tile', primary && 'sw-stat-tile-primary', className), ...rest, children: [glyph ? (_jsx("span", { "aria-hidden": "true", className: "sw-stat-tile-glyph", children: glyph })) : null, _jsxs("span", { className: "sw-stat-tile-body", children: [_jsx(Text, { as: "span", className: "sw-stat-tile-label", color: primary ? 'onAccent' : 'muted', variant: "label", children: label }), _jsx(Text, { as: "strong", className: "sw-stat-tile-value", color: primary ? 'onAccent' : tone === 'default' ? 'text' : tone, variant: "heading", children: value }), caption ? (_jsx(Text, { as: "span", className: "sw-stat-tile-caption", color: primary ? 'onAccent' : 'muted', variant: "caption", children: caption })) : null] })] }));
14
+ }
@@ -4,6 +4,11 @@ export type TableProps = HTMLAttributes<HTMLTableElement> & {
4
4
  density?: TableDensity;
5
5
  stickyHeader?: boolean;
6
6
  };
7
+ /**
8
+ * The scroll wrapper is a keyboard stop named after the table, so a wide
9
+ * table with no focusable cell can still be scrolled sideways from the
10
+ * keyboard and is announced as one group.
11
+ */
7
12
  export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableElement> & {
8
13
  density?: TableDensity;
9
14
  stickyHeader?: boolean;
@@ -1,8 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef, } from 'react';
3
3
  import { cx } from '../class-names.js';
4
+ /**
5
+ * The scroll wrapper is a keyboard stop named after the table, so a wide
6
+ * table with no focusable cell can still be scrolled sideways from the
7
+ * keyboard and is announced as one group.
8
+ */
4
9
  export const Table = forwardRef(function Table({ children, className, density = 'comfortable', stickyHeader = true, ...rest }, ref) {
5
- return (_jsx("div", { className: "sw-table-wrap", children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', stickyHeader && 'sw-table-sticky', className), ...rest, children: children }) }));
10
+ return (_jsx("div", { "aria-label": rest['aria-label'], "aria-labelledby": rest['aria-labelledby'], className: "sw-table-wrap", role: "group", tabIndex: 0, children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', stickyHeader && 'sw-table-sticky', className), ...rest, children: children }) }));
6
11
  });
7
12
  export const TableHeader = forwardRef(function TableHeader({ className, ...rest }, ref) {
8
13
  return _jsx("thead", { ref: ref, className: className, ...rest });
@@ -0,0 +1,39 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ export type TabItem = {
3
+ id: string;
4
+ label: ReactNode;
5
+ };
6
+ type TabsLabel = {
7
+ 'aria-label': string;
8
+ 'aria-labelledby'?: never;
9
+ } | {
10
+ 'aria-label'?: never;
11
+ 'aria-labelledby': string;
12
+ };
13
+ export type TabsProps = TabsLabel & {
14
+ /** Prefix for the tab and panel ids, so a `TabPanel` can point back at its tab. */
15
+ id: string;
16
+ items: readonly TabItem[];
17
+ value: string;
18
+ onChange: (id: string) => void;
19
+ };
20
+ export declare function tabId(tabsId: string, id: string): string;
21
+ export declare function tabPanelId(tabsId: string, id: string): string;
22
+ /**
23
+ * A tab strip with `tablist` semantics for the sections of one page. Arrow
24
+ * keys, Home and End move the focus and select at once; the strip scrolls
25
+ * sideways when it overflows. Pair each item with a `TabPanel`.
26
+ */
27
+ export declare function Tabs({ id, items, onChange, value, ...labelProps }: TabsProps): import("react").JSX.Element;
28
+ export type TabPanelProps = Omit<HTMLAttributes<HTMLDivElement>, 'id'> & {
29
+ /** The `id` of the `Tabs` this panel belongs to. */
30
+ tabsId: string;
31
+ /** The item id this panel shows. */
32
+ id: string;
33
+ /** The current `Tabs` value; the panel hides itself when another tab is current. */
34
+ value: string;
35
+ children: ReactNode;
36
+ };
37
+ /** The content of one tab, labelled by its tab and hidden while another tab is current. */
38
+ export declare function TabPanel({ children, className, id, tabsId, value, ...rest }: TabPanelProps): import("react").JSX.Element;
39
+ export {};
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRef, } from 'react';
3
+ import { cx } from '../class-names.js';
4
+ export function tabId(tabsId, id) {
5
+ return `${tabsId}-tab-${id}`;
6
+ }
7
+ export function tabPanelId(tabsId, id) {
8
+ return `${tabsId}-panel-${id}`;
9
+ }
10
+ /**
11
+ * A tab strip with `tablist` semantics for the sections of one page. Arrow
12
+ * keys, Home and End move the focus and select at once; the strip scrolls
13
+ * sideways when it overflows. Pair each item with a `TabPanel`.
14
+ */
15
+ export function Tabs({ id, items, onChange, value, ...labelProps }) {
16
+ const tabs = useRef(new Map());
17
+ function select(next) {
18
+ if (!next || next.id === value) {
19
+ return;
20
+ }
21
+ onChange(next.id);
22
+ tabs.current.get(next.id)?.focus();
23
+ }
24
+ function onKeyDown(event) {
25
+ const index = items.findIndex((item) => item.id === value);
26
+ if (index < 0) {
27
+ return;
28
+ }
29
+ const last = items.length - 1;
30
+ const target = event.key === 'ArrowRight'
31
+ ? items[index === last ? 0 : index + 1]
32
+ : event.key === 'ArrowLeft'
33
+ ? items[index === 0 ? last : index - 1]
34
+ : event.key === 'Home'
35
+ ? items[0]
36
+ : event.key === 'End'
37
+ ? items[last]
38
+ : undefined;
39
+ if (!target) {
40
+ return;
41
+ }
42
+ event.preventDefault();
43
+ select(target);
44
+ }
45
+ return (_jsx("div", { className: "sw-tabs", onKeyDown: onKeyDown, role: "tablist", ...labelProps, children: items.map((item) => {
46
+ const selected = item.id === value;
47
+ return (_jsx("button", { "aria-controls": tabPanelId(id, item.id), "aria-selected": selected, className: cx('sw-tab', selected && 'sw-tab-selected'), id: tabId(id, item.id), onClick: () => select(item), ref: (node) => {
48
+ if (node) {
49
+ tabs.current.set(item.id, node);
50
+ }
51
+ else {
52
+ tabs.current.delete(item.id);
53
+ }
54
+ }, role: "tab", tabIndex: selected ? 0 : -1, type: "button", children: item.label }, item.id));
55
+ }) }));
56
+ }
57
+ /** The content of one tab, labelled by its tab and hidden while another tab is current. */
58
+ export function TabPanel({ children, className, id, tabsId, value, ...rest }) {
59
+ return (_jsx("div", { "aria-labelledby": tabId(tabsId, id), className: cx('sw-tab-panel', className), hidden: value !== id, id: tabPanelId(tabsId, id), role: "tabpanel", tabIndex: 0, ...rest, children: children }));
60
+ }
@@ -4,4 +4,5 @@ export declare function buttonClassNames(options: {
4
4
  variant: ButtonVariant;
5
5
  }): string[];
6
6
  export declare function cssButtonClasses(): string;
7
+ export declare const buttonGroupJustifies: readonly ["start", "end", "between"];
7
8
  export declare function buttonClassCatalog(): string[];
@@ -1,4 +1,5 @@
1
1
  import { buttonSizes, buttonVariants, controlScale, typographyVariants, } from '@scalewing/tokens';
2
+ import { breakpointQuery } from './breakpoints.js';
2
3
  const label = typographyVariants.label;
3
4
  export function buttonClassNames(options) {
4
5
  return [
@@ -57,12 +58,32 @@ export function cssButtonClasses() {
57
58
  }
58
59
 
59
60
  ${variantRules()}
60
- ${sizeRules()}`;
61
+ ${sizeRules()}
62
+ ${groupRules()}`;
63
+ }
64
+ export const buttonGroupJustifies = ['start', 'end', 'between'];
65
+ /** The action row: one line from md up, full-width stacked buttons below it. */
66
+ function groupRules() {
67
+ const justify = {
68
+ start: 'flex-start',
69
+ end: 'flex-end',
70
+ between: 'space-between',
71
+ };
72
+ return [
73
+ `.sw-button-group { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sw-space-2); min-width: 0; }`,
74
+ ...buttonGroupJustifies.map((name) => `.sw-button-group-${name} { justify-content: ${justify[name]}; }`),
75
+ `@media ${breakpointQuery('below', 'md')} {
76
+ .sw-button-group { align-items: stretch; flex-direction: column; }
77
+ .sw-button-group > * { width: 100%; }
78
+ }`,
79
+ ].join('\n');
61
80
  }
62
81
  export function buttonClassCatalog() {
63
82
  return [
64
83
  'sw-button',
65
84
  ...buttonVariants.map((variant) => `sw-button-${variant}`),
66
85
  ...buttonSizes.map((size) => `sw-button-${size}`),
86
+ 'sw-button-group',
87
+ ...buttonGroupJustifies.map((name) => `sw-button-group-${name}`),
67
88
  ];
68
89
  }
@@ -17,6 +17,8 @@ import { cssSpinnerClasses } from './css-spinner.js';
17
17
  import { cssProgressClasses } from './css-progress.js';
18
18
  import { cssTooltipClasses } from './css-tooltip.js';
19
19
  import { cssSeparatorClasses } from './css-separator.js';
20
+ import { cssStatTileClasses } from './css-stat-tile.js';
21
+ import { cssTabsClasses } from './css-tabs.js';
20
22
  import { cssResponsiveClasses } from './css-responsive.js';
21
23
  import { cssFilterChipsClasses } from './css-filter-chips.js';
22
24
  import { cssGridClasses } from './css-grid.js';
@@ -133,6 +135,10 @@ ${cssTooltipClasses()}
133
135
 
134
136
  ${cssSeparatorClasses()}
135
137
 
138
+ ${cssStatTileClasses()}
139
+
140
+ ${cssTabsClasses()}
141
+
136
142
  ${cssFilterChipsClasses()}
137
143
 
138
144
  ${cssGridClasses()}
@@ -1,4 +1,4 @@
1
- export declare const badgeTones: readonly ["neutral", "accent", "success", "danger"];
1
+ export declare const badgeTones: readonly ["neutral", "accent", "success", "danger", "warning"];
2
2
  export type BadgeTone = (typeof badgeTones)[number];
3
3
  export declare const badgeSizes: readonly ["sm", "md"];
4
4
  export type BadgeSize = (typeof badgeSizes)[number];
@@ -1,5 +1,11 @@
1
1
  import { trackInset, typographyVariants } from '@scalewing/tokens';
2
- export const badgeTones = ['neutral', 'accent', 'success', 'danger'];
2
+ export const badgeTones = [
3
+ 'neutral',
4
+ 'accent',
5
+ 'success',
6
+ 'danger',
7
+ 'warning',
8
+ ];
3
9
  export const badgeSizes = ['sm', 'md'];
4
10
  const caption = typographyVariants.caption;
5
11
  const data = typographyVariants.data;
@@ -54,6 +60,12 @@ export function cssDataClasses() {
54
60
  color: var(--sw-color-danger);
55
61
  }
56
62
 
63
+ .sw-badge-warning {
64
+ background: transparent;
65
+ border-color: var(--sw-color-warning);
66
+ color: var(--sw-color-warning);
67
+ }
68
+
57
69
  .sw-segmented {
58
70
  background: var(--sw-glass-fill);
59
71
  border: 1px solid var(--sw-glass-border);
@@ -114,11 +126,24 @@ export function cssDataClasses() {
114
126
  color: var(--sw-color-onAccent);
115
127
  }
116
128
 
129
+ .sw-segmented-disabled {
130
+ opacity: var(--sw-disabled-opacity);
131
+ }
132
+
133
+ .sw-segmented-item:disabled {
134
+ cursor: not-allowed;
135
+ }
136
+
117
137
  .sw-table-wrap {
118
138
  overflow: auto;
119
139
  width: 100%;
120
140
  }
121
141
 
142
+ .sw-table-wrap:focus-visible {
143
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
144
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
145
+ }
146
+
122
147
  .sw-table {
123
148
  border-collapse: collapse;
124
149
  width: 100%;
@@ -203,6 +228,7 @@ export function dataClassCatalog() {
203
228
  'sw-segmented-item',
204
229
  'sw-segmented-item-selected',
205
230
  'sw-segmented-filled',
231
+ 'sw-segmented-disabled',
206
232
  'sw-table-wrap',
207
233
  'sw-table',
208
234
  'sw-table-end',
@@ -0,0 +1,5 @@
1
+ export declare const statTileTones: readonly ["default", "accent", "success", "danger", "warning"];
2
+ export type StatTileTone = (typeof statTileTones)[number];
3
+ /** The figure tile: a glyph circle before a label, a tabular value and a caption. Text colours come from `Text color`. */
4
+ export declare function cssStatTileClasses(): string;
5
+ export declare function statTileClassCatalog(): string[];
@@ -0,0 +1,73 @@
1
+ export const statTileTones = [
2
+ 'default',
3
+ 'accent',
4
+ 'success',
5
+ 'danger',
6
+ 'warning',
7
+ ];
8
+ /** The figure tile: a glyph circle before a label, a tabular value and a caption. Text colours come from `Text color`. */
9
+ export function cssStatTileClasses() {
10
+ return `.sw-stat-tile {
11
+ align-items: center;
12
+ background: var(--sw-color-surface);
13
+ border: 1px solid var(--sw-color-border);
14
+ border-radius: var(--sw-radius-lg);
15
+ box-sizing: border-box;
16
+ color: var(--sw-color-text);
17
+ display: flex;
18
+ gap: var(--sw-space-3);
19
+ min-width: 0;
20
+ padding: var(--sw-space-4);
21
+ }
22
+
23
+ .sw-stat-tile-glyph {
24
+ align-items: center;
25
+ background: var(--sw-glass-fill);
26
+ border: 1px solid var(--sw-color-border);
27
+ border-radius: var(--sw-radius-pill);
28
+ color: var(--sw-color-accent);
29
+ display: inline-flex;
30
+ flex: none;
31
+ height: var(--sw-control-md-min-height);
32
+ justify-content: center;
33
+ width: var(--sw-control-md-min-height);
34
+ }
35
+
36
+ .sw-stat-tile-body {
37
+ display: grid;
38
+ gap: var(--sw-space-1);
39
+ min-width: 0;
40
+ }
41
+
42
+ .sw-stat-tile-value {
43
+ font-variant-numeric: tabular-nums;
44
+ overflow-wrap: anywhere;
45
+ }
46
+
47
+ .sw-stat-tile-primary {
48
+ background: var(--sw-color-accent);
49
+ border-color: transparent;
50
+ }
51
+
52
+ .sw-stat-tile-primary .sw-stat-tile-glyph {
53
+ background: transparent;
54
+ border-color: var(--sw-color-onAccent);
55
+ color: var(--sw-color-onAccent);
56
+ }
57
+
58
+ @media (forced-colors: active) {
59
+ .sw-stat-tile { border-color: CanvasText; }
60
+ .sw-stat-tile-primary { background: Highlight; }
61
+ }`;
62
+ }
63
+ export function statTileClassCatalog() {
64
+ return [
65
+ 'sw-stat-tile',
66
+ 'sw-stat-tile-primary',
67
+ 'sw-stat-tile-glyph',
68
+ 'sw-stat-tile-body',
69
+ 'sw-stat-tile-label',
70
+ 'sw-stat-tile-value',
71
+ 'sw-stat-tile-caption',
72
+ ];
73
+ }
@@ -0,0 +1,3 @@
1
+ /** The tab strip: a scrollable row of tabs with an accent underline under the current one. */
2
+ export declare function cssTabsClasses(): string;
3
+ export declare function tabsClassCatalog(): string[];
@@ -0,0 +1,64 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ const label = typographyVariants.label;
3
+ /** The tab strip: a scrollable row of tabs with an accent underline under the current one. */
4
+ export function cssTabsClasses() {
5
+ return `.sw-tabs {
6
+ border-bottom: 1px solid var(--sw-color-border);
7
+ display: flex;
8
+ gap: var(--sw-space-1);
9
+ margin: 0;
10
+ overflow-x: auto;
11
+ padding: 0;
12
+ scrollbar-width: none;
13
+ }
14
+
15
+ .sw-tabs::-webkit-scrollbar {
16
+ display: none;
17
+ }
18
+
19
+ .sw-tab {
20
+ appearance: none;
21
+ background: transparent;
22
+ border: 0;
23
+ border-bottom: 2px solid transparent;
24
+ border-radius: var(--sw-radius-sm) var(--sw-radius-sm) 0 0;
25
+ color: var(--sw-color-muted);
26
+ cursor: pointer;
27
+ flex: none;
28
+ font-family: var(--sw-font-sans);
29
+ font-size: ${label.fontSize}px;
30
+ font-weight: ${label.fontWeight};
31
+ letter-spacing: ${label.letterSpacing}px;
32
+ line-height: ${label.lineHeight}px;
33
+ margin-bottom: -1px;
34
+ min-height: var(--sw-control-md-min-height);
35
+ padding-inline: var(--sw-control-md-padding-inline);
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .sw-tab:hover {
40
+ color: var(--sw-color-text);
41
+ }
42
+
43
+ .sw-tab:focus-visible {
44
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
45
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
46
+ }
47
+
48
+ .sw-tab-selected {
49
+ border-bottom-color: var(--sw-color-accent);
50
+ color: var(--sw-color-accent);
51
+ }
52
+
53
+ .sw-tab-panel:focus-visible {
54
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
55
+ outline-offset: var(--sw-focus-ring-offset);
56
+ }
57
+
58
+ @media (forced-colors: active) {
59
+ .sw-tab-selected { border-bottom-color: Highlight; color: Highlight; }
60
+ }`;
61
+ }
62
+ export function tabsClassCatalog() {
63
+ return ['sw-tabs', 'sw-tab', 'sw-tab-selected', 'sw-tab-panel'];
64
+ }
@@ -9,6 +9,8 @@ import { spinnerClassCatalog } from './css-spinner.js';
9
9
  import { progressClassCatalog } from './css-progress.js';
10
10
  import { tooltipClassCatalog } from './css-tooltip.js';
11
11
  import { separatorClassCatalog } from './css-separator.js';
12
+ import { statTileClassCatalog } from './css-stat-tile.js';
13
+ import { tabsClassCatalog } from './css-tabs.js';
12
14
  import { responsiveClassCatalog } from './css-responsive.js';
13
15
  import { filterChipsClassCatalog } from './css-filter-chips.js';
14
16
  import { gridClassCatalog } from './css-grid.js';
@@ -57,6 +59,8 @@ export function utilityClassCatalog() {
57
59
  ...progressClassCatalog(),
58
60
  ...tooltipClassCatalog(),
59
61
  ...separatorClassCatalog(),
62
+ ...statTileClassCatalog(),
63
+ ...tabsClassCatalog(),
60
64
  ...filterChipsClassCatalog(),
61
65
  ...gridClassCatalog(),
62
66
  ...denominationGridClassCatalog(),
package/dist/index.d.ts CHANGED
@@ -13,8 +13,11 @@ export { Badge, type BadgeProps, type BadgeSize, type BadgeTone, badgeSizes, bad
13
13
  export { BarChart, type BarChartItem, type BarChartProps, } from './components/BarChart.js';
14
14
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant, } from './components/Button.js';
15
15
  export { Box, type BoxElement, type BoxProps } from './components/Box.js';
16
+ export { ButtonGroup, type ButtonGroupJustify, type ButtonGroupProps, } from './components/ButtonGroup.js';
16
17
  export { Card, type CardProps, type CardVariant } from './components/Card.js';
17
18
  export { Dialog, type DialogProps, type DialogSize, } from './components/Dialog.js';
19
+ export { StatTile, type StatTileEmphasis, type StatTileProps, type StatTileTone, statTileTones, } from './components/StatTile.js';
20
+ export { TabPanel, type TabItem, type TabPanelProps, Tabs, type TabsProps, tabId, tabPanelId, } from './components/Tabs.js';
18
21
  export { Toast } from './components/Toast.js';
19
22
  export type { ToastProps } from './components/Toast.js';
20
23
  export { Field, type FieldProps, type FieldSize } from './components/Field.js';
package/dist/index.js CHANGED
@@ -12,8 +12,11 @@ export { Badge, badgeSizes, badgeTones, } from './components/Badge.js';
12
12
  export { BarChart, } from './components/BarChart.js';
13
13
  export { Button, } from './components/Button.js';
14
14
  export { Box } from './components/Box.js';
15
+ export { ButtonGroup, } from './components/ButtonGroup.js';
15
16
  export { Card } from './components/Card.js';
16
17
  export { Dialog, } from './components/Dialog.js';
18
+ export { StatTile, statTileTones, } from './components/StatTile.js';
19
+ export { TabPanel, Tabs, tabId, tabPanelId, } from './components/Tabs.js';
17
20
  export { Toast } from './components/Toast.js';
18
21
  export { Field } from './components/Field.js';
19
22
  export { Inline } from './components/Inline.js';
package/dist/styles.css CHANGED
@@ -10,6 +10,7 @@
10
10
  --sw-color-danger: #B42318;
11
11
  --sw-color-onDanger: #FFFFFF;
12
12
  --sw-color-success: #087443;
13
+ --sw-color-warning: #B54708;
13
14
  --sw-color-border: #D2D2D7;
14
15
  --sw-space-0: 0px;
15
16
  --sw-space-1: 4px;
@@ -66,6 +67,7 @@
66
67
  --sw-color-danger: #F97066;
67
68
  --sw-color-onDanger: #101214;
68
69
  --sw-color-success: #5BE0A0;
70
+ --sw-color-warning: #FDB022;
69
71
  --sw-color-border: #3A3A3C;
70
72
  --sw-space-0: 0px;
71
73
  --sw-space-1: 4px;
@@ -776,6 +778,14 @@ body {
776
778
  .sw-button-xs { min-height: 28px; padding-inline: 10px; }
777
779
  .sw-button-sm { min-height: 32px; padding-inline: 12px; }
778
780
  .sw-button-md { min-height: 44px; padding-inline: 16px; }
781
+ .sw-button-group { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sw-space-2); min-width: 0; }
782
+ .sw-button-group-start { justify-content: flex-start; }
783
+ .sw-button-group-end { justify-content: flex-end; }
784
+ .sw-button-group-between { justify-content: space-between; }
785
+ @media not all and (min-width: 48rem) {
786
+ .sw-button-group { align-items: stretch; flex-direction: column; }
787
+ .sw-button-group > * { width: 100%; }
788
+ }
779
789
 
780
790
  .sw-checkbox {
781
791
  display: flex;
@@ -1064,6 +1074,12 @@ body {
1064
1074
  color: var(--sw-color-danger);
1065
1075
  }
1066
1076
 
1077
+ .sw-badge-warning {
1078
+ background: transparent;
1079
+ border-color: var(--sw-color-warning);
1080
+ color: var(--sw-color-warning);
1081
+ }
1082
+
1067
1083
  .sw-segmented {
1068
1084
  background: var(--sw-glass-fill);
1069
1085
  border: 1px solid var(--sw-glass-border);
@@ -1124,11 +1140,24 @@ body {
1124
1140
  color: var(--sw-color-onAccent);
1125
1141
  }
1126
1142
 
1143
+ .sw-segmented-disabled {
1144
+ opacity: var(--sw-disabled-opacity);
1145
+ }
1146
+
1147
+ .sw-segmented-item:disabled {
1148
+ cursor: not-allowed;
1149
+ }
1150
+
1127
1151
  .sw-table-wrap {
1128
1152
  overflow: auto;
1129
1153
  width: 100%;
1130
1154
  }
1131
1155
 
1156
+ .sw-table-wrap:focus-visible {
1157
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
1158
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
1159
+ }
1160
+
1132
1161
  .sw-table {
1133
1162
  border-collapse: collapse;
1134
1163
  width: 100%;
@@ -1904,6 +1933,116 @@ body {
1904
1933
  .sw-separator { background: CanvasText; }
1905
1934
  }
1906
1935
 
1936
+ .sw-stat-tile {
1937
+ align-items: center;
1938
+ background: var(--sw-color-surface);
1939
+ border: 1px solid var(--sw-color-border);
1940
+ border-radius: var(--sw-radius-lg);
1941
+ box-sizing: border-box;
1942
+ color: var(--sw-color-text);
1943
+ display: flex;
1944
+ gap: var(--sw-space-3);
1945
+ min-width: 0;
1946
+ padding: var(--sw-space-4);
1947
+ }
1948
+
1949
+ .sw-stat-tile-glyph {
1950
+ align-items: center;
1951
+ background: var(--sw-glass-fill);
1952
+ border: 1px solid var(--sw-color-border);
1953
+ border-radius: var(--sw-radius-pill);
1954
+ color: var(--sw-color-accent);
1955
+ display: inline-flex;
1956
+ flex: none;
1957
+ height: var(--sw-control-md-min-height);
1958
+ justify-content: center;
1959
+ width: var(--sw-control-md-min-height);
1960
+ }
1961
+
1962
+ .sw-stat-tile-body {
1963
+ display: grid;
1964
+ gap: var(--sw-space-1);
1965
+ min-width: 0;
1966
+ }
1967
+
1968
+ .sw-stat-tile-value {
1969
+ font-variant-numeric: tabular-nums;
1970
+ overflow-wrap: anywhere;
1971
+ }
1972
+
1973
+ .sw-stat-tile-primary {
1974
+ background: var(--sw-color-accent);
1975
+ border-color: transparent;
1976
+ }
1977
+
1978
+ .sw-stat-tile-primary .sw-stat-tile-glyph {
1979
+ background: transparent;
1980
+ border-color: var(--sw-color-onAccent);
1981
+ color: var(--sw-color-onAccent);
1982
+ }
1983
+
1984
+ @media (forced-colors: active) {
1985
+ .sw-stat-tile { border-color: CanvasText; }
1986
+ .sw-stat-tile-primary { background: Highlight; }
1987
+ }
1988
+
1989
+ .sw-tabs {
1990
+ border-bottom: 1px solid var(--sw-color-border);
1991
+ display: flex;
1992
+ gap: var(--sw-space-1);
1993
+ margin: 0;
1994
+ overflow-x: auto;
1995
+ padding: 0;
1996
+ scrollbar-width: none;
1997
+ }
1998
+
1999
+ .sw-tabs::-webkit-scrollbar {
2000
+ display: none;
2001
+ }
2002
+
2003
+ .sw-tab {
2004
+ appearance: none;
2005
+ background: transparent;
2006
+ border: 0;
2007
+ border-bottom: 2px solid transparent;
2008
+ border-radius: var(--sw-radius-sm) var(--sw-radius-sm) 0 0;
2009
+ color: var(--sw-color-muted);
2010
+ cursor: pointer;
2011
+ flex: none;
2012
+ font-family: var(--sw-font-sans);
2013
+ font-size: 15px;
2014
+ font-weight: 600;
2015
+ letter-spacing: -0.2px;
2016
+ line-height: 20px;
2017
+ margin-bottom: -1px;
2018
+ min-height: var(--sw-control-md-min-height);
2019
+ padding-inline: var(--sw-control-md-padding-inline);
2020
+ white-space: nowrap;
2021
+ }
2022
+
2023
+ .sw-tab:hover {
2024
+ color: var(--sw-color-text);
2025
+ }
2026
+
2027
+ .sw-tab:focus-visible {
2028
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
2029
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
2030
+ }
2031
+
2032
+ .sw-tab-selected {
2033
+ border-bottom-color: var(--sw-color-accent);
2034
+ color: var(--sw-color-accent);
2035
+ }
2036
+
2037
+ .sw-tab-panel:focus-visible {
2038
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
2039
+ outline-offset: var(--sw-focus-ring-offset);
2040
+ }
2041
+
2042
+ @media (forced-colors: active) {
2043
+ .sw-tab-selected { border-bottom-color: Highlight; color: Highlight; }
2044
+ }
2045
+
1907
2046
  .sw-filter-chips {
1908
2047
  border: 0;
1909
2048
  margin: 0;
@@ -2031,6 +2170,7 @@ body {
2031
2170
  .sw-denomination-row-accent { --sw-denomination-tone: var(--sw-color-accent); }
2032
2171
  .sw-denomination-row-success { --sw-denomination-tone: var(--sw-color-success); }
2033
2172
  .sw-denomination-row-danger { --sw-denomination-tone: var(--sw-color-danger); }
2173
+ .sw-denomination-row-warning { --sw-denomination-tone: var(--sw-color-warning); }
2034
2174
 
2035
2175
  .sw-denomination-strip {
2036
2176
  border-collapse: collapse;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "README.md"
39
39
  ],
40
40
  "dependencies": {
41
- "@scalewing/tokens": "^1.1.0"
41
+ "@scalewing/tokens": "^1.2.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "react": "^19.0.0",