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
@@ -37,6 +37,72 @@ const nextElevation = (value) => {
37
37
  };
38
38
 
39
39
 
40
+ /**
41
+ * The tags HTML already makes reachable: they are on the Tab path, they answer to
42
+ * Enter or Space, and a screen reader already knows what they are. Nothing here
43
+ * has to be done for them, and nothing here may be done *to* them — a native
44
+ * `<button>` given `role="button"` and a second key handler fires its action
45
+ * twice.
46
+ *
47
+ * @type {Set<string>}
48
+ */
49
+ const NATIVELY_REACHABLE = new Set([
50
+ "A",
51
+ "BUTTON",
52
+ "INPUT",
53
+ "SELECT",
54
+ "TEXTAREA",
55
+ ]);
56
+
57
+ /**
58
+ * Makes an element that only answers to a mouse answer to a keyboard too.
59
+ *
60
+ * `onPress` is routed to `click`, and a `click` on a `div` is not something a
61
+ * keyboard can produce: the widget was invisible to Tab, had no name for an
62
+ * assistive technology, and did nothing on Enter. This is the whole of the
63
+ * contract the framework owes — the same three things HTML would have given the
64
+ * widget for free had it been built from a `button`.
65
+ *
66
+ * Everything is conditional on the app not having said so already: an explicit
67
+ * `role` or `tabIndex` is a decision, and `-1` ("keep this off the Tab path") is
68
+ * a decision the engine must not overwrite.
69
+ *
70
+ * @param {HTMLElement} widget - The pressable element.
71
+ * @param {Array<{event: string, handler: Function}>} listeners - The effect
72
+ * listener list, so the key handler is released by the next update like every
73
+ * other effect here.
74
+ * @returns {void}
75
+ */
76
+ const makeKeyboardPressable = (widget, listeners) => {
77
+ if (NATIVELY_REACHABLE.has(widget.tagName)) return;
78
+ if (widget.disabled === true || widget.getAttribute("aria-disabled") === "true") {
79
+ return;
80
+ }
81
+
82
+ if (!widget.hasAttribute("role")) widget.setAttribute("role", "button");
83
+ if (!widget.hasAttribute("tabindex")) widget.tabIndex = 0;
84
+
85
+ /**
86
+ * Enter and Space are what a press is for someone with no pointer.
87
+ */
88
+ const onKeyDown = (event) => {
89
+ // A nested control owns its own keys: pressing Enter on a Button inside a
90
+ // clickable Card must open the card as well as fire the button.
91
+ if (event.target !== widget) return;
92
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") {
93
+ return;
94
+ }
95
+ // Held down, Space is one press, not sixty.
96
+ if (event.repeat) return;
97
+ // Space would otherwise scroll the page instead of pressing the control.
98
+ event.preventDefault();
99
+ if (typeof widget.onclick === "function") widget.onclick(event);
100
+ };
101
+
102
+ widget.addEventListener("keydown", onKeyDown);
103
+ listeners.push({ event: "keydown", handler: onKeyDown });
104
+ };
105
+
40
106
  /**
41
107
  * Applies interactive visual effects to a widget that has a click handler.
42
108
  *
@@ -47,7 +113,11 @@ const nextElevation = (value) => {
47
113
  * **Base effects (always applied):**
48
114
  * - `-webkit-tap-highlight-color` set to `transparent` to suppress the default
49
115
  * mobile tap flash.
50
- * - `outline` removed for a cleaner focused appearance.
116
+ *
117
+ * The focus ring is **not** touched here. It used to be: this function wrote
118
+ * `outline: none` onto every widget, which read as a cleaner screenshot and cost
119
+ * every keyboard user the answer to "where am I". The ring is the document's
120
+ * own rule (`src/utils/focusRing.js`) and an inline `outline` would outrank it.
51
121
  *
52
122
  * **Press / hover effects (only when a click handler is present and
53
123
  * `disableTransform !== true`):**
@@ -79,11 +149,15 @@ export const applyEffects = (widget) => {
79
149
  }
80
150
 
81
151
  widget.style.webkitTapHighlightColor = "transparent";
82
- widget.style.outline = "none";
83
152
 
84
153
  const hasClick = widget.onclick && typeof widget.onclick === "function";
85
154
  if (!hasClick) return widget;
86
155
 
156
+ // Declared here because reachability is not a transform: a widget with
157
+ // `disableTransform` still has to be operable without a mouse.
158
+ const listeners = [];
159
+ makeKeyboardPressable(widget, listeners);
160
+
87
161
  // Store the current shadow so it can be restored after press / hover effects.
88
162
  //
89
163
  // Skipped while the pointer is over the widget: `update()` re-runs
@@ -93,7 +167,6 @@ export const applyEffects = (widget) => {
93
167
  const hoverShadow = nextElevation(widget._originalShadow);
94
168
 
95
169
  const disableTransform = widget._originalProps?.disableTransform === true;
96
- const listeners = [];
97
170
 
98
171
  if (!disableTransform) {
99
172
  let isPressed = false;
@@ -76,12 +76,6 @@ export const endRenderPass = () => {
76
76
  */
