@scalewing/react 1.33.0 → 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,22 @@
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
+
14
+ ## 1.33.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 2a52f01: `Split` no longer widens the page in a narrow container. The split is now at most its container's width less the split minimum (`max-width: calc(100% - var(--sw-split-min))`), so the next column keeps 14rem and the start pane shrinks below its 22rem default instead of running past the container. Before, at 390 px the pane kept 22rem, the next column got no width and its words pushed the page out sideways (the gallery measured 481 px; found by Teisoro F-011). The drag and keyboard range follows the same room: the pane's maximum is the container less the handle and the next column's minimum (as before), and when that room is under the minimum the range narrows to it, so a gesture starts from the width on screen, a move to the end never collapses or jumps the pane, and one step to the start collapses it. The pane keeps the width the reader chose (a collapse never overwrites it) and shows it held to the container's range, so a pane expanded after the container widens shows at least its minimum. The separator's `aria-valuenow` reports the width that shows (100 when the container leaves no range) and follows container resizes. No API, class or token changes.
19
+
3
20
  ## 1.33.0
4
21
 
5
22
  ### Minor 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`.
@@ -1,21 +1,16 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { spacingScale } from '@scalewing/tokens';
4
- import { useCallback, useId, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { useCallback, useId, useRef, useState, } from 'react';
5
5
  import { cx } from '../class-names.js';
6
- import { applySplitDrag, applySplitKey, cssLengthToPx, effectiveSplitMax, readSplitMetrics, splitPercent, } from '../split-size.js';
7
- function metricsFrom(node) {
8
- const styles = getComputedStyle(node);
9
- const remPx = cssLengthToPx(getComputedStyle(document.documentElement).fontSize) || 16;
10
- return readSplitMetrics((name) => styles.getPropertyValue(name), remPx);
11
- }
6
+ import { applySplitDrag, applySplitKey, clampSplitSize, splitPercent, } from '../split-size.js';
7
+ import { useSplitRange } from './use-split-range.js';
12
8
  export function Split({ children, className, collapsed: collapsedProp, label, onCollapsedChange, ...rest }) {
13
9
  const paneId = useId();
14
10
  const splitRef = useRef(null);
15
11
  const handleRef = useRef(null);
16
12
  const dragRef = useRef(null);
17
13
  const [width, setWidth] = useState(null);
18
- const [range, setRange] = useState(null);
19
14
  const [collapsedUncontrolled, setCollapsedUncontrolled] = useState(false);
20
15
  const collapsed = collapsedProp ?? collapsedUncontrolled;
21
16
  const setCollapsed = useCallback((next) => {
@@ -24,38 +19,25 @@ export function Split({ children, className, collapsed: collapsedProp, label, on
24
19
  setCollapsedUncontrolled(next);
25
20
  }
26
21
  }, [collapsedProp, onCollapsedChange]);
27
- useLayoutEffect(() => {
28
- const node = splitRef.current;
29
- if (!node) {
30
- return;
31
- }
32
- const measured = metricsFrom(node);
33
- setRange({ min: measured.min, max: measured.max });
34
- setWidth((current) => current ?? measured.defaultWidth);
35
- }, []);
36
- function dragMetrics() {
37
- const split = splitRef.current;
38
- const handle = handleRef.current;
39
- if (!split) {
40
- return { min: 0, max: 0, step: 16, defaultWidth: 0 };
41
- }
42
- const measured = metricsFrom(split);
43
- const parentWidth = split.parentElement?.getBoundingClientRect().width ?? 0;
44
- const handleWidth = handle?.getBoundingClientRect().width ?? 0;
45
- return {
46
- ...measured,
47
- max: effectiveSplitMax(measured.max, parentWidth, handleWidth, measured.min),
48
- };
49
- }
50
- function currentSnapshot() {
51
- const measured = dragMetrics();
22
+ const { range, measure } = useSplitRange(splitRef, handleRef);
23
+ /**
24
+ * `width` is the reader's chosen width; what shows is it held to the range
25
+ * the container leaves, so an expansion never shows a pane under its
26
+ * minimum or past the room, and a gesture starts from what shows.
27
+ */
28
+ function currentSnapshot(metrics) {
52
29
  return {
53
30
  collapsed,
54
- width: width ?? measured.defaultWidth,
31
+ width: clampSplitSize(width ?? metrics.defaultWidth, metrics.min, metrics.max),
55
32
  };
56
33
  }
57
- function applySnapshot(next) {
58
- setWidth(next.width);
34
+ function applySnapshot(next, start) {
35
+ // Only a gesture that moves the pane chooses a width: a collapse, or a
36
+ // move held at the end of a narrow container's range, keeps the chosen
37
+ // one, so it shows again when the container widens.
38
+ if (!next.collapsed && next.width !== start.width) {
39
+ setWidth(next.width);
40
+ }
59
41
  if (next.collapsed !== collapsed) {
60
42
  setCollapsed(next.collapsed);
61
43
  }
@@ -67,11 +49,12 @@ export function Split({ children, className, collapsed: collapsedProp, label, on
67
49
  event.preventDefault();
68
50
  event.currentTarget.focus();
69
51
  event.currentTarget.setPointerCapture?.(event.pointerId);
52
+ const metrics = measure();
70
53
  dragRef.current = {
71
54
  pointerId: event.pointerId,
72
55
  startX: event.clientX,
73
- snapshot: currentSnapshot(),
74
- metrics: dragMetrics(),
56
+ snapshot: currentSnapshot(metrics),
57
+ metrics,
75
58
  moved: false,
76
59
  };
77
60
  }
@@ -84,7 +67,7 @@ export function Split({ children, className, collapsed: collapsedProp, label, on
84
67
  if (Math.abs(delta) >= spacingScale[1]) {
85
68
  drag.moved = true;
86
69
  }
87
- applySnapshot(applySplitDrag(drag.snapshot, delta, drag.metrics));
70
+ applySnapshot(applySplitDrag(drag.snapshot, delta, drag.metrics), drag.snapshot);
88
71
  }
89
72
  function endDrag(event) {
90
73
  const drag = dragRef.current;
@@ -101,17 +84,21 @@ export function Split({ children, className, collapsed: collapsedProp, label, on
101
84
  if (!splitRef.current) {
102
85
  return;
103
86
  }
104
- const next = applySplitKey(event.key, currentSnapshot(), dragMetrics());
87
+ const metrics = measure();
88
+ const start = currentSnapshot(metrics);
89
+ const next = applySplitKey(event.key, start, metrics);
105
90
  if (!next) {
106
91
  return;
107
92
  }
108
93
  event.preventDefault();
109
- applySnapshot(next);
94
+ applySnapshot(next, start);
110
95
  }
111
- const valueNow = collapsed
96
+ // What shows: the chosen width held to the container's range.
97
+ const shownWidth = range
98
+ ? clampSplitSize(width ?? range.defaultWidth, range.min, range.max)
99
+ : null;
100
+ const valueNow = collapsed || !range || shownWidth === null
112
101
  ? 0
113
- : range && width !== null
114
- ? splitPercent(width, range.min, range.max)
115
- : 0;
116
- return (_jsxs("div", { ...rest, ref: splitRef, className: cx('sw-split', className), "data-collapsed": collapsed ? '' : undefined, children: [_jsx("div", { className: "sw-split-pane", id: paneId, "aria-hidden": collapsed || undefined, style: width === null ? undefined : { width: `${width}px` }, children: children }), _jsx("div", { ref: handleRef, "aria-controls": paneId, "aria-label": collapsed ? `Show ${label}` : `Resize ${label}`, "aria-orientation": "vertical", "aria-valuemax": 100, "aria-valuemin": 0, "aria-valuenow": valueNow, className: "sw-split-handle", onKeyDown: onKeyDown, onPointerCancel: endDrag, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, role: "separator", tabIndex: 0, children: _jsx("span", { "aria-hidden": "true", className: "sw-split-grip" }) })] }));
102
+ : splitPercent(shownWidth, range.min, range.max);
103
+ return (_jsxs("div", { ...rest, ref: splitRef, className: cx('sw-split', className), "data-collapsed": collapsed ? '' : undefined, children: [_jsx("div", { className: "sw-split-pane", id: paneId, "aria-hidden": collapsed || undefined, style: shownWidth === null ? undefined : { width: `${shownWidth}px` }, children: children }), _jsx("div", { ref: handleRef, "aria-controls": paneId, "aria-label": collapsed ? `Show ${label}` : `Resize ${label}`, "aria-orientation": "vertical", "aria-valuemax": 100, "aria-valuemin": 0, "aria-valuenow": valueNow, className: "sw-split-handle", onKeyDown: onKeyDown, onPointerCancel: endDrag, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, role: "separator", tabIndex: 0, children: _jsx("span", { "aria-hidden": "true", className: "sw-split-grip" }) })] }));
117
104
  }
@@ -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,17 @@
1
+ import { type RefObject } from 'react';
2
+ import { type SplitMetrics } from '../split-size.js';
3
+ export type SplitRange = Pick<SplitMetrics, 'min' | 'max'> & {
4
+ defaultWidth: number;
5
+ };
6
+ /**
7
+ * The range a Split's pane can take in its container, measured on layout and
8
+ * whenever the container resizes, and `measure` for a gesture, which also
9
+ * brings the shown range up to date. An unchanged range keeps its state, so
10
+ * a resize that changes nothing does not render again.
11
+ */
12
+ export declare function useSplitRange(splitRef: RefObject<HTMLDivElement | null>, handleRef: RefObject<HTMLDivElement | null>): {
13
+ range: SplitRange | null;
14
+ measure: () => SplitMetrics & {
15
+ defaultWidth: number;
16
+ };
17
+ };
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { useCallback, useLayoutEffect, useState } from 'react';
3
+ import { containerContentWidth, splitTokenMetrics } from '../split-measure.js';
4
+ import { effectiveSplitRange } from '../split-size.js';
5
+ function sameRange(a, b) {
6
+ return (a !== null &&
7
+ a.min === b.min &&
8
+ a.max === b.max &&
9
+ a.defaultWidth === b.defaultWidth);
10
+ }
11
+ /**
12
+ * The range a Split's pane can take in its container, measured on layout and
13
+ * whenever the container resizes, and `measure` for a gesture, which also
14
+ * brings the shown range up to date. An unchanged range keeps its state, so
15
+ * a resize that changes nothing does not render again.
16
+ */
17
+ export function useSplitRange(splitRef, handleRef) {
18
+ const [range, setRange] = useState(null);
19
+ const measure = useCallback(() => {
20
+ const split = splitRef.current;
21
+ if (!split) {
22
+ return { min: 0, max: 0, step: 16, defaultWidth: 0 };
23
+ }
24
+ const tokens = splitTokenMetrics(split);
25
+ const handleWidth = handleRef.current?.getBoundingClientRect().width ?? 0;
26
+ const measured = {
27
+ ...tokens,
28
+ ...effectiveSplitRange(tokens, containerContentWidth(split), handleWidth),
29
+ };
30
+ const next = {
31
+ min: measured.min,
32
+ max: measured.max,
33
+ defaultWidth: measured.defaultWidth,
34
+ };
35
+ setRange((current) => (sameRange(current, next) ? current : next));
36
+ return measured;
37
+ }, [splitRef, handleRef]);
38
+ useLayoutEffect(() => {
39
+ const parent = splitRef.current?.parentElement;
40
+ measure();
41
+ if (!parent || typeof ResizeObserver === 'undefined') {
42
+ return;
43
+ }
44
+ const observer = new ResizeObserver(() => measure());
45
+ observer.observe(parent);
46
+ return () => observer.disconnect();
47
+ }, [measure, splitRef]);
48
+ return { range, measure };
49
+ }
@@ -6,6 +6,7 @@ export function cssSplitClasses() {
6
6
  display: flex;
7
7
  flex: 0 0 auto;
8
8
  flex-direction: row;
9
+ max-width: calc(100% - var(--sw-split-min));
9
10
  min-width: 0;
10
11
  }
11
12
 
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ import { type SplitMetrics } from './split-size.js';
2
+ /** The container's content width, which the split's percentage cap uses. */
3
+ export declare function containerContentWidth(node: HTMLElement): number;
4
+ /** The split's token range, step and default width, in px. */
5
+ export declare function splitTokenMetrics(node: HTMLElement): SplitMetrics & {
6
+ defaultWidth: number;
7
+ };
@@ -0,0 +1,21 @@
1
+ // DOM reads for Split: the split's token metrics from its computed custom
2
+ // properties, and its container's content width (what the stylesheet's
3
+ // percentage cap resolves against). The range math stays pure in
4
+ // `split-size.ts`.
5
+ import { cssLengthToPx, readSplitMetrics, } from './split-size.js';
6
+ /** The container's content width, which the split's percentage cap uses. */
7
+ export function containerContentWidth(node) {
8
+ const parent = node.parentElement;
9
+ if (!parent) {
10
+ return 0;
11
+ }
12
+ const styles = getComputedStyle(parent);
13
+ const padding = cssLengthToPx(styles.paddingLeft) + cssLengthToPx(styles.paddingRight);
14
+ return Math.max(0, parent.clientWidth - padding);
15
+ }
16
+ /** The split's token range, step and default width, in px. */
17
+ export function splitTokenMetrics(node) {
18
+ const styles = getComputedStyle(node);
19
+ const remPx = cssLengthToPx(getComputedStyle(document.documentElement).fontSize) || 16;
20
+ return readSplitMetrics((name) => styles.getPropertyValue(name), remPx);
21
+ }
@@ -12,7 +12,21 @@ export declare function readSplitMetrics(read: (name: string) => string, remPx?:
12
12
  defaultWidth: number;
13
13
  };
14
14
  export declare function clampSplitSize(size: number, min: number, max: number): number;
15
+ /**
16
+ * The pane's place between its minimum and maximum as 0–100. When the
17
+ * container leaves no range (min equals max), the pane is as wide as it can
18
+ * be: 100.
19
+ */
15
20
  export declare function splitPercent(width: number, min: number, max: number): number;
16
- export declare function effectiveSplitMax(tokenMax: number, parentWidth: number, handleWidth: number, min: number): number;
21
+ /**
22
+ * The range a pane can take in its container. The stylesheet keeps the split
23
+ * at most its container less the split minimum, so the next column keeps that
24
+ * minimum and the pane gets the room left after the handle. In a narrow
25
+ * container that room is under the token minimum, so the range narrows to it:
26
+ * a pane never grows past what shows, and only a move narrower than the room
27
+ * collapses it. A container that is not laid out (zero width) keeps the
28
+ * token range.
29
+ */
30
+ export declare function effectiveSplitRange(tokens: Pick<SplitMetrics, 'min' | 'max'>, containerWidth: number, handleWidth: number): Pick<SplitMetrics, 'min' | 'max'>;
17
31
  export declare function applySplitDrag(start: SplitSnapshot, deltaX: number, metrics: SplitMetrics): SplitSnapshot;
18
32
  export declare function applySplitKey(key: string, start: SplitSnapshot, metrics: SplitMetrics): SplitSnapshot | null;
@@ -23,21 +23,33 @@ export function readSplitMetrics(read, remPx = 16) {
23
23
  export function clampSplitSize(size, min, max) {
24
24
  return Math.min(max, Math.max(min, size));
25
25
  }
26
+ /**
27
+ * The pane's place between its minimum and maximum as 0–100. When the
28
+ * container leaves no range (min equals max), the pane is as wide as it can
29
+ * be: 100.
30
+ */
26
31
  export function splitPercent(width, min, max) {
27
32
  if (max <= min) {
28
- return 0;
33
+ return 100;
29
34
  }
30
35
  return Math.round(((clampSplitSize(width, min, max) - min) / (max - min)) * 100);
31
36
  }
32
- export function effectiveSplitMax(tokenMax, parentWidth, handleWidth, min) {
33
- if (parentWidth <= 0) {
34
- return tokenMax;
37
+ /**
38
+ * The range a pane can take in its container. The stylesheet keeps the split
39
+ * at most its container less the split minimum, so the next column keeps that
40
+ * minimum and the pane gets the room left after the handle. In a narrow
41
+ * container that room is under the token minimum, so the range narrows to it:
42
+ * a pane never grows past what shows, and only a move narrower than the room
43
+ * collapses it. A container that is not laid out (zero width) keeps the
44
+ * token range.
45
+ */
46
+ export function effectiveSplitRange(tokens, containerWidth, handleWidth) {
47
+ if (containerWidth <= 0) {
48
+ return { min: tokens.min, max: tokens.max };
35
49
  }
36
- const room = parentWidth - handleWidth - min;
37
- if (room < min) {
38
- return min;
39
- }
40
- return Math.min(tokenMax, room);
50
+ const room = Math.max(0, containerWidth - handleWidth - tokens.min);
51
+ const max = Math.min(tokens.max, room);
52
+ return { min: Math.min(tokens.min, max), max };
41
53
  }
42
54
  export function applySplitDrag(start, deltaX, metrics) {
43
55
  if (start.collapsed) {
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
  }
@@ -5321,6 +5377,7 @@ body {
5321
5377
  display: flex;
5322
5378
  flex: 0 0 auto;
5323
5379
  flex-direction: row;
5380
+ max-width: calc(100% - var(--sw-split-min));
5324
5381
  min-width: 0;
5325
5382
  }
5326
5383
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",