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
@@ -131,18 +131,27 @@ export const AppBar = (props) => {
131
131
  // Auto-create a back button if requested and no custom leading was supplied.
132
132
  let finalLeading = leading;
133
133
  if (showBackButton && !finalLeading) {
134
- const backButton = Icon({
134
+ // `onclick` as a prop, not an assignment: the engine reads it while the
135
+ // widget is being built, and that is what gives the icon a role, a Tab stop
136
+ // and Enter. Assigned afterwards it is a mouse-only glyph. A named icon is
137
+ // also the only way it is not `aria-hidden`, and an aria-hidden control you
138
+ // can tab to is worse than no control.
139
+ finalLeading = Icon({
135
140
  name: "arrow_back",
136
141
  size: 24,
142
+ // 24 of glyph plus 8 of padding is the 40-unit target a thumb needs. Both
143
+ // numbers are framework units, so the target grows with the reader's font.
144
+ paddingVertical: 8,
145
+ paddingHorizontal: 8,
137
146
  color: iconColor,
138
147
  cursor: "pointer",
148
+ "aria-label": "Go back",
149
+ onclick: () => {
150
+ if (onBackPress) onBackPress();
151
+ else if (backButtonRoute) goTo(backButtonRoute);
152
+ else goBack();
153
+ },
139
154
  });
140
- backButton.onclick = () => {
141
- if (onBackPress) onBackPress();
142
- else if (backButtonRoute) goTo(backButtonRoute);
143
- else goBack();
144
- };
145
- finalLeading = backButton;
146
155
  }
147
156
 
148
157
  // ========== BACKGROUND ==========
@@ -101,6 +101,10 @@ export const BottomNavigation = (props) => {
101
101
 
102
102
  const container = WidgetFactory({
103
103
  tag: "nav",
104
+ // A page with a sidebar, a tab bar and this bar has three regions called
105
+ // "navigation"; the name is what tells them apart. An app that disagrees can
106
+ // pass its own `aria-label`, which wins below.
107
+ "aria-label": "Main navigation",
104
108
  display: "flex",
105
109
  width: `calc(100% - ${toREM(finalMarginLeft)} - ${toREM(finalMarginRight)})`,
106
110
  height: height,
@@ -116,6 +120,19 @@ export const BottomNavigation = (props) => {
116
120
  ...rest,
117
121
  });
118
122
 
123
+ /**
124
+ * Mark which tab is the page you are on. The colour says it to an eye and
125
+ * nothing says it to a reader, who otherwise hears a row of identical buttons.
126
+ *
127
+ * @param {number} index - The index of the newly active tab.
128
+ */
129
+ const markActive = (index) => {
130
+ buttons.forEach((btnData, i) => {
131
+ if (i === index) btnData.btn.setAttribute("aria-current", "page");
132
+ else btnData.btn.removeAttribute("aria-current");
133
+ });
134
+ };
135
+
119
136
  /**
120
137
  * Update icon and label colors for the given active tab index.
121
138
  * Used exclusively in non-router mode where the bar manages its own state.
@@ -123,6 +140,7 @@ export const BottomNavigation = (props) => {
123
140
  * @param {number} index - The index of the newly active tab.
124
141
  */
125
142
  const updateActive = (index) => {
143
+ markActive(index);
126
144
  buttons.forEach((btnData, i) => {
127
145
  const isActive = i === index;
128
146
  const icon = btnData.btn.querySelector(".material-icons");
@@ -159,6 +177,8 @@ export const BottomNavigation = (props) => {
159
177
  if (isSelected) activeIndex = idx;
160
178
  });
161
179
 
180
+ markActive(activeIndex);
181
+
162
182
  // Fire only when the route actually moved the selection. A consumer that
163
183
  // navigates from `onTabChange` (the normal case) would otherwise recurse:
164
184
  // route change -> callback -> goTo -> route change, until the stack blows.
@@ -189,6 +209,9 @@ export const BottomNavigation = (props) => {
189
209
  cursor: "pointer",
190
210
  padding: "8px 0",
191
211
  backgroundColor: "transparent",
212
+ // Which page you are on has to be said, not only coloured. In router mode
213
+ // `markActive` re-states this on every navigation.
214
+ "aria-current": isActive ? "page" : undefined,
192
215
  onclick: () => {
193
216
  if (useRouter) {
194
217
  goTo(itemRoute);
@@ -218,7 +241,7 @@ export const BottomNavigation = (props) => {
218
241
  // vacío. La prop correcta para contenido de texto es `text`.
219
242
  labelWidget = Text({
220
243
  text: item.label,
221
- size: 11,
244
+ size: 12,
222
245
  color: isActive ? selectedColor : unselectedColor,
223
246
  fontWeight: isActive ? "500" : "400",
224
247
  className: "bottom-nav-label",
@@ -1,7 +1,6 @@
1
1
  // navigations/CollapsibleSideBar.js
2
2
  import { WidgetFactory } from "../widget-factory/index.js";
3
3
  import { Container } from "../widgets/Container.js";
4
- import { Column } from "../widgets/Column.js";
5
4
  import { Icon } from "../widgets/Icon.js";
6
5
  import { useState } from "../tools/useState.js";
7
6
  import { colors } from "../utils/themes.js";
@@ -52,6 +52,10 @@ let drawerInstance = null;
52
52
  * two corners that face away from the screen edge) to maintain a flush
53
53
  * attachment at the edge side.
54
54
  *
55
+ * The panel paints edge to edge and pads its content by the device's safe-area
56
+ * insets, so a header clears the notch and a footer clears the home indicator
57
+ * without the background leaving a gap at either edge.
58
+ *
55
59
  * The module keeps a singleton reference so the standalone helper functions
56
60
  * (openDrawer, closeDrawer, etc.) can be called without passing an instance.
57
61
  *
@@ -59,6 +63,7 @@ let drawerInstance = null;
59
63
  * @returns {DrawerInstance}
60
64
  */
61
65
  import { toREM } from "../utils/units.js";
66
+ import { trapFocus } from "../utils/focusScope.js";
62
67
 
63
68
  export const Drawer = (props = {}) => {
64
69
  const {
@@ -77,12 +82,16 @@ export const Drawer = (props = {}) => {
77
82
  closeOnEsc = true,
78
83
  borderRadius = 24,
79
84
  margin = 0,
80
- ...rest
81
85
  } = props;
82
86
 
83
87
  const element = document.createElement("div");
84
88
  let isOpen = false;
85
89
  let closeTimer = null;
90
+ // What `trapFocus` hands back while the drawer is open: with a backdrop over
91
+ // the page, Tab must not walk into that page (see Modal.js for the argument).
92
+ // `trapFocus`'s release is idempotent, so close and unmount can both call it,
93
+ // and the no-op default covers "closed before it was ever opened".
94
+ let releaseFocusScope = () => {};
86
95
 
87
96
  // The outer wrapper covers the full screen so the backdrop sits above all
88
97
  // other content. It starts hidden and becomes visible on open().
@@ -116,12 +125,26 @@ export const Drawer = (props = {}) => {
116
125
  drawerPanel.style.flexDirection = "column";
117
126
  drawerPanel.style.backgroundColor = bgColor;
118
127
  drawerPanel.style.overflow = "hidden";
128
+ // A full-height panel reaches both edges of the screen, and on a notched
129
+ // phone those edges are the status bar and the home indicator: the header
130
+ // disappeared under the island and the footer's last row sat under the
131
+ // gesture bar. The background must stay flush, so only the content moves —
132
+ // which makes the insets padding, and `border-box` is what stops that padding
133
+ // from pushing the panel past the height it was just given. `env()` reads 0
134
+ // wherever the device has no inset, so this is the same no-configuration
135
+ // platform detection the AppBar's top padding already uses.
136
+ drawerPanel.style.boxSizing = "border-box";
137
+ drawerPanel.style.paddingTop = "env(safe-area-inset-top, 0px)";
138
+ drawerPanel.style.paddingBottom = "env(safe-area-inset-bottom, 0px)";
119
139
 
120
140
  // Apply border radius only to the corners that face away from the screen edge.
121
141
  // A left drawer gets rounded right corners; a right drawer gets rounded left corners.
122
142
  if (position === "left") {
123
143
  drawerPanel.style.left = toREM(margin);
124
144
  drawerPanel.style.right = "auto";
145
+ // In landscape the notch moves to the anchored side, so the content steps
146
+ // away from it while the panel keeps covering the edge.
147
+ drawerPanel.style.paddingLeft = "env(safe-area-inset-left, 0px)";
125
148
  drawerPanel.style.borderTopRightRadius = toREM(borderRadius);
126
149
  drawerPanel.style.borderBottomRightRadius = toREM(borderRadius);
127
150
  drawerPanel.style.borderTopLeftRadius = "0";
@@ -129,6 +152,7 @@ export const Drawer = (props = {}) => {
129
152
  } else {
130
153
  drawerPanel.style.right = toREM(margin);
131
154
  drawerPanel.style.left = "auto";
155
+ drawerPanel.style.paddingRight = "env(safe-area-inset-right, 0px)";
132
156
  drawerPanel.style.borderTopLeftRadius = toREM(borderRadius);
133
157
  drawerPanel.style.borderBottomLeftRadius = toREM(borderRadius);
134
158
  drawerPanel.style.borderTopRightRadius = "0";
@@ -200,6 +224,8 @@ export const Drawer = (props = {}) => {
200
224
  });
201
225
 
202
226
  drawerPanel.appendChild(contentContainer);
227
+ drawerPanel.setAttribute("role", "dialog");
228
+ drawerPanel.setAttribute("aria-modal", "true");
203
229
  element.appendChild(overlay);
204
230
  element.appendChild(drawerPanel);
205
231
 
@@ -232,6 +258,9 @@ export const Drawer = (props = {}) => {
232
258
  closeTimer = null;
233
259
  if (onClose) onClose();
234
260
  }, 300);
261
+ // Released now, not with the 300 ms hide: the person closing the drawer
262
+ // should have their keyboard back while it is still sliding out.
263
+ releaseFocusScope();
235
264
  if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
236
265
  };
237
266
 
@@ -257,6 +286,7 @@ export const Drawer = (props = {}) => {
257
286
  drawerPanel.style.transform = "translateX(0)";
258
287
  if (onOpen) onOpen();
259
288
  if (closeOnEsc) document.addEventListener("keydown", handleKeyDown);
289
+ releaseFocusScope = trapFocus(drawerPanel);
260
290
  };
261
291
 
262
292
  /**
@@ -273,6 +303,7 @@ export const Drawer = (props = {}) => {
273
303
  */
274
304
  const destroy = () => {
275
305
  if (closeTimer) clearTimeout(closeTimer);
306
+ releaseFocusScope();
276
307
  if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
277
308
  removeResizeListener();
278
309
  if (element.parentNode) element.parentNode.removeChild(element);
@@ -218,7 +218,7 @@ export const DrawerItem = (props) => {
218
218
 
219
219
  const textWidget = Text({
220
220
  text: label,
221
- size: 15,
221
+ size: 14,
222
222
  color: unselectedColor,
223
223
  fontWeight: "400",
224
224
  flex: 1,
@@ -358,7 +358,6 @@ export const goTo = (path, params = {}) => {
358
358
  console.error(`Route "${cleanPathStr}" does not exist`);
359
359
  // Graceful fallback to a wildcard or 404 route when defined.
360
360
  if (routes["*"] || routes["/404"]) {
361
- const notFoundRoute = routes["*"] || routes["/404"];
362
361
  currentPath = "*";
363
362
  currentParams = {};
364
363
  currentQuery = {};
@@ -1,5 +1,6 @@
1
1
  // navigations/Scaffold.js
2
2
  import { WidgetFactory } from "../widget-factory/index.js";
3
+ import { applyEffects } from "../widget-factory/effects.js";
3
4
  import { colors } from "../utils/themes.js";
4
5
  import { initRouter, getCurrentRoute, subscribe } from "./Router.js";
5
6
 
@@ -143,8 +144,6 @@ export const Scaffold = (props) => {
143
144
  let rightNavBarContainer = null;
144
145
  let mainContentContainer = null;
145
146
 
146
- let currentAppBar = null;
147
-
148
147
  /**
149
148
  * Removes all direct children from a container, calling their `_cleanup`
150
149
  * hook first to release subscriptions and event listeners.
@@ -200,7 +199,6 @@ export const Scaffold = (props) => {
200
199
  : toElement(cfg);
201
200
  if (widget) {
202
201
  appBarContainer.appendChild(widget);
203
- currentAppBar = widget;
204
202
  // Auto-wire the first Material Icons "menu" icon to open the drawer,
205
203
  // so callers don't need to pass an explicit onPress handler.
206
204
  if (drawer && drawerInstance) {
@@ -219,6 +217,14 @@ export const Scaffold = (props) => {
219
217
  drawerInstance.toggle();
220
218
  }
221
219
  };
220
+ // An unnamed `Icon` is `aria-hidden` decoration, and a control you can
221
+ // tab to that a reader is told to ignore is worse than either. Then run
222
+ // the engine's own pass over it: role, Tab stop, Enter and Space — and
223
+ // the release of the handler it adds, which a hand-written
224
+ // `addEventListener` would leak.
225
+ menuIcon.removeAttribute("aria-hidden");
226
+ menuIcon.setAttribute("aria-label", "Open menu");
227
+ applyEffects(menuIcon);
222
228
  }
223
229
  }
224
230
  }
@@ -7,6 +7,7 @@ import { Row } from "../widgets/Row.js";
7
7
  import { Text } from "../widgets/Text.js";
8
8
  import { Icon } from "../widgets/Icon.js";
9
9
  import { toREM } from "../utils/units.js";
10
+ import { PILL } from "../tools/index.js";
10
11
 
11
12
  /**
12
13
  * @typedef {Object} TabDefinition
@@ -48,8 +49,11 @@ import { toREM } from "../utils/units.js";
48
49
  * - `pills` — rounded pill-shaped tab buttons.
49
50
  * - `slider` — an animated filled thumb that slides under the active tab.
50
51
  *
51
- * The active tab tracks a `currentIndex` state internally. Keyboard
52
- * navigation (Enter/Space) and ARIA attributes are included for accessibility.
52
+ * The active tab tracks a `currentIndex` state internally. The bar is a
53
+ * `tablist` of `tab`s that control a `tabpanel`: the bar is one stop on the Tab
54
+ * path (roving `tabIndex`) and the arrow keys, Home and End move between the
55
+ * tabs and activate what they land on. Enter and Space press the tab through
56
+ * the engine's own keyboard handler.
53
57
  *
54
58
  * A ResizeObserver and a window resize listener keep the slider thumb
55
59
  * position correct when the tab bar dimensions change.
@@ -59,6 +63,8 @@ import { toREM } from "../utils/units.js";
59
63
  * - `activeIndex` (getter/setter) — read or set the active tab index.
60
64
  * - `setActiveTab(index: number)` — alias for the setter.
61
65
  */
66
+ let tabsIdSeed = 0;
67
+
62
68
  export const Tabs = (props) => {
63
69
  const {
64
70
  tabs = [],
@@ -94,6 +100,16 @@ export const Tabs = (props) => {
94
100
  let tabsWrapperRef = null;
95
101
  let tabButtonsRef = [];
96
102
 
103
+ // A tab and its panel point at each other (`aria-controls` one way,
104
+ // `aria-labelledby` back), so both need ids. Derived from the caller's `id`
105
+ // when there is one, so a re-render keeps the pairing instead of moving it.
106
+ const baseId =
107
+ typeof rest.id === "string" && rest.id
108
+ ? rest.id
109
+ : `fletbox-tabs-${++tabsIdSeed}`;
110
+ const panelId = `${baseId}-panel`;
111
+ const tabId = (idx) => `${baseId}-tab-${idx}`;
112
+
97
113
  // Size preset lookup — controls padding, font size, icon size, content
98
114
  // padding, and minimum height in a single prop for consistent scaling.
99
115
  const sizes = {
@@ -151,6 +167,10 @@ export const Tabs = (props) => {
151
167
  });
152
168
 
153
169
  const tabsWrapper = Row({
170
+ // The bar is a set of tabs, not a row of buttons: without the `tablist`
171
+ // parent, a reader hears five unrelated controls and a panel belonging to
172
+ // none of them.
173
+ role: "tablist",
154
174
  alignItems: "center",
155
175
  justifyContent: fullWidth ? "space-between" : "flex-start",
156
176
  gap: variant === "pills" ? 4 : 0,
@@ -165,6 +185,27 @@ export const Tabs = (props) => {
165
185
 
166
186
  const tabButtons = [];
167
187
 
188
+ /**
189
+ * Activate a tab and, when asked, hand it the keyboard.
190
+ *
191
+ * One helper because three doors lead here: the click, the arrow keys and the
192
+ * public `activeIndex` setter. Each used to repeat the same three statements,
193
+ * and only the click and the setter carried the panel with them.
194
+ */
195
+ const selectTab = (idx, moveFocus = false) => {
196
+ if (idx < 0 || idx >= tabs.length || currentIndex === idx) return;
197
+ currentIndex = idx;
198
+ updateActiveTab(idx);
199
+ onChange?.(idx);
200
+ if (moveFocus) tabButtonsRef[idx]?.focus();
201
+ };
202
+
203
+ /** The tab `delta` places along the bar, wrapping at both ends. */
204
+ const stepTab = (from, delta) => {
205
+ if (!tabs.length) return 0;
206
+ return (((from + delta) % tabs.length) + tabs.length) % tabs.length;
207
+ };
208
+
168
209
  tabs.forEach((tab, idx) => {
169
210
  const label = getLabel(tab);
170
211
  const icon = getIcon(tab);
@@ -192,11 +233,14 @@ export const Tabs = (props) => {
192
233
  : null;
193
234
  const badgeWidget = badge
194
235
  ? Container({
195
- backgroundColor: colors.danger,
196
- borderRadius: 10,
197
- padding: "2px 6px",
236
+ // The signal red is a words-on-a-page tone; white on it measured
237
+ // 2.77:1 in the dark theme. A count is a fill carrying white, so it
238
+ // takes the plate (`dangerPlate`) and the palette's white (`onSecondary`).
239
+ backgroundColor: colors.dangerPlate,
240
+ borderRadius: PILL,
241
+ padding: "4px 8px",
198
242
  marginLeft: 4,
199
- child: Text({ text: String(badge), size: 10, color: "#fff" }),
243
+ child: Text({ text: String(badge), size: 12, color: colors.onSecondary }),
200
244
  })
201
245
  : null;
202
246
 
@@ -241,31 +285,53 @@ export const Tabs = (props) => {
241
285
  // Subtract the slider's internal padding so buttons don't overflow it.
242
286
  minHeight: sz.h - (variant === "slider" ? 8 : 0),
243
287
  zIndex: 2,
244
- // Accessibility: expose as a tab role with aria-selected.
288
+ // Accessibility: a tab, selected or not, that owns the panel below it.
289
+ // `aria-controls` is what tells a reader that pressing here has a place
290
+ // they will land, not only a colour change.
245
291
  role: "tab",
246
- tabIndex: 0,
292
+ id: tabId(idx),
293
+ "aria-controls": panelId,
247
294
  "aria-selected": isActive.toString(),
295
+ // Roving tabindex: the whole bar is one stop on the Tab path and the
296
+ // arrows move inside it. Every tab left at 0 would make a reader Tab
297
+ // through the entire bar before reaching the content it is on about.
298
+ tabIndex: isActive ? 0 : -1,
299
+ // The press is a prop, not an assignment made after `Container()` has
300
+ // returned: the engine reads `widget.onclick` while the widget is built,
301
+ // and a handler wired afterwards is a tab only a mouse can press. That
302
+ // is also why Enter and Space are absent below — the engine's own key
303
+ // handler turns them into this click, and repeating them here would run
304
+ // the press twice.
305
+ onclick: () => selectTab(idx),
306
+ // The indicator is placed from `getBoundingClientRect()`, so a press that
307
+ // scaled the tab would report a narrower button than the one it is
308
+ // measuring and the underline would land off-centre mid-press.
309
+ disableTransform: true,
248
310
  onKeyDown: (e) => {
249
- if (e.key === "Enter" || e.key === " ") {
250
- e.preventDefault();
251
- if (currentIndex !== idx) {
252
- currentIndex = idx;
253
- updateActiveTab(idx);
254
- onChange?.(idx);
255
- }
311
+ switch (e.key) {
312
+ case "ArrowRight":
313
+ case "ArrowDown":
314
+ e.preventDefault();
315
+ selectTab(stepTab(currentIndex, 1), true);
316
+ break;
317
+ case "ArrowLeft":
318
+ case "ArrowUp":
319
+ e.preventDefault();
320
+ selectTab(stepTab(currentIndex, -1), true);
321
+ break;
322
+ case "Home":
323
+ e.preventDefault();
324
+ selectTab(0, true);
325
+ break;
326
+ case "End":
327
+ e.preventDefault();
328
+ selectTab(tabs.length - 1, true);
329
+ break;
256
330
  }
257
331
  },
258
332
  child: content,
259
333
  });
260
334
 
261
- btn.onclick = () => {
262
- if (currentIndex !== idx) {
263
- currentIndex = idx;
264
- updateActiveTab(idx);
265
- onChange?.(idx);
266
- }
267
- };
268
-
269
335
  tabButtons.push(btn);
270
336
  tabsWrapper.appendChild(btn);
271
337
  });
@@ -306,6 +372,13 @@ export const Tabs = (props) => {
306
372
  container.appendChild(tabBar);
307
373
 
308
374
  const contentContainer = Container({
375
+ // The pane is the thing a tab `aria-controls`: named by the tab that opened
376
+ // it, and a stop itself, so a reader who presses Tab from the bar lands
377
+ // inside the content instead of jumping past it.
378
+ role: "tabpanel",
379
+ id: panelId,
380
+ "aria-labelledby": tabs.length ? tabId(currentIndex) : undefined,
381
+ tabIndex: 0,
309
382
  display: "flex",
310
383
  padding: `${toREM(sz.cp)} 0`,
311
384
  width: "100%",
@@ -347,8 +420,12 @@ export const Tabs = (props) => {
347
420
  tabButtonsRef.forEach((btn, i) => {
348
421
  const isActive = i === idx;
349
422
 
350
- // Keep aria-selected in sync with the visual state.
423
+ // Keep the announced state, the Tab path and the panel's name in sync with
424
+ // the visual state. All three are the same fact seen from the keyboard: a
425
+ // tab that looks active but is not `aria-selected`, or a bar where every
426
+ // tab is still a stop, is a control that answers differently to two senses.
351
427
  btn.setAttribute("aria-selected", isActive.toString());
428
+ btn.tabIndex = isActive ? 0 : -1;
352
429
 
353
430
  const textSpan = btn.querySelector("span:not(.material-icons)");
354
431
  if (textSpan) {
@@ -365,6 +442,8 @@ export const Tabs = (props) => {
365
442
 
366
443
  updateSliderPosition();
367
444
  updateContent();
445
+ // The pane changed hands, so the tab it is named by changes too.
446
+ contentContainer.setAttribute("aria-labelledby", tabId(idx));
368
447
  };
369
448
 
370
449
  // Mount the initially active pane during construction. Without this the pane
@@ -436,13 +515,7 @@ export const Tabs = (props) => {
436
515
  // ========== PUBLIC API ==========
437
516
  Object.defineProperty(container, "activeIndex", {
438
517
  get: () => currentIndex,
439
- set: (idx) => {
440
- if (idx >= 0 && idx < tabs.length && currentIndex !== idx) {
441
- currentIndex = idx;
442
- updateActiveTab(idx);
443
- onChange?.(idx);
444
- }
445
- },
518
+ set: (idx) => selectTab(idx),
446
519
  });
447
520
 
448
521
  /**
@@ -37,9 +37,8 @@ const notifySubscribers = (key, newValue, oldValue) => {
37
37
  export const saveRam = (key, value) => {
38
38
  try {
39
39
  const oldValue = ramStore.get(key);
40
- const result = ramStore.set(key, value);
40
+ ramStore.set(key, value);
41
41
  notifySubscribers(key, value, oldValue);
42
- // console.log(`[RamStore] saved: ${key} =`, value);
43
42
  return true;
44
43
  } catch (error) {
45
44
  console.error("Error saving to RAM:", error);
@@ -0,0 +1,113 @@
1
+ // src/tools/breakpoints.js
2
+ // The framework's responsive vocabulary: one width table, the name it answers,
3
+ // and a subscription that only fires when the name changes.
4
+
5
+ /**
6
+ * The widths a FletBox screen answers to. These are viewport widths in CSS
7
+ * pixels, not framework units: a breakpoint is a fact about the window, and the
8
+ * window does not scale with the reader's font.
9
+ *
10
+ * `md` is 768 because that is the switch `AdaptiveScaffold` has always used —
11
+ * the drawer side of the fence — and a framework with two breakpoint tables
12
+ * gives every app two answers to one question.
13
+ */
14
+ export const BREAKPOINTS = Object.freeze({ xs: 0, sm: 560, md: 768, lg: 1024, xl: 1280 });
15
+
16
+ /** The names in width order, so `pick()` can fall back toward smaller steps. */
17
+ export const BREAKPOINT_NAMES = Object.freeze(["xs", "sm", "md", "lg", "xl"]);
18
+
19
+ /**
20
+ * Which breakpoint a viewport width answers to.
21
+ *
22
+ * @param {number} px - A width in CSS pixels.
23
+ * @returns {"xs" | "sm" | "md" | "lg" | "xl"} The largest step the width reaches.
24
+ */
25
+ export const breakpointForWidth = (px) => {
26
+ let name = "xs";
27
+ for (const candidate of BREAKPOINT_NAMES) {
28
+ if (px >= BREAKPOINTS[candidate]) name = candidate;
29
+ }
30
+ return name;
31
+ };
32
+
33
+ /**
34
+ * The current breakpoint, read live from the window.
35
+ *
36
+ * @returns {"xs" | "sm" | "md" | "lg" | "xl"}
37
+ */
38
+ export const currentBreakpoint = () =>
39
+ breakpointForWidth(typeof window === "undefined" ? 0 : window.innerWidth);
40
+
41
+ /**
42
+ * Whether the viewport has reached a breakpoint. `atLeast("md")` is the sentence
43
+ * a two-column layout actually needs, and it is the same 768 the scaffold uses.
44
+ *
45
+ * @param {"xs" | "sm" | "md" | "lg" | "xl"} name
46
+ * @returns {boolean}
47
+ */
48
+ export const atLeast = (name) => {
49
+ const from = BREAKPOINT_NAMES.indexOf(currentBreakpoint());
50
+ const wanted = BREAKPOINT_NAMES.indexOf(name);
51
+ // An unknown name is not "true by accident": it asks for nothing, so any
52
+ // screen answers it.
53
+ if (wanted === -1) return true;
54
+ return from >= wanted;
55
+ };
56
+
57
+ /**
58
+ * Choose a value by breakpoint. Keys are breakpoint names, and a step that is
59
+ * not named inherits the nearest smaller one that is:
60
+ * `{ xs: 8, md: 16 }` gives 8, 8, 16, 16, 16.
61
+ *
62
+ * @param {Record<string, any>} values - Values keyed by breakpoint name.
63
+ * @param {any} [fallback=undefined] - What to return when nothing matches.
64
+ * @returns {any}
65
+ */
66
+ export const pick = (values, fallback) => {
67
+ if (!values) return fallback;
68
+ const name = currentBreakpoint();
69
+ for (let index = BREAKPOINT_NAMES.indexOf(name); index >= 0; index--) {
70
+ const key = BREAKPOINT_NAMES[index];
71
+ if (key in values) return values[key];
72
+ }
73
+ return fallback;
74
+ };
75
+
76
+ // One resize listener for the whole framework, and one remembered name. The
77
+ // callback fires when the *breakpoint* changes, not on every pixel: a screen
78
+ // that rebuilt itself per pixel would resize itself into a loop the moment its
79
+ // own layout changed the scrollbar.
80
+ let lastName = null;
81
+ const listeners = new Set();
82
+ let removeResize = null;
83
+
84
+ const handleResize = () => {
85
+ const next = currentBreakpoint();
86
+ if (next === lastName) return;
87
+ lastName = next;
88
+ for (const listener of [...listeners]) listener(next);
89
+ };
90
+
91
+ /**
92
+ * Subscribe to breakpoint crossings.
93
+ *
94
+ * @param {(breakpoint: "xs" | "sm" | "md" | "lg" | "xl") => void} listener
95
+ * Called with the new name whenever the window crosses a step.
96
+ * @returns {() => void} The unsubscribe. Call it from `onUnmount`.
97
+ */
98
+ export const onBreakpointChange = (listener) => {
99
+ if (typeof listener !== "function") return () => {};
100
+ if (lastName === null) lastName = currentBreakpoint();
101
+ listeners.add(listener);
102
+ if (typeof window !== "undefined" && !removeResize) {
103
+ window.addEventListener("resize", handleResize);
104
+ removeResize = () => {
105
+ window.removeEventListener("resize", handleResize);
106
+ removeResize = null;
107
+ };
108
+ }
109
+ return () => {
110
+ listeners.delete(listener);
111
+ if (listeners.size === 0) removeResize?.();
112
+ };
113
+ };
@@ -1,7 +1,5 @@
1
1
  // src/components/flet-box/tools/createList.js
2
2
 
3
- import { random } from "./random.js";
4
-
5
3
  /**
6
4
  * Creates a list of items with generated data
7
5
  * @module tools/createList