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.
- package/README.md +16 -12
- package/bin/commands/checkUi.js +276 -45
- package/bin/commands/create.js +8 -1
- package/bin/commands/createBundle.js +18 -6
- package/bin/commands/createScreen.js +1 -1
- package/bin/commands/runBundle.js +1 -1
- package/bin/utils/bundleConfig.js +47 -3
- package/bin/utils/colors.js +0 -8
- package/bin/utils/helpers.js +0 -5
- package/bin/utils/images/jpeg.js +2 -3
- package/bin/utils/templates.js +101 -85
- package/docs/README.md +24 -1
- package/docs/cli/README.md +51 -10
- package/docs/guides/README.md +5 -2
- package/docs/guides/accessibility.md +282 -0
- package/docs/guides/clean-ui.md +53 -8
- package/docs/guides/first-app.md +6 -7
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +51 -0
- package/docs/guides/online-updates.md +18 -6
- package/docs/guides/utilities.md +116 -4
- package/docs/guides/widget-structure.md +30 -0
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +134 -11
- package/docs/tools/device.md +75 -3
- package/docs/tools/text-and-time.md +116 -6
- package/docs/widget/Accordion.md +2 -0
- package/docs/widget/AnimatedBox.md +46 -20
- package/docs/widget/AnimatedText.md +13 -7
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/COMMON_PROPS.md +26 -3
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +7 -1
- package/docs/widget/CircularChart.md +2 -0
- package/docs/widget/Drawer.md +1 -0
- package/docs/widget/GridView.md +4 -1
- package/docs/widget/Input.md +2 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/Markdown.md +4 -0
- package/docs/widget/Rating.md +2 -0
- package/docs/widget/Slider.md +2 -0
- package/docs/widget/Stack.md +6 -5
- package/docs/widget/Stepper.md +2 -0
- package/docs/widget/Tooltip.md +3 -1
- package/package.json +3 -2
- package/skills/fletbox-design/SKILL.md +390 -14
- package/skills/fletbox-motion/SKILL.md +145 -47
- package/skills/fletbox-rules/SKILL.md +140 -1
- package/src/animations/AnimatedBox.js +321 -226
- package/src/animations/MatrixRain.js +7 -1
- package/src/animations/ParallaxBox.js +13 -10
- package/src/animations/animate.js +269 -106
- package/src/animations/color.js +91 -0
- package/src/animations/easings.js +275 -0
- package/src/animations/index.js +21 -0
- package/src/animations/motion.js +204 -0
- package/src/animations/transitions.js +327 -0
- package/src/core/runApp.js +0 -6
- package/src/helpers/boxEdges.js +69 -0
- package/src/helpers/html.js +39 -0
- package/src/index.d.ts +377 -25
- package/src/index.js +77 -0
- package/src/navigations/AdaptiveScaffold.js +6 -2
- package/src/navigations/AppBar.js +16 -7
- package/src/navigations/BottomNavigation.js +24 -1
- package/src/navigations/CollapsibleSideBar.js +0 -1
- package/src/navigations/Drawer.js +32 -1
- package/src/navigations/DrawerItem.js +1 -1
- package/src/navigations/Router.js +0 -1
- package/src/navigations/Scaffold.js +9 -3
- package/src/navigations/Tabs.js +104 -31
- package/src/services/RamStore.js +1 -2
- package/src/tools/breakpoints.js +113 -0
- package/src/tools/createList.js +0 -2
- package/src/tools/fonts.js +99 -0
- package/src/tools/index.js +74 -1
- package/src/tools/margin.js +5 -63
- package/src/tools/padding.js +5 -63
- package/src/tools/print.js +0 -8
- package/src/tools/scale.js +9 -2
- package/src/tools/time.js +56 -15
- package/src/tools/useState.js +1 -54
- package/src/utils/fieldFont.js +77 -0
- package/src/utils/focusRing.js +73 -0
- package/src/utils/focusScope.js +113 -0
- package/src/utils/markdownParser.js +7 -9
- package/src/utils/readableInk.js +7 -1
- package/src/utils/stackLayout.js +55 -0
- package/src/utils/styleInjector.js +25 -0
- package/src/utils/syntaxHighlight.js +1 -17
- package/src/utils/themes.js +15 -0
- package/src/utils/visualEffects.js +49 -47
- package/src/widget-factory/createWidget.js +16 -3
- package/src/widget-factory/effects.js +76 -3
- package/src/widget-factory/reactiveValue.js +0 -7
- package/src/widget-factory/tools.js +26 -143
- package/src/widget-factory/translateProps.js +5 -0
- package/src/widget-factory/widgetFactory.js +1 -1
- package/src/widgets/Accordion.js +70 -12
- package/src/widgets/AdMob.js +1 -1
- package/src/widgets/AdSense.js +1 -1
- package/src/widgets/AlertDialog.js +11 -9
- package/src/widgets/Avatar.js +10 -1
- package/src/widgets/Badge.js +7 -1
- package/src/widgets/BottomSheet.js +39 -5
- package/src/widgets/Chart.js +41 -9
- package/src/widgets/Checkbox.js +59 -9
- package/src/widgets/Chip.js +13 -1
- package/src/widgets/CircularBar.js +17 -5
- package/src/widgets/CircularChart.js +24 -2
- package/src/widgets/CodeViewer.js +5 -3
- package/src/widgets/Dropdown.js +223 -20
- package/src/widgets/GridView.js +17 -1
- package/src/widgets/Icon.js +21 -0
- package/src/widgets/Input.js +75 -44
- package/src/widgets/InstallButton.js +8 -2
- package/src/widgets/ListTile.js +6 -5
- package/src/widgets/ListView.js +21 -7
- package/src/widgets/Markdown.js +25 -44
- package/src/widgets/Modal.js +43 -6
- package/src/widgets/Pagination.js +35 -3
- package/src/widgets/ProgressBar.js +18 -0
- package/src/widgets/Radio.js +7 -0
- package/src/widgets/Rating.js +104 -16
- package/src/widgets/Skeleton.js +9 -5
- package/src/widgets/Slider.js +87 -7
- package/src/widgets/SnackBar.js +30 -1
- package/src/widgets/Stack.js +25 -13
- package/src/widgets/Stepper.js +112 -64
- package/src/widgets/Switch.js +10 -1
- package/src/widgets/Tooltip.js +93 -10
- package/src/widgets/TreeView.js +5 -5
- package/src/create-index.sh +0 -237
- package/src/widget-factory/pixTool.js +0 -40
- 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
|
-
|
|
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:
|
|
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);
|
|
@@ -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
|
}
|
package/src/navigations/Tabs.js
CHANGED
|
@@ -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.
|
|
52
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
/**
|
package/src/services/RamStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|