flet-box 1.0.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/bin/commands/checkUi.js +327 -46
  2. package/bin/commands/create.js +48 -19
  3. package/bin/commands/createScreen.js +1 -1
  4. package/bin/commands/runBundle.js +1 -1
  5. package/bin/utils/colors.js +0 -8
  6. package/bin/utils/helpers.js +0 -5
  7. package/bin/utils/images/jpeg.js +2 -3
  8. package/bin/utils/skills.js +59 -0
  9. package/bin/utils/templates.js +196 -575
  10. package/docs/README.md +3 -1
  11. package/docs/cli/README.md +18 -1
  12. package/docs/guides/README.md +6 -1
  13. package/docs/guides/accessibility.md +282 -0
  14. package/docs/guides/clean-ui.md +84 -21
  15. package/docs/guides/minification-and-protection.md +1 -1
  16. package/docs/guides/mobile-and-platforms.md +51 -0
  17. package/docs/guides/utilities.md +116 -4
  18. package/docs/guides/widget-structure.md +30 -0
  19. package/docs/tools/README.md +1 -1
  20. package/docs/tools/animation.md +134 -11
  21. package/docs/tools/device.md +75 -3
  22. package/docs/tools/text-and-time.md +116 -6
  23. package/docs/widget/Accordion.md +2 -0
  24. package/docs/widget/AnimatedBox.md +46 -20
  25. package/docs/widget/AnimatedText.md +13 -7
  26. package/docs/widget/Badge.md +1 -0
  27. package/docs/widget/COMMON_PROPS.md +26 -3
  28. package/docs/widget/Chart.md +1 -0
  29. package/docs/widget/Checkbox.md +7 -1
  30. package/docs/widget/CircularChart.md +2 -0
  31. package/docs/widget/Drawer.md +1 -0
  32. package/docs/widget/GridView.md +4 -1
  33. package/docs/widget/Input.md +2 -0
  34. package/docs/widget/InstallButton.md +1 -0
  35. package/docs/widget/Markdown.md +4 -0
  36. package/docs/widget/Rating.md +2 -0
  37. package/docs/widget/Slider.md +2 -0
  38. package/docs/widget/Stack.md +6 -5
  39. package/docs/widget/Stepper.md +2 -0
  40. package/docs/widget/Tooltip.md +3 -1
  41. package/package.json +4 -2
  42. package/skills/fletbox-design/SKILL.md +949 -0
  43. package/skills/fletbox-memory/SKILL.md +134 -0
  44. package/skills/fletbox-motion/SKILL.md +266 -0
  45. package/skills/fletbox-rules/SKILL.md +293 -0
  46. package/src/animations/AnimatedBox.js +321 -226
  47. package/src/animations/MatrixRain.js +7 -1
  48. package/src/animations/ParallaxBox.js +13 -10
  49. package/src/animations/animate.js +269 -106
  50. package/src/animations/color.js +91 -0
  51. package/src/animations/easings.js +275 -0
  52. package/src/animations/index.js +21 -0
  53. package/src/animations/motion.js +204 -0
  54. package/src/animations/transitions.js +327 -0
  55. package/src/core/runApp.js +0 -6
  56. package/src/helpers/boxEdges.js +69 -0
  57. package/src/helpers/html.js +39 -0
  58. package/src/index.d.ts +377 -25
  59. package/src/index.js +77 -0
  60. package/src/navigations/AdaptiveScaffold.js +6 -2
  61. package/src/navigations/AppBar.js +16 -7
  62. package/src/navigations/BottomNavigation.js +24 -1
  63. package/src/navigations/CollapsibleSideBar.js +0 -1
  64. package/src/navigations/Drawer.js +32 -1
  65. package/src/navigations/DrawerItem.js +1 -1
  66. package/src/navigations/Router.js +0 -1
  67. package/src/navigations/Scaffold.js +9 -3
  68. package/src/navigations/Tabs.js +104 -31
  69. package/src/services/RamStore.js +1 -2
  70. package/src/tools/breakpoints.js +113 -0
  71. package/src/tools/createList.js +0 -2
  72. package/src/tools/fonts.js +99 -0
  73. package/src/tools/index.js +74 -1
  74. package/src/tools/margin.js +5 -63
  75. package/src/tools/padding.js +5 -63
  76. package/src/tools/print.js +0 -8
  77. package/src/tools/scale.js +9 -2
  78. package/src/tools/time.js +56 -15
  79. package/src/tools/useState.js +1 -54
  80. package/src/utils/fieldFont.js +77 -0
  81. package/src/utils/focusRing.js +73 -0
  82. package/src/utils/focusScope.js +113 -0
  83. package/src/utils/markdownParser.js +7 -9
  84. package/src/utils/readableInk.js +7 -1
  85. package/src/utils/stackLayout.js +55 -0
  86. package/src/utils/styleInjector.js +25 -0
  87. package/src/utils/syntaxHighlight.js +1 -17
  88. package/src/utils/themes.js +31 -0
  89. package/src/utils/visualEffects.js +49 -47
  90. package/src/widget-factory/createWidget.js +16 -3
  91. package/src/widget-factory/effects.js +76 -3
  92. package/src/widget-factory/reactiveValue.js +0 -7
  93. package/src/widget-factory/tools.js +26 -143
  94. package/src/widget-factory/translateProps.js +5 -0
  95. package/src/widget-factory/widgetFactory.js +1 -1
  96. package/src/widgets/Accordion.js +70 -12
  97. package/src/widgets/AdMob.js +1 -1
  98. package/src/widgets/AdSense.js +1 -1
  99. package/src/widgets/AlertDialog.js +11 -9
  100. package/src/widgets/Avatar.js +10 -1
  101. package/src/widgets/Badge.js +7 -1
  102. package/src/widgets/BottomSheet.js +39 -5
  103. package/src/widgets/Chart.js +41 -9
  104. package/src/widgets/Checkbox.js +59 -9
  105. package/src/widgets/Chip.js +13 -1
  106. package/src/widgets/CircularBar.js +17 -5
  107. package/src/widgets/CircularChart.js +24 -2
  108. package/src/widgets/CodeViewer.js +5 -3
  109. package/src/widgets/Dropdown.js +223 -20
  110. package/src/widgets/GridView.js +17 -1
  111. package/src/widgets/Icon.js +21 -0
  112. package/src/widgets/Input.js +75 -44
  113. package/src/widgets/InstallButton.js +8 -2
  114. package/src/widgets/ListTile.js +6 -5
  115. package/src/widgets/ListView.js +21 -7
  116. package/src/widgets/Markdown.js +25 -44
  117. package/src/widgets/Modal.js +43 -6
  118. package/src/widgets/Pagination.js +35 -3
  119. package/src/widgets/ProgressBar.js +18 -0
  120. package/src/widgets/Radio.js +7 -0
  121. package/src/widgets/Rating.js +104 -16
  122. package/src/widgets/Skeleton.js +9 -5
  123. package/src/widgets/Slider.js +87 -7
  124. package/src/widgets/SnackBar.js +30 -1
  125. package/src/widgets/Stack.js +25 -13
  126. package/src/widgets/Stepper.js +112 -64
  127. package/src/widgets/Switch.js +10 -1
  128. package/src/widgets/Tooltip.js +93 -10
  129. package/src/widgets/TreeView.js +5 -5
  130. package/src/create-index.sh +0 -237
  131. package/src/widget-factory/pixTool.js +0 -40
  132. package/src/widget-factory/stringTool.js +0 -26
