@scalewing/react 1.10.0 → 1.10.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 +28 -0
- package/README.md +5 -1
- package/dist/anchored-position.d.ts +17 -5
- package/dist/anchored-position.js +19 -4
- package/dist/components/ActionMenu.js +2 -58
- package/dist/components/action-menu/ActionMenuList.d.ts +21 -0
- package/dist/components/action-menu/ActionMenuList.js +27 -0
- package/dist/components/date-field/CalendarDialog.js +1 -1
- package/dist/components/use-anchored-popover.d.ts +14 -0
- package/dist/components/{date-field/use-anchored-popover.js → use-anchored-popover.js} +31 -9
- package/dist/css/css-action-menu.js +25 -6
- package/dist/css/css-calendar-button.js +2 -3
- package/dist/css/css-components.js +2 -25
- package/dist/css/css-date-field.js +2 -1
- package/dist/css/css-palettes.js +11 -1
- package/dist/css/css-text.d.ts +1 -0
- package/dist/css/css-text.js +42 -0
- package/dist/css/stacking.js +2 -1
- package/dist/css/touch-target.d.ts +2 -0
- package/dist/css/touch-target.js +7 -0
- package/dist/palette/amber.css +2 -0
- package/dist/palette/capri.css +2 -0
- package/dist/palette/cerulean.css +2 -0
- package/dist/palette/cornflower.css +2 -0
- package/dist/palette/fuchsia.css +2 -0
- package/dist/palette/harvest.css +2 -0
- package/dist/palette/ink.css +2 -0
- package/dist/palette/mocha.css +2 -0
- package/dist/palette/navy.css +2 -0
- package/dist/palette/postcard.css +2 -0
- package/dist/palette/raspberry.css +2 -0
- package/dist/palette/signal.css +2 -0
- package/dist/palette/sky.css +2 -0
- package/dist/palette/soft-blue.css +2 -0
- package/dist/palette/sunburst.css +2 -0
- package/dist/palette/synthwave.css +2 -0
- package/dist/palette/tangerine.css +2 -0
- package/dist/styles.css +70 -13
- package/package.json +1 -1
- package/dist/components/date-field/use-anchored-popover.d.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @scalewing/react
|
|
2
2
|
|
|
3
|
+
## 1.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7c27935: `ActionMenu` places its menu with the shared anchored-popover code that the `DateField` and `CalendarButton` calendar use (`docs/requests/teisoro-action-menu.md`, 2026-09-28 follow-up). The menu now opens a `space-1` (4 px) gap below its trigger, or above it when it only fits there, keeps at least `space-2` (8 px) from every viewport edge, and aligns to the trigger's inline start (its right edge in right-to-left), or to the trigger's other edge when the trigger ends a row and the menu would not fit from its start. Before, it sat directly on the trigger and was pushed flush against the screen edge.
|
|
8
|
+
|
|
9
|
+
- The menu element is rendered only while it is open, like the calendar; it was a `hidden` element before. `aria-controls` already pointed at it only while open. A test that looked for the closed menu with `{ hidden: true }` now finds nothing.
|
|
10
|
+
- `.sw-action-menu-list` is as wide as its longest command (`width: max-content`, at least the 44 px control height) up to the viewport less both insets, so a long command wraps inside the screen instead of running off it. It resets the popover layer's `inset`, so the placed position holds in right-to-left documents too. Commands take the label line height with a `space-1` block padding, still 28 px tall on one line.
|
|
11
|
+
- The unused `.sw-action-menu-list[hidden]` rule is gone.
|
|
12
|
+
|
|
13
|
+
No API change and no new dependencies.
|
|
14
|
+
|
|
15
|
+
- ab61ca6: `ActionMenu` gives a coarse pointer the 44 px touch target (`docs/requests/teisoro-action-menu.md`, 2026-09-28 touch-target follow-up). Under `@media (pointer: coarse)` the trigger is at least `--sw-control-md-min-height` (44 px) tall and wide, and each command at least 44 px tall; a fine pointer keeps the compact xs height. This follows `CalendarButton`'s coarse-pointer rule, and both now read the target from one internal module.
|
|
16
|
+
|
|
17
|
+
Visual change on touch screens only. No API change and no new dependencies.
|
|
18
|
+
|
|
19
|
+
- df1fa80: The `ActionMenu` menu and the `DateField` and `CalendarButton` calendar are placed again when the popover or its anchor changes size while open, such as commands that change while the menu is open or a month with another row of weeks (`docs/requests/teisoro-action-menu.md`, 2026-09-28 placement follow-up). Before, their shared placement ran only on opening, scroll and window resize. It uses `ResizeObserver` where the runtime has it.
|
|
20
|
+
|
|
21
|
+
No API change and no new dependencies.
|
|
22
|
+
|
|
23
|
+
- 91665ce: The `DateField` and `CalendarButton` calendar and the `ActionMenu` menu now fit the viewport a classic scrollbar leaves (`docs/requests/teisoro-action-menu.md`, 2026-09-28 placement follow-up). Their shared placement measured `window.innerWidth` and `innerHeight`, which include a classic scrollbar, so a popover clamped to the right edge could sit under the scrollbar instead of `space-2` from it. It now measures the root element's client box. The calendar's `max-width` is `100%` of the top layer less both insets instead of `100vw`, as the menu's is.
|
|
24
|
+
|
|
25
|
+
Only pages with classic (non-overlay) scrollbars change. No API change and no new dependencies.
|
|
26
|
+
|
|
27
|
+
- 7b4058e: `Text` drops the browser's paragraph and heading margins (`docs/requests/teisoro-text.md`). Its `p` and `h1`–`h4` elements kept about 1em of margin above and below, which a flex `Stack` or `Inline` adds to its `gap`. A generated `:where(.sw-text-*) { margin: 0; }` rule removes it at zero specificity, so an authored margin (`sw-sr-only`, a consumer class) still wins.
|
|
28
|
+
|
|
29
|
+
Visual change: text in a `Stack` or `Inline` sits at the `gap` the page asked for, so dialog titles, section headings and card text are tighter. A page that needs more space raises its `gap`. No API change and no new dependencies.
|
|
30
|
+
|
|
3
31
|
## 1.10.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
|
|
|
94
94
|
</Stack>
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
`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.
|
|
97
|
+
`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. The open menu sits on the popover layer a `space-1` gap below its trigger (above it when it only fits there), at least `space-2` from every viewport edge (a classic scrollbar is not part of the viewport), and aligned to the trigger's inline start (its right edge in right-to-left), or to its other edge when the trigger ends a row and the menu would not fit from its start. The menu is as wide as its longest command up to the viewport less both insets, and a longer command wraps. The menu follows its trigger through scroll, window resize, and a change in the menu's or the trigger's size. The menu is in the DOM only while it is open. The trigger and its commands use the compact xs control height for a fine pointer; on a coarse pointer the trigger is at least 44 × 44 px (the md control height, as on `CalendarButton`) and every command is at least 44 px tall.
|
|
98
98
|
|
|
99
99
|
`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.
|
|
100
100
|
|
|
@@ -114,8 +114,12 @@ Import the CSS once at the application entry. Do not copy it into your source tr
|
|
|
114
114
|
|
|
115
115
|
`Card` defaults to `glass`; `outlined` and `elevated` are solid surfaces, and `filled` is a quiet `--sw-color-subtle` fill with no border for plain information apart from pressable rows.
|
|
116
116
|
|
|
117
|
+
`--sw-color-accentSubtle` is the accent tint from `@scalewing/tokens`: a quiet fill behind accent text or glyphs that keeps 4.5:1. The stylesheet and every palette file set it, and a palette that moves the accent or surface retints it.
|
|
118
|
+
|
|
117
119
|
`Text` steps `display` and `heading` down to the compact token sizes below the `md` breakpoint; consumers do not size type per viewport.
|
|
118
120
|
|
|
121
|
+
`Text` has no margin of its own. Its default `p` and `h1`–`h4` elements drop the browser's block margins, so the `gap` of the `Stack` or `Inline` around it alone sets the spacing; put more space in that `gap`, not in a margin. The reset has zero specificity (`:where(.sw-text-*)`), so an authored margin, such as `sw-sr-only` or a consumer class, still applies.
|
|
122
|
+
|
|
119
123
|
`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.
|
|
120
124
|
|
|
121
125
|
`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.
|
|
@@ -16,13 +16,25 @@ export type AnchoredPosition = {
|
|
|
16
16
|
left: number;
|
|
17
17
|
top: number;
|
|
18
18
|
};
|
|
19
|
+
export type AnchoredPositionOptions = {
|
|
20
|
+
/** Space between the anchor and the popover, in px. */
|
|
21
|
+
gap?: number;
|
|
22
|
+
/** Space the popover keeps from every viewport edge, in px. */
|
|
23
|
+
inset?: number;
|
|
24
|
+
/** Right-to-left text: the anchor's inline start is its right edge. */
|
|
25
|
+
rtl?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* When the popover would cross the viewport's inline end aligned to the
|
|
28
|
+
* anchor's start, align it to the anchor's end instead (a menu opened from
|
|
29
|
+
* a trigger at the end of a row), as long as that fits.
|
|
30
|
+
*/
|
|
31
|
+
flipInline?: boolean;
|
|
32
|
+
};
|
|
19
33
|
/**
|
|
20
34
|
* Where a fixed popover goes beside its anchor: below and aligned to the
|
|
21
35
|
* anchor's inline start (its end in right-to-left), above when it does not
|
|
22
36
|
* fit below but does above, and always kept inside the viewport by `inset`.
|
|
37
|
+
* With `flipInline`, a popover that does not fit start-aligned lines up with
|
|
38
|
+
* the anchor's end instead.
|
|
23
39
|
*/
|
|
24
|
-
export declare function anchoredPosition(anchor: AnchorRect, size: PopoverSize, viewport: Viewport,
|
|
25
|
-
gap?: number | undefined;
|
|
26
|
-
inset?: number | undefined;
|
|
27
|
-
rtl?: boolean | undefined;
|
|
28
|
-
}): AnchoredPosition;
|
|
40
|
+
export declare function anchoredPosition(anchor: AnchorRect, size: PopoverSize, viewport: Viewport, options?: AnchoredPositionOptions): AnchoredPosition;
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
function fitsInline(left, width, viewport, inset) {
|
|
2
|
+
return left >= inset && left + width <= viewport.width - inset;
|
|
3
|
+
}
|
|
4
|
+
function inlineLeft(anchor, size, viewport, { inset = 0, rtl = false, flipInline = false }) {
|
|
5
|
+
const startAligned = rtl ? anchor.right - size.width : anchor.left;
|
|
6
|
+
const endAligned = rtl ? anchor.left : anchor.right - size.width;
|
|
7
|
+
const preferred = flipInline &&
|
|
8
|
+
!fitsInline(startAligned, size.width, viewport, inset) &&
|
|
9
|
+
fitsInline(endAligned, size.width, viewport, inset)
|
|
10
|
+
? endAligned
|
|
11
|
+
: startAligned;
|
|
12
|
+
const maxLeft = viewport.width - inset - size.width;
|
|
13
|
+
return Math.max(inset, Math.min(preferred, maxLeft));
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Where a fixed popover goes beside its anchor: below and aligned to the
|
|
3
17
|
* anchor's inline start (its end in right-to-left), above when it does not
|
|
4
18
|
* fit below but does above, and always kept inside the viewport by `inset`.
|
|
19
|
+
* With `flipInline`, a popover that does not fit start-aligned lines up with
|
|
20
|
+
* the anchor's end instead.
|
|
5
21
|
*/
|
|
6
|
-
export function anchoredPosition(anchor, size, viewport,
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const left = Math.max(inset, Math.min(preferredLeft, maxLeft));
|
|
22
|
+
export function anchoredPosition(anchor, size, viewport, options = {}) {
|
|
23
|
+
const { gap = 0, inset = 0 } = options;
|
|
24
|
+
const left = inlineLeft(anchor, size, viewport, options);
|
|
10
25
|
const below = anchor.bottom + gap;
|
|
11
26
|
const above = anchor.top - gap - size.height;
|
|
12
27
|
const fitsBelow = below + size.height <= viewport.height - inset;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { ActionMenuList } from './action-menu/ActionMenuList.js';
|
|
5
5
|
function nextEnabled(items, current, direction) {
|
|
6
6
|
if (items.length === 0)
|
|
7
7
|
return -1;
|
|
@@ -14,9 +14,7 @@ function nextEnabled(items, current, direction) {
|
|
|
14
14
|
}
|
|
15
15
|
export function ActionMenu({ label, items, trigger, disabled = false, }) {
|
|
16
16
|
const menuId = useId();
|
|
17
|
-
const rootRef = useRef(null);
|
|
18
17
|
const triggerRef = useRef(null);
|
|
19
|
-
const menuRef = useRef(null);
|
|
20
18
|
const openRef = useRef(false);
|
|
21
19
|
const [open, setOpen] = useState(false);
|
|
22
20
|
const [focusIndex, setFocusIndex] = useState(-1);
|
|
@@ -41,55 +39,6 @@ export function ActionMenu({ label, items, trigger, disabled = false, }) {
|
|
|
41
39
|
setFocusIndex(index);
|
|
42
40
|
setOpen(true);
|
|
43
41
|
}
|
|
44
|
-
function positionMenu() {
|
|
45
|
-
const triggerNode = triggerRef.current;
|
|
46
|
-
const menuNode = menuRef.current;
|
|
47
|
-
if (!triggerNode || !menuNode)
|
|
48
|
-
return;
|
|
49
|
-
const rect = triggerNode.getBoundingClientRect();
|
|
50
|
-
const width = menuNode.offsetWidth;
|
|
51
|
-
const height = menuNode.offsetHeight;
|
|
52
|
-
menuNode.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - width))}px`;
|
|
53
|
-
menuNode.style.top = `${Math.max(0, rect.bottom + height <= window.innerHeight ? rect.bottom : rect.top - height)}px`;
|
|
54
|
-
}
|
|
55
|
-
useLayoutEffect(() => {
|
|
56
|
-
const menuNode = menuRef.current;
|
|
57
|
-
if (!menuNode)
|
|
58
|
-
return;
|
|
59
|
-
const supportsPopover = typeof menuNode.showPopover === 'function';
|
|
60
|
-
if (supportsPopover)
|
|
61
|
-
menuNode.setAttribute('popover', 'manual');
|
|
62
|
-
if (!open) {
|
|
63
|
-
if (supportsPopover && menuNode.matches(':popover-open'))
|
|
64
|
-
menuNode.hidePopover();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (supportsPopover && !menuNode.matches(':popover-open'))
|
|
68
|
-
menuNode.showPopover();
|
|
69
|
-
positionMenu();
|
|
70
|
-
const commands = menuNode.querySelectorAll('[role="menuitem"]');
|
|
71
|
-
commands[focusIndex]?.focus();
|
|
72
|
-
}, [focusIndex, open]);
|
|
73
|
-
useLayoutEffect(() => {
|
|
74
|
-
if (!open)
|
|
75
|
-
return;
|
|
76
|
-
function onPointerDown(event) {
|
|
77
|
-
const target = event.target;
|
|
78
|
-
if (target instanceof Node && !rootRef.current?.contains(target))
|
|
79
|
-
close();
|
|
80
|
-
}
|
|
81
|
-
function onMove() {
|
|
82
|
-
positionMenu();
|
|
83
|
-
}
|
|
84
|
-
document.addEventListener('pointerdown', onPointerDown);
|
|
85
|
-
window.addEventListener('resize', onMove);
|
|
86
|
-
window.addEventListener('scroll', onMove, true);
|
|
87
|
-
return () => {
|
|
88
|
-
document.removeEventListener('pointerdown', onPointerDown);
|
|
89
|
-
window.removeEventListener('resize', onMove);
|
|
90
|
-
window.removeEventListener('scroll', onMove, true);
|
|
91
|
-
};
|
|
92
|
-
}, [open]);
|
|
93
42
|
function onTriggerKeyDown(event) {
|
|
94
43
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
95
44
|
event.preventDefault();
|
|
@@ -135,10 +84,5 @@ export function ActionMenu({ label, items, trigger, disabled = false, }) {
|
|
|
135
84
|
close(true);
|
|
136
85
|
item.onSelect();
|
|
137
86
|
}
|
|
138
|
-
return (_jsxs("div", { className: "sw-action-menu",
|
|
139
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
140
|
-
event.preventDefault();
|
|
141
|
-
select(item);
|
|
142
|
-
}
|
|
143
|
-
}, role: "menuitem", tabIndex: index === focusIndex ? 0 : -1, type: "button", children: [item.icon ? _jsx("span", { "aria-hidden": "true", children: item.icon }) : null, _jsx("span", { children: item.label })] }, item.id))) })] }));
|
|
87
|
+
return (_jsxs("div", { className: "sw-action-menu", 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 }), open ? (_jsx(ActionMenuList, { focusIndex: focusIndex, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
|
|
144
88
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type KeyboardEvent, type RefObject } from 'react';
|
|
2
|
+
import type { ActionMenuItem } from '../ActionMenu.js';
|
|
3
|
+
type ActionMenuListProps = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
items: readonly ActionMenuItem[];
|
|
7
|
+
focusIndex: number;
|
|
8
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
onFocusIndex: (index: number) => void;
|
|
11
|
+
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
12
|
+
onSelect: (item: ActionMenuItem) => void;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The open menu: on the popover layer beside the trigger (a gap below or
|
|
16
|
+
* above it, inset from the viewport edges, and lined up with the trigger's
|
|
17
|
+
* end when it would not fit from its start), with the focused command
|
|
18
|
+
* holding DOM focus.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ActionMenuList({ id, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }: ActionMenuListProps): import("react").JSX.Element;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, } from 'react';
|
|
4
|
+
import { cx } from '../../class-names.js';
|
|
5
|
+
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
6
|
+
/**
|
|
7
|
+
* The open menu: on the popover layer beside the trigger (a gap below or
|
|
8
|
+
* above it, inset from the viewport edges, and lined up with the trigger's
|
|
9
|
+
* end when it would not fit from its start), with the focused command
|
|
10
|
+
* holding DOM focus.
|
|
11
|
+
*/
|
|
12
|
+
export function ActionMenuList({ id, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }) {
|
|
13
|
+
const menuRef = useRef(null);
|
|
14
|
+
useAnchoredPopover(menuRef, triggerRef, triggerRef, onDismiss, {
|
|
15
|
+
flipInline: true,
|
|
16
|
+
});
|
|
17
|
+
useLayoutEffect(() => {
|
|
18
|
+
const commands = menuRef.current?.querySelectorAll('[role="menuitem"]');
|
|
19
|
+
commands?.[focusIndex]?.focus();
|
|
20
|
+
}, [focusIndex]);
|
|
21
|
+
return (_jsx("div", { "aria-label": label, className: "sw-action-menu-list", id: id, onKeyDown: onKeyDown, 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: () => onSelect(item), onFocus: () => onFocusIndex(index), onKeyDown: (event) => {
|
|
22
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
onSelect(item);
|
|
25
|
+
}
|
|
26
|
+
}, role: "menuitem", tabIndex: index === focusIndex ? 0 : -1, type: "button", children: [item.icon ? _jsx("span", { "aria-hidden": "true", children: item.icon }) : null, _jsx("span", { children: item.label })] }, item.id))) }));
|
|
27
|
+
}
|
|
@@ -5,7 +5,7 @@ import { clampDateOnly, daysInMonth, formatDateOnly, isOutsideDateRange, parseDa
|
|
|
5
5
|
import { Button } from '../Button.js';
|
|
6
6
|
import { CalendarGrid } from './CalendarGrid.js';
|
|
7
7
|
import { CalendarHeader } from './CalendarHeader.js';
|
|
8
|
-
import { useAnchoredPopover } from '
|
|
8
|
+
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
9
9
|
/** The dialog's tab stops: enabled buttons and the grid's roving day. */
|
|
10
10
|
function isTabStop(node) {
|
|
11
11
|
if (node instanceof HTMLButtonElement)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export type AnchoredPopoverPlacement = {
|
|
3
|
+
/** Line up with the anchor's end when the start-aligned popover overflows. */
|
|
4
|
+
flipInline?: boolean;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Shows a mounted element on the top layer as a manual popover, keeps it
|
|
8
|
+
* beside `anchorRef` through scroll, window resize, and a change in its own
|
|
9
|
+
* or the anchor's size, a `space-1` gap from it and a
|
|
10
|
+
* `space-2` inset from the viewport edges, and calls `onDismiss` for a press
|
|
11
|
+
* outside it and outside `ignoreRef` (the button that toggles it).
|
|
12
|
+
* Browsers without the popover API get the same fixed placement.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useAnchoredPopover(popoverRef: RefObject<HTMLElement | null>, anchorRef: RefObject<HTMLElement | null>, ignoreRef: RefObject<HTMLElement | null>, onDismiss: () => void, { flipInline }?: AnchoredPopoverPlacement): void;
|
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
import { spacingScale } from '@scalewing/tokens';
|
|
2
2
|
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
-
import { anchoredPosition } from '
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { anchoredPosition } from '../anchored-position.js';
|
|
4
|
+
/**
|
|
5
|
+
* The viewport a fixed popover can use: the root's client box, which leaves
|
|
6
|
+
* out a classic scrollbar (`innerWidth` and `100vw` include it). Falls back
|
|
7
|
+
* to the window size where nothing is laid out, as in jsdom.
|
|
8
|
+
*/
|
|
9
|
+
function layoutViewport() {
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
return {
|
|
12
|
+
width: root.clientWidth || window.innerWidth,
|
|
13
|
+
height: root.clientHeight || window.innerHeight,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function placePopover(node, anchor, { flipInline = false }) {
|
|
17
|
+
const position = anchoredPosition(anchor.getBoundingClientRect(), { width: node.offsetWidth, height: node.offsetHeight }, layoutViewport(), {
|
|
6
18
|
gap: spacingScale[1],
|
|
7
19
|
inset: spacingScale[2],
|
|
8
20
|
rtl: getComputedStyle(anchor).direction === 'rtl',
|
|
21
|
+
flipInline,
|
|
9
22
|
});
|
|
10
23
|
node.style.left = `${position.left}px`;
|
|
11
24
|
node.style.top = `${position.top}px`;
|
|
12
25
|
}
|
|
13
26
|
/**
|
|
14
27
|
* Shows a mounted element on the top layer as a manual popover, keeps it
|
|
15
|
-
* beside `anchorRef` through scroll
|
|
16
|
-
*
|
|
28
|
+
* beside `anchorRef` through scroll, window resize, and a change in its own
|
|
29
|
+
* or the anchor's size, a `space-1` gap from it and a
|
|
30
|
+
* `space-2` inset from the viewport edges, and calls `onDismiss` for a press
|
|
31
|
+
* outside it and outside `ignoreRef` (the button that toggles it).
|
|
17
32
|
* Browsers without the popover API get the same fixed placement.
|
|
18
33
|
*/
|
|
19
|
-
export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss) {
|
|
34
|
+
export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss, { flipInline = false } = {}) {
|
|
20
35
|
const dismissRef = useRef(onDismiss);
|
|
21
36
|
useLayoutEffect(() => {
|
|
22
37
|
dismissRef.current = onDismiss;
|
|
@@ -34,7 +49,7 @@ export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss)
|
|
|
34
49
|
if (!node.matches(':popover-open'))
|
|
35
50
|
node.showPopover();
|
|
36
51
|
}
|
|
37
|
-
placePopover(node, anchor);
|
|
52
|
+
placePopover(node, anchor, { flipInline });
|
|
38
53
|
function onPointerDown(event) {
|
|
39
54
|
const target = event.target;
|
|
40
55
|
if (!(target instanceof Node))
|
|
@@ -45,17 +60,24 @@ export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss)
|
|
|
45
60
|
}
|
|
46
61
|
function onMove() {
|
|
47
62
|
if (node && anchor)
|
|
48
|
-
placePopover(node, anchor);
|
|
63
|
+
placePopover(node, anchor, { flipInline });
|
|
49
64
|
}
|
|
65
|
+
// A popover or anchor that changes size while open (new commands, a
|
|
66
|
+
// month with another row of weeks, a relabelled trigger) moves too.
|
|
67
|
+
// ResizeObserver is missing in some runtimes, such as jsdom.
|
|
68
|
+
const resizes = typeof ResizeObserver === 'function' ? new ResizeObserver(onMove) : null;
|
|
69
|
+
resizes?.observe(node);
|
|
70
|
+
resizes?.observe(anchor);
|
|
50
71
|
document.addEventListener('pointerdown', onPointerDown);
|
|
51
72
|
window.addEventListener('resize', onMove);
|
|
52
73
|
window.addEventListener('scroll', onMove, true);
|
|
53
74
|
return () => {
|
|
75
|
+
resizes?.disconnect();
|
|
54
76
|
document.removeEventListener('pointerdown', onPointerDown);
|
|
55
77
|
window.removeEventListener('resize', onMove);
|
|
56
78
|
window.removeEventListener('scroll', onMove, true);
|
|
57
79
|
if (supportsPopover && node.matches(':popover-open'))
|
|
58
80
|
node.hidePopover();
|
|
59
81
|
};
|
|
60
|
-
}, [anchorRef, ignoreRef, popoverRef]);
|
|
82
|
+
}, [anchorRef, flipInline, ignoreRef, popoverRef]);
|
|
61
83
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
2
|
import { zIndex } from './stacking.js';
|
|
3
|
+
import { coarsePointerQuery, touchTarget } from './touch-target.js';
|
|
3
4
|
const label = typographyVariants.label;
|
|
4
5
|
export function cssActionMenuClasses() {
|
|
5
6
|
return `.sw-action-menu {
|
|
@@ -39,20 +40,22 @@ export function cssActionMenuClasses() {
|
|
|
39
40
|
-webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
|
|
40
41
|
box-sizing: border-box;
|
|
41
42
|
color: var(--sw-color-text);
|
|
43
|
+
/* The popover layer's inset: 0 would ignore the placed left in right-to-left. */
|
|
44
|
+
inset: auto;
|
|
42
45
|
margin: 0;
|
|
43
46
|
max-height: var(--sw-select-max);
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
/* On the top layer 100% is the viewport without a classic scrollbar. */
|
|
48
|
+
max-width: calc(100% - var(--sw-space-2) - var(--sw-space-2));
|
|
49
|
+
/* Never narrower than a touch target. */
|
|
50
|
+
min-width: var(--sw-control-md-min-height);
|
|
46
51
|
overflow: auto;
|
|
47
52
|
padding: var(--sw-space-1);
|
|
48
53
|
position: fixed;
|
|
54
|
+
/* As wide as its longest command, until max-width wraps it. */
|
|
55
|
+
width: max-content;
|
|
49
56
|
${zIndex('popup')}
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
.sw-action-menu-list[hidden] {
|
|
53
|
-
display: none;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
59
|
.sw-action-menu-item {
|
|
57
60
|
align-items: center;
|
|
58
61
|
appearance: none;
|
|
@@ -65,7 +68,11 @@ export function cssActionMenuClasses() {
|
|
|
65
68
|
font-family: var(--sw-font-sans);
|
|
66
69
|
font-size: ${label.fontSize}px;
|
|
67
70
|
gap: var(--sw-space-2);
|
|
71
|
+
line-height: ${label.lineHeight}px;
|
|
68
72
|
min-height: var(--sw-control-xs-min-height);
|
|
73
|
+
/* A long command wraps inside the capped menu, a word at a time if it must. */
|
|
74
|
+
overflow-wrap: anywhere;
|
|
75
|
+
padding-block: var(--sw-space-1);
|
|
69
76
|
padding-inline: var(--sw-control-xs-padding-inline);
|
|
70
77
|
text-align: start;
|
|
71
78
|
width: 100%;
|
|
@@ -83,6 +90,18 @@ export function cssActionMenuClasses() {
|
|
|
83
90
|
|
|
84
91
|
.sw-action-menu-item-danger {
|
|
85
92
|
color: var(--sw-color-danger);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* A coarse pointer gets the full touch target: the trigger at least square, and every command as tall. */
|
|
96
|
+
@media ${coarsePointerQuery} {
|
|
97
|
+
.sw-action-menu-trigger {
|
|
98
|
+
min-height: ${touchTarget};
|
|
99
|
+
min-width: ${touchTarget};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sw-action-menu-item {
|
|
103
|
+
min-height: ${touchTarget};
|
|
104
|
+
}
|
|
86
105
|
}`;
|
|
87
106
|
}
|
|
88
107
|
export function actionMenuClassCatalog() {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { buttonSizes } from '@scalewing/tokens';
|
|
2
|
-
|
|
3
|
-
const touchTarget = 'var(--sw-control-md-min-height)';
|
|
2
|
+
import { coarsePointerQuery, touchTarget } from './touch-target.js';
|
|
4
3
|
/* Square at every Button size: as wide as the size's control height. */
|
|
5
4
|
function squareRules() {
|
|
6
5
|
return buttonSizes
|
|
@@ -21,7 +20,7 @@ ${squareRules()}
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
/* A coarse pointer gets the full touch target at the smaller sizes too. */
|
|
24
|
-
@media
|
|
23
|
+
@media ${coarsePointerQuery} {
|
|
25
24
|
.sw-button.sw-calendar-button {
|
|
26
25
|
min-height: ${touchTarget};
|
|
27
26
|
min-width: ${touchTarget};
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { compactTypographyVariants, typographyVariants, } from '@scalewing/tokens';
|
|
2
|
-
import { breakpointQuery } from './breakpoints.js';
|
|
3
1
|
import { cssActionBarClasses } from './css-action-bar.js';
|
|
4
2
|
import { cssActionMenuClasses } from './css-action-menu.js';
|
|
5
3
|
import { cssAccordionClasses } from './css-accordion.js';
|
|
@@ -15,6 +13,7 @@ import { cssDialogClasses } from './css-dialog.js';
|
|
|
15
13
|
import { cssSelectClasses } from './css-select.js';
|
|
16
14
|
import { cssSplitClasses } from './css-split.js';
|
|
17
15
|
import { cssSwitchClasses } from './css-switch.js';
|
|
16
|
+
import { cssTextClasses } from './css-text.js';
|
|
18
17
|
import { cssSpinnerClasses } from './css-spinner.js';
|
|
19
18
|
import { cssProgressClasses } from './css-progress.js';
|
|
20
19
|
import { cssTooltipClasses } from './css-tooltip.js';
|
|
@@ -58,22 +57,6 @@ function reducedTransparencyRules() {
|
|
|
58
57
|
}
|
|
59
58
|
}`;
|
|
60
59
|
}
|
|
61
|
-
function textVariantRules() {
|
|
62
|
-
return Object.entries(typographyVariants)
|
|
63
|
-
.map(([name, variant]) => {
|
|
64
|
-
const tabular = 'tabularNums' in variant && variant.tabularNums
|
|
65
|
-
? ' font-variant-numeric: tabular-nums;'
|
|
66
|
-
: '';
|
|
67
|
-
return `.sw-text-${name} { font-family: var(--sw-font-sans); font-size: ${variant.fontSize}px; line-height: ${variant.lineHeight}px; font-weight: ${variant.fontWeight}; letter-spacing: ${variant.letterSpacing}px;${tabular} }`;
|
|
68
|
-
})
|
|
69
|
-
.join('\n');
|
|
70
|
-
}
|
|
71
|
-
function compactTextVariantRules() {
|
|
72
|
-
const rules = Object.entries(compactTypographyVariants)
|
|
73
|
-
.map(([name, variant]) => ` .sw-text-${name} { font-size: ${variant.fontSize}px; line-height: ${variant.lineHeight}px; letter-spacing: ${variant.letterSpacing}px; }`)
|
|
74
|
-
.join('\n');
|
|
75
|
-
return `@media ${breakpointQuery('below', 'md')} {\n${rules}\n}`;
|
|
76
|
-
}
|
|
77
60
|
export function cssComponentClasses() {
|
|
78
61
|
return `.sw-card {
|
|
79
62
|
border-radius: var(--sw-radius-lg);
|
|
@@ -106,10 +89,6 @@ ${cssPopupHostRules()}
|
|
|
106
89
|
background: var(--sw-color-subtle);
|
|
107
90
|
}
|
|
108
91
|
|
|
109
|
-
.sw-text-align-start { text-align: start; }
|
|
110
|
-
.sw-text-align-center { text-align: center; }
|
|
111
|
-
.sw-text-align-end { text-align: end; }
|
|
112
|
-
|
|
113
92
|
.sw-truncate {
|
|
114
93
|
overflow: hidden;
|
|
115
94
|
text-overflow: ellipsis;
|
|
@@ -120,9 +99,7 @@ ${cssPopupHostRules()}
|
|
|
120
99
|
font-variant-numeric: tabular-nums;
|
|
121
100
|
}
|
|
122
101
|
|
|
123
|
-
${
|
|
124
|
-
|
|
125
|
-
${compactTextVariantRules()}
|
|
102
|
+
${cssTextClasses()}
|
|
126
103
|
|
|
127
104
|
${cssButtonClasses()}
|
|
128
105
|
|
|
@@ -142,7 +142,8 @@ function calendarRules() {
|
|
|
142
142
|
height: auto;
|
|
143
143
|
inset: auto;
|
|
144
144
|
margin: 0;
|
|
145
|
-
|
|
145
|
+
/* On the top layer 100% is the viewport without a classic scrollbar. */
|
|
146
|
+
max-width: calc(100% - var(--sw-space-2) - var(--sw-space-2));
|
|
146
147
|
overflow: visible;
|
|
147
148
|
padding: var(--sw-space-3);
|
|
148
149
|
position: fixed;
|
package/dist/css/css-palettes.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { colorsForPalette, createTheme, paletteHasStylesheet, palettesWithStylesheets, secondaryActionBorder, semanticColorKeys, } from '@scalewing/tokens';
|
|
2
|
+
/**
|
|
3
|
+
* A palette sets the colours it overlays, plus the accent tint whenever it
|
|
4
|
+
* moves the accent or surface the tint is mixed from.
|
|
5
|
+
*/
|
|
6
|
+
function overlaidColorKey(key, overlay) {
|
|
7
|
+
if (key === 'accentSubtle') {
|
|
8
|
+
return Boolean(overlay.accentSubtle || overlay.accent || overlay.surface);
|
|
9
|
+
}
|
|
10
|
+
return Boolean(overlay[key]);
|
|
11
|
+
}
|
|
2
12
|
function paletteCustomProperties(id, scheme) {
|
|
3
13
|
const overlay = colorsForPalette(id, scheme);
|
|
4
14
|
const theme = createTheme({ colorScheme: scheme, palette: id });
|
|
5
15
|
const lines = [];
|
|
6
16
|
for (const key of semanticColorKeys) {
|
|
7
|
-
if (overlay
|
|
17
|
+
if (overlaidColorKey(key, overlay)) {
|
|
8
18
|
lines.push(` --sw-color-${key}: ${theme.colors[key]};`);
|
|
9
19
|
}
|
|
10
20
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cssTextClasses(): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { compactTypographyVariants, typographyVariants, } from '@scalewing/tokens';
|
|
2
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
3
|
+
function textClassNames() {
|
|
4
|
+
return Object.keys(typographyVariants).map((name) => `.sw-text-${name}`);
|
|
5
|
+
}
|
|
6
|
+
/*
|
|
7
|
+
* Text renders `p` and `h1`–`h4` by default. The browser gives those a
|
|
8
|
+
* block margin, which a flex Stack or Inline does not collapse, so it would
|
|
9
|
+
* add to every gap. `:where()` keeps the reset at zero specificity: it beats
|
|
10
|
+
* the browser's margins (author styles win over the user agent's) and loses
|
|
11
|
+
* to any authored margin, such as `sw-sr-only` or a consumer's class.
|
|
12
|
+
*/
|
|
13
|
+
function textMarginReset() {
|
|
14
|
+
return `:where(${textClassNames().join(', ')}) { margin: 0; }`;
|
|
15
|
+
}
|
|
16
|
+
function textVariantRules() {
|
|
17
|
+
return Object.entries(typographyVariants)
|
|
18
|
+
.map(([name, variant]) => {
|
|
19
|
+
const tabular = 'tabularNums' in variant && variant.tabularNums
|
|
20
|
+
? ' font-variant-numeric: tabular-nums;'
|
|
21
|
+
: '';
|
|
22
|
+
return `.sw-text-${name} { font-family: var(--sw-font-sans); font-size: ${variant.fontSize}px; line-height: ${variant.lineHeight}px; font-weight: ${variant.fontWeight}; letter-spacing: ${variant.letterSpacing}px;${tabular} }`;
|
|
23
|
+
})
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
function compactTextVariantRules() {
|
|
27
|
+
const rules = Object.entries(compactTypographyVariants)
|
|
28
|
+
.map(([name, variant]) => ` .sw-text-${name} { font-size: ${variant.fontSize}px; line-height: ${variant.lineHeight}px; letter-spacing: ${variant.letterSpacing}px; }`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
return `@media ${breakpointQuery('below', 'md')} {\n${rules}\n}`;
|
|
31
|
+
}
|
|
32
|
+
export function cssTextClasses() {
|
|
33
|
+
return `.sw-text-align-start { text-align: start; }
|
|
34
|
+
.sw-text-align-center { text-align: center; }
|
|
35
|
+
.sw-text-align-end { text-align: end; }
|
|
36
|
+
|
|
37
|
+
${textMarginReset()}
|
|
38
|
+
|
|
39
|
+
${textVariantRules()}
|
|
40
|
+
|
|
41
|
+
${compactTextVariantRules()}`;
|
|
42
|
+
}
|
package/dist/css/stacking.js
CHANGED
|
@@ -30,7 +30,8 @@ export function zIndex(layer) {
|
|
|
30
30
|
}
|
|
31
31
|
const openPopups = [
|
|
32
32
|
'.sw-select-list',
|
|
33
|
-
|
|
33
|
+
// Rendered only while open; on the top layer where the popover API exists.
|
|
34
|
+
'.sw-action-menu-list',
|
|
34
35
|
'.sw-tooltip:not([hidden])',
|
|
35
36
|
// DateField and CalendarButton share it. Rendered only while open; on the
|
|
36
37
|
// top layer where the popover API exists.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The comfortable touch target on a coarse pointer: the md control height,
|
|
3
|
+
* 44 px, both ways. Controls drawn smaller for a fine pointer (CalendarButton
|
|
4
|
+
* at sm and xs, the ActionMenu trigger and commands) grow to it on touch.
|
|
5
|
+
*/
|
|
6
|
+
export const touchTarget = 'var(--sw-control-md-min-height)';
|
|
7
|
+
export const coarsePointerQuery = '(pointer: coarse)';
|
package/dist/palette/amber.css
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[data-theme='light'] {
|
|
4
4
|
--sw-color-accent: #B45309;
|
|
5
5
|
--sw-color-onAccent: #FFFFFF;
|
|
6
|
+
--sw-color-accentSubtle: #FAF3EE;
|
|
6
7
|
--sw-glass-blur: 24px;
|
|
7
8
|
--sw-glass-saturate: 1;
|
|
8
9
|
--sw-glass-fill: rgba(255, 255, 255, 1);
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
[data-theme='dark'] {
|
|
14
15
|
--sw-color-accent: #FCD34D;
|
|
15
16
|
--sw-color-onAccent: #101214;
|
|
17
|
+
--sw-color-accentSubtle: #322E23;
|
|
16
18
|
--sw-glass-blur: 24px;
|
|
17
19
|
--sw-glass-saturate: 1.8;
|
|
18
20
|
--sw-glass-fill: rgba(68, 61, 38, 0.72);
|