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
@@ -10,6 +10,8 @@ import { Text } from "./Text.js";
10
10
  import { Icon } from "./Icon.js";
11
11
  import { Container } from "./Container.js";
12
12
  import { toREM } from "../utils/units.js";
13
+ import { PILL } from "../tools/index.js";
14
+ import { trapFocus } from "../utils/focusScope.js";
13
15
 
14
16
  export const BottomSheet = (props) => {
15
17
  const {
@@ -55,8 +57,6 @@ export const BottomSheet = (props) => {
55
57
  // Callbacks
56
58
  onOpen,
57
59
  onClose,
58
-
59
- ...rest
60
60
  } = props;
61
61
 
62
62
  let isOpen = false;
@@ -64,6 +64,11 @@ export const BottomSheet = (props) => {
64
64
  // Pending "hide after the close animation" timer, so it can be cancelled.
65
65
  let closeTimer = null;
66
66
  let sheetContainer = null;
67
+ // What `trapFocus` hands back while the sheet is open (see Modal.js for the
68
+ // whole argument): Tab stays inside the panel and focus goes home on close.
69
+ // The release is idempotent, so close and unmount can both call it.
70
+ let releaseFocusScope = () => {};
71
+
67
72
  let startY = 0;
68
73
  let currentY = 0;
69
74
 
@@ -76,6 +81,15 @@ export const BottomSheet = (props) => {
76
81
  height: "100%",
77
82
  backgroundColor: overlayColor,
78
83
  zIndex: overlayZIndex,
84
+ // The scrim closes the sheet for a pointer and is nothing for a keyboard —
85
+ // left to the engine, its `click` would become a full-viewport tab stop.
86
+ role: "presentation",
87
+ tabIndex: -1,
88
+ // ...but it is also the panel's parent, and the engine's press feedback writes
89
+ // `scale: 0.98` on whatever carries the handler: pressing the backdrop shrank the
90
+ // whole layer, so the sheet dipped and grew a shadow-less frame for the length of
91
+ // the press. A scrim is not a control — it presses nothing, it only dismisses.
92
+ disableTransform: true,
79
93
  opacity: 0,
80
94
  visibility: "hidden",
81
95
  transition: `opacity ${animationDuration}ms ease, visibility ${animationDuration}ms ease`,
@@ -97,7 +111,7 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
97
111
  width: dragHandleWidth,
98
112
  height: dragHandleHeight,
99
113
  backgroundColor: dragHandleColor,
100
- borderRadius: dragHandleHeight / 2,
114
+ borderRadius: PILL,
101
115
  }),
102
116
  })
103
117
  : null;
@@ -116,13 +130,23 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
116
130
  );
117
131
  }
118
132
  if (showCloseButton) {
133
+ // Both the name and the handler are props, not post-hoc assignments: `Icon`
134
+ // decides whether it is decoration from the props it is handed, and the engine
135
+ // only makes a control keyboard-operable when it can see the press while the
136
+ // widget is built. Set after the fact, this close button was a mouse-only "×"
137
+ // that a reader could not even find.
119
138
  const closeBtn = Icon({
120
139
  name: "close",
121
- size: 22,
140
+ size: 24,
141
+ // 24 + 2 x 8 = 40 framework units: the same target floor every other
142
+ // pressable control answers to, and it scales with the reader's font.
143
+ paddingVertical: 8,
144
+ paddingHorizontal: 8,
122
145
  color: colors.textSecondary,
123
146
  cursor: "pointer",
147
+ onclick: () => close(),
148
+ "aria-label": "Close sheet",
124
149
  });
125
- closeBtn.onclick = () => close();
126
150
  headerChildren.push(closeBtn);
127
151
  }
128
152
 
@@ -189,6 +213,12 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
189
213
  ),
190
214
  });
191
215
 
216
+ sheetContainer.setAttribute("role", "dialog");
217
+ sheetContainer.setAttribute("aria-modal", "true");
218
+ if (typeof title === "string" && title) {
219
+ sheetContainer.setAttribute("aria-label", title);
220
+ }
221
+
192
222
  sheetContainer.appendChild(sheetBody);
193
223
  overlay.appendChild(sheetContainer);
194
224
  document.body.appendChild(overlay);