@@ -31,10 +31,18 @@ export const Rating = (props) => {
31
31
  } = props;
32
32
 
33
33
  // Colors from theme
34
+ //
35
+ // Both inks are the readable end of their own hue, for the same reason Radio's
36
+ // off-ring is: `colors.border` measured 1.27:1 on a white card and 1.19:1 on a
37
+ // dark one, so the empty stars were not a dim state but an absent one, and
38
+ // `colors.warning` (#F59E0B) measured 2.15:1 on white, which made a 4-out-of-5
39
+ // score harder to see than the label next to it. `gray500` is the neutral
40
+ // control outline (4.83:1 light, 3.67:1 dark) and `warningStrong` is the amber
41
+ // the palette already declares for words (5.02:1 light, 12.29:1 dark).
34
42
  let activeColor =
35
- propActiveColor !== undefined ? propActiveColor : colors.warning;
43
+ propActiveColor !== undefined ? propActiveColor : colors.warningStrong;
36
44
  let inactiveColor =
37
- propInactiveColor !== undefined ? propInactiveColor : colors.border;
45
+ propInactiveColor !== undefined ? propInactiveColor : colors.gray500;
38
46
  let currentValue = Math.min(Math.max(value, 0), max);
39
47
 
40
48
  let starIcons = []; // stores the SPAN elements of each star
