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