@@ -203,6 +233,7 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
203
233
  closeTimer = null;
204
234
  }
205
235
  document.removeEventListener("keydown", handleKeyDown);
236
+ releaseFocusScope();
206
237
  });
207
238
 
208
239
  // Drag handlers
@@ -254,6 +285,7 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
254
285
  sheetContainer.style.transform = "translateY(0)";
255
286
  if (onOpen) onOpen();
256
287
  document.addEventListener("keydown", handleKeyDown);
288
+ releaseFocusScope = trapFocus(sheetContainer);
257
289
 
258
290
  if (closeOnDragDown && dragHandle) {
259
291
  dragHandle.addEventListener("mousedown", onTouchStart);
@@ -277,6 +309,7 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
277
309
  closeTimer = null;
278
310
  overlay.style.visibility = "hidden";
279
311
  }, animationDuration);
312
+ releaseFocusScope();
280
313
  if (onClose) onClose();
281
314
  document.removeEventListener("keydown", handleKeyDown);
282
315
 
@@ -297,6 +330,7 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
297
330
 
298
331
  const destroy = () => {
299
332
  if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
333
+ releaseFocusScope();
300
334
  document.removeEventListener("keydown", handleKeyDown);
301
335
  };
302
336
 
@@ -10,6 +10,36 @@
10
10
  import { WidgetFactory } from "../widget-factory/index.js";
11
11
  import { colors } from "../utils/themes.js";
12
12
 
13
+ /**
14
+ * The step a Y axis labels in: a round number whose mantissa is 1, 2 or 5.
15
+ *
16
+ * Reading the axis off the padded range divided into five — what this did before —
17
+ * puts the labels wherever the data happens to end: a `[6, 21]` series wrote
18
+ * `0, 4, 8, 13, 17, 21`, whose steps are 4, 4, 5, 4, 4. Equal gridline spacing with
19
+ * unequal labelled steps reads as a rounding bug even when nothing is rounding, and
20
+ * it makes the top label disagree with the line it sits on.
21
+ *
22
+ * @param {number} rough - The step the desired number of lines implies.
23
+ * @returns {number} The nearest round step at or above it.
24
+ */
25
+ const niceStep = (rough) => {
26
+ if (!(rough > 0)) return 1;
27
+ const exponent = Math.pow(10, Math.floor(Math.log10(rough)));
28
+ const mantissa = rough / exponent;
29
+ const nice = mantissa <= 1 ? 1 : mantissa <= 2 ? 2 : mantissa <= 5 ? 5 : 10;
30
+ return nice * exponent;
31
+ };
32
+
33
+ /**
34
+ * How many decimals a step needs to be written honestly: a 0.5 step labelled as
35
+ * `0` and `1` describes neither of its lines.
36
+ *
37
+ * @param {number} step
38
+ * @returns {number}
39
+ */
40
+ const stepDecimals = (step) =>
41
+ Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));
42
+
13
43
  /**
14
44
  * Creates a Chart widget rendered on an HTML5 `<canvas>` element.
15
45
  *
@@ -364,7 +394,6 @@ export const Chart = (props) => {
364
394
  * Draws a candlestick chart onto the canvas context.
365
395
  *
366
396
  * @param {CanvasRenderingContext2D} ctx
367
- * @param {number} canvasWidth
368
397
  * @param {number} chartHeight
369
398
  * @param {number} maxValue
370
399
  * @param {number} minValue
@@ -373,7 +402,6 @@ export const Chart = (props) => {
373
402
  */
