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.
Files changed (135) hide show
  1. package/README.md +16 -12
  2. package/bin/commands/checkUi.js +276 -45
  3. package/bin/commands/create.js +8 -1
  4. package/bin/commands/createBundle.js +18 -6
  5. package/bin/commands/createScreen.js +1 -1
  6. package/bin/commands/runBundle.js +1 -1
  7. package/bin/utils/bundleConfig.js +47 -3
  8. package/bin/utils/colors.js +0 -8
  9. package/bin/utils/helpers.js +0 -5
  10. package/bin/utils/images/jpeg.js +2 -3
  11. package/bin/utils/templates.js +101 -85
  12. package/docs/README.md +24 -1
  13. package/docs/cli/README.md +51 -10
  14. package/docs/guides/README.md +5 -2
  15. package/docs/guides/accessibility.md +282 -0
  16. package/docs/guides/clean-ui.md +53 -8
  17. package/docs/guides/first-app.md +6 -7
  18. package/docs/guides/minification-and-protection.md +1 -1
  19. package/docs/guides/mobile-and-platforms.md +51 -0
  20. package/docs/guides/online-updates.md +18 -6
  21. package/docs/guides/utilities.md +116 -4
  22. package/docs/guides/widget-structure.md +30 -0
  23. package/docs/tools/README.md +1 -1
  24. package/docs/tools/animation.md +134 -11
  25. package/docs/tools/device.md +75 -3
  26. package/docs/tools/text-and-time.md +116 -6
  27. package/docs/widget/Accordion.md +2 -0
  28. package/docs/widget/AnimatedBox.md +46 -20
  29. package/docs/widget/AnimatedText.md +13 -7
  30. package/docs/widget/Badge.md +1 -0
  31. package/docs/widget/COMMON_PROPS.md +26 -3
  32. package/docs/widget/Chart.md +1 -0
  33. package/docs/widget/Checkbox.md +7 -1
  34. package/docs/widget/CircularChart.md +2 -0
  35. package/docs/widget/Drawer.md +1 -0
  36. package/docs/widget/GridView.md +4 -1
  37. package/docs/widget/Input.md +2 -0
  38. package/docs/widget/InstallButton.md +1 -0
  39. package/docs/widget/Markdown.md +4 -0
  40. package/docs/widget/Rating.md +2 -0
  41. package/docs/widget/Slider.md +2 -0
  42. package/docs/widget/Stack.md +6 -5
  43. package/docs/widget/Stepper.md +2 -0
  44. package/docs/widget/Tooltip.md +3 -1
  45. package/package.json +3 -2
  46. package/skills/fletbox-design/SKILL.md +390 -14
  47. package/skills/fletbox-motion/SKILL.md +145 -47
  48. package/skills/fletbox-rules/SKILL.md +140 -1
  49. package/src/animations/AnimatedBox.js +321 -226
  50. package/src/animations/MatrixRain.js +7 -1
  51. package/src/animations/ParallaxBox.js +13 -10
  52. package/src/animations/animate.js +269 -106
  53. package/src/animations/color.js +91 -0
  54. package/src/animations/easings.js +275 -0
  55. package/src/animations/index.js +21 -0
  56. package/src/animations/motion.js +204 -0
  57. package/src/animations/transitions.js +327 -0
  58. package/src/core/runApp.js +0 -6
  59. package/src/helpers/boxEdges.js +69 -0
  60. package/src/helpers/html.js +39 -0
  61. package/src/index.d.ts +377 -25
  62. package/src/index.js +77 -0
  63. package/src/navigations/AdaptiveScaffold.js +6 -2
  64. package/src/navigations/AppBar.js +16 -7
  65. package/src/navigations/BottomNavigation.js +24 -1
  66. package/src/navigations/CollapsibleSideBar.js +0 -1
  67. package/src/navigations/Drawer.js +32 -1
  68. package/src/navigations/DrawerItem.js +1 -1
  69. package/src/navigations/Router.js +0 -1
  70. package/src/navigations/Scaffold.js +9 -3
  71. package/src/navigations/Tabs.js +104 -31
  72. package/src/services/RamStore.js +1 -2
  73. package/src/tools/breakpoints.js +113 -0
  74. package/src/tools/createList.js +0 -2
  75. package/src/tools/fonts.js +99 -0
  76. package/src/tools/index.js +74 -1
  77. package/src/tools/margin.js +5 -63
  78. package/src/tools/padding.js +5 -63
  79. package/src/tools/print.js +0 -8
  80. package/src/tools/scale.js +9 -2
  81. package/src/tools/time.js +56 -15
  82. package/src/tools/useState.js +1 -54
  83. package/src/utils/fieldFont.js +77 -0
  84. package/src/utils/focusRing.js +73 -0
  85. package/src/utils/focusScope.js +113 -0
  86. package/src/utils/markdownParser.js +7 -9
  87. package/src/utils/readableInk.js +7 -1
  88. package/src/utils/stackLayout.js +55 -0
  89. package/src/utils/styleInjector.js +25 -0
  90. package/src/utils/syntaxHighlight.js +1 -17
  91. package/src/utils/themes.js +15 -0
  92. package/src/utils/visualEffects.js +49 -47
  93. package/src/widget-factory/createWidget.js +16 -3
  94. package/src/widget-factory/effects.js +76 -3
  95. package/src/widget-factory/reactiveValue.js +0 -7
  96. package/src/widget-factory/tools.js +26 -143
  97. package/src/widget-factory/translateProps.js +5 -0
  98. package/src/widget-factory/widgetFactory.js +1 -1
  99. package/src/widgets/Accordion.js +70 -12
  100. package/src/widgets/AdMob.js +1 -1
  101. package/src/widgets/AdSense.js +1 -1
  102. package/src/widgets/AlertDialog.js +11 -9
  103. package/src/widgets/Avatar.js +10 -1
  104. package/src/widgets/Badge.js +7 -1
  105. package/src/widgets/BottomSheet.js +39 -5
  106. package/src/widgets/Chart.js +41 -9
  107. package/src/widgets/Checkbox.js +59 -9
  108. package/src/widgets/Chip.js +13 -1
  109. package/src/widgets/CircularBar.js +17 -5
  110. package/src/widgets/CircularChart.js +24 -2
  111. package/src/widgets/CodeViewer.js +5 -3
  112. package/src/widgets/Dropdown.js +223 -20
  113. package/src/widgets/GridView.js +17 -1
  114. package/src/widgets/Icon.js +21 -0
  115. package/src/widgets/Input.js +75 -44
  116. package/src/widgets/InstallButton.js +8 -2
  117. package/src/widgets/ListTile.js +6 -5
  118. package/src/widgets/ListView.js +21 -7
  119. package/src/widgets/Markdown.js +25 -44
  120. package/src/widgets/Modal.js +43 -6
  121. package/src/widgets/Pagination.js +35 -3
  122. package/src/widgets/ProgressBar.js +18 -0
  123. package/src/widgets/Radio.js +7 -0
  124. package/src/widgets/Rating.js +104 -16
  125. package/src/widgets/Skeleton.js +9 -5
  126. package/src/widgets/Slider.js +87 -7
  127. package/src/widgets/SnackBar.js +30 -1
  128. package/src/widgets/Stack.js +25 -13
  129. package/src/widgets/Stepper.js +112 -64
  130. package/src/widgets/Switch.js +10 -1
  131. package/src/widgets/Tooltip.js +93 -10
  132. package/src/widgets/TreeView.js +5 -5
  133. package/src/create-index.sh +0 -237
  134. package/src/widget-factory/pixTool.js +0 -40
  135. 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
