flet-box 1.1.0 → 1.1.2
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/README.md +16 -12
- package/bin/commands/checkUi.js +276 -45
- package/bin/commands/create.js +8 -1
- package/bin/commands/createBundle.js +18 -6
- package/bin/commands/createScreen.js +1 -1
- package/bin/commands/runBundle.js +1 -1
- package/bin/utils/bundleConfig.js +47 -3
- package/bin/utils/colors.js +0 -8
- package/bin/utils/helpers.js +0 -5
- package/bin/utils/images/jpeg.js +2 -3
- package/bin/utils/templates.js +101 -85
- package/docs/README.md +24 -1
- package/docs/cli/README.md +51 -10
- package/docs/guides/README.md +5 -2
- package/docs/guides/accessibility.md +282 -0
- package/docs/guides/clean-ui.md +53 -8
- package/docs/guides/first-app.md +6 -7
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +51 -0
- package/docs/guides/online-updates.md +18 -6
- package/docs/guides/utilities.md +116 -4
- package/docs/guides/widget-structure.md +30 -0
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +134 -11
- package/docs/tools/device.md +75 -3
- package/docs/tools/text-and-time.md +116 -6
- package/docs/widget/Accordion.md +2 -0
- package/docs/widget/AnimatedBox.md +46 -20
- package/docs/widget/AnimatedText.md +13 -7
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/COMMON_PROPS.md +26 -3
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +7 -1
- package/docs/widget/CircularChart.md +2 -0
- package/docs/widget/Drawer.md +1 -0
- package/docs/widget/GridView.md +4 -1
- package/docs/widget/Input.md +2 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/Markdown.md +4 -0
- package/docs/widget/Rating.md +2 -0
- package/docs/widget/Slider.md +2 -0
- package/docs/widget/Stack.md +6 -5
- package/docs/widget/Stepper.md +2 -0
- package/docs/widget/Tooltip.md +3 -1
- package/package.json +3 -2
- package/skills/fletbox-design/SKILL.md +390 -14
- package/skills/fletbox-motion/SKILL.md +145 -47
- package/skills/fletbox-rules/SKILL.md +140 -1
- package/src/animations/AnimatedBox.js +321 -226
- package/src/animations/MatrixRain.js +7 -1
- package/src/animations/ParallaxBox.js +13 -10
- package/src/animations/animate.js +269 -106
- package/src/animations/color.js +91 -0
- package/src/animations/easings.js +275 -0
- package/src/animations/index.js +21 -0
- package/src/animations/motion.js +204 -0
- package/src/animations/transitions.js +327 -0
- package/src/core/runApp.js +0 -6
- package/src/helpers/boxEdges.js +69 -0
- package/src/helpers/html.js +39 -0
- package/src/index.d.ts +377 -25
- package/src/index.js +77 -0
- package/src/navigations/AdaptiveScaffold.js +6 -2
- package/src/navigations/AppBar.js +16 -7
- package/src/navigations/BottomNavigation.js +24 -1
- package/src/navigations/CollapsibleSideBar.js +0 -1
- package/src/navigations/Drawer.js +32 -1
- package/src/navigations/DrawerItem.js +1 -1
- package/src/navigations/Router.js +0 -1
- package/src/navigations/Scaffold.js +9 -3
- package/src/navigations/Tabs.js +104 -31
- package/src/services/RamStore.js +1 -2
- package/src/tools/breakpoints.js +113 -0
- package/src/tools/createList.js +0 -2
- package/src/tools/fonts.js +99 -0
- package/src/tools/index.js +74 -1
- package/src/tools/margin.js +5 -63
- package/src/tools/padding.js +5 -63
- package/src/tools/print.js +0 -8
- package/src/tools/scale.js +9 -2
- package/src/tools/time.js +56 -15
- package/src/tools/useState.js +1 -54
- package/src/utils/fieldFont.js +77 -0
- package/src/utils/focusRing.js +73 -0
- package/src/utils/focusScope.js +113 -0
- package/src/utils/markdownParser.js +7 -9
- package/src/utils/readableInk.js +7 -1
- package/src/utils/stackLayout.js +55 -0
- package/src/utils/styleInjector.js +25 -0
- package/src/utils/syntaxHighlight.js +1 -17
- package/src/utils/themes.js +15 -0
- package/src/utils/visualEffects.js +49 -47
- package/src/widget-factory/createWidget.js +16 -3
- package/src/widget-factory/effects.js +76 -3
- package/src/widget-factory/reactiveValue.js +0 -7
- package/src/widget-factory/tools.js +26 -143
- package/src/widget-factory/translateProps.js +5 -0
- package/src/widget-factory/widgetFactory.js +1 -1
- package/src/widgets/Accordion.js +70 -12
- package/src/widgets/AdMob.js +1 -1
- package/src/widgets/AdSense.js +1 -1
- package/src/widgets/AlertDialog.js +11 -9
- package/src/widgets/Avatar.js +10 -1
- package/src/widgets/Badge.js +7 -1
- package/src/widgets/BottomSheet.js +39 -5
- package/src/widgets/Chart.js +41 -9
- package/src/widgets/Checkbox.js +59 -9
- package/src/widgets/Chip.js +13 -1
- package/src/widgets/CircularBar.js +17 -5
- package/src/widgets/CircularChart.js +24 -2
- package/src/widgets/CodeViewer.js +5 -3
- package/src/widgets/Dropdown.js +223 -20
- package/src/widgets/GridView.js +17 -1
- package/src/widgets/Icon.js +21 -0
- package/src/widgets/Input.js +75 -44
- package/src/widgets/InstallButton.js +8 -2
- package/src/widgets/ListTile.js +6 -5
- package/src/widgets/ListView.js +21 -7
- package/src/widgets/Markdown.js +25 -44
- package/src/widgets/Modal.js +43 -6
- package/src/widgets/Pagination.js +35 -3
- package/src/widgets/ProgressBar.js +18 -0
- package/src/widgets/Radio.js +7 -0
- package/src/widgets/Rating.js +104 -16
- package/src/widgets/Skeleton.js +9 -5
- package/src/widgets/Slider.js +87 -7
- package/src/widgets/SnackBar.js +30 -1
- package/src/widgets/Stack.js +25 -13
- package/src/widgets/Stepper.js +112 -64
- package/src/widgets/Switch.js +10 -1
- package/src/widgets/Tooltip.js +93 -10
- package/src/widgets/TreeView.js +5 -5
- package/src/create-index.sh +0 -237
- package/src/widget-factory/pixTool.js +0 -40
- package/src/widget-factory/stringTool.js +0 -26
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
* @description PWA install prompt button; hidden unless install is available.
|
|
4
4
|
*/
|
|
5
5
|
import { Button } from "./Button.js";
|
|
6
|
-
import { colors } from "../utils/themes.js";
|
|
7
6
|
|
|
8
7
|
export const InstallButton = (props) => {
|
|
9
8
|
const {
|
|
10
|
-
|
|
9
|
+
// The glyph used to be the 📲 emoji inside the text. An emoji is a picture the
|
|
10
|
+
// operating system draws, so the button rendered as a tofu box wherever that
|
|
11
|
+
// font is missing (and the framework already stopped putting emoji in UI in
|
|
12
|
+
// favour of the bundled Material vocabulary). `get_app` is in `icons.css` for
|
|
13
|
+
// every platform and inherits `color`, so it themes with the button.
|
|
14
|
+
text = "Install",
|
|
15
|
+
icon = "get_app",
|
|
11
16
|
variant = "filled",
|
|
12
17
|
size = "medium",
|
|
13
18
|
borderRadius = 16,
|
|
@@ -59,6 +64,7 @@ export const InstallButton = (props) => {
|
|
|
59
64
|
button = Button({
|
|
60
65
|
widgetName: "InstallButton",
|
|
61
66
|
text: text,
|
|
67
|
+
icon: icon,
|
|
62
68
|
variant: variant,
|
|
63
69
|
size: size,
|
|
64
70
|
bgColor: backgroundColor,
|
package/src/widgets/ListTile.js
CHANGED
|
@@ -92,9 +92,8 @@ export const ListTile = (props) => {
|
|
|
92
92
|
textChildren.push(
|
|
93
93
|
Text({
|
|
94
94
|
text: subtitle,
|
|
95
|
-
size:
|
|
95
|
+
size: 14,
|
|
96
96
|
color: colors.textSecondary,
|
|
97
|
-
marginTop: 2,
|
|
98
97
|
...subtitleProps,
|
|
99
98
|
}),
|
|
100
99
|
);
|
|
@@ -105,8 +104,10 @@ export const ListTile = (props) => {
|
|
|
105
104
|
Text({
|
|
106
105
|
text: description,
|
|
107
106
|
size: 12,
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
// `textDisabled` is the ink of a control that cannot be used, and the
|
|
108
|
+
// dark palette answers it with #52525B — 2.57:1 on the page. A
|
|
109
|
+
// description is content, so it takes the muted-but-readable token.
|
|
110
|
+
color: colors.textSecondary,
|
|
110
111
|
...descriptionProps,
|
|
111
112
|
}),
|
|
112
113
|
);
|
|
@@ -116,7 +117,7 @@ export const ListTile = (props) => {
|
|
|
116
117
|
const centerColumn =
|
|
117
118
|
textChildren.length > 0
|
|
118
119
|
? Column({
|
|
119
|
-
gap:
|
|
120
|
+
gap: 4,
|
|
120
121
|
alignItems: "flex-start",
|
|
121
122
|
flex: 1,
|
|
122
123
|
minWidth: 0, // allows the column to shrink below its content size in a flex row
|
package/src/widgets/ListView.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
6
|
import { injectKeyframes } from "../utils/styleInjector.js";
|
|
7
|
+
import { markMotionLoop } from "../animations/motion.js";
|
|
7
8
|
import { toREM, getBaseFontSize } from "../utils/units.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -77,12 +78,6 @@ export const ListView = (props) => {
|
|
|
77
78
|
...rest
|
|
78
79
|
} = props;
|
|
79
80
|
|
|
80
|
-
// When expand is true the widget fills its flex parent, ignoring the fixed height prop
|
|
81
|
-
let finalHeight = height;
|
|
82
|
-
if (expand) {
|
|
83
|
-
finalHeight = "100%";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
81
|
// Outer element — owns the overall dimensions
|
|
87
82
|
const element = WidgetFactory({
|
|
88
83
|
tag: "div",
|
|
@@ -184,11 +179,18 @@ export const ListView = (props) => {
|
|
|
184
179
|
* Calculates the logical height of the virtual item area. Header and footer are
|
|
185
180
|
* siblings of `innerContainer` and contribute to the scrollbar through their own
|
|
186
181
|
* layout boxes, so they must not be added here.
|
|
182
|
+
*
|
|
183
|
+
* Grid mode keeps the trailing gutter. Each cell carries half of the spacing on
|
|
184
|
+
* every side, so a row ends `slotHeight()` below where it starts and the last one
|
|
185
|
+
* really is that tall; subtracting the gap there shortened the area by a gutter and
|
|
186
|
+
* the final row painted over the footer.
|
|
187
|
+
*
|
|
187
188
|
* @returns {number} Total height in pixels.
|
|
188
189
|
*/
|
|
189
190
|
const getTotalHeight = () => {
|
|
190
191
|
const rows = getTotalRows();
|
|
191
192
|
if (rows === 0) return 0;
|
|
193
|
+
if (isGridMode) return rows * slotHeight();
|
|
192
194
|
return Math.max(0, rows * slotHeight() - gapHeight());
|
|
193
195
|
};
|
|
194
196
|
|
|
@@ -258,8 +260,19 @@ export const ListView = (props) => {
|
|
|
258
260
|
const col = i % cols;
|
|
259
261
|
wrapper.style.left = `${(col / cols) * 100}%`;
|
|
260
262
|
wrapper.style.width = `${100 / cols}%`;
|
|
261
|
-
|
|
263
|
+
// The slot is the cell: `itemSize` tall, with `gap` split across the four
|
|
264
|
+
// sides so two neighbours meet at exactly one gutter. Without the height the
|
|
265
|
+
// wrapper was only as tall as its content, so `itemSize` reserved the row but
|
|
266
|
+
// never sized the cell — a grid of labels floating at the top of their rows.
|
|
267
|
+
// Written in framework units, like the `getItemTop` pitch it must match.
|
|
268
|
+
wrapper.style.height = toREM(itemSize + gap);
|
|
269
|
+
wrapper.style.padding = toREM(gap / 2);
|
|
262
270
|
wrapper.style.boxSizing = "border-box";
|
|
271
|
+
// The cell fills its slot by being stretched, not by writing a height into
|
|
272
|
+
// the element the caller handed over: their node stays theirs, and a cell
|
|
273
|
+
// that asked for its own height still keeps it.
|
|
274
|
+
wrapper.style.display = "grid";
|
|
275
|
+
wrapper.style.gridTemplateRows = "minmax(0, 1fr)";
|
|
263
276
|
} else {
|
|
264
277
|
wrapper.style.left = "0";
|
|
265
278
|
wrapper.style.right = "0";
|
|
@@ -391,6 +404,7 @@ export const ListView = (props) => {
|
|
|
391
404
|
spinner.style.borderTop = "2px solid #007aff";
|
|
392
405
|
spinner.style.borderRadius = "50%";
|
|
393
406
|
spinner.style.animation = "flet-spin 0.8s linear infinite";
|
|
407
|
+
markMotionLoop(spinner);
|
|
394
408
|
refreshIndicator.appendChild(spinner);
|
|
395
409
|
scrollContainer.insertBefore(refreshIndicator, scrollContainer.firstChild);
|
|
396
410
|
|
package/src/widgets/Markdown.js
CHANGED
|
@@ -7,25 +7,9 @@ import { WidgetFactory } from "../widget-factory/index.js";
|
|
|
7
7
|
import { colors } from "../utils/themes.js";
|
|
8
8
|
import { composeUpdate } from "../utils/composeUpdate.js";
|
|
9
9
|
import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Escapes text for safe insertion as HTML. Local to this file on purpose:
|
|
13
|
-
* importing it from utils/markdownParser.js would pull six widget modules into
|
|
14
|
-
* the dependency graph of every Markdown instance.
|
|
15
|
-
*
|
|
16
|
-
* @param {string} text - Raw text to escape.
|
|
17
|
-
* @returns {string} Text with `&`, `<`, `>`, `"` and `'` replaced by entities.
|
|
18
|
-
*/
|
|
10
|
+
import { escapeHtml } from "../helpers/html.js";
|
|
19
11
|
import { toREM } from "../utils/units.js";
|
|
20
12
|
|
|
21
|
-
const escapeCode = (text) =>
|
|
22
|
-
String(text)
|
|
23
|
-
.replace(/&/g, "&")
|
|
24
|
-
.replace(/</g, "<")
|
|
25
|
-
.replace(/>/g, ">")
|
|
26
|
-
.replace(/"/g, """)
|
|
27
|
-
.replace(/'/g, "'");
|
|
28
|
-
|
|
29
13
|
/** Monotonic counter giving each Markdown instance its own CSS scope class. */
|
|
30
14
|
let markdownScopeUid = 0;
|
|
31
15
|
|
|
@@ -113,19 +97,6 @@ const SH = {
|
|
|
113
97
|
variable: "#e5c07b",
|
|
114
98
|
};
|
|
115
99
|
|
|
116
|
-
/**
|
|
117
|
-
* Wraps `text` in a `<span>` with the given color, HTML-escaping the content.
|
|
118
|
-
* Returns plain escaped text when `color` is falsy.
|
|
119
|
-
*
|
|
120
|
-
* @param {string} text
|
|
121
|
-
* @param {string} [color]
|
|
122
|
-
* @returns {string}
|
|
123
|
-
*/
|
|
124
|
-
const shSpan = (text, color) => {
|
|
125
|
-
const safe = escapeCode(text);
|
|
126
|
-
return color ? `<span style="color:${color}">${safe}</span>` : safe;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
100
|
/**
|
|
130
101
|
* Applies a sequence of `[regex, color]` replacement rules to `line`, escaping
|
|
131
102
|
* every character that is NOT inside a match so raw `<`/`>` cannot break the
|
|
@@ -152,15 +123,15 @@ const applyRules = (line, rules) => {
|
|
|
152
123
|
let m;
|
|
153
124
|
while ((m = combined.exec(line)) !== null) {
|
|
154
125
|
// Text between the previous match and this one — escape and emit as-is.
|
|
155
|
-
if (m.index > last) out +=
|
|
126
|
+
if (m.index > last) out += escapeHtml(line.slice(last, m.index));
|
|
156
127
|
// Find which named group matched.
|
|
157
128
|
const ruleIndex = rules.findIndex((_, i) => m.groups[`r${i}`] !== undefined);
|
|
158
129
|
const color = rules[ruleIndex][1];
|
|
159
|
-
out += `<span style="color:${color}">${
|
|
130
|
+
out += `<span style="color:${color}">${escapeHtml(m[0])}</span>`;
|
|
160
131
|
last = m.index + m[0].length;
|
|
161
132
|
}
|
|
162
133
|
// Remaining plain text.
|
|
163
|
-
if (last < line.length) out +=
|
|
134
|
+
if (last < line.length) out += escapeHtml(line.slice(last));
|
|
164
135
|
return out;
|
|
165
136
|
};
|
|
166
137
|
|
|
@@ -212,8 +183,6 @@ const RULES_JSON = [
|
|
|
212
183
|
[/([{}[\]:,])/, SH.punctuation],
|
|
213
184
|
];
|
|
214
185
|
|
|
215
|
-
const RULES_TS = RULES_PYTHON; // TypeScript re-uses the applyRules path below via JS highlighter override
|
|
216
|
-
|
|
217
186
|
/**
|
|
218
187
|
* Highlights one source line using the appropriate language highlighter.
|
|
219
188
|
* Falls through to plain HTML-escaped text for unknown languages.
|
|
@@ -258,7 +227,7 @@ const highlightLine = (line, lang) => {
|
|
|
258
227
|
return applyRules(line, RULES_JSON);
|
|
259
228
|
|
|
260
229
|
default:
|
|
261
|
-
return
|
|
230
|
+
return escapeHtml(line);
|
|
262
231
|
}
|
|
263
232
|
};
|
|
264
233
|
|
|
@@ -311,7 +280,7 @@ const legacyCopy = (text) => {
|
|
|
311
280
|
const renderFencedBlock = (block) => {
|
|
312
281
|
const lang = block.lang.replace(/[^A-Za-z0-9#+._-]/g, "").toLowerCase();
|
|
313
282
|
const cls = lang ? ` class="language-${lang}"` : "";
|
|
314
|
-
const label = lang ? `<span class="md-lang-label">${
|
|
283
|
+
const label = lang ? `<span class="md-lang-label">${escapeHtml(lang)}</span>` : "";
|
|
315
284
|
|
|
316
285
|
// Highlight line-by-line. Lines are joined with a bare "\n" — no wrapper
|
|
317
286
|
// <span> per line because display:block + \n produced two line separators,
|
|
@@ -323,7 +292,7 @@ const renderFencedBlock = (block) => {
|
|
|
323
292
|
|
|
324
293
|
// The raw code is embedded in a hidden <textarea> so the copy handler can
|
|
325
294
|
// read it without stripping HTML and without touching the DOM of <code>.
|
|
326
|
-
const safeRaw =
|
|
295
|
+
const safeRaw = escapeHtml(block.code);
|
|
327
296
|
|
|
328
297
|
return (
|
|
329
298
|
`<div class="md-code-block">` +
|
|
@@ -591,7 +560,7 @@ const parseMarkdown = (text) => {
|
|
|
591
560
|
// a `<style>` opened by a sample swallows every following block as CSS.
|
|
592
561
|
html = html.replace(
|
|
593
562
|
/`(.*?)`/g,
|
|
594
|
-
(_match, code) => `<code>${
|
|
563
|
+
(_match, code) => `<code>${escapeHtml(code)}</code>`,
|
|
595
564
|
);
|
|
596
565
|
|
|
597
566
|
// Pull real HTML tags out too, so the inline rules below never rewrite an
|
|
@@ -623,7 +592,7 @@ const parseMarkdown = (text) => {
|
|
|
623
592
|
html = html.replace(
|
|
624
593
|
/!\[(.*?)\]\((.*?)\)/g,
|
|
625
594
|
(_match, alt, src) =>
|
|
626
|
-
`<img src="${
|
|
595
|
+
`<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="lazy">`,
|
|
627
596
|
);
|
|
628
597
|
|
|
629
598
|
// Links — opens in a new tab with rel="noopener" for security. The URL is
|
|
@@ -632,7 +601,7 @@ const parseMarkdown = (text) => {
|
|
|
632
601
|
html = html.replace(
|
|
633
602
|
/\[(.*?)\]\((.*?)\)/g,
|
|
634
603
|
(_match, label, href) =>
|
|
635
|
-
`<a href="${
|
|
604
|
+
`<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${label}</a>`,
|
|
636
605
|
);
|
|
637
606
|
|
|
638
607
|
// Unordered lists — tag each "- item" line as an unordered <li>, then group
|
|
@@ -813,13 +782,25 @@ export const Markdown = (props) => {
|
|
|
813
782
|
color = colors.text,
|
|
814
783
|
|
|
815
784
|
// Link styling
|
|
816
|
-
|
|
817
|
-
|
|
785
|
+
//
|
|
786
|
+
// `primary` is the palette's plate, and in light that plate is #18181B — the
|
|
787
|
+
// page's own ink. A link coloured with it measured rgb(24,24,27) against text
|
|
788
|
+
// rgb(9,9,11) — 1.12:1 apart, a sentence with an invisible word in it, since `linkUnderline` is off by
|
|
789
|
+
// default and colour was the only cue. `infoStrong` is the blue this palette
|
|
790
|
+
// already writes words in (the focus ring uses it for the same reason):
|
|
791
|
+
// 6.70:1 on the light page, 9.82:1 on the dark card, and unmistakably not body
|
|
792
|
+
// ink. Hover keeps the same hue and adds the underline — brightening to `info`
|
|
793
|
+
// would drop to 3.68:1 in light, and a hover state is still text.
|
|
794
|
+
linkColor = colors.infoStrong,
|
|
795
|
+
linkHoverColor = colors.infoStrong,
|
|
818
796
|
linkUnderline = false,
|
|
819
797
|
|
|
820
798
|
// Inline code styling
|
|
821
799
|
codeBgColor = colors.gray100,
|
|
822
|
-
|
|
800
|
+
// `danger` is a signal colour for icons and borders; as 12px words on the
|
|
801
|
+
// `gray100` plate it measured 3.42:1 in light. `dangerStrong` is the palette's
|
|
802
|
+
// own "red you may write words in" — 5.89:1 on that plate, 8.68:1 in dark.
|
|
803
|
+
codeColor = colors.dangerStrong,
|
|
823
804
|
codeFontSize = 12,
|
|
824
805
|
codeFontFamily =
|
|
825
806
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace',
|
package/src/widgets/Modal.js
CHANGED
|
@@ -6,6 +6,7 @@ import { WidgetFactory } from "../widget-factory/index.js";
|
|
|
6
6
|
import { transition } from "../tools/index.js";
|
|
7
7
|
import { colors } from "../utils/themes.js";
|
|
8
8
|
import { injectKeyframes } from "../utils/styleInjector.js";
|
|
9
|
+
import { trapFocus } from "../utils/focusScope.js";
|
|
9
10
|
import { Container } from "./Container.js";
|
|
10
11
|
import { Column } from "./Column.js";
|
|
11
12
|
import { Row } from "./Row.js";
|
|
@@ -25,6 +26,17 @@ const ModalOverlay = ({
|
|
|
25
26
|
// main modal transparent
|
|
26
27
|
return WidgetFactory({
|
|
27
28
|
tag: "div",
|
|
29
|
+
// The scrim closes the dialog for a pointer, and is nothing at all for a
|
|
30
|
+
// keyboard: the close button and the actions are the controls. Without these
|
|
31
|
+
// two the engine would read its `click` as a control and put a full-viewport
|
|
32
|
+
// "button" on the Tab path, inside the trap, ahead of the dialog's own.
|
|
33
|
+
role: "presentation",
|
|
34
|
+
tabIndex: -1,
|
|
35
|
+
// And no press feedback either: the panel is this element's child, so the
|
|
36
|
+
// engine's `scale: 0.98` shrank the whole layer — the dialog dipped 1.45px and
|
|
37
|
+
// lost 9px of width for the length of the press. A scrim dismisses; it is not
|
|
38
|
+
// a control that answers to being pushed.
|
|
39
|
+
disableTransform: true,
|
|
28
40
|
position: "fixed",
|
|
29
41
|
top: "0",
|
|
30
42
|
left: "0",
|
|
@@ -68,7 +80,10 @@ const ModalHeader = ({
|
|
|
68
80
|
Text({
|
|
69
81
|
text: title,
|
|
70
82
|
size: 18,
|
|
71
|
-
weight
|
|
83
|
+
// `80%` is not a font weight — the browser drops the declaration and the
|
|
84
|
+
// dialog title rendered at the body weight. The sibling overlay header
|
|
85
|
+
// (`BottomSheet`) asks for `bold`, and this is the same decision.
|
|
86
|
+
weight: "bold",
|
|
72
87
|
color: headerTextColor || colors.text,
|
|
73
88
|
flex: 1,
|
|
74
89
|
}),
|
|
@@ -77,10 +92,13 @@ const ModalHeader = ({
|
|
|
77
92
|
if (showCloseButton) {
|
|
78
93
|
const closeIcon = Icon({
|
|
79
94
|
name: "close",
|
|
80
|
-
size:
|
|
95
|
+
size: 24,
|
|
81
96
|
color: headerTextColor || colors.textSecondary,
|
|
82
97
|
cursor: "pointer",
|
|
83
98
|
onclick: onClose,
|
|
99
|
+
// A glyph is not a name. The engine makes this pressable reachable; this is
|
|
100
|
+
// what a screen reader then reads.
|
|
101
|
+
"aria-label": "Close dialog",
|
|
84
102
|
transition: transition({
|
|
85
103
|
property: "opacity",
|
|
86
104
|
duration: 0.2,
|
|
@@ -89,7 +107,10 @@ const ModalHeader = ({
|
|
|
89
107
|
}),
|
|
90
108
|
// transition: "opacity 0.2s ease",
|
|
91
109
|
borderRadius: "50%",
|
|
92
|
-
|
|
110
|
+
// 24 + 2 x 8 = the 40-unit target; framework units, so it scales with the
|
|
111
|
+
// reader's font instead of freezing at a pixel size.
|
|
112
|
+
paddingVertical: 8,
|
|
113
|
+
paddingHorizontal: 8,
|
|
93
114
|
});
|
|
94
115
|
// Hover effect for close button
|
|
95
116
|
closeIcon.addEventListener("mouseenter", () => {
|
|
@@ -309,8 +330,6 @@ export const Modal = (props = {}) => {
|
|
|
309
330
|
// Callbacks
|
|
310
331
|
onOpen,
|
|
311
332
|
onClose,
|
|
312
|
-
|
|
313
|
-
...rest
|
|
314
333
|
} = props;
|
|
315
334
|
|
|
316
335
|
let isOpen = false;
|
|
@@ -318,6 +337,11 @@ export const Modal = (props = {}) => {
|
|
|
318
337
|
// Pending "hide after the close animation" timer, so it can be cancelled.
|
|
319
338
|
let closeTimer = null;
|
|
320
339
|
let modalBody = null;
|
|
340
|
+
// What `trapFocus` hands back while the dialog is open: a keyboard user must
|
|
341
|
+
// not be able to Tab out behind the scrim, and focus must return to the button
|
|
342
|
+
// that opened the dialog when it closes. The release is idempotent, so close
|
|
343
|
+
// and unmount can both call it.
|
|
344
|
+
let releaseFocusScope = () => {};
|
|
321
345
|
|
|
322
346
|
// Handlers
|
|
323
347
|
const handleClose = () => {
|
|
@@ -336,6 +360,7 @@ export const Modal = (props = {}) => {
|
|
|
336
360
|
if (overlay) overlay.style.visibility = "hidden";
|
|
337
361
|
}, 300);
|
|
338
362
|
if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
|
|
363
|
+
releaseFocusScope();
|
|
339
364
|
if (onClose) onClose();
|
|
340
365
|
};
|
|
341
366
|
|
|
@@ -357,6 +382,7 @@ export const Modal = (props = {}) => {
|
|
|
357
382
|
}
|
|
358
383
|
if (onOpen) onOpen();
|
|
359
384
|
if (closeOnEsc) document.addEventListener("keydown", handleKeyDown);
|
|
385
|
+
releaseFocusScope = trapFocus(modalBody);
|
|
360
386
|
};
|
|
361
387
|
|
|
362
388
|
const handleKeyDown = (e) => {
|
|
@@ -416,6 +442,15 @@ export const Modal = (props = {}) => {
|
|
|
416
442
|
zIndex,
|
|
417
443
|
overlayColor,
|
|
418
444
|
});
|
|
445
|
+
// What the panel is to an assistive technology: a dialog, modal while it is
|
|
446
|
+
// open, and named by its title. `aria-modal` is not decoration — without it a
|
|
447
|
+
// screen reader keeps reading the page behind the scrim as if it were live.
|
|
448
|
+
modalBody.setAttribute("role", "dialog");
|
|
449
|
+
modalBody.setAttribute("aria-modal", "true");
|
|
450
|
+
if (typeof title === "string" && title) {
|
|
451
|
+
modalBody.setAttribute("aria-label", title);
|
|
452
|
+
}
|
|
453
|
+
|
|
419
454
|
overlay.appendChild(modalBody);
|
|
420
455
|
document.body.appendChild(overlay);
|
|
421
456
|
|
|
@@ -429,6 +464,7 @@ export const Modal = (props = {}) => {
|
|
|
429
464
|
closeTimer = null;
|
|
430
465
|
}
|
|
431
466
|
if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
|
|
467
|
+
releaseFocusScope();
|
|
432
468
|
});
|
|
433
469
|
|
|
434
470
|
// Public API
|
|
@@ -440,6 +476,7 @@ export const Modal = (props = {}) => {
|
|
|
440
476
|
if (overlay && overlay.parentNode)
|
|
441
477
|
overlay.parentNode.removeChild(overlay);
|
|
442
478
|
if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
|
|
479
|
+
releaseFocusScope();
|
|
443
480
|
},
|
|
444
481
|
get isOpen() {
|
|
445
482
|
return isOpen;
|
|
@@ -492,7 +529,7 @@ export const Modal = (props = {}) => {
|
|
|
492
529
|
height: 32,
|
|
493
530
|
border: `2px solid ${colors.border}`,
|
|
494
531
|
borderTop: `2px solid ${colors.primary}`,
|
|
495
|
-
borderRadius:
|
|
532
|
+
borderRadius: "50%",
|
|
496
533
|
animation: "modal-spin 0.8s linear infinite",
|
|
497
534
|
}),
|
|
498
535
|
Text({
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* @file Pagination.js
|
|
3
3
|
* @description Page-number control built from Buttons inside a Row.
|
|
4
4
|
*/
|
|
5
|
-
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
5
|
import { Row } from "./Row.js";
|
|
7
6
|
import { Button } from "./Button.js";
|
|
8
7
|
import { Text } from "./Text.js";
|
|
@@ -84,6 +83,10 @@ export const Pagination = (props) => {
|
|
|
84
83
|
|
|
85
84
|
return Button({
|
|
86
85
|
text: String(page),
|
|
86
|
+
// The plate is the only thing that says "this is the page you are on", and a
|
|
87
|
+
// colour is not something a reader hears. `aria-current` is the same fact in
|
|
88
|
+
// the accessibility tree.
|
|
89
|
+
...(isActive ? { "aria-current": "page" } : null),
|
|
87
90
|
variant: buttonVariant,
|
|
88
91
|
bgColor: isActive ? color : "transparent",
|
|
89
92
|
color: buttonColor,
|
|
@@ -95,7 +98,18 @@ export const Pagination = (props) => {
|
|
|
95
98
|
};
|
|
96
99
|
|
|
97
100
|
const createIconButton = (iconName, onClick, isDisabled = false) => {
|
|
101
|
+
// A glyph button has no text to read. The `Icon` inside is decoration — the
|
|
102
|
+
// ligature would be spoken as `chevron_left` — so the name belongs to the
|
|
103
|
+
// button, and these five glyphs are the whole vocabulary this strip uses.
|
|
104
|
+
const glyphNames = {
|
|
105
|
+
first_page: "First page",
|
|
106
|
+
last_page: "Last page",
|
|
107
|
+
chevron_left: "Previous page",
|
|
108
|
+
chevron_right: "Next page",
|
|
109
|
+
};
|
|
110
|
+
|
|
98
111
|
return Button({
|
|
112
|
+
"aria-label": glyphNames[iconName] || "Page",
|
|
99
113
|
child: Icon({
|
|
100
114
|
name: iconName,
|
|
101
115
|
size: sz.iconSize,
|
|
@@ -113,6 +127,10 @@ export const Pagination = (props) => {
|
|
|
113
127
|
// Main container using Row (already a widget)
|
|
114
128
|
const container = Row({
|
|
115
129
|
widgetName: "Pagination",
|
|
130
|
+
// A row of bare numbers is not a landmark. Named, it is something a reader can
|
|
131
|
+
// jump to, jump past, and understand the buttons inside.
|
|
132
|
+
role: "navigation",
|
|
133
|
+
"aria-label": "Pagination",
|
|
116
134
|
alignItems: "center",
|
|
117
135
|
gap: sz.gap,
|
|
118
136
|
justifyContent: "center",
|
|
@@ -143,7 +161,14 @@ export const Pagination = (props) => {
|
|
|
143
161
|
container.appendChild(createPageButton(1));
|
|
144
162
|
if (pageNumbers[0] > 2) {
|
|
145
163
|
container.appendChild(
|
|
146
|
-
Text({
|
|
164
|
+
Text({
|
|
165
|
+
text: "...",
|
|
166
|
+
// The skipped pages are a visual ellipsis: spoken as "dot dot dot" it is
|
|
167
|
+
// noise, and the jump is already implied by the numbers around it.
|
|
168
|
+
"aria-hidden": "true",
|
|
169
|
+
size: sz.fontSize,
|
|
170
|
+
color: colors.textSecondary,
|
|
171
|
+
}),
|
|
147
172
|
);
|
|
148
173
|
}
|
|
149
174
|
}
|
|
@@ -155,7 +180,14 @@ export const Pagination = (props) => {
|
|
|
155
180
|
if (pageNumbers[pageNumbers.length - 1] < totalPages) {
|
|
156
181
|
if (pageNumbers[pageNumbers.length - 1] < totalPages - 1) {
|
|
157
182
|
container.appendChild(
|
|
158
|
-
Text({
|
|
183
|
+
Text({
|
|
184
|
+
text: "...",
|
|
185
|
+
// The skipped pages are a visual ellipsis: spoken as "dot dot dot" it is
|
|
186
|
+
// noise, and the jump is already implied by the numbers around it.
|
|
187
|
+
"aria-hidden": "true",
|
|
188
|
+
size: sz.fontSize,
|
|
189
|
+
color: colors.textSecondary,
|
|
190
|
+
}),
|
|
159
191
|
);
|
|
160
192
|
}
|
|
161
193
|
container.appendChild(createPageButton(totalPages));
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
6
|
import { colors } from "../utils/themes.js";
|
|
7
7
|
import { injectKeyframes } from "../utils/styleInjector.js";
|
|
8
|
+
import { markMotionLoop } from "../animations/motion.js";
|
|
8
9
|
|
|
9
10
|
import { toREM } from "../utils/units.js";
|
|
10
11
|
|
|
@@ -54,6 +55,7 @@ export const ProgressBar = (props) => {
|
|
|
54
55
|
// Animated stripes
|
|
55
56
|
if (animatedStripes || indeterminate) {
|
|
56
57
|
fill.style.animation = `progress-stripes ${indeterminate ? "1.5s" : "0.5s"} linear infinite`;
|
|
58
|
+
markMotionLoop(fill);
|
|
57
59
|
injectKeyframes(
|
|
58
60
|
"progress-stripes",
|
|
59
61
|
"0% { background-position: 0 0; } 100% { background-position: 28px 0; }",
|
|
@@ -65,6 +67,7 @@ export const ProgressBar = (props) => {
|
|
|
65
67
|
fill.style.width = "50%";
|
|
66
68
|
fill.style.transform = "translateX(-100%)";
|
|
67
69
|
fill.style.animation = "indeterminate-progress 1.5s ease-in-out infinite";
|
|
70
|
+
markMotionLoop(fill);
|
|
68
71
|
injectKeyframes(
|
|
69
72
|
"indeterminate-progress",
|
|
70
73
|
"0% { transform: translateX(-100%); } 50% { transform: translateX(0%); } 100% { transform: translateX(100%); }",
|
|
@@ -146,6 +149,18 @@ export const ProgressBar = (props) => {
|
|
|
146
149
|
// Main container
|
|
147
150
|
const container = WidgetFactory({
|
|
148
151
|
widgetName: "ProgressBar",
|
|
152
|
+
// A bar is a picture of a number, and a picture says nothing. The role is what
|
|
153
|
+
// lets a reader hear the number instead: `aria-valuenow` is the same value the
|
|
154
|
+
// fill width is drawn from.
|
|
155
|
+
role: "progressbar",
|
|
156
|
+
"aria-valuemin": "0",
|
|
157
|
+
"aria-valuemax": String(max),
|
|
158
|
+
// An indeterminate bar has no progress to report — giving it a number would be
|
|
159
|
+
// a confident lie, so it is left to `aria-busy` and the label.
|
|
160
|
+
...(indeterminate
|
|
161
|
+
? { "aria-busy": "true" }
|
|
162
|
+
: { "aria-valuenow": String(progressValue) }),
|
|
163
|
+
...(label ? { "aria-label": label } : null),
|
|
149
164
|
display: "flex",
|
|
150
165
|
flexDirection: isColumn ? "column" : "row",
|
|
151
166
|
alignItems: "center",
|
|
@@ -161,6 +176,9 @@ export const ProgressBar = (props) => {
|
|
|
161
176
|
progressValue = Math.min(Math.max(newValue, 0), max);
|
|
162
177
|
percentage = (progressValue / max) * 100;
|
|
163
178
|
fill.style.width = `${percentage}%`;
|
|
179
|
+
// The announced number has to be the drawn number: a reader kept on the build
|
|
180
|
+
// value would hear "0%" while the bar filled.
|
|
181
|
+
container.setAttribute("aria-valuenow", String(progressValue));
|
|
164
182
|
|
|
165
183
|
if (valueElRef) {
|
|
166
184
|
valueElRef.textContent = `${Math.round(percentage)}%`;
|
package/src/widgets/Radio.js
CHANGED
|
@@ -35,6 +35,7 @@ export const Radio = (props) => {
|
|
|
35
35
|
const offColor = disabled ? colors.border : colors.gray500;
|
|
36
36
|
|
|
37
37
|
const updateUI = (newSelected) => {
|
|
38
|
+
if (radio) radio.setAttribute("aria-checked", String(Boolean(newSelected)));
|
|
38
39
|
if (radioRef) {
|
|
39
40
|
radioRef.style.backgroundColor = newSelected ? activeColor : "transparent";
|
|
40
41
|
radioRef.style.border = `2px solid ${newSelected ? activeColor : offColor}`;
|
|
@@ -89,6 +90,11 @@ export const Radio = (props) => {
|
|
|
89
90
|
|
|
90
91
|
const rootProps = {
|
|
91
92
|
widgetName: "Radio",
|
|
93
|
+
// A ring and a dot are drawing instructions, not a control. The role names it
|
|
94
|
+
// and `aria-checked` is the state; both are set before the factory runs so the
|
|
95
|
+
// engine does not settle for `button`.
|
|
96
|
+
role: "radio",
|
|
97
|
+
"aria-checked": String(Boolean(internalSelected)),
|
|
92
98
|
display: "inline-flex",
|
|
93
99
|
alignItems: "center",
|
|
94
100
|
justifyContent: isLabelled ? "flex-start" : "center",
|
|
@@ -121,6 +127,7 @@ export const Radio = (props) => {
|
|
|
121
127
|
// Inline usage (a radio dropped mid-paragraph) would otherwise baseline-align
|
|
122
128
|
// the box's bottom edge and lift the ring above the text.
|
|
123
129
|
radio.style.verticalAlign = "middle";
|
|
130
|
+
if (disabled) radio.setAttribute("aria-disabled", "true");
|
|
124
131
|
|
|
125
132
|
// Handle click
|
|
126
133
|
if (!disabled) {
|