@scalewing/react 1.4.1 → 1.6.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 +55 -0
- package/README.md +17 -2
- package/dist/column-span-classes.d.ts +9 -0
- package/dist/column-span-classes.js +8 -0
- package/dist/components/ActionMenu.js +3 -1
- package/dist/components/Box.d.ts +3 -2
- package/dist/components/Box.js +3 -2
- package/dist/components/Dialog.d.ts +11 -1
- package/dist/components/Dialog.js +12 -28
- package/dist/components/Grid.js +4 -7
- package/dist/components/Tooltip.js +6 -2
- package/dist/components/dialog-close-requests.d.ts +38 -0
- package/dist/components/dialog-close-requests.js +141 -0
- package/dist/css/css-components.js +2 -0
- package/dist/css/css-grid-span.d.ts +7 -0
- package/dist/css/css-grid-span.js +35 -0
- package/dist/css/stylesheet.js +2 -0
- package/dist/grid-counts.d.ts +3 -0
- package/dist/grid-counts.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/styles.css +37 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# @scalewing/react
|
|
2
2
|
|
|
3
|
+
## 1.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a42f7dd: `Dialog` is now fully controlled by `open`. Escape, a platform close request,
|
|
8
|
+
a backdrop press and a `<form method="dialog">` submit (the form's `method` or
|
|
9
|
+
the submitter's `formmethod`) are each prevented and ask `onClose()`; none of
|
|
10
|
+
them closes the native `<dialog>` itself. A dialog whose consumer keeps `open`
|
|
11
|
+
true (for example while a form is saving) stays shown, including on a repeated
|
|
12
|
+
Escape in Chromium. A consumer `onKeyDown`, `onCancel`, `onSubmit` or
|
|
13
|
+
`onPointerDown` that prevents the event vetoes that request (`onPointerDown`
|
|
14
|
+
used to be dropped). Escape in a search field that holds text clears the field
|
|
15
|
+
first, and a descendant's own `cancel` (a dismissed file picker) is not a close
|
|
16
|
+
request. A backdrop press counts only on the
|
|
17
|
+
dialog element itself, so a nested dialog's backdrop or an overflowing popover
|
|
18
|
+
does not close the outer dialog. See `docs/requests/teisoro-dialog-close.md`.
|
|
19
|
+
|
|
20
|
+
`Tooltip` now prevents the Escape it uses while it is visible, so one Escape
|
|
21
|
+
hides a tooltip inside a `Dialog` without also closing the dialog.
|
|
22
|
+
|
|
23
|
+
`ActionMenu` returns focus to its trigger before running a selected command,
|
|
24
|
+
as Escape already did, so a `Dialog` opened from a command returns focus to
|
|
25
|
+
the trigger when it closes (`docs/requests/teisoro-action-menu.md`).
|
|
26
|
+
|
|
27
|
+
Behavior change: `Dialog` no longer calls `onClose` when `open` turns false
|
|
28
|
+
(it used to echo the native `close` event). Migration: if you ran cleanup in
|
|
29
|
+
`onClose` after setting `open` to false yourself, run it where you set `open`
|
|
30
|
+
to false.
|
|
31
|
+
|
|
32
|
+
No prop or type is added or removed.
|
|
33
|
+
|
|
34
|
+
## 1.5.0
|
|
35
|
+
|
|
36
|
+
### Minor Changes
|
|
37
|
+
|
|
38
|
+
- 7b51094: Add `columnSpan` to `Box` and every component built on it (`Stack`, `Inline`,
|
|
39
|
+
`Card`, `Grid`, …), so a direct child of a `Grid` can cover 1, 2, 3, 4 or 6
|
|
40
|
+
columns (see `docs/requests/teisoro-grid-column-span.md`). Three columns with a
|
|
41
|
+
two-column child give a two-to-one page:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
<Grid columns={3} columnsBelow={{ md: 1 }} gap={4}>
|
|
45
|
+
<Stack columnSpan={2} gap={4}>
|
|
46
|
+
{form}
|
|
47
|
+
</Stack>
|
|
48
|
+
<Card variant="outlined">{panel}</Card>
|
|
49
|
+
</Grid>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The generated `sw-grid-span-{1,2,3,4,6}` classes are capped at the
|
|
53
|
+
columns the grid has at the current width, so a span never adds an implicit
|
|
54
|
+
column and `columnsBelow` still stacks the page on a phone. A span outside the
|
|
55
|
+
catalog throws a `RangeError`. New exported types: `GridColumnSpan`,
|
|
56
|
+
`ColumnSpanProps`. Additive; no existing class or prop changes.
|
|
57
|
+
|
|
3
58
|
## 1.4.1
|
|
4
59
|
|
|
5
60
|
### Patch 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
|
|
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
|
|
|
@@ -68,7 +70,20 @@ Import the CSS once at the application entry. Do not copy it into your source tr
|
|
|
68
70
|
|
|
69
71
|
`Text` takes an optional `align` (`start`, `center`, `end`) mapped to generated `sw-text-align-*` classes, for a heading that must stay centered when it wraps.
|
|
70
72
|
|
|
71
|
-
`Grid` places children in one to four equal-width columns with a token `gap` step. `columnsBelow={{ md: 2 }}` drops to fewer columns below the `md` breakpoint so tiles and stat cards stay readable on a phone. It accepts every `Box` prop.
|
|
73
|
+
`Grid` places children in one to four (or six) equal-width columns with a token `gap` step. `columnsBelow={{ md: 2 }}` drops to fewer columns below the `md` breakpoint so tiles and stat cards stay readable on a phone. It accepts every `Box` prop.
|
|
74
|
+
|
|
75
|
+
Every `Box`-based component (`Box`, `Stack`, `Inline`, `Card`, `Grid`, …) takes `columnSpan` (1, 2, 3, 4 or 6) to cover several columns of the `Grid` it sits in directly, through the generated `sw-grid-span-*` classes. A span is capped at the columns the grid has at the current width, so it never adds an implicit column, and `columnsBelow` still decides the phone layout. A form about twice the width of a side panel, stacking below `md`:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<Grid columns={3} columnsBelow={{ md: 1 }} gap={4}>
|
|
79
|
+
<Stack columnSpan={2} gap={4}>
|
|
80
|
+
{form}
|
|
81
|
+
</Stack>
|
|
82
|
+
<Card variant="outlined">{panel}</Card>
|
|
83
|
+
</Grid>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Values outside the catalog throw a `RangeError`. There is no per-breakpoint span and no arbitrary column template.
|
|
72
87
|
|
|
73
88
|
`DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that moves under the label on a phone. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
|
|
74
89
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type GridColumnSpan } from './css/css-grid-span.js';
|
|
2
|
+
export type ColumnSpanProps = {
|
|
3
|
+
/**
|
|
4
|
+
* Columns this box spans when it is a direct child of a `Grid`, capped at
|
|
5
|
+
* the grid's column count at the current width.
|
|
6
|
+
*/
|
|
7
|
+
columnSpan?: GridColumnSpan;
|
|
8
|
+
};
|
|
9
|
+
export declare function columnSpanClassNames({ columnSpan, }: ColumnSpanProps): string[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { gridSpanClass } from './css/css-grid-span.js';
|
|
2
|
+
import { assertGridCount } from './grid-counts.js';
|
|
3
|
+
export function columnSpanClassNames({ columnSpan, }) {
|
|
4
|
+
if (columnSpan === undefined)
|
|
5
|
+
return [];
|
|
6
|
+
assertGridCount('columnSpan', columnSpan);
|
|
7
|
+
return [gridSpanClass(columnSpan)];
|
|
8
|
+
}
|
|
@@ -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
|
-
|
|
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) => {
|
package/dist/components/Box.d.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { type RadiusStep, type SemanticColorKey } from '@scalewing/tokens';
|
|
2
2
|
import { type AnchorHTMLAttributes, type HTMLAttributes } from 'react';
|
|
3
|
+
import { type ColumnSpanProps } from '../column-span-classes.js';
|
|
3
4
|
import { type SpacingProps } from '../spacing-classes.js';
|
|
4
5
|
import { type VisibilityProps } from '../visibility-classes.js';
|
|
5
6
|
export type BoxElement = 'div' | 'section' | 'article' | 'header' | 'footer' | 'main' | 'nav' | 'aside' | 'span' | 'a' | 'label';
|
|
6
|
-
export type BoxProps = SpacingProps & VisibilityProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, 'download' | 'href' | 'rel' | 'target'> & {
|
|
7
|
+
export type BoxProps = SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, 'download' | 'href' | 'rel' | 'target'> & {
|
|
7
8
|
as?: BoxElement;
|
|
8
9
|
background?: Extract<SemanticColorKey, 'background' | 'surface'>;
|
|
9
10
|
radius?: RadiusStep;
|
|
10
11
|
border?: boolean;
|
|
11
12
|
};
|
|
12
|
-
export declare const Box: import("react").ForwardRefExoticComponent<SpacingProps & VisibilityProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "download" | "href" | "rel" | "target"> & {
|
|
13
|
+
export declare const Box: import("react").ForwardRefExoticComponent<SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "download" | "href" | "rel" | "target"> & {
|
|
13
14
|
as?: BoxElement;
|
|
14
15
|
background?: Extract<SemanticColorKey, "background" | "surface">;
|
|
15
16
|
radius?: RadiusStep;
|
package/dist/components/Box.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
import { columnSpanClassNames, } from '../column-span-classes.js';
|
|
4
5
|
import { spacingClassNames } from '../spacing-classes.js';
|
|
5
6
|
import { visibilityClassNames, } from '../visibility-classes.js';
|
|
6
|
-
export const Box = forwardRef(function Box({ as = 'div', background, border, className, hideBelow, hideFrom, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, style, ...rest }, ref) {
|
|
7
|
+
export const Box = forwardRef(function Box({ as = 'div', background, border, className, columnSpan, hideBelow, hideFrom, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, style, ...rest }, ref) {
|
|
7
8
|
const Component = as;
|
|
8
9
|
return (_jsx(Component, { ref: ref, className: cx(...spacingClassNames({
|
|
9
10
|
padding,
|
|
@@ -13,7 +14,7 @@ export const Box = forwardRef(function Box({ as = 'div', background, border, cla
|
|
|
13
14
|
paddingTop,
|
|
14
15
|
paddingX,
|
|
15
16
|
paddingY,
|
|
16
|
-
}), ...visibilityClassNames({ hideBelow, hideFrom }), className), style: {
|
|
17
|
+
}), ...visibilityClassNames({ hideBelow, hideFrom }), ...columnSpanClassNames({ columnSpan }), className), style: {
|
|
17
18
|
backgroundColor: background
|
|
18
19
|
? `var(--sw-color-${background})`
|
|
19
20
|
: undefined,
|
|
@@ -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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}
|
package/dist/components/Grid.js
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../class-names.js';
|
|
3
3
|
import { breakpoints } from '../css/breakpoints.js';
|
|
4
|
-
import {
|
|
4
|
+
import { gridColumnsBelowClass, gridColumnsClass, } from '../css/css-grid.js';
|
|
5
5
|
import { spacingClass } from '../css/spacing-classes.js';
|
|
6
|
+
import { assertGridCount } from '../grid-counts.js';
|
|
6
7
|
import { Box } from './Box.js';
|
|
7
|
-
function assertColumns(columns) {
|
|
8
|
-
if (!gridColumnCounts.includes(columns))
|
|
9
|
-
throw new RangeError(`columns must be one of ${gridColumnCounts.join(', ')}`);
|
|
10
|
-
}
|
|
11
8
|
export function Grid({ className, columns = 1, columnsBelow, gap = 0, ...rest }) {
|
|
12
|
-
|
|
9
|
+
assertGridCount('columns', columns);
|
|
13
10
|
const belowClasses = breakpoints.flatMap((breakpoint) => {
|
|
14
11
|
const count = columnsBelow?.[breakpoint];
|
|
15
12
|
if (count === undefined)
|
|
16
13
|
return [];
|
|
17
|
-
|
|
14
|
+
assertGridCount('columnsBelow', count);
|
|
18
15
|
return [gridColumnsBelowClass(breakpoint, count)];
|
|
19
16
|
});
|
|
20
17
|
return (_jsx(Box, { className: cx('sw-grid', gridColumnsClass(columns), ...belowClasses, spacingClass('gap', 'all', gap), className), ...rest }));
|
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
@@ -22,6 +22,7 @@ import { cssTabsClasses } from './css-tabs.js';
|
|
|
22
22
|
import { cssResponsiveClasses } from './css-responsive.js';
|
|
23
23
|
import { cssFilterChipsClasses } from './css-filter-chips.js';
|
|
24
24
|
import { cssGridClasses } from './css-grid.js';
|
|
25
|
+
import { cssGridSpanClasses } from './css-grid-span.js';
|
|
25
26
|
import { cssDenominationGridClasses } from './css-denomination-grid.js';
|
|
26
27
|
import { cssFieldClasses } from './css-field.js';
|
|
27
28
|
import { cssToastClasses } from './css-toast.js';
|
|
@@ -149,6 +150,7 @@ ${cssTabsClasses()}
|
|
|
149
150
|
${cssFilterChipsClasses()}
|
|
150
151
|
|
|
151
152
|
${cssGridClasses()}
|
|
153
|
+
${cssGridSpanClasses()}
|
|
152
154
|
${cssDenominationGridClasses()}
|
|
153
155
|
|
|
154
156
|
${cssFieldClasses()}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type GridColumns } from './css-grid.js';
|
|
2
|
+
/** A child spans one of the same bounded counts a `Grid` can have. */
|
|
3
|
+
export type GridColumnSpan = GridColumns;
|
|
4
|
+
export declare const gridColumnSpans: readonly [1, 2, 3, 4, 6];
|
|
5
|
+
export declare function gridSpanClass(span: GridColumnSpan): string;
|
|
6
|
+
export declare function cssGridSpanClasses(): string;
|
|
7
|
+
export declare function gridSpanClassCatalog(): string[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { breakpointQuery, breakpoints } from './breakpoints.js';
|
|
2
|
+
import { gridColumnCounts, gridColumnsBelowClass, gridColumnsClass, } from './css-grid.js';
|
|
3
|
+
export const gridColumnSpans = gridColumnCounts;
|
|
4
|
+
export function gridSpanClass(span) {
|
|
5
|
+
return `sw-grid-span-${span}`;
|
|
6
|
+
}
|
|
7
|
+
function spanRule(selector, span) {
|
|
8
|
+
return `${selector} { grid-column: span ${span}; }`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Caps a span at the columns its grid has at the current width, so a wide
|
|
12
|
+
* child never adds an implicit column. `:where()` keeps the cap at one class
|
|
13
|
+
* of specificity; source order lets it override the plain span rule.
|
|
14
|
+
*/
|
|
15
|
+
function capRule(gridClass, columns, span) {
|
|
16
|
+
return spanRule(`:where(.${gridClass}) > .${gridSpanClass(span)}`, Math.min(span, columns));
|
|
17
|
+
}
|
|
18
|
+
function widerSpans(columns) {
|
|
19
|
+
return gridColumnSpans.filter((span) => span > columns);
|
|
20
|
+
}
|
|
21
|
+
export function cssGridSpanClasses() {
|
|
22
|
+
const spans = gridColumnSpans.map((span) => spanRule(`.${gridSpanClass(span)}`, span));
|
|
23
|
+
const caps = gridColumnCounts.flatMap((columns) => widerSpans(columns).map((span) => capRule(gridColumnsClass(columns), columns, span)));
|
|
24
|
+
// Below a breakpoint every span wider than one is restated for the phone
|
|
25
|
+
// column count, which also undoes a desktop cap when that count is larger.
|
|
26
|
+
const below = breakpoints.map((breakpoint) => `@media ${breakpointQuery('below', breakpoint)} {
|
|
27
|
+
${gridColumnCounts
|
|
28
|
+
.flatMap((columns) => widerSpans(1).map((span) => ` ${capRule(gridColumnsBelowClass(breakpoint, columns), columns, span)}`))
|
|
29
|
+
.join('\n')}
|
|
30
|
+
}`);
|
|
31
|
+
return [...spans, ...caps, ...below].join('\n');
|
|
32
|
+
}
|
|
33
|
+
export function gridSpanClassCatalog() {
|
|
34
|
+
return gridColumnSpans.map((span) => gridSpanClass(span));
|
|
35
|
+
}
|
package/dist/css/stylesheet.js
CHANGED
|
@@ -14,6 +14,7 @@ import { tabsClassCatalog } from './css-tabs.js';
|
|
|
14
14
|
import { responsiveClassCatalog } from './css-responsive.js';
|
|
15
15
|
import { filterChipsClassCatalog } from './css-filter-chips.js';
|
|
16
16
|
import { gridClassCatalog } from './css-grid.js';
|
|
17
|
+
import { gridSpanClassCatalog } from './css-grid-span.js';
|
|
17
18
|
import { denominationGridClassCatalog } from './css-denomination-grid.js';
|
|
18
19
|
import { fieldClassCatalog } from './css-field.js';
|
|
19
20
|
import { cssComponentClasses } from './css-components.js';
|
|
@@ -63,6 +64,7 @@ export function utilityClassCatalog() {
|
|
|
63
64
|
...tabsClassCatalog(),
|
|
64
65
|
...filterChipsClassCatalog(),
|
|
65
66
|
...gridClassCatalog(),
|
|
67
|
+
...gridSpanClassCatalog(),
|
|
66
68
|
...denominationGridClassCatalog(),
|
|
67
69
|
...fieldClassCatalog(),
|
|
68
70
|
...splitClassCatalog(),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { gridColumnCounts } from './css/css-grid.js';
|
|
2
|
+
/** Fails closed on a column count or span outside the bounded catalog. */
|
|
3
|
+
export function assertGridCount(prop, value) {
|
|
4
|
+
if (!gridColumnCounts.includes(value))
|
|
5
|
+
throw new RangeError(`${prop} must be one of ${gridColumnCounts.join(', ')}`);
|
|
6
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export { Separator } from './components/Separator.js';
|
|
|
38
38
|
export type { SeparatorOrientation, SeparatorProps, } from './components/Separator.js';
|
|
39
39
|
export { Grid } from './components/Grid.js';
|
|
40
40
|
export type { GridColumns, GridProps } from './components/Grid.js';
|
|
41
|
+
export type { GridColumnSpan } from './css/css-grid-span.js';
|
|
42
|
+
export type { ColumnSpanProps } from './column-span-classes.js';
|
|
41
43
|
export { DenominationGrid } from './components/DenominationGrid.js';
|
|
42
44
|
export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridTone, } from './components/DenominationGrid.js';
|
|
43
45
|
export { FilterChips } from './components/FilterChips.js';
|
package/dist/styles.css
CHANGED
|
@@ -2166,6 +2166,43 @@ body {
|
|
|
2166
2166
|
.sw-grid-cols-below-md-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
2167
2167
|
.sw-grid-cols-below-md-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
2168
2168
|
}
|
|
2169
|
+
.sw-grid-span-1 { grid-column: span 1; }
|
|
2170
|
+
.sw-grid-span-2 { grid-column: span 2; }
|
|
2171
|
+
.sw-grid-span-3 { grid-column: span 3; }
|
|
2172
|
+
.sw-grid-span-4 { grid-column: span 4; }
|
|
2173
|
+
.sw-grid-span-6 { grid-column: span 6; }
|
|
2174
|
+
:where(.sw-grid-cols-1) > .sw-grid-span-2 { grid-column: span 1; }
|
|
2175
|
+
:where(.sw-grid-cols-1) > .sw-grid-span-3 { grid-column: span 1; }
|
|
2176
|
+
:where(.sw-grid-cols-1) > .sw-grid-span-4 { grid-column: span 1; }
|
|
2177
|
+
:where(.sw-grid-cols-1) > .sw-grid-span-6 { grid-column: span 1; }
|
|
2178
|
+
:where(.sw-grid-cols-2) > .sw-grid-span-3 { grid-column: span 2; }
|
|
2179
|
+
:where(.sw-grid-cols-2) > .sw-grid-span-4 { grid-column: span 2; }
|
|
2180
|
+
:where(.sw-grid-cols-2) > .sw-grid-span-6 { grid-column: span 2; }
|
|
2181
|
+
:where(.sw-grid-cols-3) > .sw-grid-span-4 { grid-column: span 3; }
|
|
2182
|
+
:where(.sw-grid-cols-3) > .sw-grid-span-6 { grid-column: span 3; }
|
|
2183
|
+
:where(.sw-grid-cols-4) > .sw-grid-span-6 { grid-column: span 4; }
|
|
2184
|
+
@media not all and (min-width: 48rem) {
|
|
2185
|
+
:where(.sw-grid-cols-below-md-1) > .sw-grid-span-2 { grid-column: span 1; }
|
|
2186
|
+
:where(.sw-grid-cols-below-md-1) > .sw-grid-span-3 { grid-column: span 1; }
|
|
2187
|
+
:where(.sw-grid-cols-below-md-1) > .sw-grid-span-4 { grid-column: span 1; }
|
|
2188
|
+
:where(.sw-grid-cols-below-md-1) > .sw-grid-span-6 { grid-column: span 1; }
|
|
2189
|
+
:where(.sw-grid-cols-below-md-2) > .sw-grid-span-2 { grid-column: span 2; }
|
|
2190
|
+
:where(.sw-grid-cols-below-md-2) > .sw-grid-span-3 { grid-column: span 2; }
|
|
2191
|
+
:where(.sw-grid-cols-below-md-2) > .sw-grid-span-4 { grid-column: span 2; }
|
|
2192
|
+
:where(.sw-grid-cols-below-md-2) > .sw-grid-span-6 { grid-column: span 2; }
|
|
2193
|
+
:where(.sw-grid-cols-below-md-3) > .sw-grid-span-2 { grid-column: span 2; }
|
|
2194
|
+
:where(.sw-grid-cols-below-md-3) > .sw-grid-span-3 { grid-column: span 3; }
|
|
2195
|
+
:where(.sw-grid-cols-below-md-3) > .sw-grid-span-4 { grid-column: span 3; }
|
|
2196
|
+
:where(.sw-grid-cols-below-md-3) > .sw-grid-span-6 { grid-column: span 3; }
|
|
2197
|
+
:where(.sw-grid-cols-below-md-4) > .sw-grid-span-2 { grid-column: span 2; }
|
|
2198
|
+
:where(.sw-grid-cols-below-md-4) > .sw-grid-span-3 { grid-column: span 3; }
|
|
2199
|
+
:where(.sw-grid-cols-below-md-4) > .sw-grid-span-4 { grid-column: span 4; }
|
|
2200
|
+
:where(.sw-grid-cols-below-md-4) > .sw-grid-span-6 { grid-column: span 4; }
|
|
2201
|
+
:where(.sw-grid-cols-below-md-6) > .sw-grid-span-2 { grid-column: span 2; }
|
|
2202
|
+
:where(.sw-grid-cols-below-md-6) > .sw-grid-span-3 { grid-column: span 3; }
|
|
2203
|
+
:where(.sw-grid-cols-below-md-6) > .sw-grid-span-4 { grid-column: span 4; }
|
|
2204
|
+
:where(.sw-grid-cols-below-md-6) > .sw-grid-span-6 { grid-column: span 6; }
|
|
2205
|
+
}
|
|
2169
2206
|
.sw-denomination-grid {
|
|
2170
2207
|
color: var(--sw-color-text);
|
|
2171
2208
|
font-family: var(--sw-font-sans);
|