flet-box 1.0.9 → 1.1.1
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/bin/commands/checkUi.js +327 -46
- package/bin/commands/create.js +48 -19
- package/bin/commands/createScreen.js +1 -1
- package/bin/commands/runBundle.js +1 -1
- 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/skills.js +59 -0
- package/bin/utils/templates.js +196 -575
- package/docs/README.md +3 -1
- package/docs/cli/README.md +18 -1
- package/docs/guides/README.md +6 -1
- package/docs/guides/accessibility.md +282 -0
- package/docs/guides/clean-ui.md +84 -21
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +51 -0
- 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 +4 -2
- package/skills/fletbox-design/SKILL.md +949 -0
- package/skills/fletbox-memory/SKILL.md +134 -0
- package/skills/fletbox-motion/SKILL.md +266 -0
- package/skills/fletbox-rules/SKILL.md +293 -0
- 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 +31 -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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file fieldFont.js
|
|
3
|
+
* @description
|
|
4
|
+
* Keeps a focused text field from zooming the page on iOS.
|
|
5
|
+
*
|
|
6
|
+
* WebKit magnifies the whole viewport when a text control whose computed
|
|
7
|
+
* `font-size` is below 16px takes focus, so typing in a compiled iOS app made
|
|
8
|
+
* the screen grow and stay deformed. The design sizes here are framework units
|
|
9
|
+
* (12 / 14 / 16 for `small` / `medium` / `large`), and two of them sit under
|
|
10
|
+
* that threshold.
|
|
11
|
+
*
|
|
12
|
+
* The fix is a floor, not a new size: on iOS the field text is at least 16px
|
|
13
|
+
* while everything else keeps its rem value. The widget writes the length it
|
|
14
|
+
* paints with into `--fb-field-font`, so a reader who raises the browser font
|
|
15
|
+
* still gets a larger field (medium becomes 17.5px at a 20px root) — a flat
|
|
16
|
+
* `16px` would have shrunk that field instead of merely flooring it.
|
|
17
|
+
*
|
|
18
|
+
* The rule arrives in a stylesheet because an inline `font-size` outranks one,
|
|
19
|
+
* and it is scoped to `html[data-fb-ios]` so every other platform keeps the
|
|
20
|
+
* design's own sizes. That attribute also gives the regression harness a switch
|
|
21
|
+
* it can flip without an iPhone.
|
|
22
|
+
*
|
|
23
|
+
* @module fieldFont
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** `id` of the injected tag, so the guard reaches it after HMR or a `document.write`. */
|
|
27
|
+
const STYLE_ID = "fletbox-field-font";
|
|
28
|
+
|
|
29
|
+
/** WebKit's auto-zoom threshold: a focused control under 16px scales the page. */
|
|
30
|
+
const MIN_FIELD_PX = 16;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether these navigator values describe iOS.
|
|
34
|
+
*
|
|
35
|
+
* iPadOS 13 and later report a desktop Mac user agent, so the touch points are
|
|
36
|
+
* what separate an iPad from a MacBook: a Mac reports 0, an iPad reports 5.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} [ua] - `navigator.userAgent`.
|
|
39
|
+
* @param {string} [platform] - `navigator.platform`.
|
|
40
|
+
* @param {number} [touchPoints] - `navigator.maxTouchPoints`.
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export const looksLikeIOS = (ua = "", platform = "", touchPoints = 0) => {
|
|
44
|
+
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
|
45
|
+
return platform === "MacIntel" && touchPoints > 1;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const runningOnIOS = () =>
|
|
49
|
+
typeof navigator !== "undefined" &&
|
|
50
|
+
looksLikeIOS(navigator.userAgent || "", navigator.platform || "", navigator.maxTouchPoints || 0);
|
|
51
|
+
|
|
52
|
+
const FIELD_FONT_RULE = `html[data-fb-ios] [data-fb-field] input,
|
|
53
|
+
html[data-fb-ios] [data-fb-field] textarea {
|
|
54
|
+
font-size: max(${MIN_FIELD_PX}px, var(--fb-field-font, 1rem)) !important;
|
|
55
|
+
}`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Marks the document as iOS and injects the field floor, once.
|
|
59
|
+
*
|
|
60
|
+
* The attribute is re-checked on every call because it is what the rule keys
|
|
61
|
+
* on; the `<style>` tag is the part that must not be duplicated. No-ops outside
|
|
62
|
+
* a browser, so the package stays importable in Node.
|
|
63
|
+
*
|
|
64
|
+
* @returns {void}
|
|
65
|
+
*/
|
|
66
|
+
export function ensureFieldFont() {
|
|
67
|
+
if (typeof document === "undefined") return;
|
|
68
|
+
if (runningOnIOS()) document.documentElement.setAttribute("data-fb-ios", "");
|
|
69
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
70
|
+
|
|
71
|
+
const style = document.createElement("style");
|
|
72
|
+
style.id = STYLE_ID;
|
|
73
|
+
style.textContent = FIELD_FONT_RULE;
|
|
74
|
+
document.head.appendChild(style);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default ensureFieldFont;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file focusRing.js
|
|
3
|
+
* @description
|
|
4
|
+
* The one focus indicator the framework paints, injected once per document.
|
|
5
|
+
*
|
|
6
|
+
* A ring cannot be an inline style: `:focus-visible` is a *state*, and a state
|
|
7
|
+
* only exists in a stylesheet. So this module plays the same role for the
|
|
8
|
+
* keyboard that `styleInjector.js` plays for `@keyframes` and `motion.js` plays
|
|
9
|
+
* for reduced motion — one owned `<style>` tag in `<head>`, inserted the first
|
|
10
|
+
* time a widget is created and never again.
|
|
11
|
+
*
|
|
12
|
+
* The color is `var(--color-focusRing)`, which `applyPalette` already writes for
|
|
13
|
+
* every theme token, so the ring moves with the theme without this module
|
|
14
|
+
* knowing anything about themes. The literal after the comma is the boot
|
|
15
|
+
* palette's value: it is what a page shows before the first `setTheme`.
|
|
16
|
+
*
|
|
17
|
+
* Two deliberate choices:
|
|
18
|
+
* - `:where(...)` keeps specificity at zero, so a widget or an app that wants a
|
|
19
|
+
* different ring overrides it with one class instead of an `!important`.
|
|
20
|
+
* - The widths are pixels, not rem, and belong with `SHADOW_LEVELS` and the
|
|
21
|
+
* hairline `borderWidth` on the list of honest pixels: a 1px ring is invisible
|
|
22
|
+
* and a 6px one is a blob, whatever the reader's font size is.
|
|
23
|
+
*
|
|
24
|
+
* @module focusRing
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** `id` of the injected tag, so the guard reaches it after HMR or a `document.write`. */
|
|
28
|
+
const STYLE_ID = "fletbox-focus-ring";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The rule. `[tabindex]:not([tabindex="-1"])` is what makes an element the
|
|
32
|
+
* engine turned pressable show up here; `-1` is an element that can be focused
|
|
33
|
+
* by script but is not a stop on the Tab path, and must not announce one.
|
|
34
|
+
*
|
|
35
|
+
* The second pair belongs to a composite field (`Input`). Its native `<input>`
|
|
36
|
+
* is the thing that holds focus, but it is not the thing a person sees: the
|
|
37
|
+
* field is a wrapper with a border, an icon and maybe a trailing button, and a
|
|
38
|
+
* ring drawn around the text box alone cuts across that control. So the ring
|
|
39
|
+
* moves to the wrapper — which is what `data-fb-field` marks — and the inner
|
|
40
|
+
* box is silenced by a rule, never by an inline `outline` an app could never
|
|
41
|
+
* override.
|
|
42
|
+
*/
|
|
43
|
+
const FOCUS_RULE = `:where(a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
|
|
44
|
+
outline: 2px solid var(--color-focusRing, #1d4ed8);
|
|
45
|
+
outline-offset: 2px;
|
|
46
|
+
}
|
|
47
|
+
:where([data-fb-field]):has(> input:focus-visible) {
|
|
48
|
+
outline: 2px solid var(--color-focusRing, #1d4ed8);
|
|
49
|
+
outline-offset: 2px;
|
|
50
|
+
}
|
|
51
|
+
:where([data-fb-field]) > input:focus-visible {
|
|
52
|
+
outline: none;
|
|
53
|
+
}`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Appends the focus rule to `<head>` unless it is already there.
|
|
57
|
+
*
|
|
58
|
+
* No-ops outside a browser, like every other document-touching utility here, so
|
|
59
|
+
* the package stays importable in Node.
|
|
60
|
+
*
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
export function ensureFocusRing() {
|
|
64
|
+
if (typeof document === "undefined") return;
|
|
65
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
66
|
+
|
|
67
|
+
const style = document.createElement("style");
|
|
68
|
+
style.id = STYLE_ID;
|
|
69
|
+
style.textContent = FOCUS_RULE;
|
|
70
|
+
document.head.appendChild(style);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default ensureFocusRing;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file focusScope.js
|
|
3
|
+
* @description
|
|
4
|
+
* The keyboard half of a modal: while a panel is open, Tab walks around *inside*
|
|
5
|
+
* it, and when it closes focus goes back where it came from.
|
|
6
|
+
*
|
|
7
|
+
* Escape already exists in the three overlays — the mouse can dismiss them. What
|
|
8
|
+
* none of them did was hold the Tab key, so a keyboard user pressing Tab on an
|
|
9
|
+
* open Modal walked out of the dialog, behind the scrim, into the page they could
|
|
10
|
+
* no longer see or reach, and had no way back except tabbing through the whole
|
|
11
|
+
* document. That is the defect this module closes, and it is the reason the three
|
|
12
|
+
* overlays share one implementation instead of each writing its own listener.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately **not** handled here: Escape. Every overlay already listens for it
|
|
15
|
+
* on `document`, and a second listener would fire the close twice — an `onClose`
|
|
16
|
+
* that runs twice is a state update that happens twice.
|
|
17
|
+
*
|
|
18
|
+
* @module focusScope
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the browser would let a person land on: HTML's own reachable elements,
|
|
23
|
+
* plus whatever the engine put on the Tab path. `tabindex="-1"` is excluded —
|
|
24
|
+
* that is the document's own words for "focusable by script, not a stop".
|
|
25
|
+
*
|
|
26
|
+
* @type {string}
|
|
27
|
+
*/
|
|
28
|
+
export const FOCUSABLE_SELECTOR = [
|
|
29
|
+
"a[href]",
|
|
30
|
+
"button",
|
|
31
|
+
"input",
|
|
32
|
+
"select",
|
|
33
|
+
"textarea",
|
|
34
|
+
"summary",
|
|
35
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
36
|
+
].join(",");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Traps the Tab key inside `container` until the returned function is called.
|
|
40
|
+
*
|
|
41
|
+
* @param {HTMLElement} container - The open panel to keep the keyboard in.
|
|
42
|
+
* @returns {function(): void} Idempotent release: removes the listener and puts
|
|
43
|
+
* focus back on whatever was focused before the panel opened. Call it from the
|
|
44
|
+
* widget's `onUnmount` disposer like every other registered resource.
|
|
45
|
+
*/
|
|
46
|
+
export function trapFocus(container) {
|
|
47
|
+
if (typeof document === "undefined" || !container) return () => {};
|
|
48
|
+
|
|
49
|
+
const previous = document.activeElement;
|
|
50
|
+
let released = false;
|
|
51
|
+
|
|
52
|
+
// `offsetParent` is null for a `position: fixed` element, which is what every
|
|
53
|
+
// overlay is, so the usual visibility test would filter out the whole panel.
|
|
54
|
+
// A box that renders has client rects, hidden or not.
|
|
55
|
+
const reachable = () =>
|
|
56
|
+
[...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(
|
|
57
|
+
(el) => el.getClientRects().length > 0,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const onKeyDown = (event) => {
|
|
61
|
+
if (event.key !== "Tab") return;
|
|
62
|
+
const stops = reachable();
|
|
63
|
+
if (stops.length === 0) {
|
|
64
|
+
// Nothing to reach: keep the panel itself focused rather than let Tab
|
|
65
|
+
// escape to the page behind it.
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const first = stops[0];
|
|
70
|
+
const last = stops[stops.length - 1];
|
|
71
|
+
const active = document.activeElement;
|
|
72
|
+
|
|
73
|
+
if (event.shiftKey && (active === first || active === container)) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
last.focus();
|
|
76
|
+
} else if (!event.shiftKey && active === last) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
first.focus();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
container.addEventListener("keydown", onKeyDown);
|
|
83
|
+
|
|
84
|
+
// A panel with no control in it (an alert with two lines of text) still has to
|
|
85
|
+
// take the keyboard, or the trap has nothing to hold. `-1` is the honest value:
|
|
86
|
+
// it is focusable because the dialog is open, not a stop on the Tab path.
|
|
87
|
+
const stops = reachable();
|
|
88
|
+
if (stops.length > 0) {
|
|
89
|
+
stops[0].focus();
|
|
90
|
+
} else if (!container.hasAttribute("tabindex")) {
|
|
91
|
+
container.tabIndex = -1;
|
|
92
|
+
container._focusScopeMadeTabbable = true;
|
|
93
|
+
container.focus();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
100
|
+
if (container._focusScopeMadeTabbable) {
|
|
101
|
+
container.removeAttribute("tabindex");
|
|
102
|
+
delete container._focusScopeMadeTabbable;
|
|
103
|
+
}
|
|
104
|
+
// Only hand focus back if the element it came from is still there: a route
|
|
105
|
+
// change that replaced the page while the dialog was open has nothing to go
|
|
106
|
+
// back to, and focusing a detached node is a silent no-op at best.
|
|
107
|
+
if (previous && previous.isConnected && typeof previous.focus === "function") {
|
|
108
|
+
previous.focus();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default trapFocus;
|
|
@@ -6,6 +6,7 @@ import { Row } from "../widgets/Row.js";
|
|
|
6
6
|
import { Icon } from "../widgets/Icon.js";
|
|
7
7
|
import { CodeViewer } from "../widgets/CodeViewer.js";
|
|
8
8
|
import { colors } from "./themes.js";
|
|
9
|
+
import { escapeHtml } from "../helpers/html.js";
|
|
9
10
|
|
|
10
11
|
// ========== TABLES ==========
|
|
11
12
|
const isTableLine = (line) => {
|
|
@@ -346,16 +347,13 @@ export const parseInlineMarkdown = (text) => text;
|
|
|
346
347
|
/**
|
|
347
348
|
* Escapes HTML special characters to prevent injection when inserting into innerHTML.
|
|
348
349
|
* Converts `& < > " '` to their corresponding HTML entities.
|
|
349
|
-
*
|
|
350
|
-
*
|
|
350
|
+
*
|
|
351
|
+
* Re-exported from the shared escaper so this module and the widgets that build
|
|
352
|
+
* HTML share one implementation; the name stays public here because
|
|
353
|
+
* `parseMarkdown` has always published it.
|
|
354
|
+
* @type {import("../helpers/html.js").escapeHtml}
|
|
351
355
|
*/
|
|
352
|
-
export
|
|
353
|
-
String(text)
|
|
354
|
-
.replace(/&/g, "&")
|
|
355
|
-
.replace(/</g, "<")
|
|
356
|
-
.replace(/>/g, ">")
|
|
357
|
-
.replace(/"/g, """)
|
|
358
|
-
.replace(/'/g, "'");
|
|
356
|
+
export { escapeHtml };
|
|
359
357
|
|
|
360
358
|
// Default export
|
|
361
359
|
export default {
|
package/src/utils/readableInk.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { color } from "../tools/color.js";
|
|
2
2
|
|
|
3
|
+
// A fill this code can measure: a hex, an opaque `rgb()`/`hsl()`, or a named color.
|
|
4
|
+
// The `$` anchors the whole alternation, so each branch has to match the *entire*
|
|
5
|
+
// value — the `rgba?\(\s*\d` this file used to carry could only ever match the
|
|
6
|
+
// three-character string "rgb", which made every functional color fall through to
|
|
7
|
+
// the caller's default. An alpha'd fill stays unmeasured on purpose: what reads on
|
|
8
|
+
// it depends on what is behind it.
|
|
3
9
|
const SOLID_COLOR =
|
|
4
|
-
/^(#[0-9a-f]{3,8}|
|
|
10
|
+
/^(#[0-9a-f]{3,8}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|hsl\(\s*[\d.]+(?:deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*\)|black|white|red|green|blue|yellow|cyan|magenta|gray|darkgray|lightgray)$/i;
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Black or white, whichever reads better on `background` by WCAG contrast.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file stackLayout.js
|
|
3
|
+
* @description Makes a `Stack` paint its children in the same box.
|
|
4
|
+
*
|
|
5
|
+
* `Stack` is documented as the overlay container and `alignment` as the corner
|
|
6
|
+
* every child is anchored to. Neither was true: the children arrived in normal
|
|
7
|
+
* flow, so the second one landed under the first and the only way to actually
|
|
8
|
+
* layer them was for each child to declare a `position` of its own.
|
|
9
|
+
*
|
|
10
|
+
* The rule takes every child but the first out of the flow. The first stays in
|
|
11
|
+
* it because a Stack with no explicit `width`/`height` has to be sized by
|
|
12
|
+
* something: with every child floating the box collapses to 0px and the overlay
|
|
13
|
+
* disappears, which is the same bug wearing a different symptom.
|
|
14
|
+
*
|
|
15
|
+
* A floating child that names no edge is placed at the container's `alignment`
|
|
16
|
+
* point for free — an absolutely positioned child of a flex container takes its
|
|
17
|
+
* static position from that container's own `justify-content`/`align-items`, so
|
|
18
|
+
* the nine-point map in `Stack.js` anchors the layers without a second set of
|
|
19
|
+
* rules. A child that does name an edge (`top`, `right`, …) keeps its own
|
|
20
|
+
* declaration, and one that declares `position` inline outranks this rule, so
|
|
21
|
+
* opting back into the flow is possible.
|
|
22
|
+
*
|
|
23
|
+
* It lives in a stylesheet instead of on each child because `update({ children })`
|
|
24
|
+
* rebuilds them: styles written onto the nodes would leave with the nodes, and a
|
|
25
|
+
* rule reaches whatever arrives next.
|
|
26
|
+
*
|
|
27
|
+
* @module stackLayout
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** `id` of the injected tag, so a guard can count it after HMR. */
|
|
31
|
+
const STYLE_ID = "fletbox-stack";
|
|
32
|
+
|
|
33
|
+
/** One rule, no lengths: nothing here scales with the root font size. */
|
|
34
|
+
const STACK_RULE = `[data-fb-stack] > :not(:first-child) {
|
|
35
|
+
position: absolute;
|
|
36
|
+
}`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Inject the overlay rule, once per document.
|
|
40
|
+
*
|
|
41
|
+
* No-ops outside a browser, so the package stays importable in Node.
|
|
42
|
+
*
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
export function ensureStackLayout() {
|
|
46
|
+
if (typeof document === "undefined") return;
|
|
47
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
48
|
+
|
|
49
|
+
const style = document.createElement("style");
|
|
50
|
+
style.id = STYLE_ID;
|
|
51
|
+
style.textContent = STACK_RULE;
|
|
52
|
+
document.head.appendChild(style);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default ensureStackLayout;
|
|
@@ -14,6 +14,31 @@
|
|
|
14
14
|
/** Animation names already injected in this document. */
|
|
15
15
|
const injected = new Set();
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Stable name derived from a keyframe body.
|
|
19
|
+
*
|
|
20
|
+
* `@keyframes` is global, so two effects that build their body from their own
|
|
21
|
+
* options must not share a name: the first injection would win for both, and a
|
|
22
|
+
* glow in red would come out blue. Hashing the body gives every distinct
|
|
23
|
+
* animation its own name and lets identical ones reuse a single `<style>` — the
|
|
24
|
+
* same rule that keeps `AnimatedText` (one animation per letter) from appending
|
|
25
|
+
* a tag per letter.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} prefix - Human-readable head, e.g. `"glow-pulse"`.
|
|
28
|
+
* @param {string} frames - The keyframe body that determines the motion.
|
|
29
|
+
* @returns {string} A CSS-safe identifier, e.g. `"glow-pulse-1a2b3c4d5e"`.
|
|
30
|
+
*/
|
|
31
|
+
export const animationName = (prefix, frames) => {
|
|
32
|
+
let h1 = 0x811c9dc5;
|
|
33
|
+
let h2 = 0x01000193;
|
|
34
|
+
for (let i = 0; i < frames.length; i++) {
|
|
35
|
+
const c = frames.charCodeAt(i);
|
|
36
|
+
h1 = Math.imul(h1 ^ c, 0x01000193);
|
|
37
|
+
h2 = Math.imul(h2 + c, 0x85ebca6b) ^ (h2 >>> 13);
|
|
38
|
+
}
|
|
39
|
+
return `${prefix}-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
17
42
|
/**
|
|
18
43
|
* Injects `@keyframes <name> { <frames> }` into `<head>` the first time the
|
|
19
44
|
* given name is requested. Subsequent calls for the same name are no-ops.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// utils/syntaxHighlight.js
|
|
2
2
|
import { color } from "../tools/color.js";
|
|
3
3
|
import { getTheme } from "./themes.js";
|
|
4
|
+
import { escapeHtml } from "../helpers/html.js";
|
|
4
5
|
|
|
5
6
|
// ========== FLETBOX COLORS ==========
|
|
6
7
|
const fletboxColors = {
|
|
@@ -387,21 +388,4 @@ export const generateHighlightedHtml = (
|
|
|
387
388
|
return html;
|
|
388
389
|
};
|
|
389
390
|
|
|
390
|
-
// ========== ESCAPE HTML ==========
|
|
391
|
-
/**
|
|
392
|
-
* Escapes HTML special characters so that token values can be safely inserted
|
|
393
|
-
* into a `<span>` element's content.
|
|
394
|
-
* @param {string} text - Raw token text.
|
|
395
|
-
* @returns {string} HTML-safe string.
|
|
396
|
-
* @private
|
|
397
|
-
*/
|
|
398
|
-
const escapeHtml = (text) => {
|
|
399
|
-
return text
|
|
400
|
-
.replace(/&/g, "&")
|
|
401
|
-
.replace(/</g, "<")
|
|
402
|
-
.replace(/>/g, ">")
|
|
403
|
-
.replace(/"/g, """)
|
|
404
|
-
.replace(/'/g, "'");
|
|
405
|
-
};
|
|
406
|
-
|
|
407
391
|
export default { tokenize, generateHighlightedHtml, highlightColors, highlightPalettes, syntaxPaletteFor };
|
package/src/utils/themes.js
CHANGED
|
@@ -55,6 +55,14 @@ const DEFAULT_COLORS = {
|
|
|
55
55
|
glass: "rgba(255, 255, 255, 0.66)",
|
|
56
56
|
glassBorder: "rgba(255, 255, 255, 0.75)",
|
|
57
57
|
border: "#e2e8f0",
|
|
58
|
+
// The ring around whatever the keyboard is on. It is a token for the same
|
|
59
|
+
// reason the rim is one: the theme has to be able to move it. It answers to
|
|
60
|
+
// WCAG 2.2's 3:1 against the surface it is drawn on, which `#1d4ed8` clears at
|
|
61
|
+
// 6.4:1 on this boot page and 6.4:1 on its `surface`. It is deliberately not
|
|
62
|
+
// `primary`: in both shipped palettes the brand plate is neutral zinc, so a
|
|
63
|
+
// brand-coloured ring would read as "this is selected" instead of "this is
|
|
64
|
+
// where my keys are".
|
|
65
|
+
focusRing: "#1d4ed8",
|
|
58
66
|
gray50: "#f8fafc",
|
|
59
67
|
gray100: "#f1f5f9",
|
|
60
68
|
gray200: "#e2e8f0",
|
|
@@ -103,6 +111,22 @@ export const palettes = {
|
|
|
103
111
|
infoStrong: "#1d4ed8",
|
|
104
112
|
infoPlate: "#1d4ed8",
|
|
105
113
|
info: "#3b82f6",
|
|
114
|
+
// The alert tints, and the red the word "error" names. `check-ui` tells an app
|
|
115
|
+
// that "`colors.primaryBg`, `colors.errorBg` and friends are the tints" and the
|
|
116
|
+
// boot palette has had these five since the start, but the light palette did
|
|
117
|
+
// not — so an app that themed its alerts the framework way lost every tint the
|
|
118
|
+
// moment it booted into light (`colors.errorBg` came back `undefined`, and a
|
|
119
|
+
// `bgColor` of undefined is no background at all). Dark answered them first
|
|
120
|
+
// because a pale pink fill is unreadable on a black page and had to be
|
|
121
|
+
// invented; this is the same idea read from the light end: the tint is nearly
|
|
122
|
+
// white, so the ink on it is the `*Strong` of that hue — 5.91, 5.21, 4.84 and
|
|
123
|
+
// 6.16:1 in order, all over the AA line — and the page ink `#09090B` sits on
|
|
124
|
+
// all four at 18:1 and up.
|
|
125
|
+
error: "#ef4444",
|
|
126
|
+
errorBg: "#fef2f2",
|
|
127
|
+
successBg: "#ecfdf5",
|
|
128
|
+
warningBg: "#fffbeb",
|
|
129
|
+
infoBg: "#eff6ff",
|
|
106
130
|
background: "#ffffff",
|
|
107
131
|
surface: "#fafafa",
|
|
108
132
|
card: "#ffffff",
|
|
@@ -119,6 +143,10 @@ export const palettes = {
|
|
|
119
143
|
textSecondary: "#52525b",
|
|
120
144
|
textDisabled: "#a1a1aa",
|
|
121
145
|
border: "#e4e4e7",
|
|
146
|
+
// The blue is this palette's own `infoStrong`, measured 6.70:1 on the page,
|
|
147
|
+
// 6.42:1 on `surface` and 6.10:1 on `primaryBg` — over the 3:1 a focus
|
|
148
|
+
// indicator needs wherever it lands, and never mistaken for the zinc plate.
|
|
149
|
+
focusRing: "#1d4ed8",
|
|
122
150
|
gray50: "#fafafa",
|
|
123
151
|
gray100: "#f4f4f5",
|
|
124
152
|
gray200: "#e4e4e7",
|
|
@@ -195,6 +223,9 @@ export const palettes = {
|
|
|
195
223
|
textSecondary: "#a1a1aa",
|
|
196
224
|
textDisabled: "#52525b",
|
|
197
225
|
border: "#27272a",
|
|
226
|
+
// Points the other way down the ladder like every other token here: this is
|
|
227
|
+
// dark's `infoStrong`, 11.03:1 on the page and 9.82:1 on a card.
|
|
228
|
+
focusRing: "#93c5fd",
|
|
198
229
|
gray50: "#111111",
|
|
199
230
|
gray100: "#1f1f1f",
|
|
200
231
|
gray200: "#27272a",
|
|
@@ -4,21 +4,15 @@
|
|
|
4
4
|
* @module utils/visualEffects
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { prefersReducedMotion, markMotionLoop } from "../animations/motion.js";
|
|
8
|
+
import { injectKeyframes, animationName } from "./styleInjector.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Re-exported for callers that imported it from here before the injection was
|
|
12
|
+
* centralised: the implementation and the dedupe set are the shared ones in
|
|
13
|
+
* `utils/styleInjector.js`, never a second set local to this file.
|
|
14
14
|
*/
|
|
15
|
-
export
|
|
16
|
-
if (injectedKeyframes.has(name)) return;
|
|
17
|
-
const style = document.createElement("style");
|
|
18
|
-
style.textContent = `@keyframes ${name} { ${keyframes} }`;
|
|
19
|
-
document.head.appendChild(style);
|
|
20
|
-
injectedKeyframes.add(name);
|
|
21
|
-
};
|
|
15
|
+
export { injectKeyframes };
|
|
22
16
|
|
|
23
17
|
/**
|
|
24
18
|
* Apply stripe pattern to an element
|
|
@@ -46,15 +40,20 @@ export const applyStripes = (element, options = {}) => {
|
|
|
46
40
|
element.style.backgroundSize = `${size * 2}px ${size * 2}px`;
|
|
47
41
|
|
|
48
42
|
if (animated) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"progress-stripes",
|
|
53
|
-
`
|
|
43
|
+
// The body depends on `size`, so the name is derived from it: two stripe
|
|
44
|
+
// widths are two animations, not one animation whose first width wins.
|
|
45
|
+
const frames = `
|
|
54
46
|
0% { background-position: 0 0; }
|
|
55
47
|
100% { background-position: ${size * 2}px 0; }
|
|
56
|
-
|
|
57
|
-
);
|
|
48
|
+
`;
|
|
49
|
+
const name = animationName("stripes", frames);
|
|
50
|
+
element.style.animation = `${name} ${duration} linear infinite`;
|
|
51
|
+
// A `infinite` CSS loop is stopped by the media rule, not by a JS check: the
|
|
52
|
+
// check reads the preference when the effect is applied, so a loop already
|
|
53
|
+
// running when the reader flips the OS setting would keep running.
|
|
54
|
+
markMotionLoop(element);
|
|
55
|
+
|
|
56
|
+
injectKeyframes(name, frames);
|
|
58
57
|
}
|
|
59
58
|
};
|
|
60
59
|
|
|
@@ -85,15 +84,15 @@ export const applyShimmer = (element, options = {}) => {
|
|
|
85
84
|
${baseColor} 100%
|
|
86
85
|
)`;
|
|
87
86
|
element.style.backgroundSize = "200% 100%";
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
injectKeyframes(
|
|
91
|
-
"shimmer",
|
|
92
|
-
`
|
|
87
|
+
const frames = `
|
|
93
88
|
0% { background-position: 200% 0; }
|
|
94
89
|
100% { background-position: -200% 0; }
|
|
95
|
-
|
|
96
|
-
);
|
|
90
|
+
`;
|
|
91
|
+
const name = animationName("shimmer", frames);
|
|
92
|
+
element.style.animation = `${name} ${duration} ease-in-out infinite`;
|
|
93
|
+
markMotionLoop(element);
|
|
94
|
+
|
|
95
|
+
injectKeyframes(name, frames);
|
|
97
96
|
};
|
|
98
97
|
|
|
99
98
|
/**
|
|
@@ -107,16 +106,16 @@ export const applyGlow = (element, options = {}) => {
|
|
|
107
106
|
} = options;
|
|
108
107
|
|
|
109
108
|
element.style.transition = "box-shadow 0.2s ease";
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
injectKeyframes(
|
|
113
|
-
"glow-pulse",
|
|
114
|
-
`
|
|
109
|
+
const frames = `
|
|
115
110
|
0% { box-shadow: 0 0 0 ${color}; }
|
|
116
111
|
50% { box-shadow: 0 0 ${intensity}px ${color}; }
|
|
117
112
|
100% { box-shadow: 0 0 0 ${color}; }
|
|
118
|
-
|
|
119
|
-
);
|
|
113
|
+
`;
|
|
114
|
+
const name = animationName("glow-pulse", frames);
|
|
115
|
+
element.style.animation = `${name} ${duration} ease-in-out infinite`;
|
|
116
|
+
markMotionLoop(element);
|
|
117
|
+
|
|
118
|
+
injectKeyframes(name, frames);
|
|
120
119
|
};
|
|
121
120
|
|
|
122
121
|
/**
|
|
@@ -127,16 +126,16 @@ export const applyIndeterminate = (element, options = {}) => {
|
|
|
127
126
|
|
|
128
127
|
element.style.position = "relative";
|
|
129
128
|
element.style.width = width;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
injectKeyframes(
|
|
133
|
-
"indeterminate-progress",
|
|
134
|
-
`
|
|
129
|
+
const frames = `
|
|
135
130
|
0% { transform: translateX(-100%); }
|
|
136
131
|
50% { transform: translateX(0%); }
|
|
137
132
|
100% { transform: translateX(100%); }
|
|
138
|
-
|
|
139
|
-
);
|
|
133
|
+
`;
|
|
134
|
+
const name = animationName("indeterminate-progress", frames);
|
|
135
|
+
element.style.animation = `${name} ${duration} ease-in-out infinite`;
|
|
136
|
+
markMotionLoop(element);
|
|
137
|
+
|
|
138
|
+
injectKeyframes(name, frames);
|
|
140
139
|
};
|
|
141
140
|
|
|
142
141
|
/**
|
|
@@ -145,17 +144,20 @@ export const applyIndeterminate = (element, options = {}) => {
|
|
|
145
144
|
export const applyPulse = (element, options = {}) => {
|
|
146
145
|
const { scale = 1.05, duration = "0.3s" } = options;
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
element
|
|
147
|
+
// A pulse is decoration saying "look here", so reduced motion removes it rather
|
|
148
|
+
// than shortening it — the element is left exactly as it was.
|
|
149
|
+
if (prefersReducedMotion()) return;
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`
|
|
151
|
+
element.style.transition = `transform ${duration} ease`;
|
|
152
|
+
const frames = `
|
|
154
153
|
0% { transform: scale(1); }
|
|
155
154
|
50% { transform: scale(${scale}); }
|
|
156
155
|
100% { transform: scale(1); }
|
|
157
|
-
|
|
158
|
-
);
|
|
156
|
+
`;
|
|
157
|
+
const name = animationName("pulse", frames);
|
|
158
|
+
element.style.animation = `${name} ${duration} ease-in-out`;
|
|
159
|
+
|
|
160
|
+
injectKeyframes(name, frames);
|
|
159
161
|
};
|
|
160
162
|
|
|
161
163
|
export default {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// core/createWidget.js
|
|
2
2
|
|
|
3
|
+
import { ensureFocusRing } from "../utils/focusRing.js";
|
|
4
|
+
import { ensureFieldFont } from "../utils/fieldFont.js";
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Creates a bare DOM element for use as a widget.
|
|
5
8
|
*
|
|
@@ -8,12 +11,22 @@
|
|
|
8
11
|
* rely on for listener tracking. No props are applied here — that is the
|
|
9
12
|
* responsibility of `assignProps`.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* It is also where the document gets its focus rule: this is the one step every
|
|
15
|
+
* widget passes through, and a ring has to be in the stylesheet before anyone
|
|
16
|
+
* can press Tab.
|
|
17
|
+
*
|
|
18
|
+
* The field-font floor rides along for the same reason: it is a document-level
|
|
19
|
+
* rule about text fields, and this is the only step that runs exactly once per
|
|
20
|
+
* widget without being owned by any of them.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} tag - HTML tag name for the widget.
|
|
12
23
|
* Defaults to `'div'` when the value is not a string.
|
|
13
|
-
* @param {Object} props - Reserved for future use; currently unused.
|
|
14
24
|
* @returns {HTMLElement} The newly created DOM element with `_events` initialised.
|
|
15
25
|
*/
|
|
16
|
-
export const createWidget = (tag
|
|
26
|
+
export const createWidget = (tag) => {
|
|
27
|
+
ensureFocusRing();
|
|
28
|
+
ensureFieldFont();
|
|
29
|
+
|
|
17
30
|
// Validateste that tag is a string; fall back to 'div' when it is not.
|
|
18
31
|
let finalTag = tag;
|
|
19
32
|
if (typeof finalTag !== "string") {
|