flet-box 1.0.7 → 1.0.9

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 (139) hide show
  1. package/README.md +3 -0
  2. package/bin/cli.js +13 -0
  3. package/bin/commands/checkApi.js +13 -1
  4. package/bin/commands/checkUi.js +1369 -0
  5. package/bin/commands/create.js +49 -5
  6. package/bin/commands/createBundle.js +21 -0
  7. package/bin/commands/runServer.js +11 -2
  8. package/bin/utils/pwaShell.js +137 -0
  9. package/bin/utils/templates.js +1398 -443
  10. package/docs/README.md +4 -0
  11. package/docs/cli/README.md +63 -2
  12. package/docs/guides/README.md +2 -0
  13. package/docs/guides/clean-ui.md +211 -0
  14. package/docs/guides/minification-and-protection.md +1 -1
  15. package/docs/guides/mobile-and-platforms.md +31 -0
  16. package/docs/guides/online-updates.md +48 -0
  17. package/docs/tools/README.md +1 -1
  18. package/docs/tools/animation.md +1 -1
  19. package/docs/tools/markdown.md +3 -3
  20. package/docs/tools/theme.md +42 -1
  21. package/docs/widget/Accordion.md +3 -0
  22. package/docs/widget/AdMob.md +4 -1
  23. package/docs/widget/AdSense.md +3 -0
  24. package/docs/widget/AnimatedBox.md +6 -3
  25. package/docs/widget/AnimatedText.md +3 -0
  26. package/docs/widget/Audio.md +3 -0
  27. package/docs/widget/Avatar.md +3 -0
  28. package/docs/widget/Badge.md +3 -0
  29. package/docs/widget/Button.md +3 -0
  30. package/docs/widget/COMMON_PROPS.md +52 -0
  31. package/docs/widget/Card.md +3 -0
  32. package/docs/widget/Carousel.md +3 -0
  33. package/docs/widget/Chart.md +3 -0
  34. package/docs/widget/Checkbox.md +3 -0
  35. package/docs/widget/Chip.md +3 -0
  36. package/docs/widget/CircularBar.md +3 -0
  37. package/docs/widget/CircularChart.md +3 -0
  38. package/docs/widget/CodeViewer.md +3 -0
  39. package/docs/widget/Column.md +3 -0
  40. package/docs/widget/Container.md +3 -0
  41. package/docs/widget/DataTable.md +3 -0
  42. package/docs/widget/Divider.md +3 -0
  43. package/docs/widget/DraggBox.md +3 -0
  44. package/docs/widget/Dropdown.md +3 -0
  45. package/docs/widget/DroppBox.md +3 -0
  46. package/docs/widget/FloatingActionButton.md +3 -0
  47. package/docs/widget/GridView.md +3 -0
  48. package/docs/widget/Icon.md +3 -0
  49. package/docs/widget/Image.md +3 -0
  50. package/docs/widget/Input.md +3 -0
  51. package/docs/widget/Inspector.md +2 -2
  52. package/docs/widget/InstallButton.md +3 -0
  53. package/docs/widget/ListTile.md +3 -0
  54. package/docs/widget/ListView.md +3 -0
  55. package/docs/widget/Maps.md +3 -0
  56. package/docs/widget/Markdown.md +3 -0
  57. package/docs/widget/MatrixRain.md +3 -0
  58. package/docs/widget/Pagination.md +3 -0
  59. package/docs/widget/ParallaxBox.md +3 -0
  60. package/docs/widget/ProgressBar.md +3 -0
  61. package/docs/widget/QRCode.md +3 -0
  62. package/docs/widget/README.md +2 -0
  63. package/docs/widget/Radio.md +4 -1
  64. package/docs/widget/Rating.md +3 -0
  65. package/docs/widget/Row.md +3 -0
  66. package/docs/widget/Skeleton.md +3 -0
  67. package/docs/widget/Slider.md +3 -0
  68. package/docs/widget/Stack.md +3 -0
  69. package/docs/widget/Stepper.md +3 -0
  70. package/docs/widget/Switch.md +3 -0
  71. package/docs/widget/Tabs.md +5 -5
  72. package/docs/widget/Text.md +3 -0
  73. package/docs/widget/Tooltip.md +3 -0
  74. package/docs/widget/TreeView.md +3 -0
  75. package/docs/widget/Video.md +3 -0
  76. package/package.json +2 -2
  77. package/src/animations/AnimatedBox.js +4 -1
  78. package/src/animations/MatrixRain.js +3 -3
  79. package/src/index.d.ts +64 -8
  80. package/src/index.js +1 -0
  81. package/src/navigations/AppBar.js +7 -12
  82. package/src/navigations/BottomNavigation.js +8 -13
  83. package/src/navigations/CollapsibleSideBar.js +30 -8
  84. package/src/navigations/Drawer.js +16 -14
  85. package/src/navigations/DrawerItem.js +3 -1
  86. package/src/navigations/Scaffold.js +26 -12
  87. package/src/navigations/Tabs.js +104 -59
  88. package/src/tools/color.js +54 -0
  89. package/src/tools/index.js +9 -1
  90. package/src/tools/scale.js +41 -0
  91. package/src/tools/shadow.js +20 -0
  92. package/src/utils/index.js +1 -0
  93. package/src/utils/readableInk.js +19 -0
  94. package/src/utils/syntaxHighlight.js +78 -10
  95. package/src/utils/themes.js +265 -58
  96. package/src/utils/units.js +33 -11
  97. package/src/widget-factory/effects.js +56 -5
  98. package/src/widget-factory/processProps.js +10 -1
  99. package/src/widget-factory/remTool.js +15 -29
  100. package/src/widget-factory/stackPosition.js +18 -3
  101. package/src/widget-factory/translateProps.js +28 -0
  102. package/src/widgets/Accordion.js +20 -23
  103. package/src/widgets/AdMob.js +7 -2
  104. package/src/widgets/Avatar.js +18 -12
  105. package/src/widgets/Badge.js +3 -2
  106. package/src/widgets/BottomSheet.js +14 -13
  107. package/src/widgets/Button.js +69 -30
  108. package/src/widgets/Card.js +6 -2
  109. package/src/widgets/Carousel.js +12 -10
  110. package/src/widgets/Chart.js +28 -5
  111. package/src/widgets/Checkbox.js +76 -18
  112. package/src/widgets/Chip.js +31 -10
  113. package/src/widgets/CircularBar.js +7 -7
  114. package/src/widgets/CircularChart.js +26 -8
  115. package/src/widgets/CodeViewer.js +93 -20
  116. package/src/widgets/DataTable.js +17 -11
  117. package/src/widgets/DraggBox.js +2 -1
  118. package/src/widgets/Dropdown.js +26 -13
  119. package/src/widgets/DroppBox.js +4 -2
  120. package/src/widgets/FloatingActionButton.js +13 -7
  121. package/src/widgets/Icon.js +3 -1
  122. package/src/widgets/Input.js +30 -23
  123. package/src/widgets/Inspector.js +54 -1
  124. package/src/widgets/InstallButton.js +6 -4
  125. package/src/widgets/ListTile.js +4 -3
  126. package/src/widgets/ListView.js +30 -12
  127. package/src/widgets/Markdown.js +37 -26
  128. package/src/widgets/Modal.js +14 -11
  129. package/src/widgets/Pagination.js +16 -11
  130. package/src/widgets/ProgressBar.js +6 -4
  131. package/src/widgets/Radio.js +73 -15
  132. package/src/widgets/Rating.js +24 -6
  133. package/src/widgets/Skeleton.js +31 -31
  134. package/src/widgets/Slider.js +29 -18
  135. package/src/widgets/SnackBar.js +35 -39
  136. package/src/widgets/Stepper.js +13 -9
  137. package/src/widgets/Switch.js +59 -18
  138. package/src/widgets/Tooltip.js +12 -10
  139. package/src/widgets/TreeView.js +27 -15
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { injectKeyframes } from "../utils/styleInjector.js";
7
+ import { toREM, getBaseFontSize } from "../utils/units.js";
7
8
 