77
77
  export const isStructuralRead = (key) => structuralKeys.has(key);
78
78
 
79
- /** Forgets every observed key. Used by hot-reload teardown. */
80
- export const resetStructuralReads = () => {
81
- structuralKeys = new Set();
82
- passKeys = renderPassDepth > 0 ? new Set() : null;
83
- };
84
-
85
79
  /**
86
80
  * True when `val` is a reactive value (live or snapshot).
87
81
  *
@@ -166,7 +160,6 @@ export default {
166
160
  resolveReactive,
167
161
  createReactiveValue,
168
162
  isStructuralRead,
169
- resetStructuralReads,
170
163
  beginRenderPass,
171
164
  endRenderPass,
172
165
  };
@@ -1,29 +1,8 @@
1
- // ============================================================
2
- // 📋 READ: Get styles
3
- // ============================================================
4
-
5
- /**
6
- * Reads one or all inline styles from a DOM element.
7
- *
8
- * @param {HTMLElement|null} el - The target element.
9
- * @param {string} [prop] - CSS property name. When provided, returns only that
10
- * property's value (or `null` when absent). When omitted, returns an object
11
- * containing every non-empty inline style.
12
- * @returns {string|Object|null} The property value, a styles object, or `null`
13
- * when `el` is falsy or the property is not set.
14
- */
15
- export function getStyles(el, prop) {
16
- if (!el) return null;
17
- if (prop) return el.style[prop] || null;
18
-
19
- const styles = {};
20
- for (let i = 0; i < el.style.length; i++) {
21
- const p = el.style[i];
22
- const v = el.style[p];
23
- if (v !== "") styles[p] = v;
24
- }
25
- return styles;
26
- }
1
+ // The engine owns the reduced-motion contract: a widget that writes an
2
+ // `infinite` CSS animation through these helpers is marked so the injected
3
+ // `@media (prefers-reduced-motion: reduce)` rule can reach it, even when the
4
+ // reader flips the OS setting long after the element was built.
5
+ import { markMotionLoop } from "../animations/motion.js";
27
6
 
28
7
  // ============================================================
29
8
  // ➕ CREATE / UPDATE: Assign styles
@@ -81,6 +60,23 @@ const toCSSValue = (key, value) => {
81
60
  return value;
82
61
  };
83
62
 
