@scalewing/react 1.5.0 → 1.6.1

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,47 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 22ae9c2: `TableCell numeric` and `TableCell align="end"` cells now line up at the end of
8
+ the cell again. The base `.sw-table th, .sw-table td { text-align: start }` rule
9
+ was more specific than `.sw-table-numeric` and `.sw-table-end`, so those cells
10
+ had been aligned to the start since the table styles were added. The alignment
11
+ rules are now `.sw-table .sw-table-numeric` and `.sw-table .sw-table-end`. No
12
+ prop changes.
13
+
14
+ ## 1.6.0
15
+
16
+ ### Minor Changes
17
+
18
+ - a42f7dd: `Dialog` is now fully controlled by `open`. Escape, a platform close request,
19
+ a backdrop press and a `<form method="dialog">` submit (the form's `method` or
20
+ the submitter's `formmethod`) are each prevented and ask `onClose()`; none of
21
+ them closes the native `<dialog>` itself. A dialog whose consumer keeps `open`
22
+ true (for example while a form is saving) stays shown, including on a repeated
23
+ Escape in Chromium. A consumer `onKeyDown`, `onCancel`, `onSubmit` or
24
+ `onPointerDown` that prevents the event vetoes that request (`onPointerDown`
25
+ used to be dropped). Escape in a search field that holds text clears the field
26
+ first, and a descendant's own `cancel` (a dismissed file picker) is not a close
27
+ request. A backdrop press counts only on the
28
+ dialog element itself, so a nested dialog's backdrop or an overflowing popover
29
+ does not close the outer dialog. See `docs/requests/teisoro-dialog-close.md`.
30
+
31
+ `Tooltip` now prevents the Escape it uses while it is visible, so one Escape
32
+ hides a tooltip inside a `Dialog` without also closing the dialog.
33
+
34
+ `ActionMenu` returns focus to its trigger before running a selected command,
35
+ as Escape already did, so a `Dialog` opened from a command returns focus to
36
+ the trigger when it closes (`docs/requests/teisoro-action-menu.md`).
37
+
38
+ Behavior change: `Dialog` no longer calls `onClose` when `open` turns false
39
+ (it used to echo the native `close` event). Migration: if you ran cleanup in
40
+ `onClose` after setting `open` to false yourself, run it where you set `open`
41
+ to false.
42
+
43
+ No prop or type is added or removed.
44
+
3
45
  ## 1.5.0
4
46
 
5
47
  ### Minor Changes
package/README.md CHANGED
@@ -48,7 +48,9 @@ Import the CSS once at the application entry. Do not copy it into your source tr
48
48
 
49
49
  `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby`, appends a stable message ID, and replaces the hint with an alert when an error is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The consumer owns validation and localized messages.
50
50
 
51
- `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape returns focus to the trigger, and outside interaction dismisses the menu.
51
+ `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape and choosing a command both return focus to the trigger (before the command runs, so a `Dialog` it opens hands focus back to the trigger on close), and outside interaction dismisses the menu.
52
+
53
+ `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true.
52
54
 
53
55
  `Switch` is a controlled on/off input. It keeps native checkbox keyboard behavior and exposes switch semantics. Provide the current `checked` value and update it in `onCheckedChange`.
54
56
 
@@ -130,7 +130,9 @@ export function ActionMenu({ label, items, trigger, disabled = false, }) {
130
130
  function select(item) {
131
131
  if (!openRef.current || !enabled || item.disabled)
132
132
  return;
133
- close();
133
+ // Focus leaves the hidden item first, so a dialog the command opens
134
+ // records the trigger as its opener and returns focus there on close.
135
+ close(true);
134
136
  item.onSelect();
135
137
  }
136
138
  return (_jsxs("div", { className: "sw-action-menu", ref: rootRef, children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: "sw-action-menu-trigger", disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: trigger ?? label }), _jsx("div", { "aria-label": label, className: "sw-action-menu-list", hidden: !open, id: menuId, onKeyDown: onMenuKeyDown, ref: menuRef, role: "menu", children: items.map((item, index) => (_jsxs("button", { className: cx('sw-action-menu-item', item.destructive && 'sw-action-menu-item-danger'), disabled: item.disabled, onClick: () => select(item), onFocus: () => setFocusIndex(index), onKeyDown: (event) => {
@@ -5,10 +5,20 @@ import { type DialogHTMLAttributes, type ReactNode } from 'react';
5
5
  */
6
6
  export type DialogSize = 'md' | 'lg';
7
7
  export type DialogProps = Omit<DialogHTMLAttributes<HTMLDialogElement>, 'onClose' | 'open' | 'title' | 'children'> & {
8
+ /** Whether the dialog is shown. The element follows it and nothing else. */
8
9
  open: boolean;
10
+ /**
11
+ * Asked on every close request: Escape, a backdrop press, a platform close
12
+ * request, or a `<form method="dialog">` submit. Each is prevented, so the
13
+ * element does not close itself. Set `open` to false to close; keep it true
14
+ * (for example while saving) and the dialog stays shown. It is not called
15
+ * when `open` turns false. A consumer `onKeyDown`, `onCancel`, `onSubmit`
16
+ * or `onPointerDown` that prevents the event vetoes that request. It is required: a dialog
17
+ * that must not be dismissed passes a callback that keeps `open` true.
18
+ */
9
19
  onClose: () => void;
10
20
  size?: DialogSize;
11
21
  title: string;
12
22
  children: ReactNode;
13
23
  };
14
- export declare function Dialog({ children, className, onClose, open, size, title, ...rest }: DialogProps): import("react").JSX.Element;
24
+ export declare function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size, title, ...rest }: DialogProps): import("react").JSX.Element;
@@ -1,36 +1,20 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, } from 'react';
3
+ import { useId } from 'react';
4
4
  import { cx } from '../class-names.js';
5
5
  import { spacingClassNames } from '../spacing-classes.js';
6
+ import { useDialogCloseRequests } from './dialog-close-requests.js';
6
7
  import { Stack } from './Stack.js';
7
8
  import { Text } from './Text.js';
8
- export function Dialog({ children, className, onClose, open, size = 'md', title, ...rest }) {
9
- const nodeRef = useRef(null);
9
+ export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size = 'md', title, ...rest }) {
10
10
  const titleId = useId();
11
- useEffect(() => {
12
- const node = nodeRef.current;
13
- if (!node) {
14
- return;
15
- }
16
- if (open) {
17
- if (!node.open) {
18
- node.showModal();
19
- }
20
- return;
21
- }
22
- if (node.open) {
23
- node.close();
24
- }
25
- }, [open]);
26
- return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', ...spacingClassNames({ padding: 5 }), className), onClose: onClose, onPointerDown: (event) => {
27
- const rect = event.currentTarget.getBoundingClientRect();
28
- const inside = event.clientX >= rect.left &&
29
- event.clientX <= rect.right &&
30
- event.clientY >= rect.top &&
31
- event.clientY <= rect.bottom;
32
- if (!inside) {
33
- onClose();
34
- }
35
- }, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { id: titleId, variant: "title", children: title }), children] }) }));
11
+ const { nodeRef, handlers } = useDialogCloseRequests({
12
+ open,
13
+ onClose,
14
+ onCancel,
15
+ onKeyDown,
16
+ onPointerDown,
17
+ onSubmit,
18
+ });
19
+ return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', ...spacingClassNames({ padding: 5 }), className), ...handlers, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { id: titleId, variant: "title", children: title }), children] }) }));
36
20
  }
@@ -25,8 +25,12 @@ export function Tooltip({ content, trigger }) {
25
25
  if (!event.currentTarget.contains(event.relatedTarget))
26
26
  setOpen(false);
27
27
  }, onFocusCapture: () => setOpen(true), onKeyDownCapture: (event) => {
28
- if (event.key === 'Escape')
29
- setOpen(false);
28
+ // Escape hides a visible tooltip and nothing else, so a surrounding
29
+ // Dialog does not also close. A hidden tooltip leaves Escape alone.
30
+ if (event.key !== 'Escape' || !open)
31
+ return;
32
+ event.preventDefault();
33
+ setOpen(false);
30
34
  }, onPointerEnter: (event) => {
31
35
  if (event.pointerType === 'mouse' || event.pointerType === 'pen')
32
36
  setOpen(true);
@@ -0,0 +1,38 @@
1
+ import { type FormEvent, type FormEventHandler, type KeyboardEvent, type KeyboardEventHandler, type PointerEvent, type PointerEventHandler, type ReactEventHandler, type SyntheticEvent } from 'react';
2
+ type CloseRequestOptions = {
3
+ open: boolean;
4
+ onClose: () => void;
5
+ onCancel?: ReactEventHandler<HTMLDialogElement>;
6
+ onKeyDown?: KeyboardEventHandler<HTMLDialogElement>;
7
+ onPointerDown?: PointerEventHandler<HTMLDialogElement>;
8
+ onSubmit?: FormEventHandler<HTMLDialogElement>;
9
+ };
10
+ /**
11
+ * Keeps a native modal `<dialog>` controlled by `open`.
12
+ *
13
+ * Every close request (Escape, a platform close request such as Android back,
14
+ * a backdrop press, a `<form method="dialog">` submit) is prevented and
15
+ * becomes one `onClose()` call. The caller answers by setting `open` to
16
+ * false, or keeps it true (for example while a form is saving) and the
17
+ * dialog stays shown without being closed and shown again.
18
+ *
19
+ * Escape is taken at `keydown`, before the browser turns it into a close
20
+ * request: Chromium only lets a page cancel one close request per user
21
+ * activation, so a second Escape would otherwise close the element even
22
+ * though its `cancel` event was prevented. Two closes cannot be prevented: a
23
+ * repeated platform close request whose `cancel` is not cancelable, and an
24
+ * Escape that never reaches the dialog (focus fell to the body). Those are
25
+ * reconciled with `open` after the caller's update, which shows the dialog
26
+ * again only if `open` is still true.
27
+ */
28
+ export declare function useDialogCloseRequests({ open, onClose, onCancel, onKeyDown, onPointerDown, onSubmit, }: CloseRequestOptions): {
29
+ nodeRef: import("react").RefObject<HTMLDialogElement | null>;
30
+ handlers: {
31
+ onCancel: (event: SyntheticEvent<HTMLDialogElement>) => void;
32
+ onClose: (event: SyntheticEvent<HTMLDialogElement>) => void;
33
+ onKeyDown: (event: KeyboardEvent<HTMLDialogElement>) => void;
34
+ onPointerDown: (event: PointerEvent<HTMLDialogElement>) => void;
35
+ onSubmit: (event: FormEvent<HTMLDialogElement>) => void;
36
+ };
37
+ };
38
+ export {};
@@ -0,0 +1,141 @@
1
+ import { useEffect, useRef, useState, } from 'react';
2
+ /**
3
+ * A press on the backdrop: the target is the dialog element itself (not a
4
+ * child, a nested dialog's backdrop, or a popover that overflows the box)
5
+ * and the point lies outside the dialog's box.
6
+ */
7
+ function isBackdropPress(event) {
8
+ if (event.target !== event.currentTarget)
9
+ return false;
10
+ const rect = event.currentTarget.getBoundingClientRect();
11
+ return (event.clientX < rect.left ||
12
+ event.clientX > rect.right ||
13
+ event.clientY < rect.top ||
14
+ event.clientY > rect.bottom);
15
+ }
16
+ /** Escape typed into an input method (Safari reports it as keyCode 229). */
17
+ function isComposing(event) {
18
+ return event.nativeEvent.isComposing || event.keyCode === 229;
19
+ }
20
+ /**
21
+ * Escape in a search field that holds text clears the field (the browser's
22
+ * own default action) and does not close the dialog.
23
+ */
24
+ function clearsSearchField(event) {
25
+ const target = event.target;
26
+ return (target instanceof HTMLInputElement &&
27
+ target.type === 'search' &&
28
+ target.value !== '');
29
+ }
30
+ /**
31
+ * A submit that the browser would turn into closing this dialog: the
32
+ * submitter's `formmethod`, or else the form's `method`, is `dialog`, and the
33
+ * form belongs to this dialog rather than to a nested one.
34
+ */
35
+ function isDialogSubmit(event) {
36
+ const form = event.target;
37
+ if (!(form instanceof HTMLFormElement))
38
+ return false;
39
+ if (form.closest('dialog') !== event.currentTarget)
40
+ return false;
41
+ const submitter = event.nativeEvent.submitter;
42
+ const method = submitter?.getAttribute('formmethod') ?? form.getAttribute('method');
43
+ return method?.trim().toLowerCase() === 'dialog';
44
+ }
45
+ /**
46
+ * Keeps a native modal `<dialog>` controlled by `open`.
47
+ *
48
+ * Every close request (Escape, a platform close request such as Android back,
49
+ * a backdrop press, a `<form method="dialog">` submit) is prevented and
50
+ * becomes one `onClose()` call. The caller answers by setting `open` to
51
+ * false, or keeps it true (for example while a form is saving) and the
52
+ * dialog stays shown without being closed and shown again.
53
+ *
54
+ * Escape is taken at `keydown`, before the browser turns it into a close
55
+ * request: Chromium only lets a page cancel one close request per user
56
+ * activation, so a second Escape would otherwise close the element even
57
+ * though its `cancel` event was prevented. Two closes cannot be prevented: a
58
+ * repeated platform close request whose `cancel` is not cancelable, and an
59
+ * Escape that never reaches the dialog (focus fell to the body). Those are
60
+ * reconciled with `open` after the caller's update, which shows the dialog
61
+ * again only if `open` is still true.
62
+ */
63
+ export function useDialogCloseRequests({ open, onClose, onCancel, onKeyDown, onPointerDown, onSubmit, }) {
64
+ const nodeRef = useRef(null);
65
+ const openRef = useRef(open);
66
+ // The last `cancel` could not be prevented, so its `close` follows and
67
+ // `onClose` has already been asked.
68
+ const forcedCloseRef = useRef(false);
69
+ const [browserCloses, setBrowserCloses] = useState(0);
70
+ useEffect(() => {
71
+ openRef.current = open;
72
+ const node = nodeRef.current;
73
+ if (!node)
74
+ return;
75
+ if (open && !node.open)
76
+ node.showModal();
77
+ if (!open && node.open)
78
+ node.close();
79
+ }, [open, browserCloses]);
80
+ function handleKeyDown(event) {
81
+ onKeyDown?.(event);
82
+ // A nested control (a menu, a listbox, a tooltip, an inner dialog) or the
83
+ // consumer that used Escape prevents it.
84
+ if (event.key !== 'Escape' ||
85
+ event.defaultPrevented ||
86
+ isComposing(event) ||
87
+ clearsSearchField(event)) {
88
+ return;
89
+ }
90
+ event.preventDefault();
91
+ onClose();
92
+ }
93
+ function handleCancel(event) {
94
+ onCancel?.(event);
95
+ // A descendant's own `cancel` (a file picker dismissed from an
96
+ // `<input type="file">` bubbles one) is not a close request for the dialog.
97
+ if (event.target !== event.currentTarget)
98
+ return;
99
+ const vetoed = event.defaultPrevented;
100
+ event.preventDefault();
101
+ if (!event.cancelable)
102
+ forcedCloseRef.current = true;
103
+ if (!vetoed)
104
+ onClose();
105
+ }
106
+ function handleSubmit(event) {
107
+ onSubmit?.(event);
108
+ if (event.defaultPrevented || !isDialogSubmit(event))
109
+ return;
110
+ event.preventDefault();
111
+ onClose();
112
+ }
113
+ function handleClose(event) {
114
+ // A nested dialog's `close` is not this dialog's.
115
+ if (event.target !== event.currentTarget)
116
+ return;
117
+ const forced = forcedCloseRef.current;
118
+ forcedCloseRef.current = false;
119
+ // Closed by the `open` prop, or a stale event after it opened again.
120
+ if (!openRef.current || nodeRef.current?.open)
121
+ return;
122
+ if (!forced)
123
+ onClose();
124
+ setBrowserCloses((count) => count + 1);
125
+ }
126
+ function handlePointerDown(event) {
127
+ onPointerDown?.(event);
128
+ if (!event.defaultPrevented && isBackdropPress(event))
129
+ onClose();
130
+ }
131
+ return {
132
+ nodeRef,
133
+ handlers: {
134
+ onCancel: handleCancel,
135
+ onClose: handleClose,
136
+ onKeyDown: handleKeyDown,
137
+ onPointerDown: handlePointerDown,
138
+ onSubmit: handleSubmit,
139
+ },
140
+ };
141
+ }
@@ -166,11 +166,12 @@ export function cssDataClasses() {
166
166
  line-height: ${caption.lineHeight}px;
167
167
  }
168
168
 
169
- .sw-table-end {
169
+ /* Descendant form outranks .sw-table th / .sw-table td, which set text-align: start. */
170
+ .sw-table .sw-table-end {
170
171
  text-align: end;
171
172
  }
172
173
 
173
- .sw-table-numeric {
174
+ .sw-table .sw-table-numeric {
174
175
  font-variant-numeric: tabular-nums;
175
176
  text-align: end;
176
177
  }
package/dist/styles.css CHANGED
@@ -1187,11 +1187,12 @@ body {
1187
1187
  line-height: 18px;
1188
1188
  }
1189
1189
 
1190
- .sw-table-end {
1190
+ /* Descendant form outranks .sw-table th / .sw-table td, which set text-align: start. */
1191
+ .sw-table .sw-table-end {
1191
1192
  text-align: end;
1192
1193
  }
1193
1194
 
1194
- .sw-table-numeric {
1195
+ .sw-table .sw-table-numeric {
1195
1196
  font-variant-numeric: tabular-nums;
1196
1197
  text-align: end;
1197
1198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",