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
@@ -36,6 +36,11 @@ const BINDABLE_KEYS = [
36
36
  "clearable",
37
37
  ];
38
38
 
39
+ /** The menu is mounted into a portal on `document.body`, so the combobox has to
40
+ * point at it by id (`aria-controls`, then `aria-activedescendant` for the
41
+ * highlighted row). Same seed pattern as `Input`. */
42
+ let dropdownIdSeed = 0;
43
+
39
44
  export const Dropdown = (inputProps = {}) => {
40
45
  // The factory never receives these keys, so it can neither resolve a `useState`
41
46
  // value for them (a live reactive is a *function*, and `findSelected()` would
@@ -99,6 +104,20 @@ export const Dropdown = (inputProps = {}) => {
99
104
  let resizeListener = null;
100
105
  let scrollListener = null;
101
106
 
107
+ // A keyboard user never moves focus into the menu: the combobox keeps it and
108
+ // points at the highlighted row through `aria-activedescendant`. So the open
109
+ // menu needs its option nodes in build order, and one index into them.
110
+ let optionNodes = [];
111
+ let activeIndex = -1;
112
+
113
+ const baseId =
114
+ typeof rest.id === "string" && rest.id
115
+ ? rest.id
116
+ : `fletbox-dropdown-${++dropdownIdSeed}`;
117
+ const listboxId = `${baseId}-listbox`;
118
+ const labelId = `${baseId}-label`;
119
+ const optionId = (index) => `${listboxId}-option-${index}`;
120
+
102
121
  /** [vertical, horizontal] in framework units — see `Input`: a `"8px 14px"`
103
122
  * string is raw CSS and stops the field growing with the reader's font. */
104
123
  const sizes = {
@@ -176,7 +195,14 @@ export const Dropdown = (inputProps = {}) => {
176
195
  const clearIcon = Icon({
177
196
  name: "close",
178
197
  size: 16,
198
+ // 16 of glyph plus 12 of padding on each side is the 40-unit target. The
199
+ // negative vertical margin keeps that box from stretching the field: the
200
+ // padding is paid to the thumb, not to the header.
201
+ paddingVertical: 12,
202
+ paddingHorizontal: 12,
203
+ marginVertical: -12,
179
204
  cursor: "pointer",
205
+ "aria-label": "Clear selection",
180
206
  onclick: (e) => {
181
207
  e.stopPropagation();
182
208
  if (!currentDisabled) {
@@ -197,7 +223,37 @@ export const Dropdown = (inputProps = {}) => {
197
223
  });
198
224
  rightContent.push(arrowIcon);
199
225
 
226
+ // The engine decides whether a control is keyboard-operable by reading
227
+ // `widget.onclick` while the factory runs, so the press path belongs in these
228
+ // props and not in an assignment made after `Row()` has returned — assigned
229
+ // late, the selector stayed a plain `div` no keyboard could ever reach.
230
+ //
231
+ // `disableTransform` because this row is the full width of the field: it must
232
+ // not shrink or lift on press, and the arrow already rotates for that.
233
+ const pressSelector = (event) => {
234
+ if (event) event.stopPropagation();
235
+ if (currentDisabled) return;
236
+ if (!isOpen) {
237
+ openMenu();
238
+ return;
239
+ }
240
+ // Open: Enter and Space take the highlighted row, the way a native select
241
+ // does. With nothing highlighted they only close it.
242
+ if (activeIndex >= 0) chooseOption(activeIndex);
243
+ else closeMenu();
244
+ };
245
+
200
246
  const selector = Row({
247
+ role: "combobox",
248
+ "aria-haspopup": "listbox",
249
+ "aria-expanded": "false",
250
+ // The label is a sibling `<label>` that describes nothing of its own; naming
251
+ // the combobox from it is what makes the field read as "Country, collapsed"
252
+ // instead of an unnamed box.
253
+ "aria-labelledby": currentLabel ? labelId : undefined,
254
+ tabIndex: currentDisabled ? -1 : 0,
255
+ onclick: pressSelector,
256
+ disableTransform: true,
201
257
  alignItems: "center",
202
258
  justifyContent: "space-between",
203
259
  width: "100%",
@@ -257,6 +313,7 @@ export const Dropdown = (inputProps = {}) => {
257
313
  if (currentLabel) {
258
314
  const labelEl = WidgetFactory({
259
315
  tag: "label",
316
+ id: labelId,
260
317
  textContent: currentLabel,
261
318
  fontSize: 12,
262
319
  color: currentError ? colors.danger : colors.textSecondary,
@@ -267,6 +324,19 @@ export const Dropdown = (inputProps = {}) => {
267
324
  }
268
325
  container.appendChild(selector);
269
326
 
327
+ // The states that follow these props are set here instead of in the props
328
+ // object, because `update()` patches this child directly and the factory never
329
+ // re-runs for it — a prop could only be read once, at build.
330
+ const syncControlState = () => {
331
+ selector.setAttribute("aria-disabled", String(Boolean(currentDisabled)));
332
+ selector.tabIndex = currentDisabled ? -1 : 0;
333
+ if (currentError) selector.setAttribute("aria-invalid", "true");
334
+ else selector.removeAttribute("aria-invalid");
335
+ if (currentLabel) selector.setAttribute("aria-labelledby", labelId);
336
+ else selector.removeAttribute("aria-labelledby");
337
+ };
338
+ syncControlState();
339
+
270
340
  // Menu functions (portal)
271
341
  const closeMenu = () => {
272
342
  if (!isOpen) return;
@@ -274,8 +344,16 @@ export const Dropdown = (inputProps = {}) => {
274
344
  menuElement.parentNode.removeChild(menuElement);
275
345
  }
276
346
  menuElement = null;
347
+ optionNodes = [];
348
+ activeIndex = -1;
277
349
  arrowIcon.style.transform = "rotate(0deg)";
278
350
  isOpen = false;
351
+ // The listbox is gone from the DOM: leaving `aria-controls` or
352
+ // `aria-activedescendant` pointing at it is a broken reference a screen
353
+ // reader reads as an announcement that never arrives.
354
+ selector.setAttribute("aria-expanded", "false");
355
+ selector.removeAttribute("aria-controls");
356
+ selector.removeAttribute("aria-activedescendant");
279
357
 
280
358
  if (outsideClickListener)
281
359
  document.removeEventListener("click", outsideClickListener);
@@ -319,12 +397,57 @@ export const Dropdown = (inputProps = {}) => {
319
397
  }
320
398
  };
321
399
 
400
+ /** A row's background comes from its state, not from the last pointer event:
401
+ * one question — highlighted, selected, or neither — so the keyboard and the
402
+ * mouse cannot paint two different answers. */
403
+ const optionBackground = (index) => {
404
+ if (index === activeIndex) return currentOptionHoverColor;
405
+ const opt = currentOptions[index];
406
+ return opt !== undefined && getValue(opt) === selectedValue
407
+ ? currentColor + "15"
408
+ : "transparent";
409
+ };
410
+
411
+ const paintOption = (index) => {
412
+ const node = optionNodes[index];
413
+ if (node) node.style.backgroundColor = optionBackground(index);
414
+ };
415
+
416
+ /** Focus never moves into the menu: the combobox keeps it and points at the
417
+ * highlighted row, which is what lets a 200-item list stay one tab stop. */
418
+ const setActive = (next) => {
419
+ if (!optionNodes.length) return;
420
+ const clamped = Math.max(0, Math.min(next, optionNodes.length - 1));
421
+ if (clamped === activeIndex) return;
422
+ const previous = activeIndex;
423
+ activeIndex = clamped;
424
+ paintOption(previous);
425
+ const node = optionNodes[activeIndex];
426
+ paintOption(activeIndex);
427
+ node.scrollIntoView({ block: "nearest" });
428
+ selector.setAttribute("aria-activedescendant", node.id);
429
+ };
430
+
431
+ /** The single path that commits a value — the click on a row and Enter on the
432
+ * highlighted row both come through here, so `onPress` fires exactly once. */
433
+ const chooseOption = (index) => {
434
+ const opt = currentOptions[index];
435
+ if (opt === undefined) return;
436
+ const optValue = getValue(opt);
437
+ selectedValue = optValue;
438
+ if (effectiveOnChange) effectiveOnChange(optValue);
439
+ updateDisplay();
440
+ closeMenu();
441
+ };
442
+
322
443
  const openMenu = () => {
323
444
  if (currentDisabled || isOpen) return;
324
445
  closeMenu();
325
446
  isOpen = true;
326
447
 
327
448
  menuElement = Column({
449
+ id: listboxId,
450
+ role: "listbox",
328
451
  zIndex: 10000,
329
452
  border: `1px solid ${colors.border}`,
330
453
  backgroundColor: currentBgColor,
@@ -337,7 +460,7 @@ export const Dropdown = (inputProps = {}) => {
337
460
  color: currentTextColor, // inherit textColor
338
461
  });
339
462
 
340
- currentOptions.forEach((opt) => {
463
+ currentOptions.forEach((opt, index) => {
341
464
  const optValue = getValue(opt);
342
465
  const optLabel = getLabel(opt);
343
466
  const optIcon = getIcon(opt);
@@ -358,6 +481,15 @@ export const Dropdown = (inputProps = {}) => {
358
481
  );
359
482
 
360
483
  const optionItem = Row({
484
+ id: optionId(index),
485
+ // `option`, not `button`: a screen reader in listbox mode reads the rows
486
+ // as choices and stays in browse mode, so the arrow keys keep meaning
487
+ // "next choice" instead of "next focus stop". `tabIndex -1` for the same
488
+ // reason — a long list must not become a hundred tab stops; the highlight
489
+ // moves instead of the focus.
490
+ role: "option",
491
+ "aria-selected": String(isSelected),
492
+ tabIndex: -1,
361
493
  paddingVertical: 8,
362
494
  paddingHorizontal: 12,
363
495
  minHeight: TAP_MINIMUM,
@@ -379,25 +511,18 @@ export const Dropdown = (inputProps = {}) => {
379
511
  ].filter(Boolean),
380
512
  });
381
513
 
382
- optionItem.onmouseenter = () => {
383
- if (!isSelected)
384
- optionItem.style.backgroundColor = currentOptionHoverColor;
385
- };
386
- optionItem.onmouseleave = () => {
387
- optionItem.style.backgroundColor = isSelected
388
- ? currentColor + "15"
389
- : "transparent";
390
- };
514
+ // Hover moves the highlight, so the row under the pointer is the row Enter
515
+ // would take. Leaving repaints from state instead of wiping to
516
+ // "transparent": that is what dropped the keyboard highlight as soon as the
517
+ // mouse crossed an option on its way past it.
518
+ optionItem.onmouseenter = () => setActive(index);
519
+ optionItem.onmouseleave = () => paintOption(index);
391
520
  optionItem.onclick = (e) => {
392
521
  e.stopPropagation();
393
- if (!currentDisabled) {
394
- selectedValue = optValue;
395
- if (effectiveOnChange) effectiveOnChange(optValue);
396
- updateDisplay();
397
- closeMenu();
398
- }
522
+ if (!currentDisabled) chooseOption(index);
399
523
  };
400
524
  menuElement.appendChild(optionItem);
525
+ optionNodes.push(optionItem);
401
526
  });
402
527
 
403
528
  if (portal) {
@@ -415,6 +540,15 @@ export const Dropdown = (inputProps = {}) => {
415
540
  menuElement.style.width = "100%";
416
541
  }
417
542
 
543
+ // Only once the listbox is in the DOM: while it is detached these are
544
+ // references to nothing.
545
+ selector.setAttribute("aria-expanded", "true");
546
+ selector.setAttribute("aria-controls", listboxId);
547
+ const selectedIndex = currentOptions.findIndex(
548
+ (entry) => getValue(entry) === selectedValue,
549
+ );
550
+ if (selectedIndex >= 0) setActive(selectedIndex);
551
+
418
552
  arrowIcon.style.transform = "rotate(180deg)";
419
553
 
420
554
  outsideClickListener = (e) => {
@@ -428,12 +562,79 @@ export const Dropdown = (inputProps = {}) => {
428
562
  document.addEventListener("click", outsideClickListener);
429
563
  };
430
564
 
431
- selector.onclick = (e) => {
432
- e.stopPropagation();
433
- if (currentDisabled) return;
434
- isOpen ? closeMenu() : openMenu();
565
+ // Enter and Space are deliberately absent: the engine's own key handler calls
566
+ // `onclick` for those, and `pressSelector` already opens while closed and takes
567
+ // the highlighted row while open. Handling them again here would run the press
568
+ // twice — open, then immediately choose.
569
+ //
570
+ // `event.target !== selector` leaves a keystroke to whatever control inside the
571
+ // field actually holds it.
572
+ const onSelectorKeyDown = (e) => {
573
+ if (e.target !== selector || currentDisabled) return;
574
+ const total = optionNodes.length;
575
+
576
+ if (!isOpen) {
577
+ // Opening with an arrow lands on the current value, or the first choice
578
+ // when nothing is selected yet — the same place a native select puts it.
579
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
580
+ e.preventDefault();
581
+ openMenu();
582
+ const start = currentOptions.findIndex(
583
+ (entry) => getValue(entry) === selectedValue,
584
+ );
585
+ setActive(start >= 0 ? start : 0);
586
+ }
587
+ return;
588
+ }
589
+
590
+ switch (e.key) {
591
+ case "ArrowDown":
592
+ e.preventDefault();
593
+ setActive(activeIndex + 1);
594
+ break;
595
+ case "ArrowUp":
596
+ e.preventDefault();
597
+ setActive(activeIndex <= 0 ? total - 1 : activeIndex - 1);
598
+ break;
599
+ case "Home":
600
+ e.preventDefault();
601
+ setActive(0);
602
+ break;
603
+ case "End":
604
+ e.preventDefault();
605
+ setActive(total - 1);
606
+ break;
607
+ case "Escape":
608
+ // Discard, the way a native select does: closing on Escape must not
609
+ // commit the row the arrows happened to rest on.
610
+ e.preventDefault();
611
+ e.stopPropagation();
612
+ closeMenu();
613
+ break;
614
+ case "Tab":
615
+ // Leaving the control closes the menu; a detached listbox behind the tab
616
+ // order is a region of the page no key can reach again.
617
+ closeMenu();
618
+ break;
619
+ default:
620
+ if (e.key.length === 1) {
621
+ const typed = e.key.toLowerCase();
622
+ const found = currentOptions.findIndex((entry) =>
623
+ getLabel(entry).toLowerCase().startsWith(typed),
624
+ );
625
+ if (found >= 0) {
626
+ e.preventDefault();
627
+ setActive(found);
628
+ }
629
+ }
630
+ }
435
631
  };
436
632
 
633
+ selector.addEventListener("keydown", onSelectorKeyDown);
634
+ container.onUnmount(() =>
635
+ selector.removeEventListener("keydown", onSelectorKeyDown),
636
+ );
637
+
437
638
  // Public API and reactivity
438
639
  const update = (newProps) => {
439
640
  let needsDisplayUpdate = false;
@@ -470,6 +671,7 @@ export const Dropdown = (inputProps = {}) => {
470
671
  else if (currentLabel) {
471
672
  const labelEl = WidgetFactory({
472
673
  tag: "label",
674
+ id: labelId,
473
675
  textContent: currentLabel,
474
676
  fontSize: "12px",
475
677
  color: currentError ? colors.danger : colors.textSecondary,
@@ -507,6 +709,7 @@ export const Dropdown = (inputProps = {}) => {
507
709
  needsDisplayUpdate = true;
508
710
  }
509
711
  if (needsDisplayUpdate) {
712
+ syncControlState();
510
713
  updateDisplay();
511
714
  selector.style.padding = `${toREM(currentSize.padding[0])} ${toREM(currentSize.padding[1])}`;
512
715
  selector.style.minHeight = toREM(currentSize.minHeight);
@@ -1,11 +1,25 @@
1
1
  /**
2
2
  * @file GridView.js
3
3
  * @description Grid layout, implemented as a `ListView` with `wrapItems`.
4
+ *
5
+ * The grid takes its items either way a list can be written: as data plus a
6
+ * builder, or as the cells themselves. `columns` is the only decision that
7
+ * cannot be inferred — the number of columns is fixed, and each cell is a
8
+ * percentage of the container, so the grid adapts to the width it is given
9
+ * rather than to a breakpoint.
4
10
  */
5
11
  import { ListView } from "./ListView.js";
6
12
 
7
13
  export const GridView = (props) => {
8
- const { columns = 2, itemHeight = 150, spacing = 8, ...rest } = props;
14
+ const { columns = 2, itemHeight = 150, spacing = 8, children, data, renderItem, ...rest } = props;
15
+
16
+ // Cells written as `children` are the same contract read the other way round:
17
+ // the items are already built, so the builder is the identity. Dropping them
18
+ // into `...rest` instead painted them as the factory's own children, which is a
19
+ // vertical stack — a `columns: 3` grid that showed three rows of full-width
20
+ // cells and never formed a column.
21
+ const items =
22
+ data ?? (children == null ? [] : Array.isArray(children) ? children : [children]);
9
23
 
10
24
  return ListView({
11
25
  widgetName: "GridView",
@@ -13,6 +27,8 @@ export const GridView = (props) => {
13
27
  crossAxisCount: columns, // ← number of columns
14
28
  itemSize: itemHeight, // ← height of each cell
15
29
  gap: spacing, // ← spacing between items
30
+ data: items,
31
+ renderItem: renderItem ?? ((cell) => cell),
16
32
  ...rest,
17
33
  });
18
34
  };
@@ -17,6 +17,21 @@ export const Icon = (props) => {
17
17
  } = props;
18
18
  const iconName = icon || name || "";
19
19
 
20
+ // The glyph is TEXT — a ligature the font draws — so a reader says the
21
+ // characters it finds: an accordion header reads "Payments expand_more" and a
22
+ // close control reads "close". An icon is therefore decoration unless the app
23
+ // names it with `aria-label`, and a generic `<span>` only carries a name when it
24
+ // has a role to put it on, which is why a named one becomes an image.
25
+ //
26
+ // A pressable icon is the exception: the engine gives it `role="button"`, and
27
+ // that is a better role than "image" for something you activate.
28
+ const label = rest["aria-label"];
29
+ const isNamed = typeof label === "string" && label.trim() !== "";
30
+ const isPressable =
31
+ typeof rest.onclick === "function" ||
32
+ typeof rest.onClick === "function" ||
33
+ typeof rest.onPress === "function";
34
+
20
35
  return WidgetFactory({
21
36
  tag: "span",
22
37
  widgetName: "Icon",
@@ -28,6 +43,12 @@ export const Icon = (props) => {
28
43
  alignItems: "center",
29
44
  justifyContent: "center",
30
45
  userSelect: "none",
46
+ ...(isNamed
47
+ ? isPressable
48
+ ? null
49
+ : { role: "img" }
50
+ : // An app that hid it deliberately keeps its own answer.
51
+ rest["aria-hidden"] === undefined ? { "aria-hidden": "true" } : null),
31
52
  ...rest,
32
53
  });
33
54
  };
@@ -119,6 +119,12 @@ export const Input = (props) => {
119
119
  ? `${rest.id}-input`
120
120
  : `fletbox-input-${++inputIdSeed}`;
121
121
 
122
+ // The message under the field is what `aria-describedby` points at while the
123
+ // field is invalid. A described-by that names a `display: none` node is a
124
+ // control that claims a description it does not have, so the reference and the
125
+ // message appear and disappear together.
126
+ const errorId = `${fieldId}-error`;
127
+
122
128
  // DOM element references — populated during the build phase below
123
129
  let inputElement = null;
124
130
  let validationIcon = null;
@@ -127,11 +133,6 @@ export const Input = (props) => {
127
133
  let container = null;
128
134
  let clearButton = null;
129
135
 
130
- // Whether the native input currently holds focus. Used to keep the focus
131
- // ring (a box-shadow) in sync with validation colors without ever changing
132
- // the border width — changing the width would resize the box and shift layout.
133
- const isFocused = () => document.activeElement === inputElement;
134
-
135
136
  // ========== SIZES ==========
136
137
  /**
137
138
  * Framework units, not CSS strings: this widget builds a real `<input>` and
@@ -257,19 +258,14 @@ export const Input = (props) => {
257
258
  borderColorValue = colors.success;
258
259
  }
259
260
 
260
- // The border keeps a fixed 1px width in every state; focus thickness is
261
- // drawn as a 1px box-shadow ring instead. This changes the look on focus
262
- // without altering the element's box size, so nothing gets pushed around.
261
+ // The border is the validation signal and nothing else: 1px in every state,
262
+ // recoloured by the verdict. Focusing the field does not touch it — the ring
263
+ // a focused control wears is the engine's (`src/utils/focusRing.js`), and a
264
+ // second hand-painted ring beside it reads as a doubled, thicker box.
263
265
  if (variant === "outlined") {
264
266
  inputWrapper.style.borderColor = borderColorValue;
265
- inputWrapper.style.boxShadow = isFocused()
266
- ? `0 0 0 1px ${borderColorValue}`
267
- : "none";
268
267
  } else if (variant === "underlined") {
269
268
  inputWrapper.style.borderBottomColor = borderColorValue;
270
- inputWrapper.style.boxShadow = isFocused()
271
- ? `inset 0 -1px 0 0 ${borderColorValue}`
272
- : "none";
273
269
  }
274
270
 
275
271
  if (showValidationIcon && validationIcon) {
@@ -282,20 +278,35 @@ export const Input = (props) => {
282
278
  }
283
279
  }
284
280
 
281
+ let messageToShow = "";
282
+ if (validationMessage && showValidationMessage && !isValid && !error) {
283
+ messageToShow = validationMessage;
284
+ } else if (error) {
285
+ messageToShow = error === true ? "Invalid value" : error;
286
+ }
287
+
285
288
  if (errorMessageElement) {
286
- if (validationMessage && showValidationMessage && !isValid && !error) {
287
- errorMessageElement.textContent = validationMessage;
288
- errorMessageElement.style.color = colors.danger;
289
- errorMessageElement.style.display = "block";
290
- } else if (error) {
291
- errorMessageElement.textContent =
292
- error === true ? "Invalid value" : error;
289
+ if (messageToShow) {
290
+ errorMessageElement.textContent = messageToShow;
293
291
  errorMessageElement.style.color = colors.danger;
294
292
  errorMessageElement.style.display = "block";
295
293
  } else {
296
294
  errorMessageElement.style.display = "none";
297
295
  }
298
296
  }
297
+
298
+ // A red rim says "wrong" to an eye and nothing to anyone else. `aria-invalid`
299
+ // is the state, and the description is the reason, so they toggle together:
300
+ // a field that is invalid with no message attached reads as a broken control.
301
+ if (inputElement) {
302
+ if (messageToShow) {
303
+ inputElement.setAttribute("aria-invalid", "true");
304
+ inputElement.setAttribute("aria-describedby", errorId);
305
+ } else {
306
+ inputElement.removeAttribute("aria-invalid");
307
+ inputElement.removeAttribute("aria-describedby");
308
+ }
309
+ }
299
310
  };
300
311
 
301
312
  /**
@@ -411,11 +422,18 @@ export const Input = (props) => {
411
422
  inputWrapper = WidgetFactory({
412
423
  tag: "div",
413
424
  disableTransform: true,
425
+ // Tells the engine's focus rule which box is the field: the ring belongs
426
+ // around this element, not around the text box inside it.
427
+ "data-fb-field": "true",
414
428
  ...wrapperStyles,
415
429
  });
416
430
 
417
431
  // --- Optional left icon ---
418
432
  if (iconLeft) {
433
+ // With `onIconPress` the icon is a control, so it is built as one: the click
434
+ // goes in as a prop — which is what lets the engine give it a role, a Tab
435
+ // stop and Enter — and it takes the glyph's own name, because an icon-only
436
+ // control with no name reads as "button".
419
437
  const iconEl = Icon({
420
438
  name: iconLeft,
421
439
  size: iconSize || sz.iconSize,
@@ -423,8 +441,9 @@ export const Input = (props) => {
423
441
  marginLeft: 12,
424
442
  flexShrink: 0,
425
443
  pointerEvents: "auto",
444
+ "aria-label": onIconPress ? iconLeft : undefined,
445
+ onclick: onIconPress,
426
446
  });
427
- if (onIconPress) iconEl.onclick = onIconPress;
428
447
  inputWrapper.appendChild(iconEl);
429
448
  }
430
449
 
@@ -443,10 +462,18 @@ export const Input = (props) => {
443
462
 
444
463
  inputElement.style.flex = "1";
445
464
  inputElement.style.padding = `${toREM(sz.padding[0])} ${toREM(sz.padding[1])}`;
465
+ // The length is published for the engine's iOS floor: below 16px WebKit zooms
466
+ // the whole page on focus, and `fieldFont.js` needs this exact rem value to
467
+ // raise only what is under the threshold, not every field.
468
+ inputElement.style.setProperty("--fb-field-font", toREM(sz.fontSize));
446
469
  inputElement.style.fontSize = toREM(sz.fontSize);
447
470
  inputElement.style.minHeight = toREM(sz.minHeight);
448
471
  inputElement.style.border = "none";
449
- inputElement.style.outline = "none";
472
+ // Deliberately no `outline` here. An inline declaration outranks every
473
+ // stylesheet rule, so the `outline: none` this widget used to write is what
474
+ // left a focused field with no ring — and the `filled` variant, which has no
475
+ // border to highlight, no focus signal at all. `src/utils/focusRing.js` paints
476
+ // the ring around the wrapper, where the whole control is.
450
477
  inputElement.style.backgroundColor = "transparent";
451
478
  inputElement.style.fontFamily = "inherit";
452
479
  inputElement.style.width = "100%";
@@ -465,16 +492,8 @@ export const Input = (props) => {
465
492
  });
466
493
 
467
494
  inputElement.addEventListener("focus", (e) => {
468
- // Highlight the border on focus to signal the active field.
469
- // The border width stays at 1px; the thicker focus look comes from a
470
- // 1px box-shadow ring so focusing never resizes the box or shifts layout.
471
- if (variant === "outlined") {
472
- inputWrapper.style.borderColor = colors.primary;
473
- inputWrapper.style.boxShadow = `0 0 0 1px ${colors.primary}`;
474
- } else if (variant === "underlined") {
475
- inputWrapper.style.borderBottomColor = colors.primary;
476
- inputWrapper.style.boxShadow = `inset 0 -1px 0 0 ${colors.primary}`;
477
- }
495
+ // No paint here. The engine's `:focus-visible` rule draws the ring, and it
496
+ // reaches this field only because nothing hides it with an inline `outline`.
478
497
  if (onFocus) onFocus(e);
479
498
  });
480
499
 
@@ -482,12 +501,8 @@ export const Input = (props) => {
482
501
  // Restore normal border and run final validation when the user leaves the field
483
502
  if (variant === "outlined") {
484
503
  inputWrapper.style.borderColor = error ? colors.danger : colors.border;
485
- inputWrapper.style.boxShadow = "none";
486
504
  } else if (variant === "underlined") {
487
- inputWrapper.style.borderBottomColor = error
488
- ? colors.danger
489
- : colors.border;
490
- inputWrapper.style.boxShadow = "none";
505
+ inputWrapper.style.borderBottomColor = error ? colors.danger : colors.border;
491
506
  }
492
507
  const result = validateValue(currentValue);
493
508
  isValid = result.valid;
@@ -507,11 +522,19 @@ export const Input = (props) => {
507
522
  // --- Trailing controls (clear / reveal) ---
508
523
  // Real `<button type="button">` elements: they are keyboard-reachable, and the
509
524
  // `type` keeps them from submitting a form the input happens to live in.
510
- const makeTrailingButton = (glyph, label, onPress) => {
525
+ //
526
+ // The glyph is a Material icon name, drawn by the same `.material-icons` face
527
+ // every other icon in the framework uses. An emoji is not a substitute: it is a
528
+ // colour bitmap the field's own `color` cannot reach, it is painted by the
529
+ // operating system's emoji font, and it is wider than the 16px slot it is given
530
+ // — measured 20×17px for `👁` against 12.2px for the `✕` beside it, so one row
531
+ // of trailing controls carried three different optical sizes and a glyph that
532
+ // could not follow the theme.
533
+ const makeTrailingButton = (iconName, label, onPress) => {
511
534
  const btn = document.createElement("button");
512
535
  btn.type = "button";
513
- btn.textContent = glyph;
514
536
  btn.setAttribute("aria-label", label);
537
+ btn.appendChild(Icon({ name: iconName, size: 16, color: colors.textSecondary }));
515
538
  btn.style.width = toREM(32);
516
539
  btn.style.height = toREM(32);
517
540
  btn.style.flexShrink = "0";
@@ -539,7 +562,7 @@ export const Input = (props) => {
539
562
  };
540
563
 
541
564
  if (clearable) {
542
- clearButton = makeTrailingButton("✕", "Clear", () => {
565
+ clearButton = makeTrailingButton("close", "Clear", () => {
543
566
  handleInput("", null);
544
567
  inputElement.focus();
545
568
  });
@@ -549,10 +572,12 @@ export const Input = (props) => {
549
572
  if (passwordToggle) {
550
573
  let revealed = type !== "password";
551
574
  const paint = (btn) => {
552
- btn.textContent = revealed ? "🙈" : "👁";
575
+ // The ligature is the text of the icon span, so changing the name is
576
+ // changing the picture — and the button keeps its one accessible name.
577
+ btn.firstElementChild.textContent = revealed ? "visibility_off" : "visibility";
553
578
  btn.setAttribute("aria-label", revealed ? "Hide value" : "Show value");
554
579
  };
555
- const btn = makeTrailingButton("👁", "Show value", () => {
580
+ const btn = makeTrailingButton("visibility", "Show value", () => {
556
581
  revealed = !revealed;
557
582
  inputElement.type = revealed ? "text" : "password";
558
583
  paint(btn);
@@ -590,8 +615,9 @@ export const Input = (props) => {
590
615
  marginRight: 12,
591
616
  flexShrink: 0,
592
617
  pointerEvents: "auto",
618
+ "aria-label": onIconPress ? iconRight : undefined,
619
+ onclick: onIconPress,
593
620
  });
594
- if (onIconPress) iconEl.onclick = onIconPress;
595
621
  inputWrapper.appendChild(iconEl);
596
622
  }
597
623
 
@@ -599,6 +625,11 @@ export const Input = (props) => {
599
625
 
600
626
  // --- Error message element rendered below the input ---
601
627
  errorMessageElement = document.createElement("span");
628
+ errorMessageElement.id = errorId;
629
+ // `polite`, not `assertive`: the message is the field's answer when the reader
630
+ // leaves it, not an interruption. An assertive one would talk over whatever the
631
+ // person was in the middle of hearing.
632
+ errorMessageElement.setAttribute("aria-live", "polite");
602
633
  errorMessageElement.textContent = "";
603
634
  errorMessageElement.style.fontSize = toREM(12);
604
635
  errorMessageElement.style.color = colors.danger;