@scalewing/react 1.33.1 → 1.34.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,16 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7e4cbb2: A whole-row press for a table row's one action, such as a search result that is picked (`docs/requests/teisoro-table-row-press.md`, Teisoro F-011 task 2185):
8
+
9
+ - `TableRow pressable` (opt-in): a plain primary pointer press anywhere on the row activates the row's first link or button. A press with a modifier key, on another control in the row, already handled, or ending a text selection is left alone, as is a row whose action is disabled. The row is not a control (no role, no tab stop): keyboard and screen reader users use the action itself. The row takes the pointer cursor and the quiet fill while hovered, pressed, or while its action has keyboard focus (an outline in `Highlight` under forced colours), and underlines its action on hover.
10
+ - `TableCell onPress` (a body cell or a row header): the cell's children become a button in the cell's own text style at weight 600, underlined on hover and focus, for the row's action. A non-function value or one on a sortable header throws a `TypeError`.
11
+
12
+ New generated classes `sw-table-row-press` and `sw-table-action`. No new tokens, no new dependency.
13
+
3
14
  ## 1.33.1
4
15
 
5
16
  ### Patch Changes
package/README.md CHANGED
@@ -403,6 +403,15 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
403
403
 
404
404
  `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. While the table is wider than the region, each inline edge with columns scrolled out past it draws a soft inset shade (`sw-scroll-more-start`, `sw-scroll-more-end`, mirrored right to left), so a phone, whose scrollbars stay hidden, still shows that the table goes on; a table that fits draws none, and in forced colors the system scrollbar is the cue. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
405
405
 
406
+ `TableRow pressable` (opt-in) makes the whole row the pointer target of its one action, such as a search result that is picked: a plain primary press anywhere on it calls `click()` on the row's first link or button, so the action's own handler runs. Left alone: a modifier key or non-primary button, a press already handled, a press on another control, a press that ends a text selection (text stays selectable), and a disabled or missing action. Keep one control in the row: a control placed before the action (an icon button, say) would take the row's press. The row is not a control (no role, no tab stop), so keyboard and screen reader users use the action itself. It takes the pointer cursor and the quiet fill while hovered, pressed (a brief flash on touch) or while its action has keyboard focus (a `Highlight` outline in forced colours), and underlines its action on hover (`sw-table-row-press`). A consumer `onClick` runs once per press. Each click of a double click presses the action, as a native button's would, so keep it safe to repeat. `TableCell onPress` (a body cell or a row header in `TableBody`) makes the cell's children that action: a button in the cell's own text style at weight 600, underlined on hover and focus, with the accent focus ring (`sw-table-action`); a non-function value or one on a sortable header throws a `TypeError`.
407
+
408
+ ```tsx
409
+ <TableRow pressable>
410
+ <TableCell onPress={() => pick('Red fox')}>Red fox</TableCell>
411
+ <TableCell>Forest</TableCell>
412
+ </TableRow>
413
+ ```
414
+
406
415
  A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `'none'`) and `onSort` is its column's sort control: its children become a button in the header's own style with a chevron (before the name in a `numeric` or end-aligned column), and the cell carries `aria-sort` only while it is the sorted column. `sort` on a data cell, or one of the two without the other, throws a `TypeError`. `Table` `layout="fixed"` (default `auto`) sizes the columns from the header row alone, so a filter that hides a row moves no column, and `verticalAlign="top"` (default `middle`) starts every cell of a tall row on its first line. `TableCell` `width` is `min` (an auto table's column takes the least room its content allows, on one line; set it on each cell of the column) or a size, `xs` to `xl` (4, 6, 8, 12 and 16 rem); in a fixed table a column without a width shares what is left, so leave one column, such as a description, unsized. The HTML `width` attribute is not accepted. Unknown values throw a `RangeError`.
407
416
 
408
417
  A ledger read across as well as down takes `Table` `columnRules` (a hairline at the inline start of every cell after a row's first, mirrored right to left) and `TableCell` `tone` (`success`, `danger`, `muted` or `accent`: the cell's text in the theme colour, as `Text color` uses it; never the only signal, so keep the sign or the words). `TableRow` `variant="sub"` (default `"default"`) sets a row under the one above, as a batch's items: its row header, a `TableCell as="th" scope="row"`, is indented a `space-6` step with a guide bar at the indent (a sub row without a row header is not indented). `TableRow` `struck` keeps a voided row on the record: the subtle fill and the text muted and struck through, toned cells included; it works with either variant. A line-through is not read aloud, so say why in the row, such as a `Badge` "Voided": a Badge in a line of text is not struck, but one that is itself a flex item (a direct child of `Inline`) is, so put it in the title's line. A row header in the body or footer (`th scope="row"`) reads as body text. An unknown `tone` or `variant` throws a `RangeError`.
@@ -46,5 +46,14 @@ export type TableCellProps = ((Omit<TdHTMLAttributes<HTMLTableCellElement>, 'wid
46
46
  * is never the only signal: keep the sign or the words.
47
47
  */
48
48
  tone?: TableCellTone;
49
+ /**
50
+ * Makes the cell's children a button in the cell's own text style at
51
+ * weight 600, underlined while hovered or focused: a row's one action,
52
+ * such as the name of a search result that picks it. Pair it with
53
+ * `TableRow pressable` so a press anywhere on the row does the same. A
54
+ * body cell's (`td`, or a row header `th` in `TableBody`; not for a
55
+ * column header in `TableHeader`), never with `sort`.
56
+ */
57
+ onPress?: () => void;
49
58
  };
50
59
  export declare const TableCell: import("react").ForwardRefExoticComponent<TableCellProps & import("react").RefAttributes<HTMLTableCellElement>>;
@@ -4,6 +4,15 @@ import { cx } from '../../class-names.js';
4
4
  import { assertTableCellTone, } from '../../css/css-table-ledger.js';
5
5
  import { assertTableColumnWidth, tableColumnWidthClass, } from './table-layout.js';
6
6
  const tableSorts = ['ascending', 'descending', 'none'];
7
+ /** A cell's press is a function, and never on a sortable header. */
8
+ function assertPress(onPress, sort) {
9
+ if (onPress === undefined)
10
+ return;
11
+ if (typeof onPress !== 'function')
12
+ throw new TypeError('TableCell onPress is a function');
13
+ if (sort !== undefined)
14
+ throw new TypeError('TableCell onPress is not for a sortable header');
15
+ }
7
16
  /** `sort` and `onSort` come together, on a header cell, with a known value. */
8
17
  function assertSort(as, sort, onSort) {
9
18
  if (sort === undefined && onSort === undefined)
@@ -15,8 +24,9 @@ function assertSort(as, sort, onSort) {
15
24
  if (!tableSorts.includes(sort))
16
25
  throw new RangeError(`Unknown TableCell sort: ${String(sort)}`);
17
26
  }
18
- export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onSort, sort, tone, truncate = false, width, ...rest }, ref) {
27
+ export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onPress, onSort, sort, tone, truncate = false, width, ...rest }, ref) {
19
28
  assertSort(as, sort, onSort);
29
+ assertPress(onPress, sort);
20
30
  assertTableColumnWidth(width);
21
31
  assertTableCellTone(tone);
22
32
  const Component = as;
@@ -25,5 +35,5 @@ export const TableCell = forwardRef(function TableCell({ align = 'start', as = '
25
35
  : align === 'end'
26
36
  ? 'sw-table-end'
27
37
  : undefined;
28
- return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : (children) }));
38
+ return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : onPress ? (_jsx("button", { className: "sw-table-action", onClick: onPress, type: "button", children: children })) : (children) }));
29
39
  });
@@ -18,6 +18,21 @@ export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
18
18
  * flex item, a direct child of `Inline`, is). Works with either variant.
19
19
  */
20
20
  struck?: boolean;
21
+ /**
22
+ * Makes the whole row a press target for its one action, such as a
23
+ * search result that is picked: the row's first link or button (a
24
+ * `TableCell onPress`, or a `Box as="a"`), so keep one control in the
25
+ * row. A plain pointer press anywhere else on the row activates that
26
+ * control, unless it is disabled; a press with a modifier key, on another
27
+ * control, or one that ends a text selection is left alone. The row takes
28
+ * the pointer cursor and the quiet fill while hovered, pressed, or while
29
+ * its action has keyboard focus. Keyboard and assistive technology use
30
+ * the action itself: the row is not a control and adds no tab stop.
31
+ * A consumer `onClick` runs once per press, not again for the click the
32
+ * row hands its action. Each click of a double click presses the action,
33
+ * as a native button's would, so keep the action safe to repeat.
34
+ */
35
+ pressable?: boolean;
21
36
  };
22
37
  export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableRowElement> & {
23
38
  selected?: boolean;
@@ -36,4 +51,19 @@ export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAtt
36
51
  * flex item, a direct child of `Inline`, is). Works with either variant.
37
52
  */
38
53
  struck?: boolean;
54
+ /**
55
+ * Makes the whole row a press target for its one action, such as a
56
+ * search result that is picked: the row's first link or button (a
57
+ * `TableCell onPress`, or a `Box as="a"`), so keep one control in the
58
+ * row. A plain pointer press anywhere else on the row activates that
59
+ * control, unless it is disabled; a press with a modifier key, on another
60
+ * control, or one that ends a text selection is left alone. The row takes
61
+ * the pointer cursor and the quiet fill while hovered, pressed, or while
62
+ * its action has keyboard focus. Keyboard and assistive technology use
63
+ * the action itself: the row is not a control and adds no tab stop.
64
+ * A consumer `onClick` runs once per press, not again for the click the
65
+ * row hands its action. Each click of a double click presses the action,
66
+ * as a native button's would, so keep the action safe to repeat.
67
+ */
68
+ pressable?: boolean;
39
69
  } & import("react").RefAttributes<HTMLTableRowElement>>;
@@ -1,8 +1,29 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from 'react';
2
+ import { forwardRef, useRef, } from 'react';
3
3
  import { cx } from '../../class-names.js';
4
4
  import { assertTableRowVariant, } from '../../css/css-table-ledger.js';
5
- export const TableRow = forwardRef(function TableRow({ className, selected = false, struck = false, variant = 'default', ...rest }, ref) {
5
+ import { rowPressAction } from './row-press.js';
6
+ export const TableRow = forwardRef(function TableRow({ className, onClick, pressable = false, selected = false, struck = false, variant = 'default', ...rest }, ref) {
6
7
  assertTableRowVariant(variant);
7
- return (_jsx("tr", { ref: ref, "aria-selected": selected || undefined, className: cx(selected && 'sw-table-row-selected', variant === 'sub' && 'sw-table-row-sub', struck && 'sw-table-row-struck', className), ...rest }));
8
+ // The click the row hands its action bubbles back to the row; it is
9
+ // the action's, so neither the row nor a consumer onClick takes it.
10
+ const forwarding = useRef(false);
11
+ const press = (event) => {
12
+ if (forwarding.current)
13
+ return;
14
+ onClick?.(event);
15
+ if (!pressable)
16
+ return;
17
+ const action = rowPressAction(event.currentTarget, event);
18
+ if (action === null)
19
+ return;
20
+ forwarding.current = true;
21
+ try {
22
+ action.click();
23
+ }
24
+ finally {
25
+ forwarding.current = false;
26
+ }
27
+ };
28
+ return (_jsx("tr", { ref: ref, "aria-selected": selected || undefined, className: cx(selected && 'sw-table-row-selected', variant === 'sub' && 'sw-table-row-sub', struck && 'sw-table-row-struck', pressable && 'sw-table-row-press', className), onClick: pressable || onClick ? press : undefined, ...rest }));
8
29
  });
@@ -0,0 +1,17 @@
1
+ /** What `rowPressAction` reads of a click. */
2
+ export type RowPress = {
3
+ button: number;
4
+ altKey: boolean;
5
+ ctrlKey: boolean;
6
+ metaKey: boolean;
7
+ shiftKey: boolean;
8
+ defaultPrevented: boolean;
9
+ target: EventTarget | null;
10
+ };
11
+ /**
12
+ * The control a press on a pressable row activates, or none: a plain
13
+ * primary press (no modifier key, not already handled) that lands on the
14
+ * row but not on a control in it, and does not end a text selection, goes
15
+ * to the row's first link or button, unless that is disabled.
16
+ */
17
+ export declare function rowPressAction(row: HTMLElement, press: RowPress): HTMLElement | null;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The controls a press on a pressable row leaves alone: a press on one of
3
+ * them is that control's own, never the row's.
4
+ */
5
+ const controlSelector = [
6
+ 'a[href]',
7
+ 'button',
8
+ 'input',
9
+ 'select',
10
+ 'textarea',
11
+ 'label',
12
+ 'summary',
13
+ '[contenteditable]:not([contenteditable="false"])',
14
+ '[role="button"]',
15
+ '[role="link"]',
16
+ '[role="checkbox"]',
17
+ '[role="switch"]',
18
+ '[role="menuitem"]',
19
+ '[role="option"]',
20
+ '[tabindex]:not([tabindex="-1"])',
21
+ ].join(', ');
22
+ /** The row's action: its first link or button. */
23
+ const actionSelector = 'a[href], button';
24
+ function isDisabled(action) {
25
+ return ((action instanceof HTMLButtonElement && action.disabled) ||
26
+ action.getAttribute('aria-disabled') === 'true');
27
+ }
28
+ /** A selection the press ended, such as text dragged over in the row. */
29
+ function endsSelection(row) {
30
+ const selection = row.ownerDocument.getSelection();
31
+ if (selection === null || selection.isCollapsed)
32
+ return false;
33
+ for (let index = 0; index < selection.rangeCount; index += 1)
34
+ if (selection.getRangeAt(index).intersectsNode(row))
35
+ return true;
36
+ return false;
37
+ }
38
+ /**
39
+ * The control a press on a pressable row activates, or none: a plain
40
+ * primary press (no modifier key, not already handled) that lands on the
41
+ * row but not on a control in it, and does not end a text selection, goes
42
+ * to the row's first link or button, unless that is disabled.
43
+ */
44
+ export function rowPressAction(row, press) {
45
+ if (press.defaultPrevented || press.button !== 0)
46
+ return null;
47
+ if (press.altKey || press.ctrlKey || press.metaKey || press.shiftKey)
48
+ return null;
49
+ if (!(press.target instanceof Element) || !row.contains(press.target))
50
+ return null;
51
+ const control = press.target.closest(controlSelector);
52
+ if (control !== null && control !== row && row.contains(control))
53
+ return null;
54
+ if (endsSelection(row))
55
+ return null;
56
+ const action = row.querySelector(actionSelector);
57
+ if (action === null || isDisabled(action))
58
+ return null;
59
+ return action;
60
+ }
@@ -0,0 +1,2 @@
1
+ export declare function cssTablePressClasses(): string;
2
+ export declare function tablePressClassCatalog(): string[];
@@ -0,0 +1,87 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { quietFill } from './quiet-fill.js';
3
+ const row = '.sw-table-row-press';
4
+ const action = '.sw-table-action';
5
+ /*
6
+ * A pressable row: the pointer cursor over all of it, and the quiet fill
7
+ * on its cells while it is hovered (on a pointer that hovers), pressed, or
8
+ * while its action has keyboard focus, so a keyboard user sees the row the
9
+ * focused name belongs to. The fill sits under the cells' text, so text,
10
+ * muted text and toned figures keep their contrast as on any quiet-filled
11
+ * row (an open menu's). Its action is underlined with it, so the row reads
12
+ * as that one action. Forced colours drop fills, so the row is outlined in
13
+ * the system highlight instead.
14
+ */
15
+ function pressableRowRules() {
16
+ return `${row} {
17
+ cursor: pointer;
18
+ }
19
+
20
+ @media (hover: hover) {
21
+ ${row}:hover > :is(td, th) {
22
+ background: ${quietFill};
23
+ }
24
+
25
+ ${row}:hover ${action} {
26
+ text-decoration-line: underline;
27
+ }
28
+ }
29
+
30
+ ${row}:active > :is(td, th),
31
+ ${row}:has(:focus-visible) > :is(td, th) {
32
+ background: ${quietFill};
33
+ }
34
+
35
+ @media (forced-colors: active) {
36
+ ${row}:hover,
37
+ ${row}:has(:focus-visible) {
38
+ outline: var(--sw-focus-ring-width) solid Highlight;
39
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
40
+ }
41
+ }`;
42
+ }
43
+ /*
44
+ * A cell's action keeps the cell's own text (its colour, size and
45
+ * alignment) at weight 600, the label weight, as a quiet link does: the
46
+ * weight, not a colour, sets the name apart from the row's other text.
47
+ * Hover and keyboard focus underline it; focus draws the accent ring.
48
+ * Forced colours paint it as any button's text.
49
+ */
50
+ function actionRules() {
51
+ return `${action} {
52
+ appearance: none;
53
+ background: transparent;
54
+ border: 0;
55
+ border-radius: var(--sw-radius-sm);
56
+ color: inherit;
57
+ cursor: pointer;
58
+ font: inherit;
59
+ font-weight: ${typographyVariants.label.fontWeight};
60
+ letter-spacing: inherit;
61
+ margin: 0;
62
+ padding: 0;
63
+ text-align: inherit;
64
+ }
65
+
66
+ ${action}:hover,
67
+ ${action}:focus-visible {
68
+ text-decoration-line: underline;
69
+ }
70
+
71
+ ${action}:focus-visible {
72
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
73
+ outline-offset: var(--sw-focus-ring-offset);
74
+ }
75
+
76
+ @media (forced-colors: active) {
77
+ ${action} { color: ButtonText; }
78
+ }`;
79
+ }
80
+ export function cssTablePressClasses() {
81
+ return `${pressableRowRules()}
82
+
83
+ ${actionRules()}`;
84
+ }
85
+ export function tablePressClassCatalog() {
86
+ return ['sw-table-row-press', 'sw-table-action'];
87
+ }
@@ -3,6 +3,7 @@ import { scrollRegionRules } from './css-scroll-region.js';
3
3
  import { cssTableLayoutClasses, tableLayoutClassCatalog, } from './css-table-layout.js';
4
4
  import { cssTableLedgerClasses, tableLedgerClassCatalog, } from './css-table-ledger.js';
5
5
  import { cssTableFooterClasses, tableFooterClassCatalog, } from './css-table-footer.js';
6
+ import { cssTablePressClasses, tablePressClassCatalog, } from './css-table-press.js';
6
7
  import { cssTableSortClasses, tableSortClassCatalog, } from './css-table-sort.js';
7
8
  import { zIndex } from './stacking.js';
8
9
  const caption = typographyVariants.caption;
@@ -95,6 +96,8 @@ ${cssTableLedgerClasses()}
95
96
 
96
97
  ${cssTableFooterClasses()}
97
98
 
99
+ ${cssTablePressClasses()}
100
+
98
101
  ${selectedRowRules()}`;