374
403
  const drawCandle = (
375
404
  ctx,
376
- canvasWidth,
377
405
  chartHeight,
378
406
  maxValue,
379
407
  minValue,
@@ -434,23 +462,28 @@ export const Chart = (props) => {
434
462
  ctx.strokeStyle = axisColor;
435
463
  ctx.lineWidth = 0.5;
436
464
 
437
- const steps = 5;
465
+ // Lines sit on round values inside the plotted range, not on fifths of it.
438
466
  const range = maxValue - minValue;
467
+ const step = niceStep(range / 5);
468
+ const decimals = stepDecimals(step);
469
+ const first = Math.ceil(minValue / step) * step;
470
+ const lines = range > 0 ? Math.floor((maxValue - first) / step) : 0;
439
471
 
440
- for (let i = 0; i <= steps; i++) {
441
- const value = minValue + (i / steps) * range;
472
+ for (let i = 0; i <= lines; i++) {
473
+ const value = first + i * step;
442
474
  const y = getYPosition(value, minValue, maxValue, chartHeight);
443
475
  ctx.beginPath();
444
476
  ctx.moveTo(pad.left, y);
445
477
  ctx.lineTo(canvasWidth - pad.right, y);
446
478
  ctx.stroke();
447
479
 
448
- // Round very small floating-point values to 0 to avoid displaying "-0.00"
449
- const displayValue = Math.abs(value) < 0.01 ? 0 : Math.round(value);
480
+ // A step of 0.5 needs one decimal; a step of 5 needs none. Writing `-0` for
481
+ // a value that is zero by rounding reads as a negative axis.
482
+ const label = Math.abs(value) < step / 100 ? 0 : value.toFixed(decimals);
450
483
  ctx.fillStyle = yAxisColor;
451
484
  ctx.font = "9px sans-serif";
452
485
  ctx.textAlign = "right";
453
- ctx.fillText(displayValue, pad.left - 5, y + 3);
486
+ ctx.fillText(label, pad.left - 5, y + 3);
454
487
  }
455
488
  };
456
489
 
@@ -534,7 +567,6 @@ export const Chart = (props) => {
534
567
  } else if (type === "candle") {
535
568
  drawCandle(
536
569
  context,
537
- canvasWidth,
538
570
  chartHeight,
539
571
  maxValue,
540
572
  minValue,
@@ -5,6 +5,7 @@
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { Text } from "./Text.js";
7
7
  import { colors } from "../utils/themes.js";
8
+ import { readableInk } from "../utils/readableInk.js";
8
9
  import { TAP_MINIMUM, LABEL_TEXT } from "../tools/index.js";
9
10
 
10
11
  export const Checkbox = (props) => {
@@ -16,6 +17,8 @@ export const Checkbox = (props) => {
16
17
  size = 20,
17
18
  activeColor = colors.primary,
18
19
  label = null,
20
+ borderRadius = null,
21
+ variant = "outlined",
19
22
  ...rest
20
23
  } = props;
21
24
 
@@ -33,22 +36,55 @@ export const Checkbox = (props) => {
33
36
  // clears the 3:1 non-text minimum on each surface.
34
37
  const offColor = disabled ? colors.border : colors.gray500;
35
38
 
39
+ // `variant: "filled"` is the same word the rest of the API already uses for a solid
40
+ // plate (`Button`, `Chip`, `Input`, `Dropdown`), and it changes the *checked* state
41
+ // only: a filled box that means "off" is a button, not a checkbox. The plate is the
42
+ // accent the control already carries, so one brand color per screen still holds, and
43
+ // the tick takes whichever of black or white reads on it — the same decision `Button`
44
+ // makes, and it falls back to the theme's own on-plate ink for a fill this code
45
+ // cannot measure (a gradient, a CSS variable).
46
+ const isFilled = variant === "filled";
47
+ const plateInk = readableInk(activeColor, colors.onPrimary);
48
+
49
+ // The corner is a decision, and this control shipped none: `borderRadius: 0` put a
50
+ // raw square plate inside the rounded cards, fields and buttons of the same screen,
51
+ // which is rule 02's "two products on one page". It is snapped to two steps of the
52
+ // radius scale rather than scaled continuously — a radius between the steps is the
53
+ // drift rule 03 calls `OFF_SCALE_RADIUS`, and two checkboxes on one form that differ
54
+ // by 1px of corner read as a mistake nobody can name. `4` on the default 20-unit box,
55
+ // `8` once the box is big enough to carry it.
56
+ const boxRadius = borderRadius ?? (size >= 28 ? 8 : 4);
57
+
58
+ // The three things a state decides, in one place, so the box built below and every
59
+ // repaint on press cannot disagree. A checked box always wears the accent: the rim
60
+ // used to fall to `colors.border` while disabled, which measured 1.2:1 on the dark
61
+ // card — a locked-on checkbox that looked unchecked.
62
+ const rimFor = (on) => (on ? activeColor : offColor);
63
+ const fillFor = (on) => (on && isFilled ? activeColor : "transparent");
64
+ const tickFor = (on) =>
65
+ on && isFilled ? plateInk : disabled ? colors.textDisabled : activeColor;
66
+
36
67
  const updateUI = (newChecked) => {
68
+ // The state an assistive technology reads is this attribute, not the tick it
69
+ // cannot see. `updateUI` is the one place the visual state is painted, so the
70
+ // attribute and the box cannot disagree.
71
+ if (checkbox) checkbox.setAttribute("aria-checked", String(Boolean(newChecked)));
37
72
  if (checkboxRef) {
38
- checkboxRef.style.border = `2px solid ${newChecked ? activeColor : offColor}`;
73
+ checkboxRef.style.border = `2px solid ${rimFor(newChecked)}`;
74
+ checkboxRef.style.backgroundColor = fillFor(newChecked);
39
75
  }
40
76
  if (checkMarkRef) {
41
77
  checkMarkRef.style.display = newChecked ? "inline-block" : "none";
78
+ checkMarkRef.style.color = tickFor(newChecked);
42
79
  }
43
80
  };
44
81
 
45
- // The tick is drawn inside the frame, in the active colour: a filled plate
46
- // would hide the border the box is supposed to show, and the theme ink it used
47
- // to carry (white in light, near-black in dark) only read on that plate.
82
+ // The tick lives inside the frame, in the accent — or in the plate's readable ink
83
+ // once `variant: "filled"` turns the box into a solid plate.
48
84
  const checkMark = WidgetFactory({
49
85
  tag: "span",
50
86
  textContent: "✓",
51
- color: disabled ? colors.textDisabled : activeColor,
87
+ color: tickFor(internalChecked),
52
88
  fontSize: Math.max(12, Math.round(size * 0.65)),
53
89
  fontWeight: "bold",
54
90
  lineHeight: 1,
@@ -56,14 +92,14 @@ export const Checkbox = (props) => {
56
92
  });
57
93
  checkMarkRef = checkMark;
58
94
 
59
- // The visual square. Everything drawn lives here, so the box below can be the
95
+ // The visual box. Everything drawn lives here, so the root below can be the
60
96
  // finger's target without becoming a 40-unit checkbox.
61
97
  const box = WidgetFactory({
62
98
  width: size,
63
99
  height: size,
64
- border: `2px solid ${disabled ? colors.border : internalChecked ? activeColor : offColor}`,
65
- borderRadius: 0,
66
- backgroundColor: "transparent",
100
+ border: `2px solid ${rimFor(internalChecked)}`,
101
+ borderRadius: boxRadius,
102
+ backgroundColor: fillFor(internalChecked),
67
103
  display: "flex",
68
104
  alignItems: "center",
69
105
  justifyContent: "center",
@@ -89,6 +125,12 @@ export const Checkbox = (props) => {
89
125
 
90
126
  const rootProps = {
91
127
  widgetName: "Checkbox",
128
+ // The drawn box is a `span` and the root is a row, so nothing here is a
129
+ // checkbox to an assistive technology unless the widget says so. `role`
130
+ // reaches the factory before `applyEffects`, which is what keeps the engine
131
+ // from labelling the control `button`.
132
+ role: "checkbox",
133
+ "aria-checked": String(Boolean(internalChecked)),
92
134
  display: "inline-flex",
93
135
  alignItems: "center",
94
136
  justifyContent: isLabelled ? "flex-start" : "center",
@@ -116,11 +158,19 @@ export const Checkbox = (props) => {
116
158
  // default press feedback, because there the root *is* the control.
117
159
  if (isLabelled) rootProps.disableTransform = true;
118
160
 
161
+ // A caller's radius is the control's, so it rounds the drawn box above. It is
162
+ // re-applied to the root only when it was actually given: that row is where a
163
+ // `bgColor` lands on a labelled checkbox, and it must keep the corner such an app
164
+ // already saw. Left alone otherwise, because the root is invisible and its own
165
+ // corner is nobody's business.
166
+ if (borderRadius !== null) rootProps.borderRadius = borderRadius;
167
+
119
168
  const checkbox = WidgetFactory({ ...rootProps, ...rest });
120
169
 
121
170
  // Inline usage (a checkbox dropped mid-paragraph) would otherwise
122
171
  // baseline-align the box's bottom edge and lift the square above the text.
123
172
  checkbox.style.verticalAlign = "middle";
173
+ if (disabled) checkbox.setAttribute("aria-disabled", "true");
124
174
 
125
175
  // Handle click
126
176
  if (!disabled) {
@@ -57,7 +57,12 @@ export const Chip = (props) => {
57
57
  */
58
58
  const chipPadding =
59
59
  padding === undefined
60
- ? { paddingVertical: 4, paddingHorizontal: 12 }
60
+ ? {
61
+ // The delete glyph carries the 40-unit target itself, so the row does
62
+ // not need a second gutter above and below it.
63
+ paddingVertical: onDelete ? 0 : 4,
64
+ paddingHorizontal: 12,
65
+ }
61
66
  : typeof padding === "number"
62
67
  ? { paddingVertical: padding, paddingHorizontal: padding * 2 }
63
68
  : { padding };
@@ -104,7 +109,14 @@ export const Chip = (props) => {
104
109
  if (onDelete) {
105
110
  const closeIcon = Icon({
106
111
  name: "close",
112
+ // The glyph is decoration and the ligature is not a name: "close" is what a
113
+ // reader would say, on a control whose whole job is to take this label away.
114
+ "aria-label": label ? `Remove ${label}` : "Remove",
107
115
  size: Math.max(12, size - 2),
116
+ // The glyph stays small; the box is the 40-unit target, in framework
117
+ // units so it grows with the reader's font like every other length.
118
+ width: TAP_MINIMUM,
119
+ height: TAP_MINIMUM,
108
120
  color: txtColor,
109
121
  cursor: "pointer",
110
122
  onclick: (e) => {
@@ -78,7 +78,6 @@ export const CircularBar = (props) => {
78
78
  let resizeObserver = null;
79
79
  let container = null;
80
80
  let currentWidth = 0;
81
- let isHovering = false;
82
81
 
83
82
  const isFixedSize = size !== null && size !== undefined;
84
83
  const fixedSize = isFixedSize ? size : 200;
@@ -297,7 +296,7 @@ export const CircularBar = (props) => {
297
296
  const setupEvents = () => {
298
297
  if (!canvas) return;
299
298
  if (onClick)
300
- canvas.addEventListener("click", (e) =>
299
+ canvas.addEventListener("click", () =>
301
300
  onClick({
302
301
  value: currentValue,
303
302
  max,
@@ -306,11 +305,9 @@ export const CircularBar = (props) => {
306
305
  );
307
306
  if (onHover) {
308
307
  canvas.addEventListener("mouseenter", () => {
309
- isHovering = true;
310
308
  onHover(true, { value: currentValue, max });
311
309
  });
312
310
  canvas.addEventListener("mouseleave", () => {
313
- isHovering = false;
314
311
  onHover(false);
315
312
  });
316
313
  }
@@ -321,6 +318,14 @@ export const CircularBar = (props) => {
321
318
  container = WidgetFactory({
322
319
  tag: "div",
323
320
  widgetName: "CircularBar",
321
+ // The arc is painted into a canvas, which is one anonymous rectangle to a
322
+ // reader. The role and the three numbers are the whole of what the ring
323
+ // means, and they are cheap to keep true.
324
+ role: "progressbar",
325
+ "aria-valuemin": "0",
326
+ "aria-valuemax": String(max),
327
+ "aria-valuenow": String(Math.min(value, max)),
328
+ ...(label ? { "aria-label": label } : null),
324
329
  display: isFixedSize ? "inline-block" : "block",
325
330
  width: isFixedSize ? `${fixedSize}px` : "100%",
326
331
  height: isFixedSize ? `${fixedSize}px` : "auto",
@@ -352,8 +357,15 @@ export const CircularBar = (props) => {
352
357
  });
353
358
 
354
359
  container.updateValue = (newValue, newMax = null) => {
355
- if (newMax !== null) max = newMax;
360
+ if (newMax !== null) {
361
+ max = newMax;
362
+ container.setAttribute("aria-valuemax", String(max));
363
+ }
356
364
  value = Math.min(newValue, max);
365
+ // The announced value is the target of the animation, not the frame it is
366
+ // drawing: a reader who hears the sweep would hear a different number every
367
+ // time they asked.
368
+ container.setAttribute("aria-valuenow", String(value));
357
369
  startAnimation();
358
370
  };
359
371
 
@@ -303,7 +303,11 @@ export const CircularChart = (props) => {
303
303
  const cy = getCenterY();
304
304
  ctx.beginPath();
305
305
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
306
- ctx.fillStyle = colors.gray200;
306
+ // The disc behind a donut is the hole's filler, not a shape of its own:
307
+ // `gray200` painted a pale plate inside the ring, so the widget read as a
308
+ // pie with a grey centre instead of a ring with nothing in it. The slices'
309
+ // separators already use `colors.card` for the same reason.
310
+ ctx.fillStyle = colors.card;
307
311
  ctx.fill();
308
312
  }
309
313
  };
@@ -390,7 +394,7 @@ export const CircularChart = (props) => {
390
394
  if (animationId) cancelAnimationFrame(animationId);
391
395
  if (!ctx || !canvas) return;
392
396
  if (data && data.length > 0) {
393
- currentData = data.map((d) => 0);
397
+ currentData = data.map(() => 0);
394
398
  } else {
395
399
  currentData = [];
396
400
  }
@@ -471,6 +475,20 @@ export const CircularChart = (props) => {
471
475
  }
472
476
  };
473
477
 
478
+ /**
479
+ * The slices as a sentence. To a reader a canvas is one empty rectangle, so
480
+ * the numbers painted inside it have to be said beside it.
481
+ */
482
+ const chartSummary = () =>
483
+ data.length === 0
484
+ ? "Chart with no data"
485
+ : `Chart: ${data
486
+ .map(
487
+ (slice, i) =>
488
+ `${slice?.label || `Slice ${i + 1}`} ${slice?.value ?? 0}`,
489
+ )
490
+ .join(", ")}`;
491
+
474
492
  const initWidget = () => {
475
493
  container = WidgetFactory({
476
494
  tag: "div",
@@ -497,6 +515,8 @@ export const CircularChart = (props) => {
497
515
  canvas.style.display = "block";
498
516
  canvas.style.width = "100%";
499
517
  canvas.style.height = "100%";
518
+ canvas.setAttribute("role", "img");
519
+ canvas.setAttribute("aria-label", chartSummary());
500
520
  canvas.addEventListener("click", handleCanvasClick);
501
521
  canvas.addEventListener("mousemove", handleCanvasMousemove);
502
522
  canvas.addEventListener("mouseleave", () => {
@@ -527,6 +547,8 @@ export const CircularChart = (props) => {
527
547
  const updateData = (newData, animateUpdate = true) => {
528
548
  if (!Array.isArray(newData)) return;
529
549
  data = newData;
550
+ // New numbers on the drawing, and new numbers in the sentence that says them.
551
+ if (canvas) canvas.setAttribute("aria-label", chartSummary());
530
552
  animate = animateUpdate;
531
553
  startAnimation();
532
554
  };
@@ -7,6 +7,7 @@ import { colors } from "../utils/themes.js";
7
7
  import { composeUpdate } from "../utils/composeUpdate.js";
8
8
  import { generateHighlightedHtml, syntaxPaletteFor } from "../utils/syntaxHighlight.js";
9
9
  import { clipboard } from "../tools/clipboard.js";
10
+ import { TAP_MINIMUM } from "../tools/index.js";
10
11
 
11
12
  import { toREM } from "../utils/units.js";
12
13
 
@@ -105,10 +106,11 @@ code = "",
105
106
  backgroundColor: colors.card,
106
107
  color: colors.text,
107
108
  border: `1px solid ${colors.border}`,
108
- borderRadius: 6,
109
- padding: "2px 8px",
109
+ borderRadius: 8,
110
+ padding: "8px 12px",
111
+ minHeight: TAP_MINIMUM,
110
112
  fontFamily: "monospace",
111
- fontSize: 11,
113
+ fontSize: 12,
112
114
  lineHeight: "1.6",
113
115
  flexShrink: 0,
114
116
  cursor: "pointer",