8
9
  /**
9
10
  * @typedef {Object} ListViewProps
@@ -11,8 +12,8 @@ import { injectKeyframes } from "../utils/styleInjector.js";
11
12
  * @property {Function} renderItem - Called with `(item, index)` and must return an `HTMLElement`.
12
13
  * @property {number|string} [height=400] - Height of the scroll container. Overridden when `expand` is true.
13
14
  * @property {number|string} [width="100%"] - Width of the widget.
14
- * @property {number} [itemSize=60] - Fixed height of each row in pixels (required for virtualization math).
15
- * @property {number} [gap=0] - Vertical gap between rows in pixels.
15
+ * @property {number} [itemSize=60] - Fixed height of each row in framework units, i.e. it grows with the base font (required for virtualization math).
16
+ * @property {number} [gap=0] - Vertical gap between rows in framework units.
16
17
  * @property {number} [bufferSize=5] - Number of extra rows to render above and below the visible viewport.
17
18
  * Larger values reduce the chance of blank flashes during fast scrolling at the cost of more DOM nodes.
18
19
  * @property {boolean} [showsScrollIndicator=true] - Whether to show the native scrollbar.
@@ -140,6 +141,23 @@ export const ListView = (props) => {
140
141
 
141
142
  // ---- Helper functions ----
142
143
 
144
+ /**
145
+ * Rendered pixels per framework unit, read from the live root font size.
146
+ *
147
+ * `itemSize` and `gap` are framework units, so the row they reserve grows with
148
+ * the reader's font like every other box. The virtualisation math compares
149
+ * against `scrollTop` and `clientHeight`, which are always CSS pixels, so the
150
+ * two spaces have to be joined here: a 60-unit row is 90px of scroll space for
151
+ * a reader on a 24px base, and rows written straight from the unit overlap.
152
+ */
153
+ const unitPx = () => {
154
+ if (typeof document === "undefined") return 1;
155
+ const root = parseFloat(getComputedStyle(document.documentElement).fontSize);
156
+ return root > 0 ? root / getBaseFontSize() : 1;
157
+ };
158
+ const slotHeight = () => (itemSize + gap) * unitPx();
159
+ const gapHeight = () => gap * unitPx();
160
+
143
161
  /** @returns {number} Total number of rows (accounting for grid columns). */