99
102
  }
100
103
  export function tableClassCatalog() {
@@ -111,5 +114,6 @@ export function tableClassCatalog() {
111
114
  ...tableSortClassCatalog(),
112
115
  ...tableLedgerClassCatalog(),
113
116
  ...tableFooterClassCatalog(),
117
+ ...tablePressClassCatalog(),
114
118
  ];
115
119
  }
package/dist/styles.css CHANGED
@@ -1841,6 +1841,62 @@ body {
1841
1841
  .sw-table-sticky thead th { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
1842
1842
  }
1843
1843
 
1844
+ .sw-table-row-press {
1845
+ cursor: pointer;
1846
+ }
1847
+
1848
+ @media (hover: hover) {
1849
+ .sw-table-row-press:hover > :is(td, th) {
1850
+ background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
1851
+ }
1852
+
1853
+ .sw-table-row-press:hover .sw-table-action {
1854
+ text-decoration-line: underline;
1855
+ }
1856
+ }
1857
+
1858
+ .sw-table-row-press:active > :is(td, th),
1859
+ .sw-table-row-press:has(:focus-visible) > :is(td, th) {
1860
+ background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
1861
+ }
1862
+
1863
+ @media (forced-colors: active) {
1864
+ .sw-table-row-press:hover,
1865
+ .sw-table-row-press:has(:focus-visible) {
1866
+ outline: var(--sw-focus-ring-width) solid Highlight;
1867
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
1868
+ }
1869
+ }
1870
+
1871
+ .sw-table-action {
1872
+ appearance: none;
1873
+ background: transparent;
1874
+ border: 0;
1875
+ border-radius: var(--sw-radius-sm);
1876
+ color: inherit;
1877
+ cursor: pointer;
1878
+ font: inherit;
1879
+ font-weight: 600;
1880
+ letter-spacing: inherit;
1881
+ margin: 0;
1882
+ padding: 0;
1883
+ text-align: inherit;
1884
+ }
1885
+
1886
+ .sw-table-action:hover,
1887
+ .sw-table-action:focus-visible {
1888
+ text-decoration-line: underline;
1889
+ }
1890
+
1891
+ .sw-table-action:focus-visible {
1892
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
1893
+ outline-offset: var(--sw-focus-ring-offset);
1894
+ }
1895
+
1896
+ @media (forced-colors: active) {
1897
+ .sw-table-action { color: ButtonText; }
1898
+ }
1899
+
1844
1900
  .sw-table-row-selected > :first-child {
1845
1901
  position: relative;
1846
1902
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.33.1",
3
+ "version": "1.34.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",