@@ -43,6 +51,9 @@ export const Rating = (props) => {
43
51
  let unsubscribeTheme = null;
44
52
  let touchActive = false; // to avoid conflicts with mouse
45
53
 
54
+ /** The score as one sentence, which is what the group is named by. */
55
+ const groupLabel = () => `${currentValue} out of ${max}`;
56
+
46
57
  // Update all stars according to currentValue
47
58
  const updateStars = () => {
48
59
  for (let i = 0; i < max; i++) {
@@ -77,6 +88,20 @@ export const Rating = (props) => {
77
88
  if (valueTextRef && showValue) {
78
89
  valueTextRef.update({ text: currentValue.toFixed(1) });
79
90
  }
91
+
92
+ // The announced state and the roving stop follow the drawn stars, so a score
93
+ // set from the keyboard, from a pointer or by `rating.value = 3` is heard the
94
+ // same way.
95
+ if (!readOnly) {
96
+ const selected = Math.round(currentValue);
97
+ for (let i = 0; i < max; i++) {
98
+ const wrapper = starWrappers[i];
99
+ if (!wrapper) continue;
100
+ wrapper.setAttribute("aria-checked", String(selected === i + 1));
101
+ wrapper.tabIndex = tabIndexFor(i);
102
+ }
103
+ }
104
+ if (starsContainer) starsContainer.setAttribute("aria-label", groupLabel());
80
105
  };
81
106
 
82
107
  // Change the actual value (persistent)
@@ -111,6 +136,33 @@ export const Rating = (props) => {
111
136
  };
112
137
 
113
138
  // ========== STAR CREATION ==========
139
+ /**
140
+ * The star a keyboard stop sits on: the one carrying the score, or the first
141
+ * when nothing is chosen yet. A group where every star is a stop makes Tab
142
+ * walk five times through one control.
143
+ */
144
+ const tabIndexFor = (i) =>
145
+ i === (currentValue > 0 ? Math.min(Math.round(currentValue), max) - 1 : 0)
146
+ ? 0
147
+ : -1;
148
+
149
+ /**
150
+ * Pick star `i + 1`. With `allowHalf`, pressing the star that is already the
151
+ * whole score turns it into the half between it and the last one.
152
+ */
153
+ const pickStar = (i) => {
154
+ let newValue = i + 1;
155
+ if (allowHalf && currentValue === newValue) newValue -= 0.5;
156
+ setValue(newValue);
157
+ };
158
+
159
+ /** Move the keyboard and the score to star `index`. */
160
+ const focusStar = (index) => {
161
+ const clamped = Math.min(Math.max(index, 0), max - 1);
162
+ starWrappers[clamped]?.focus();
163
+ setValue(clamped + 1);
164
+ };
165
+
114
166
  for (let i = 0; i < max; i++) {
115
167
  const icon = Icon({
116
168
  name: iconInactive,
@@ -130,6 +182,45 @@ export const Rating = (props) => {
130
182
  ...(readOnly
131
183
  ? { padding: 4 }
132
184
  : { minWidth: TAP_MINIMUM, minHeight: TAP_MINIMUM }),
185
+ // A score shown as filled stars is the only place that state lives: the
186
+ // glyph is decorative and the number is not rendered when `showValue` is
187
+ // off. Each star is one choice of a group, which is what `radio` means.
188
+ ...(readOnly
189
+ ? {}
190
+ : {
191
+ role: "radio",
192
+ "aria-label": `Star ${i + 1} of ${max}`,
193
+ "aria-checked": "false",
194
+ tabIndex: tabIndexFor(i),
195
+ // The press is a prop, so the engine turns Enter and Space into this
196
+ // click instead of leaving a star only a finger can light up. The
197
+ // gesture stays the widget's own `scale(1.15)` above: the engine's
198
+ // press scale would overwrite it on the same frame.
199
+ disableTransform: true,
200
+ onclick: () => pickStar(i),
201
+ onKeyDown: (e) => {
202
+ switch (e.key) {
203
+ case "ArrowRight":
204
+ case "ArrowDown":
205
+ e.preventDefault();
206
+ focusStar(i + 1);
207
+ break;
208
+ case "ArrowLeft":
209
+ case "ArrowUp":
210
+ e.preventDefault();
211
+ focusStar(i - 1);
212
+ break;
213
+ case "Home":
214
+ e.preventDefault();
215
+ focusStar(0);
216
+ break;
217
+ case "End":
218
+ e.preventDefault();
219
+ focusStar(max - 1);
220
+ break;
221
+ }
222
+ },
223
+ }),
133
224
  });
134
225
 
135
226
  starIcons.push(icon);
@@ -147,17 +238,9 @@ export const Rating = (props) => {
147
238
  wrapper.style.transform = "scale(1)";
148
239
  restoreFromPreview();
149
240
  });
150
- wrapper.addEventListener("click", () => {
151
- if (touchActive) return;
152
- let newValue = i + 1;
153
- if (allowHalf) {
154
- if (currentValue === newValue) newValue = newValue - 0.5;
155
- else if (currentValue === newValue - 0.5) newValue = newValue;
156
- else newValue = newValue;
157
- }
158
- setValue(newValue);
159
- });
160
-
241
+ // The click itself is the `onclick` prop above — that is what lets the
242
+ // engine reach this star without a pointer, and a listener here would run
243
+ // the same pick twice.
161
244
  // ----- TOUCH EVENTS -----
162
245
  wrapper.addEventListener("touchstart", (e) => {
163
246
  e.preventDefault();
@@ -207,7 +290,7 @@ export const Rating = (props) => {
207
290
  }
208
291
  touchActive = false;
209
292
  });
210
- wrapper.addEventListener("touchcancel", (e) => {
293
+ wrapper.addEventListener("touchcancel", () => {
211
294
  wrapper.style.transform = "scale(1)";
212
295
  restoreFromPreview();
213
296
  touchActive = false;
@@ -221,6 +304,11 @@ export const Rating = (props) => {
221
304
  gap: gap,
222
305
  flexWrap: "wrap",
223
306
  children: starWrappers,
307
+ // One control, not five stops: the group is what a reader lands on and the
308
+ // score is what they hear there. A read-only rating has no choice to make,
309
+ // so it is a picture of a number rather than a group of radios.
310
+ role: readOnly ? "img" : "radiogroup",
311
+ "aria-label": groupLabel(),
224
312
  });
225
313
 
226
314
  // Ensure initial state
@@ -253,8 +341,8 @@ export const Rating = (props) => {
253
341
  // Dynamic theme
254
342
  if (propActiveColor === undefined || propInactiveColor === undefined) {
255
343
  unsubscribeTheme = subscribeTheme(() => {
256
- if (propActiveColor === undefined) activeColor = colors.warning;
257
- if (propInactiveColor === undefined) inactiveColor = colors.border;
344
+ if (propActiveColor === undefined) activeColor = colors.warningStrong;
345
+ if (propInactiveColor === undefined) inactiveColor = colors.gray500;
258
346
  updateStars();
259
347
  });
260
348
  }
@@ -21,13 +21,11 @@ export const Skeleton = (props) => {
21
21
  // Color props for consistency
22
22
  bgColor = colors.gray200,
23
23
  highlightColor = colors.gray100,
24
- shimmerColor = colors.gray300,
24
+ shimmerColor,
25
25
 
26
26
  // Animation timing
27
27
  pulseDuration = "1.5s",
28
28
  waveDuration = "1.5s",
29
-
30
- ...rest
31
29
  } = props;
32
30
 
33
31
  // Default dimensions based on variant
@@ -70,9 +68,15 @@ export const Skeleton = (props) => {
70
68
  };
71
69
  }
72
70
  if (animation === "wave") {
71
+ // `shimmerColor` names the band the wave carries; `highlightColor` is the
72
+ // token it falls back to, so either prop alone paints the shimmer.
73
+ const band = shimmerColor ?? highlightColor;
73
74
  return {
74
- background: `linear-gradient(90deg, ${bgColor} 25%, ${highlightColor} 50%, ${bgColor} 75%)`,
75
- backgroundSize: "200% 100%",
75
+ // `gradient` and `bgSize` are the registered names for `background` and
76
+ // `background-size`. A raw CSS name still paints — the factory falls back
77
+ // to writing it as an inline style — but warns on every placeholder built.
78
+ gradient: `linear-gradient(90deg, ${bgColor} 25%, ${band} 50%, ${bgColor} 75%)`,
79
+ bgSize: "200% 100%",
76
80
  animation: `skeleton-wave ${waveDuration} ease-in-out infinite`,
77
81
  };
78
82
  }
@@ -7,6 +7,7 @@ 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 { injectKeyframes } from "../utils/styleInjector.js";
10
+ import { markMotionLoop } from "../animations/motion.js";
10
11
 
11
12
  /**
12
13
  * Props owned by the Slider's closure state. Routed to the widget's `update`
@@ -24,6 +25,12 @@ const SLIDER_PROP_KEYS = [
24
25
  "onChanged", "onPress", "onChangeEnd",
25
26
  ];
26
27
 
28
+ // The fill eases for a value that changes *by itself* — a key press, `setValue`,
29
+ // an `update({ value })`. During a gesture it is switched off (see `onStart`):
30
+ // an ease that restarts on every pointer move never catches up, so the bar spends
31
+ // the whole drag chasing the thumb instead of moving with it.
32
+ const FILL_EASE = "width 0.1s ease, height 0.1s ease";
33
+
27
34
  export const Slider = (props) => {
28
35
  let {
29
36
  // Basic values
@@ -77,9 +84,11 @@ export const Slider = (props) => {
77
84
  let displayPercent = inverted ? 1 - rawPercent : rawPercent;
78
85
 
79
86
  let isHorizontal = orientation === "horizontal";
80
- let trackSize = isHorizontal ? height : width;
81
- let trackLength =
82
- toREM(trackSize);
87
+ // `height` is the rail's thickness and `width` its length in both orientations:
88
+ // a vertical slider is the same box turned 90°, so its vertical extent comes from
89
+ // `width` (the root below) while the rail stays `height` thin. Reading the
90
+ // thickness from `width` here painted a 160-unit rail 160 units *wide*.
91
+ let trackLength = toREM(height);
83
92
  let thumbSizeNum = typeof thumbSize === "number" ? thumbSize : 24;
84
93
 
85
94
  // Positioning retries while the track still measures 0 (hidden parent, not laid
@@ -102,6 +111,16 @@ export const Slider = (props) => {
102
111
  const container = WidgetFactory({
103
112
  tag: "div",
104
113
  widgetName: "Slider",
114
+ // The root is the control: what a reader meets, what Tab stops on and what
115
+ // answers the arrow keys. The thumb is the picture of the value, not a second
116
+ // stop — and one element is where `updateUI` can keep `aria-valuenow` honest.
117
+ role: "slider",
118
+ tabIndex: disabled ? -1 : 0,
119
+ "aria-valuemin": String(currentMin),
120
+ "aria-valuemax": String(currentMax),
121
+ "aria-valuenow": String(currentValue),
122
+ "aria-orientation": isHorizontal ? "horizontal" : "vertical",
123
+ "aria-disabled": String(Boolean(disabled)),
105
124
  width: isHorizontal ? width : "auto",
106
125
  height: !isHorizontal ? width : "auto",
107
126
  opacity: disabled ? 0.5 : 1,
@@ -170,7 +189,7 @@ export const Slider = (props) => {
170
189
  position: "absolute",
171
190
  backgroundColor: color,
172
191
  borderRadius: trackLength,
173
- transition: disabled ? "none" : "width 0.1s ease, height 0.1s ease",
192
+ transition: disabled ? "none" : FILL_EASE,
174
193
  ...(isHorizontal
175
194
  ? {
176
195
  width: `${displayPercent * 100}%`,
@@ -189,10 +208,15 @@ export const Slider = (props) => {
189
208
  if (striped) {
190
209
  fill.style.backgroundImage = `repeating-linear-gradient(45deg, ${stripeColor} 0px, ${stripeColor} 8px, transparent 8px, transparent 16px)`;
191
210
  fill.style.backgroundSize = "16px 16px";
192
- if (animatedStripes)
211
+ if (animatedStripes) {
193
212
  fill.style.animation = "slider-stripes 0.5s linear infinite";
213
+ markMotionLoop(fill);
214
+ }
215
+ }
216
+ if (glow) {
217
+ fill.style.animation = "slider-glow 1.5s ease-in-out infinite";
218
+ markMotionLoop(fill);
194
219
  }
195
- if (glow) fill.style.animation = "slider-glow 1.5s ease-in-out infinite";
196
220
  track.appendChild(fill);
197
221
 
198
222
  // ---------- Thumb (knob) ----------
@@ -288,7 +312,7 @@ export const Slider = (props) => {
288
312
  const label = WidgetFactory({
289
313
  tag: "span",
290
314
  textContent: mark.label,
291
- fontSize: "10px",
315
+ fontSize: "12px",
292
316
  color: colors.textSecondary,
293
317
  position: "absolute",
294
318
  ...(isHorizontal
@@ -329,6 +353,13 @@ export const Slider = (props) => {
329
353
  rawPercent = (currentValue - currentMin) / (currentMax - currentMin);
330
354
  displayPercent = inverted ? 1 - rawPercent : rawPercent;
331
355
 
356
+ // The number a reader hears is the one the thumb is drawn at, so both are
357
+ // written from the same function: a state that only the drag path updates is a
358
+ // state that goes stale the first time `setValue` or `update({ min })` runs.
359
+ container.setAttribute("aria-valuenow", String(currentValue));
360
+ container.setAttribute("aria-valuemin", String(currentMin));
361
+ container.setAttribute("aria-valuemax", String(currentMax));
362
+
332
363
  if (isHorizontal) {
333
364
  fill.style.width = `${displayPercent * 100}%`;
334
365
  } else {
@@ -433,6 +464,7 @@ export const Slider = (props) => {
433
464
  document.removeEventListener("touchmove", onMove);
434
465
  document.removeEventListener("touchend", onEnd);
435
466
  thumb.style.cursor = "grab";
467
+ fill.style.transition = disabled ? "none" : FILL_EASE;
436
468
  if (wrapper.style) wrapper.style.touchAction = "";
437
469
  };
438
470
 
@@ -441,6 +473,9 @@ export const Slider = (props) => {
441
473
  e.preventDefault();
442
474
  dragging = true;
443
475
  hasMoved = false;
476
+ // Before the first value write: with the ease still on, the jump to the press
477
+ // point animates and every later move restarts that animation.
478
+ fill.style.transition = "none";
444
479
  thumb.style.cursor = "grabbing";
445
480
  if (e.pointerId !== undefined) {
446
481
  currentPointerId = e.pointerId;
@@ -471,6 +506,50 @@ export const Slider = (props) => {
471
506
  thumb.addEventListener("mousedown", onStart);
472
507
  thumb.addEventListener("touchstart", onStart);
473
508
 
509
+ // ---------- Keyboard ----------
510
+ // `role="slider"` is a promise of arrow keys: a handle you can drag but not nudge
511
+ // is announced to a keyboard user as something they cannot move. One step for an
512
+ // arrow, a coarse one for Page keys, the ends for Home and End — the same set a
513
+ // native range input answers.
514
+ const bigStep = () =>
515
+ Math.max(currentStep, (currentMax - currentMin) / 10);
516
+
517
+ const onKeyDown = (e) => {
518
+ if (disabled) return;
519
+ let next = null;
520
+ switch (e.key) {
521
+ case "ArrowRight":
522
+ case "ArrowUp":
523
+ next = currentValue + currentStep;
524
+ break;
525
+ case "ArrowLeft":
526
+ case "ArrowDown":
527
+ next = currentValue - currentStep;
528
+ break;
529
+ case "PageUp":
530
+ next = currentValue + bigStep();
531
+ break;
532
+ case "PageDown":
533
+ next = currentValue - bigStep();
534
+ break;
535
+ case "Home":
536
+ next = currentMin;
537
+ break;
538
+ case "End":
539
+ next = currentMax;
540
+ break;
541
+ default:
542
+ return;
543
+ }
544
+ // Without this, the arrows on a vertical slider scroll the page instead of
545
+ // moving the value.
546
+ e.preventDefault();
547
+ // `triggerEnd` — the same sentence a released drag says, so an app that
548
+ // commits on `onChangeEnd` commits on a key too.
549
+ setValue(next, true, true);
550
+ };
551
+ container.addEventListener("keydown", onKeyDown);
552
+
474
553
  // ---------- ResizeObserver to reposition thumb ----------
475
554
  let resizeObserver = null;
476
555
  if (typeof ResizeObserver !== "undefined") {
@@ -542,6 +621,7 @@ export const Slider = (props) => {
542
621
  track.removeEventListener("touchstart", onStart);
543
622
  thumb.removeEventListener("mousedown", onStart);
544
623
  thumb.removeEventListener("touchstart", onStart);
624
+ container.removeEventListener("keydown", onKeyDown);
545
625
  document.removeEventListener("mousemove", onMove);
546
626
  document.removeEventListener("mouseup", onEnd);
547
627
  document.removeEventListener("touchmove", onMove);
@@ -129,9 +129,15 @@ export function SnackBar(options) {
129
129
  const show = () => {
130
130
  if (closed) return;
131
131
  if (rafId !== null) cancelAnimationFrame(rafId);
132
+ // Empty it here, fill it on the frame after: that gap is the whole reason the
133
+ // toast is heard. A live region that arrives with its words already inside is
134
+ // a new landmark, not a change, and a reader who is already on the page is
135
+ // told nothing. Clearing first also makes a second `show()` speak again.
136
+ announcer.textContent = "";
132
137
  rafId = requestAnimationFrame(() => {
133
138
  rafId = null;
134
139
  if (closed) return;
140
+ announcer.textContent = message;
135
141
  snackbar.style.opacity = "1";
136
142
  snackbar.style.transform = "translateY(0)";
137
143
  });
@@ -156,6 +162,7 @@ export function SnackBar(options) {
156
162
  : `translateY(calc(-100% - ${toREM(margin)}))`;
157
163
  setTimeout(() => {
158
164
  if (snackbar.parentNode) snackbar.parentNode.removeChild(snackbar);
165
+ if (announcer.parentNode) announcer.parentNode.removeChild(announcer);
159
166
  if (onClose) onClose();
160
167
  }, animationDuration);
161
168
  };
@@ -164,11 +171,29 @@ export function SnackBar(options) {
164
171
  const messageEl = WidgetFactory({
165
172
  tag: "span",
166
173
  flex: 1,
167
- text: message,
168
174
  wordBreak: "break-word",
175
+ text: message,
169
176
  });
170
177
  snackbar.appendChild(messageEl);
171
178
 
179
+ // The thing that speaks is not the bar. An error or a warning interrupts the
180
+ // sentence a reader is in the middle of; a confirmation waits for it to end.
181
+ // `opacity: 0` and one unit of size, because a `display: none` region is not
182
+ // read at all, and the bar's own animation is what the eye follows.
183
+ const announcer = WidgetFactory({
184
+ tag: "div",
185
+ role: type === "error" || type === "warning" ? "alert" : "status",
186
+ "aria-live": type === "error" || type === "warning" ? "assertive" : "polite",
187
+ position: "fixed",
188
+ bottom: 0,
189
+ left: 0,
190
+ width: 1,
191
+ height: 1,
192
+ opacity: 0,
193
+ overflow: "hidden",
194
+ pointerEvents: "none",
195
+ });
196
+
172
197
  // Action button
173
198
  let actionBtn = null;
174
199
  if (action) {
@@ -214,6 +239,9 @@ export function SnackBar(options) {
214
239
  opacity: 0.7,
215
240
  transition: "opacity 0.2s ease",
216
241
  textContent: "✕",
242
+ // The glyph is not a name. A reader hears "multiplication sign" and has to
243
+ // guess that this is the thing that dismisses the message.
244
+ "aria-label": "Dismiss notification",
217
245
  onclick: close,
218
246
  });
219
247
  closeBtn.addEventListener("mouseenter", () => {
@@ -227,6 +255,7 @@ export function SnackBar(options) {
227
255
 
228
256
  // Add to DOM
229
257
  document.body.appendChild(snackbar);
258
+ document.body.appendChild(announcer);
230
259
  // Force reflow
231
260
  snackbar.offsetHeight;
232
261
 
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * @file Stack.js
3
- * @description Positioning container for overlaying children (relative by default,
4
- * use `position: "stack"` for absolute centering). Supports a nine-point
5
- * `alignment` that anchors its children inside the box.
3
+ * @description Positioning container that paints its children in the same box.
4
+ * The first child stays in the flow and gives the stack its size; every child
5
+ * after it floats over that box, anchored by `alignment` or by its own edges.
6
6
  */
7
7
  import { WidgetFactory } from "../widget-factory/index.js";
8
+ import { ensureStackLayout } from "../utils/stackLayout.js";
8
9
 
9
10
  /**
10
11
  * Nine-point grid → [vertical anchor, horizontal anchor].
11
12
  * `alignment` turns the stack into a column flex container, so `justifyContent`
12
13
  * carries the top/center/bottom anchor and `alignItems` the left/center/right one.
14
+ * Those same two rules are what places a floating child: an absolutely
15
+ * positioned child of a flex container takes its static position from them, so
16
+ * one map anchors both the in-flow child and every layer above it.
13
17
  */
14
18
  const ALIGNMENTS = {
15
19
  "top-left": ["flex-start", "flex-start"],
@@ -37,26 +41,34 @@ export const Stack = (props = {}) => {
37
41
  ...rest
38
42
  } = props;
39
43
 
40
- const preset = alignment ? ALIGNMENTS[alignment] : undefined;
44
+ // The corner a child with no edges of its own lands on. Flet's stack puts the
45
+ // first control in the top-left, and a stack that anchored nothing would place
46
+ // its floating children wherever the flow would have put them — which is the
47
+ // bug this widget is meant to fix, not a default.
48
+ const preset = ALIGNMENTS[alignment] ?? ALIGNMENTS["top-left"];
41
49
 
42
50
  // The preset sits before `...rest`, so an explicit `flexDirection`,
43
51
  // `justifyContent` or `alignItems` from the caller always overrides it;
44
52
  // `alignment` only fills the axes nobody claimed.
45
- return WidgetFactory({
53
+ const stack = WidgetFactory({
46
54
  tag: "div",
47
55
  widgetName: "Stack",
48
56
  position: position,
49
- display: display ?? (preset ? "flex" : "block"),
57
+ display: display ?? "flex",
50
58
  boxSizing: boxSizing,
51
- ...(preset
52
- ? {
53
- flexDirection: "column",
54
- justifyContent: preset[0],
55
- alignItems: preset[1],
56
- }
57
- : null),
59
+ flexDirection: "column",
60
+ justifyContent: preset[0],
61
+ alignItems: preset[1],
58
62
  ...rest,
59
63
  });
64
+
65
+ // The marker is what `stackLayout.js` floats every child but the first against.
66
+ // It goes on the root, not on the children, because `update({ children })`
67
+ // replaces the nodes and would take any per-node flag with it.
68
+ ensureStackLayout();
69
+ stack.setAttribute("data-fb-stack", "");
70
+
71
+ return stack;
60
72
  };
61
73
 
62
74
  export default Stack;