144
162
  const getTotalRows = () => Math.ceil(_data.length / cols);
145
163
 
@@ -159,7 +177,7 @@ export const ListView = (props) => {
159
177
  */
160
178
  const getItemTop = (index) => {
161
179
  const row = Math.floor(index / cols);
162
- return row * (itemSize + gap);
180
+ return row * slotHeight();
163
181
  };
164
182
 
165
183
  /**
@@ -171,7 +189,7 @@ export const ListView = (props) => {
171
189
  const getTotalHeight = () => {
172
190
  const rows = getTotalRows();
173
191
  if (rows === 0) return 0;
174
- return Math.max(0, rows * (itemSize + gap) - gap);
192
+ return Math.max(0, rows * slotHeight() - gapHeight());
175
193
  };
176
194
 
177
195
  /** Applies the correct logical height to the inner container. */
@@ -190,7 +208,7 @@ export const ListView = (props) => {
190
208
  if (!scrollContainer) return;
191
209
 
192
210
  const viewportH = scrollContainer.clientHeight;
193
- const itemTotalH = itemSize + gap;
211
+ const itemTotalH = slotHeight();
194
212
  // Convert the scroll position into ITEM space: rows start below the header, so
195
213
  // the first rows are still on screen while `scrollTop` is inside the header.
196
214
  const itemsTop = Math.max(0, scrollContainer.scrollTop - getHeaderHeight());
@@ -228,7 +246,7 @@ export const ListView = (props) => {
228
246
  console.error("Error in renderItem:", err);
229
247
  itemEl = document.createElement("div");
230
248
  itemEl.textContent = "Error";
231
- itemEl.style.padding = "8px";
249
+ itemEl.style.padding = toREM(8);
232
250
  }
233
251
  }
234
252
  if (!itemEl) continue;
@@ -240,12 +258,12 @@ export const ListView = (props) => {
240
258
  const col = i % cols;
241
259
  wrapper.style.left = `${(col / cols) * 100}%`;
242
260
  wrapper.style.width = `${100 / cols}%`;
243
- wrapper.style.padding = `0 ${gap / 2}px`;
261
+ wrapper.style.padding = `0 ${toREM(gap / 2)}`;
244
262
  wrapper.style.boxSizing = "border-box";
245
263
  } else {
246
264
  wrapper.style.left = "0";
247
265
  wrapper.style.right = "0";
248
- wrapper.style.padding = `0 ${gap}px`;
266
+ wrapper.style.padding = `0 ${toREM(gap)}`;
249
267
  }
250
268
  wrapper.appendChild(itemEl);
251
269
  fragment.appendChild(wrapper);
@@ -367,8 +385,8 @@ export const ListView = (props) => {
367
385
  refreshIndicator.style.transition = "height 0.2s";
368
386
 
369
387
  const spinner = document.createElement("div");
370
- spinner.style.width = "24px";
371
- spinner.style.height = "24px";
388
+ spinner.style.width = toREM(24);
389
+ spinner.style.height = toREM(24);
372
390
  spinner.style.border = "2px solid #e0e0e0";
373
391
  spinner.style.borderTop = "2px solid #007aff";
374
392
  spinner.style.borderRadius = "50%";
@@ -401,7 +419,7 @@ export const ListView = (props) => {
401
419
  if (h >= 60) {
402
420
  // Threshold met — trigger the refresh
403
421
  _refreshing = true;
404
- refreshIndicator.style.height = "60px";
422
+ refreshIndicator.style.height = toREM(60);
405
423
  onRefresh(() => {
406
424
  _refreshing = false;
407
425
  refreshIndicator.style.height = "0";
@@ -498,7 +516,7 @@ export const ListView = (props) => {
498
516
  set: (val) => {
499
517
  _refreshing = val;
500
518
  if (refreshIndicator)
501
- refreshIndicator.style.height = _refreshing ? "60px" : "0";
519
+ refreshIndicator.style.height = _refreshing ? toREM(60) : "0";
502
520
  },
503
521
  enumerable: true,
504
522
  });
@@ -16,6 +16,8 @@ import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
16
16
  * @param {string} text - Raw text to escape.
17
17
  * @returns {string} Text with `&`, `<`, `>`, `"` and `'` replaced by entities.
18
18
  */
19
+ import { toREM } from "../utils/units.js";
20
+
19
21
  const escapeCode = (text) =>
20
22
  String(text)
21
23
  .replace(/&/g, "&amp;")
@@ -633,25 +635,31 @@ const parseMarkdown = (text) => {
633
635
  `<a href="${escapeCode(href)}" target="_blank" rel="noopener">${label}</a>`,
634
636
  );
635
637
 
636
- // Unordered lists — wrap consecutive <li> items produced by the "-" rule in
637
- // a single <ul>. The replacement uses a non-greedy match anchored to the
638
- // first <li> so it does not accidentally swallow ordered-list items too.
639
- // Step 1: convert each "- item" line into a raw <li>.
640
- html = html.replace(/^[ \t]*-[ \t]+(.*$)/gm, "<li>$1</li>");
641
- // Step 2: group consecutive unordered <li> runs. We tag them with a
642
- // data-ul attribute so the ordered-list pass below does not re-wrap them.
638
+ // Unordered lists — tag each "- item" line as an unordered <li>, then group
639
+ // the consecutive runs. `*` and `+` are CommonMark markers for the same list,
640
+ // so they are accepted too: `* uno` used to stay a literal paragraph. Emphasis
641
+ // has already run by now, which is why `**bold**` and `*italic*` at the start
642
+ // of a line are no longer reachable here. The tag is what keeps the ordered
643
+ // pass below from touching these items; without it, a `- a\n- b` run came out
644
+ // as `<ul><ol><li>a</li></ol><li>b</li></ul>`, so the first bullet rendered as
645
+ // the number "1." one indent level deeper than the second.
646
+ html = html.replace(/^[ \t]*[-*+][ \t]+(.*$)/gm, '<li data-list="ul">$1</li>');
643
647
  html = html.replace(
644
- /(<li>(?:(?!<\/ol>).)*?<\/li>(\n|$))+/gs,
645
- (match) => `<ul>${match.replace(/\n$/, "")}</ul>\n`,
648
+ /(<li data-list="ul">(?:(?!<\/li>).)*?<\/li>(\n|$))+/g,
649
+ (match) =>
650
+ `<ul>${match
651
+ .replace(/\n$/, "")
652
+ .replace(/<li data-list="(?:ul|ol)">/g, "<li>")}</ul>\n`,
646
653
  );
647
654
 
648
- // Ordered lists
649
- // Step 1: convert each "1. item" line into a raw <li>.
650
- html = html.replace(/^[ \t]*\d+\.[ \t]+(.*$)/gm, "<li>$1</li>");
651
- // Step 2: group consecutive ordered <li> runs that are NOT already inside a <ul>.
655
+ // Ordered lists — the same two steps with their own tag.
656
+ html = html.replace(/^[ \t]*\d+\.[ \t]+(.*$)/gm, '<li data-list="ol">$1</li>');
652
657
  html = html.replace(
653
- /(<li>(?:(?!<\/ul>).)*?<\/li>(\n|$))+/gs,
654
- (match) => `<ol>${match.replace(/\n$/, "")}</ol>\n`,
658
+ /(<li data-list="ol">(?:(?!<\/li>).)*?<\/li>(\n|$))+/g,
659
+ (match) =>
660
+ `<ol>${match
661
+ .replace(/\n$/, "")
662
+ .replace(/<li data-list="(?:ul|ol)">/g, "<li>")}</ol>\n`,
655
663
  );
656
664
 
657
665
  // Blockquotes
@@ -842,7 +850,10 @@ export const Markdown = (props) => {
842
850
 
843
851
  // List styling
844
852
  listMargin = "0em 0",
845
- listPadding = "0 0 0 1em",
853
+ // An outside marker hangs 1.5px left of the li text at 14px font, so a 1em
854
+ // indent leaves the disc straddling the root's left edge and `overflow: auto`
855
+ // clips half of it. The extra 0.25em keeps the whole marker inside.
856
+ listPadding = "0 0 0 1.25em",
846
857
  listItemMargin = "0.0em 0",
847
858
 
848
859
  // Image styling
@@ -877,15 +888,15 @@ export const Markdown = (props) => {
877
888
  tag: "div",
878
889
  widgetName: "Markdown",
879
890
  fontFamily: fontFamily,
880
- fontSize: typeof fontSize === "number" ? `${fontSize}px` : fontSize,
891
+ fontSize: toREM(fontSize),
881
892
  lineHeight: lineHeight,
882
893
  color: color,
883
- padding: typeof padding === "number" ? `${padding}px` : padding,
884
- maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
894
+ padding: toREM(padding),
895
+ maxHeight: toREM(maxHeight),
885
896
  overflow: overflow,
886
897
  backgroundColor: backgroundColor,
887
898
  borderRadius:
888
- typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
899
+ toREM(borderRadius),
889
900
  ...rest,
890
901
  });
891
902
 
@@ -1026,9 +1037,9 @@ export const Markdown = (props) => {
1026
1037
  background-color: ${codeBgColor};
1027
1038
  color: ${codeColor};
1028
1039
  padding: ${codePadding};
1029
- border-radius: ${typeof codeBorderRadius === "number" ? `${codeBorderRadius}px` : codeBorderRadius};
1040
+ border-radius: ${toREM(codeBorderRadius)};
1030
1041
  font-family: ${codeFontFamily};
1031
- font-size: ${typeof codeFontSize === "number" ? `${codeFontSize}px` : codeFontSize};
1042
+ font-size: ${toREM(codeFontSize)};
1032
1043
  }
1033
1044
 
1034
1045
  /* Bare <pre> elements (raw HTML injected via allowDangerousHtml or future extensions).
@@ -1036,7 +1047,7 @@ export const Markdown = (props) => {
1036
1047
  .markdown-content pre:not(.md-code-block pre) {
1037
1048
  background-color: ${preBgColor};
1038
1049
  padding: ${prePadding};
1039
- border-radius: ${typeof preBorderRadius === "number" ? `${preBorderRadius}px` : preBorderRadius};
1050
+ border-radius: ${toREM(preBorderRadius)};
1040
1051
  overflow: auto;
1041
1052
  overflow-x: auto;
1042
1053
  margin: ${preMargin};
@@ -1076,7 +1087,7 @@ export const Markdown = (props) => {
1076
1087
  .markdown-content img {
1077
1088
  max-width: ${imageMaxWidth};
1078
1089
  height: auto;
1079
- border-radius: ${typeof imageBorderRadius === "number" ? `${imageBorderRadius}px` : imageBorderRadius};
1090
+ border-radius: ${toREM(imageBorderRadius)};
1080
1091
  }
1081
1092
 
1082
1093
  .markdown-content hr {
@@ -1096,7 +1107,7 @@ export const Markdown = (props) => {
1096
1107
  max-width: 100%;
1097
1108
  min-width: 0;
1098
1109
  box-sizing: border-box;
1099
- border-radius: ${typeof preBorderRadius === "number" ? `${preBorderRadius}px` : preBorderRadius};
1110
+ border-radius: ${toREM(preBorderRadius)};
1100
1111
  overflow: hidden;
1101
1112
  margin-top: 1em;
1102
1113
  margin-bottom: 1em;
@@ -1172,7 +1183,7 @@ export const Markdown = (props) => {
1172
1183
  /* Padding on <code> (not <pre>) keeps the scrollbar aligned. */
1173
1184
  padding: ${prePadding};
1174
1185
  font-family: ${codeFontFamily};
1175
- font-size: ${typeof codeFontSize === "number" ? `${codeFontSize}px` : codeFontSize};
1186
+ font-size: ${toREM(codeFontSize)};
1176
1187
  /* 1.5 matches VS Code / GitHub's code block density.
1177
1188
  Setting it here AND on md-code-line stops any ancestor
1178
1189
  line-height (the widget's default 1.6) from bleeding in. */
@@ -14,6 +14,8 @@ import { Icon } from "./Icon.js";
14
14
 
15
15
  // ========== INTERNAL COMPONENTS ==========
16
16
 
17
+ import { toREM } from "../utils/units.js";
18
+
17
19
  const ModalOverlay = ({
18
20
  closeOnOverlayClick,
19
21
  onClose,
@@ -42,10 +44,11 @@ const ModalOverlay = ({
42
44
  timing: "ease",
43
45
  delay: 0.1,
44
46
  }),
45
- // transition: "opacity 0.3s ease, visibility 0.3s ease",
46
- // onclick: () => {
47
- // if (closeOnOverlayClick) onClose();
48
- // },
47
+ onclick: (e) => {
48
+ // The panel is the overlay's only child, so a click whose target is the
49
+ // overlay itself landed on the backdrop and nowhere else.
50
+ if (e.target === e.currentTarget && closeOnOverlayClick) onClose();
51
+ },
49
52
  });
50
53
  };
51
54
 
@@ -224,10 +227,10 @@ const ModalContainer = ({
224
227
  backgroundColor: backgroundColor || colors.surface,
225
228
  borderRadius:
226
229
  typeof borderRadius === "number"
227
- ? `${borderRadius}px`
230
+ ? toREM(borderRadius)
228
231
  : borderRadius || "16px",
229
- width: typeof width === "number" ? `${width}px` : width,
230
- minWidth: typeof minWidth === "number" ? `${minWidth}px` : minWidth,
232
+ width: toREM(width),
233
+ minWidth: toREM(minWidth),
231
234
  maxWidth: maxWidth || "90%",
232
235
  maxHeight: maxHeight || "80vh",
233
236
  overflow: "hidden",
@@ -273,7 +276,7 @@ export const Modal = (props = {}) => {
273
276
 
274
277
  // Main container styles
275
278
  backgroundColor = colors.surface,
276
- borderRadius = 20,
279
+ borderRadius = 16,
277
280
  border = null,
278
281
  borderColor = colors.border,
279
282
  borderWidth = 1,
@@ -281,7 +284,7 @@ export const Modal = (props = {}) => {
281
284
  elevation = 3, // ← New: shadow by elevation
282
285
 
283
286
  // Content padding
284
- padding = "20px",
287
+ padding = 24,
285
288
  contentBgColor = colors.surface,
286
289
  contentElevation = 0, // ← Optional inner shadow
287
290
 
@@ -374,7 +377,7 @@ export const Modal = (props = {}) => {
374
377
 
375
378
  const contentElement = ModalContent({
376
379
  content,
377
- padding: typeof padding === "number" ? `${padding}px` : padding,
380
+ padding: toREM(padding),
378
381
  contentBgColor,
379
382
  contentElevation,
380
383
  });
@@ -468,7 +471,7 @@ export const Modal = (props = {}) => {
468
471
  updatePadding: (newPadding) => {
469
472
  if (contentElement) {
470
473
  contentElement.style.padding =
471
- typeof newPadding === "number" ? `${newPadding}px` : newPadding;
474
+ toREM(newPadding);
472
475
  }
473
476
  },
474
477
 
@@ -8,6 +8,8 @@ import { Button } from "./Button.js";
8
8
  import { Text } from "./Text.js";
9
9
  import { Icon } from "./Icon.js";
10
10
  import { colors } from "../utils/themes.js";
11
+ import { toREM } from "../utils/units.js";
12
+ import { TAP_MINIMUM } from "../tools/scale.js";
11
13
 
12
14
  export const Pagination = (props) => {
13
15
  const {
@@ -56,26 +58,29 @@ export const Pagination = (props) => {
56
58
  };
57
59
 
58
60
  const pageNumbers = getPageNumbers();
61
+ // Every size is at least TAP_MINIMUM units tall: a page number is a thumb
62
+ // target, and the strip may only get smaller by type and gap, never below the
63
+ // 40-unit floor the rest of the framework holds itself to.
59
64
  const sizes = {
60
- small: { buttonSize: 32, fontSize: 12, iconSize: 16, gap: 4 },
61
- medium: { buttonSize: 36, fontSize: 14, iconSize: 20, gap: 6 },
62
- large: { buttonSize: 42, fontSize: 16, iconSize: 24, gap: 8 },
65
+ small: { buttonSize: TAP_MINIMUM, fontSize: 12, iconSize: 16, gap: 4 },
66
+ medium: { buttonSize: TAP_MINIMUM, fontSize: 14, iconSize: 20, gap: 6 },
67
+ large: { buttonSize: 48, fontSize: 16, iconSize: 24, gap: 8 },
63
68
  };
64
69
  const sz = sizes[size] || sizes.medium;
65
70
 
66
71
  const buttonBase = {
67
- minWidth: `${sz.buttonSize}px`,
68
- height: `${sz.buttonSize}px`,
72
+ minWidth: toREM(sz.buttonSize),
73
+ height: toREM(sz.buttonSize),
69
74
  padding: "0",
70
- fontSize: `${sz.fontSize}px`,
75
+ fontSize: toREM(sz.fontSize),
71
76
  };
72
77
 
73
78
  const createPageButton = (page, isActive = false) => {
74
79
  const buttonVariant = isActive ? "filled" : variant;
75
- // FIX: antes `isActive ? color : colors.textSecondary` — pero bgColor también
76
- // es `color`, así que texto y fondo eran el mismo tono → número invisible.
77
- // Uso blanco para el activo (texto sobre fondo de color).
78
- const buttonColor = isActive ? "#ffffff" : colors.textSecondary;
80
+ // The active page is a primary plate, so its number is the theme's ink. It
81
+ // is disabled only to say "you are already here" — disabling it made Button
82
+ // repaint the plate grey and the number grey, erasing the accent.
83
+ const buttonColor = isActive ? colors.onPrimary : colors.textSecondary;
79
84
 
80
85
  return Button({
81
86
  text: String(page),
@@ -85,7 +90,7 @@ export const Pagination = (props) => {
85
90
  size: size,
86
91
  ...buttonBase,
87
92
  onPress: () => handlePageChange(page),
88
- disabled: disabled || isActive,
93
+ disabled: disabled,
89
94
  });
90
95
  };
91
96
 
@@ -6,6 +6,8 @@ import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { colors } from "../utils/themes.js";
7
7
  import { injectKeyframes } from "../utils/styleInjector.js";
8
8
 
9
+ import { toREM } from "../utils/units.js";
10
+
9
11
  export const ProgressBar = (props) => {
10
12
  const {
11
13
  value = 0,
@@ -37,7 +39,7 @@ export const ProgressBar = (props) => {
37
39
  backgroundColor: color,
38
40
  borderRadius:
39
41
  typeof finalBorderRadius === "number"
40
- ? `${finalBorderRadius}px`
42
+ ? toREM(finalBorderRadius)
41
43
  : finalBorderRadius,
42
44
  transition: indeterminate ? "none" : "width 0.3s ease",
43
45
  position: "relative",
@@ -73,11 +75,11 @@ export const ProgressBar = (props) => {
73
75
  // Track background
74
76
  const track = WidgetFactory({
75
77
  flex: 1,
76
- height: typeof height === "number" ? `${height}px` : height,
78
+ height: toREM(height),
77
79
  backgroundColor: backgroundColor,
78
80
  borderRadius:
79
81
  typeof finalBorderRadius === "number"
80
- ? `${finalBorderRadius}px`
82
+ ? toREM(finalBorderRadius)
81
83
  : finalBorderRadius,
82
84
  overflow: "hidden",
83
85
  position: "relative",
@@ -148,7 +150,7 @@ export const ProgressBar = (props) => {
148
150
  flexDirection: isColumn ? "column" : "row",
149
151
  alignItems: "center",
150
152
  gap: "8px",
151
- width: typeof width === "number" ? `${width}px` : width,
153
+ width: toREM(width),
152
154
  children: children,
153
155
  ...rest,
154
156
  });
@@ -3,7 +3,9 @@
3
3
  * @description Radio input with controlled/uncontrolled selection state.
4
4
  */
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
+ import { Text } from "./Text.js";
6
7
  import { colors } from "../utils/themes.js";
8
+ import { TAP_MINIMUM, LABEL_TEXT } from "../tools/index.js";
7
9
 
8
10
  export const Radio = (props) => {
9
11
  const {
@@ -14,6 +16,7 @@ export const Radio = (props) => {
14
16
  size = 20,
15
17
  name,
16
18
  activeColor = colors.primary,
19
+ label = null,
17
20
  ...rest
18
21
  } = props;
19
22
 
@@ -25,10 +28,16 @@ export const Radio = (props) => {
25
28
  let radioRef = null;
26
29
  let dotRef = null;
27
30
 
31
+ // The ring's "off" rim is a control outline, not a hairline: `colors.border`
32
+ // measured 1.2:1 on the dark card and 1.3:1 on white, so an unselected radio
33
+ // was invisible in both themes. `gray500` is the same #71717A in both
34
+ // palettes and clears the 3:1 non-text minimum on each surface.
35
+ const offColor = disabled ? colors.border : colors.gray500;
36
+
28
37
  const updateUI = (newSelected) => {
29
38
  if (radioRef) {
30
39
  radioRef.style.backgroundColor = newSelected ? activeColor : "transparent";
31
- radioRef.style.border = `2px solid ${newSelected ? activeColor : colors.border}`;
40
+ radioRef.style.border = `2px solid ${newSelected ? activeColor : offColor}`;
32
41
  }
33
42
  if (dotRef) {
34
43
  dotRef.style.display = newSelected ? "block" : "none";
@@ -37,32 +46,81 @@ export const Radio = (props) => {
37
46
 
38
47
  // Createste dot
39
48
  const dot = WidgetFactory({
40
- width: size / 2,
41
- height: size / 2,
42
- backgroundColor: "#fff",
49
+ width: Math.max(8, Math.round(size * 0.4)),
50
+ height: Math.max(8, Math.round(size * 0.4)),
51
+ backgroundColor: colors.onPrimary,
43
52
  borderRadius: "50%",
44
53
  display: internalSelected ? "block" : "none",
45
54
  });
46
55
  dotRef = dot;
47
56
 
48
- // Createste main radio container
49
- const radio = WidgetFactory({
50
- widgetName: "Radio",
57
+ // The visual circle: a 2-unit ring and the fill, nothing else.
58
+ const circle = WidgetFactory({
51
59
  width: size,
52
60
  height: size,
53
- border: `2px solid ${disabled ? colors.border : internalSelected ? activeColor : colors.border}`,
61
+ border: `2px solid ${disabled ? colors.border : internalSelected ? activeColor : offColor}`,
54
62
  borderRadius: "50%",
55
63
  backgroundColor: internalSelected ? activeColor : "transparent",
56
64
  display: "flex",
57
65
  alignItems: "center",
58
66
  justifyContent: "center",
59
67
  transition: "all 0.2s ease",
60
- cursor: disabled ? "not-allowed" : "pointer",
61
- opacity: disabled ? 0.5 : 1,
68
+ boxSizing: "border-box",
62
69
  child: dot,
63
- ...rest,
64
70
  });
65
- radioRef = radio;
71
+ radioRef = circle;
72
+
73
+ // Createste main radio container — the finger's box, larger than the ring it
74
+ // centres, because radios are chosen with a thumb and a 20-unit circle is not
75
+ // something a thumb hits reliably.
76
+ //
77
+ // The overhang is given back with a negative margin. A 40-unit box in the
78
+ // layout next to a label stretched the row to 40 and left the text at the top
79
+ // of it, so the ring sat ~10 units below the words and the requested `gap` was
80
+ // silently doubled. The press still lands on the full 40-unit box, which now
81
+ // overflows its layout footprint instead of pushing the row around it.
82
+ const overhang = Math.max(0, (TAP_MINIMUM - size) / 2);
83
+
84
+ // A `label` is part of the control, not a caption beside it: the whole row is
85
+ // pressable. It also cannot reach the factory as a flat prop, which would set
86
+ // the root's `textContent` and render the words *before* the ring it describes,
87
+ // outside the hit box, while leaving the requested `gap` unused.
88
+ const isLabelled = label !== null && label !== undefined && label !== "";
89
+
90
+ const rootProps = {
91
+ widgetName: "Radio",
92
+ display: "inline-flex",
93
+ alignItems: "center",
94
+ justifyContent: isLabelled ? "flex-start" : "center",
95
+ gap: isLabelled ? 12 : undefined,
96
+ minWidth: isLabelled ? undefined : TAP_MINIMUM,
97
+ minHeight: TAP_MINIMUM,
98
+ margin: isLabelled ? 0 : -overhang,
99
+ flexShrink: 0,
100
+ cursor: disabled ? "not-allowed" : "pointer",
101
+ opacity: disabled ? 0.5 : 1,
102
+ child: isLabelled
103
+ ? [
104
+ circle,
105
+ Text({
106
+ text: label,
107
+ size: LABEL_TEXT,
108
+ fontWeight: 500,
109
+ color: disabled ? colors.textDisabled : colors.text,
110
+ }),
111
+ ]
112
+ : circle,
113
+ };
114
+
115
+ // Pressing the words must not scale the words. The ring on its own keeps the
116
+ // default press feedback, because there the root *is* the control.
117
+ if (isLabelled) rootProps.disableTransform = true;
118
+
119
+ const radio = WidgetFactory({ ...rootProps, ...rest });
120
+
121
+ // Inline usage (a radio dropped mid-paragraph) would otherwise baseline-align
122
+ // the box's bottom edge and lift the ring above the text.
123
+ radio.style.verticalAlign = "middle";
66
124
 
67
125
  // Handle click
68
126
  if (!disabled) {
@@ -92,16 +150,16 @@ export const Radio = (props) => {
92
150
  // direct assignment died on the first `update()` or reactive setter.
93
151
  radio.update({ onPress: handleClick });
94
152
 
95
- // Hover effect
153
+ // Hover effect — on the drawn ring, not the hit box around it.
96
154
  radio.addEventListener("mouseenter", () => {
97
155
  if (!disabled && !internalSelected) {
98
- radio.style.backgroundColor = `color-mix(in srgb, ${activeColor} 12%, transparent)`;
156
+ circle.style.backgroundColor = `color-mix(in srgb, ${activeColor} 12%, transparent)`;
99
157
  }
100
158
  });
101
159
 
102
160
  radio.addEventListener("mouseleave", () => {
103
161
  if (!disabled && !internalSelected) {
104
- radio.style.backgroundColor = "transparent";
162
+ circle.style.backgroundColor = "transparent";
105
163
  }
106
164
  });
107
165
  }
@@ -7,6 +7,7 @@ import { Row } from "./Row.js";
7
7
  import { Text } from "./Text.js";
8
8
  import { Icon } from "./Icon.js";
9
9
  import { colors, subscribeTheme } from "../utils/themes.js";
10
+ import { TAP_MINIMUM } from "../tools/index.js";
10
11
 
11
12
  export const Rating = (props) => {
12
13
  const {
@@ -21,7 +22,7 @@ export const Rating = (props) => {
21
22
  iconActive = "star",
22
23
  iconInactive = "star_border",
23
24
  iconHalf = "star_half",
24
- gap = 2,
25
+ gap = 4,
25
26
  allowHalf = false,
26
27
  showValue = false,
27
28
  valueColor = colors.textSecondary,
@@ -52,12 +53,22 @@ export const Rating = (props) => {
52
53
  let iconName = iconInactive;
53
54
  let color = inactiveColor;
54
55
 
55
- if (starNumber <= currentValue) {
56
+ // A score like 4.4 must still show a half star. Matching `starNumber - 0.5
57
+ // === currentValue` only covered exact halves, so every fractional score
58
+ // fell through and the last star stayed empty.
59
+ const whole = Math.floor(currentValue);
60
+ const fraction = currentValue - whole;
61
+ if (starNumber <= whole) {
56
62
  iconName = iconActive;
57
63
  color = activeColor;
58
- } else if (allowHalf && starNumber - 0.5 === currentValue) {
59
- iconName = iconHalf;
60
- color = activeColor;
64
+ } else if (allowHalf && starNumber === whole + 1) {
65
+ if (fraction >= 0.75) {
66
+ iconName = iconActive;
67
+ color = activeColor;
68
+ } else if (fraction >= 0.25) {
69
+ iconName = iconHalf;
70
+ color = activeColor;
71
+ }
61
72
  }
62
73
 
63
74
  span.textContent = iconName;
@@ -108,10 +119,17 @@ export const Rating = (props) => {
108
119
  });
109
120
  const wrapper = WidgetFactory({
110
121
  display: "inline-flex",
122
+ alignItems: "center",
123
+ justifyContent: "center",
111
124
  cursor: readOnly ? "default" : "pointer",
112
125
  transition: "transform 0.1s ease",
113
126
  child: icon,
114
- padding: "4px", // larger touch area
127
+ // A star is picked with a thumb, so the cell — not the glyph — is the
128
+ // target: 20 units of star inside a 40-unit box. A read-only rating shows a
129
+ // score instead of a control, so it keeps its compact 4-unit gutter.
130
+ ...(readOnly
131
+ ? { padding: 4 }
132
+ : { minWidth: TAP_MINIMUM, minHeight: TAP_MINIMUM }),
115
133
  });
116
134
 
117
135
  starIcons.push(icon);