63
+ /**
64
+ * Whether a style assignment puts an endless animation on the element.
65
+ *
66
+ * Only a loop is marked: a one-shot entrance finishes on its own, and the
67
+ * reduced-motion rule would then only be noise.
68
+ *
69
+ * @param {string} prop - The CSS property being written.
70
+ * @param {*} value - The value being written.
71
+ * @returns {boolean} True when this write starts an `infinite` animation.
72
+ */
73
+ const isLoopingAnimation = (prop, value) => {
74
+ if (typeof value !== "string") return false;
75
+ if (prop === "animation") return value.includes("infinite");
76
+ if (prop === "animationIterationCount") return value.trim() === "infinite";
77
+ return false;
78
+ };
79
+
84
80
  /**
85
81
  * Applies one or more styles to a DOM element.
86
82
  *
@@ -110,12 +106,14 @@ export function setStyles(el, prop, value) {
110
106
  } catch (e) {
111
107
  // Silently ignore errors
112
108
  }
109
+ if (isLoopingAnimation(prop, cssValue)) markMotionLoop(el);
113
110
  }
114
111
  return el;
115
112
  }
116
113
 
117
114
  // Case 2: styles object
118
115
  if (typeof prop === "object" && prop !== null) {
116
+ let loops = false;
119
117
  for (const [key, val] of Object.entries(prop)) {
120
118
  const cssValue = toCSSValue(key, val);
121
119
  if (cssValue !== null) {
@@ -124,126 +122,11 @@ export function setStyles(el, prop, value) {
124
122
  } catch (e) {
125
123
  // Silently ignore errors
126
124
  }
125
+ if (isLoopingAnimation(key, cssValue)) loops = true;
127
126
  }
128
127
  }
128
+ if (loops) markMotionLoop(el);
129
129
  }
130
130
 
131
131
  return el;
132
132
  }
133
-
134
- // ============================================================
135
- // 🔄 TOGGLE: Toggle styles
136
- // ============================================================
137
-
138
- /**
139
- * Toggles a CSS property between two values.
140
- *
141
- * If the property currently equals `val1` it is set to `val2`, and vice versa.
142
- * Useful for toggling visibility, themes, or active states without external
143
- * boolean tracking.
144
- *
145
- * @param {HTMLElement} el - The target element.
146
- * @param {string} prop - CSS property name (camelCase).
147
- * @param {*} val1 - First value (the one to switch away from when current).
148
- * @param {*} val2 - Second value (the one to switch away from when current).
149
- * @returns {HTMLElement} The element (for chaining).
150
- */
151
- export function updateStyle(el, prop, val1, val2) {
152
- if (!el || !prop) return el;
153
-
154
- const current = el.style[prop] || "";
155
- const newValue = current === val1 ? val2 : val1;
156
- const cssValue = toCSSValue(prop, newValue);
157
-
158
- if (cssValue !== null) {
159
- try {
160
- el.style[prop] = cssValue;
161
- } catch (e) {
162
- // Silently ignore errors
163
- }
164
- }
165
-
166
- return el;
167
- }
168
-
169
- // ============================================================
170
- // 🗑️ DELETE: Remove styles
171
- // ============================================================
172
-
173
- /**
174
- * Removes one or more inline styles from a DOM element by setting them to an
175
- * empty string (which causes the browser to fall back to inherited / default
176
- * values).
177
- *
178
- * - `string` → removes that single property.
179
- * - `Array<string>` → removes every listed property.
180
- * - `undefined` → removes ALL currently set inline styles.
181
- *
182
- * @param {HTMLElement} el - The target element.
183
- * @param {string|string[]|undefined} [prop] - Property or properties to remove.
184
- * Omit to remove all inline styles.
185
- * @returns {HTMLElement} The element (for chaining).
186
- */
187
- export function removeStyles(el, prop) {
188
- if (!el) return el;
189
-
190
- if (typeof prop === "string") {
191
- try {
192
- el.style[prop] = "";
193
- } catch (e) {}
194
- } else if (Array.isArray(prop)) {
195
- prop.forEach((p) => {
196
- try {
197
- el.style[p] = "";
198
- } catch (e) {}
199
- });
200
- } else if (prop === undefined) {
201
- const styles = getStyles(el);
202
- Object.keys(styles).forEach((p) => {
203
- try {
204
- el.style[p] = "";
205
- } catch (e) {}
206
- });
207
- }
208
- return el;
209
- }
210
-
211
- // ============================================================
212
- // 🔍 UTILITY: Check styles
213
- // ============================================================
214
-
215
- /**
216
- * Checks whether a specific inline style is set on an element.
217
- *
218
- * @param {HTMLElement|null} el - The target element.
219
- * @param {string} prop - CSS property name (camelCase).
220
- * @returns {boolean} `true` if the property has a non-empty inline value.
221
- */
222
- export function hasStyle(el, prop) {
223
- if (!el || !prop) return false;
224
- return el.style[prop] !== "";
225
- }
226
-
227
- /**
228
- * Logs all inline styles of an element to the console in a readable format.
229
- * Useful for debugging style issues during development.
230
- *
231
- * @param {HTMLElement|null} el - The target element.
232
- * @param {string} [label="Styles"] - Label prefix shown in the console output.
233
- * @returns {Object|null} The styles object (same as `getStyles(el)`), or `null`
234
- * when `el` is falsy.
235
- */
236
- export function logStyles(el, label = "Styles") {
237
- if (!el) {
238
- console.warn("⚠️ Invalid element");
239
- return null;
240
- }
241
- const styles = getStyles(el);
242
- console.log(`📋 ${label}:`);
243
- if (Object.keys(styles).length === 0) {
244
- console.log(" (No inline styles)");
245
- } else {
246
- Object.entries(styles).forEach(([p, v]) => console.log(` ${p}: ${v}`));
247
- }
248
- return styles;
249
- }
@@ -137,6 +137,11 @@ export const ALL_PROPS = [
137
137
  { prop: "justify", domProp: "justifyContent", type: "style", unit: "none", reactive: true },
138
138
  { prop: "justifyContent", domProp: "justifyContent", type: "style", unit: "none", reactive: true },
139
139
  { prop: "alignItems", domProp: "alignItems", type: "style", unit: "none", reactive: true },
140
+ // The child's own answer to the parent's `alignItems`: a control that stays where
141
+ // it wants inside a row that stretches everything else. Routed by the engine, so
142
+ // it belongs to every widget that can be a flex child, not only to the layouts.
143
+ { prop: "alignSelf", domProp: "alignSelf", type: "style", unit: "none", reactive: true },
144
+ { prop: "selfAlign", domProp: "alignSelf", type: "style", unit: "none", reactive: true },
140
145
  { prop: "wrap", domProp: "flexWrap", type: "style", unit: "none", reactive: true, boolValues: { true: "wrap", false: "nowrap" } },
141
146
  { prop: "flexWrap", domProp: "flexWrap", type: "style", unit: "none", reactive: true, boolValues: { true: "wrap", false: "nowrap" } },
142
147
  { prop: "flex", domProp: "flex", type: "style", unit: "none", reactive: true },
@@ -104,7 +104,7 @@ export function WidgetFactory(tag, customProps = {}) {
104
104
  // ============================================================
105
105
  // 3. CREATE BASE WIDGET
106
106
  // ============================================================
107
- const widget = createWidget(finalTag, {});
107
+ const widget = createWidget(finalTag);
108
108
  widget._widgetName = widgetName;
109
109
  widget._updating = false;
110
110
  widget._events = [];
@@ -41,7 +41,7 @@ const ACCORDION_PROP_KEYS = [
41
41
  * @param {Function} [props.onToggle] - @deprecated Use `onPress` instead. Kept as alias for backward compatibility.
42
42
  * @param {'contained'|'outlined'|'ghost'} [props.variant='contained'] - Visual style variant.
43
43
  * @param {number} [props.borderRadius=8] - Corner radius in pixels.
44
- * @param {string} [props.bgColor=colors.surface] - Background color of the container (used in 'contained' variant).
44
+ * @param {string} [props.bgColor=colors.primaryBg] - Background color of the container (used in 'contained' variant).
45
45
  * @param {string} [props.titleColor=colors.text] - Text color of the title when collapsed.
46
46
  * @param {string} [props.expandedColor=colors.primary] - Accent color applied to the title-bar background when expanded.
47
47
  * @param {string|null} [props.border=null] - Explicit CSS border shorthand. Overrides variant-based border logic.
@@ -63,6 +63,8 @@ const ACCORDION_PROP_KEYS = [
63
63
  * @param {number} [props.elevation=0] - Box-shadow depth level (0–4).
64
64
  * @returns {HTMLElement} The accordion container element, augmented with public methods.
65
65
  */
66
+ let accordionIdSeed = 0;
67
+
66
68
  export const Accordion = (props) => {
67
69
  const {
68
70
  title,
@@ -73,7 +75,13 @@ export const Accordion = (props) => {
73
75
  variant = "contained", // 'contained', 'outlined', 'ghost'
74
76
  borderRadius = 8,
75
77
  // Colors
76
- bgColor = colors.surface,
78
+ //
79
+ // A 'contained' panel is a plate, so it has to read as one. `colors.surface`
80
+ // is the card an accordion already sits on — 1.00:1 in dark, 1.04:1 in light —
81
+ // which made the variant indistinguishable from 'ghost'. `primaryBg` is the
82
+ // palette's raised plate: 1.19:1 over the dark card (the overlay Material
83
+ // uses for elevation) and the same near-white step in light.
84
+ bgColor = colors.primaryBg,
77
85
  titleColor = colors.text,
78
86
  expandedColor = colors.primary,
79
87
  // Borders
@@ -128,6 +136,16 @@ export const Accordion = (props) => {
128
136
  let currentElevation = elevation;
129
137
  let currentAnimationDuration = animationDuration;
130
138
 
139
+ // The header and its panel reference each other (`aria-controls` one way,
140
+ // `aria-labelledby` back), so both need ids. Derived from the caller's `id`
141
+ // when there is one, the way `Input` does it, so a re-render keeps the pairing.
142
+ const baseId =
143
+ typeof rest.id === "string" && rest.id
144
+ ? rest.id
145
+ : `fletbox-accordion-${++accordionIdSeed}`;
146
+ const headerId = `${baseId}-header`;
147
+ const panelId = `${baseId}-panel`;
148
+
131
149
  // DOM references populated during widget construction
132
150
  let contentWrapper = null;
133
151
  let contentInner = null;
@@ -172,6 +190,13 @@ export const Accordion = (props) => {
172
190
  titleBar.style.opacity = currentDisabled ? "0.5" : "1";
173
191
  titleBar.style.padding = toREM(currentTitlePadding);
174
192
 
193
+ // `update()` cannot re-run the factory for the header, so the state the
194
+ // header announces lives here: a `disabled: true` update has to take the
195
+ // hinge off the Tab path, and an `expanded` change has to be answered.
196
+ titleBar.setAttribute("aria-expanded", String(isExpanded));
197
+ titleBar.setAttribute("aria-disabled", String(Boolean(currentDisabled)));
198
+ titleBar.tabIndex = currentDisabled ? -1 : 0;
199
+
175
200
  // Tint the title bar background slightly when expanded (contained only)
176
201
  if (currentVariant === "contained") {
177
202
  titleBar.style.backgroundColor = isExpanded
@@ -181,12 +206,17 @@ export const Accordion = (props) => {
181
206
  titleBar.style.backgroundColor = "transparent";
182
207
  }
183
208
 
184
- // Show/hide the horizontal divider between title and content
185
- if (currentDivider && isExpanded) {
186
- contentInner.style.borderTop = `1px solid ${currentDividerColor}`;
187
- } else {
188
- contentInner.style.borderTop = "none";
189
- }
209
+ // The header's hairline is the only thing that says "this row is a panel".
210
+ // The default 'contained' variant paints `colors.surface`, which measures
211
+ // 1.00:1 against a dark card and 1.04:1 against a light one, `elevation`
212
+ // starts at 0 and the border belongs to 'outlined' — so a shut accordion used
213
+ // to be a sentence with a chevron on it, and nothing marked it as pressable.
214
+ // The line belongs to the header, not to the gap below it: it is there open
215
+ // or shut, so it never doubles when the content appears.
216
+ titleBar.style.borderBottom = currentDivider
217
+ ? `1px solid ${currentDividerColor}`
218
+ : "none";
219
+ contentInner.style.borderTop = "none";
190
220
 
191
221
  // Apply border according to priority: explicit > outlined variant > none
192
222
  if (currentBorder) {
@@ -271,6 +301,10 @@ export const Accordion = (props) => {
271
301
 
272
302
  isAnimating = true;
273
303
  isExpanded = true;
304
+ // The disclosure state is answered at the press, not when the height
305
+ // animation lands: `updateUI` runs at the end of the transition, and a reader
306
+ // told "collapsed" while the panel is already opening is told a lie.
307
+ titleBar.setAttribute("aria-expanded", "true");
274
308
 
275
309
  // Make the content visible before measuring it
276
310
  contentWrapper.style.display = "block";
@@ -307,6 +341,10 @@ export const Accordion = (props) => {
307
341
  if (!isExpanded) return;
308
342
 
309
343
  isAnimating = true;
344
+ isExpanded = false;
345
+ // Same as `expand`: the disclosure answers for the state the press caused,
346
+ // not for the height the transition has not finished animating.
347
+ titleBar.setAttribute("aria-expanded", "false");
310
348
 
311
349
  // Pin the height to a pixel value first — "auto" cannot be animated
312
350
  const currentHeight = contentInner.scrollHeight + "px";
@@ -395,6 +433,21 @@ export const Accordion = (props) => {
395
433
 
396
434
  // Clickable title bar that contains the text and icon
397
435
  titleBar = Row({
436
+ // The press is a prop, not an assignment made after `Row()` has returned:
437
+ // the engine reads `widget.onclick` while the widget is built, and a header
438
+ // wired afterwards is a hinge only a mouse can operate.
439
+ onPress: () => toggle(),
440
+ role: "button",
441
+ id: headerId,
442
+ // A disclosure has to say which way it opens. Without `aria-expanded` a reader
443
+ // hears the same words for a closed section and an open one.
444
+ "aria-expanded": String(isExpanded),
445
+ "aria-controls": panelId,
446
+ "aria-disabled": String(Boolean(currentDisabled)),
447
+ tabIndex: currentDisabled ? -1 : 0,
448
+ // A header spans the whole card; the 2% press scale would move the title and
449
+ // the chevron with it.
450
+ disableTransform: true,
398
451
  alignItems: "center",
399
452
  justifyContent: "space-between",
400
453
  gap: 8,
@@ -411,6 +464,11 @@ export const Accordion = (props) => {
411
464
  // Outer wrapper whose height is animated — starts hidden when collapsed
412
465
  contentWrapper = WidgetFactory({
413
466
  tag: "div",
467
+ id: panelId,
468
+ // The panel is a landmark a reader can jump to from the header; `aria-labelledby`
469
+ // is what names it, since the title lives in the button rather than in a heading.
470
+ role: "region",
471
+ "aria-labelledby": headerId,
414
472
  overflow: "hidden",
415
473
  transition: animate
416
474
  ? `height ${currentAnimationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
@@ -436,9 +494,6 @@ export const Accordion = (props) => {
436
494
  // Sync all styles with current state
437
495
  updateUI();
438
496
 
439
- // Wire up the click handler on the title bar
440
- titleBar.onclick = () => toggle();
441
-
442
497
  // Start watching for content size changes
443
498
  setupResizeObserver();
444
499
 
@@ -491,7 +546,10 @@ export const Accordion = (props) => {
491
546
  }
492
547
  }
493
548
  }
494
- if (newProps.disabled !== undefined) currentDisabled = newProps.disabled;
549
+ if (newProps.disabled !== undefined && newProps.disabled !== currentDisabled) {
550
+ currentDisabled = newProps.disabled;
551
+ needsUIUpdate = true;
552
+ }
495
553
  if (newProps.variant !== undefined) currentVariant = newProps.variant;
496
554
  if (newProps.bgColor !== undefined) currentBgColor = newProps.bgColor;
497
555
  if (newProps.expandedColor !== undefined)
@@ -195,7 +195,7 @@ export const AdMob = (props) => {
195
195
  tag: "span",
196
196
  text: currentLabel.toUpperCase(),
197
197
  color,
198
- fontSize: 11,
198
+ fontSize: 12,
199
199
  fontWeight: 600,
200
200
  letterSpacing: 2,
201
201
  userSelect: "none",
@@ -176,7 +176,7 @@ export const AdSense = (props) => {
176
176
  tag: "span",
177
177
  text: label.toUpperCase(),
178
178
  color,
179
- fontSize: 11,
179
+ fontSize: 12,
180
180
  fontWeight: 600,
181
181
  letterSpacing: 2,
182
182
  userSelect: "none",
@@ -47,21 +47,24 @@ export const AlertDialog = (props) => {
47
47
  ...rest
48
48
  } = props;
49
49
 
50
- // Map each variant to its icon name, icon color, and confirm-button color
50
+ // Map each variant to its icon name, icon colour, and confirm-button plate.
51
+ // The signal tones (`colors.danger` and friends) are words-on-a-page inks; a
52
+ // filled confirm carries a label, so it takes the plate that label can sit on
53
+ // and lets Button pick the ink from the fill (`readableInk`).
51
54
  const variantColors = {
52
55
  normal: { confirm: colors.primary, icon: "info", iconColor: colors.info },
53
56
  danger: {
54
- confirm: colors.danger,
57
+ confirm: colors.dangerPlate,
55
58
  icon: "warning",
56
59
  iconColor: colors.danger,
57
60
  },
58
61
  warning: {
59
- confirm: colors.warning,
62
+ confirm: colors.warningPlate,
60
63
  icon: "error",
61
64
  iconColor: colors.warning,
62
65
  },
63
66
  success: {
64
- confirm: colors.success,
67
+ confirm: colors.successPlate,
65
68
  icon: "check_circle",
66
69
  iconColor: colors.success,
67
70
  },
@@ -109,9 +112,10 @@ export const AlertDialog = (props) => {
109
112
  actions.push(
110
113
  Button({
111
114
  text: cancelText,
112
- borderColor: colors.danger,
113
- variant: "filled",
114
- color: colors.danger,
115
+ // Cancel is the way out, not the decision: a second filled plate made
116
+ // both answers shout, and the red label measured 3.61:1 on the surface.
117
+ variant: "text",
118
+ color: colors.dangerStrong,
115
119
  onPress: () => {
116
120
  if (onCancel) onCancel();
117
121
  closeModal();
@@ -125,8 +129,6 @@ export const AlertDialog = (props) => {
125
129
  text: confirmText,
126
130
  variant: "filled",
127
131
  bgColor: variantStyle.confirm,
128
- color: colors.text,
129
- borderColor: colors.surface,
130
132
  onPress: () => {
131
133
  if (onConfirm) onConfirm();
132
134
  closeModal();
@@ -142,7 +142,16 @@ export const Avatar = (inputProps = {}) => {
142
142
  /** Chooses the child element for the props currently held. */
143
143
  const buildContent = () => {
144
144
  if (src) {
145
- return Image({ src, width: "100%", height: "100%", fit: "cover" });
145
+ return Image({
146
+ src,
147
+ width: "100%",
148
+ height: "100%",
149
+ fit: "cover",
150
+ // The avatar is decoration around the person's name, which the screen
151
+ // reads somewhere else. An `alt` here repeats a name the reader already
152
+ // hears; the empty string is the decision, not the omission.
153
+ alt: "",
154
+ });
146
155
  }
147
156
  if (icon) return Icon({ name: icon, size: size * 0.5, color: textColor });
148
157
  if (name) {
@@ -149,10 +149,16 @@ export const Badge = (props) => {
149
149
  return badgeElement;
150
150
  }
151
151
 
152
- // Wrap the child and the badge in a Stack so the badge is positioned relative to the child
152
+ // Wrap the child and the badge in a Stack so the badge is positioned relative to
153
+ // the child. The wrapper has to BE the child's box: `display: inline-block` only
154
+ // shrinks to fit inside a block parent, and the common parent here is a flex
155
+ // Column, whose default `align-items: stretch` pulled the anchor to the full row
156
+ // width — so a badge on a 28px icon landed 250px away from it, on the far edge of
157
+ // the card. `alignSelf` is what resists that stretch.
153
158
  const badgeStack = Stack({
154
159
  position: "relative",
155
160
  display: "inline-block",
161
+ alignSelf: "flex-start",
156
162
  children: [validChild, badgeElement],
157
163
  ...rest,
158
164
  });