- text = "📲 Install",
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,
@@ -92,9 +92,8 @@ export const ListTile = (props) => {
92
92
  textChildren.push(
93
93
  Text({
94
94
  text: subtitle,
95
- size: 13,
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
- color: colors.textDisabled,
109
- marginTop: 2,
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: 2,
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
@@ -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
- wrapper.style.padding = `0 ${toREM(gap / 2)}`;
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
 
@@ -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, "&amp;")
24
- .replace(/</g, "&lt;")
25
- .replace(/>/g, "&gt;")
26
- .replace(/"/g, "&quot;")
27
- .replace(/'/g, "&#39;");
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 += escapeCode(line.slice(last, m.index));
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}">${escapeCode(m[0])}</span>`;
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 += escapeCode(line.slice(last));
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 escapeCode(line);
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">${escapeCode(lang)}</span>` : "";
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 = escapeCode(block.code);
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>${escapeCode(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="${escapeCode(src)}" alt="${escapeCode(alt)}" loading="lazy">`,
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="${escapeCode(href)}" target="_blank" rel="noopener">${label}</a>`,
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
- linkColor = colors.primary,
817
- linkHoverColor = colors.primary,
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
- codeColor = colors.danger,
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',
@@ -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: "80%",
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: 22,
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
- padding: 4,
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: 32,
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({ text: "...", size: sz.fontSize, color: colors.textSecondary }),
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({ text: "...", size: sz.fontSize, color: colors.textSecondary }),
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)}%`;
@@ -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) {