flet-box 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +16 -12
  2. package/bin/commands/checkUi.js +276 -45
  3. package/bin/commands/create.js +8 -1
  4. package/bin/commands/createBundle.js +18 -6
  5. package/bin/commands/createScreen.js +1 -1
  6. package/bin/commands/runBundle.js +1 -1
  7. package/bin/utils/bundleConfig.js +47 -3
  8. package/bin/utils/colors.js +0 -8
  9. package/bin/utils/helpers.js +0 -5
  10. package/bin/utils/images/jpeg.js +2 -3
  11. package/bin/utils/templates.js +101 -85
  12. package/docs/README.md +24 -1
  13. package/docs/cli/README.md +51 -10
  14. package/docs/guides/README.md +5 -2
  15. package/docs/guides/accessibility.md +282 -0
  16. package/docs/guides/clean-ui.md +53 -8
  17. package/docs/guides/first-app.md +6 -7
  18. package/docs/guides/minification-and-protection.md +1 -1
  19. package/docs/guides/mobile-and-platforms.md +51 -0
  20. package/docs/guides/online-updates.md +18 -6
  21. package/docs/guides/utilities.md +116 -4
  22. package/docs/guides/widget-structure.md +30 -0
  23. package/docs/tools/README.md +1 -1
  24. package/docs/tools/animation.md +134 -11
  25. package/docs/tools/device.md +75 -3
  26. package/docs/tools/text-and-time.md +116 -6
  27. package/docs/widget/Accordion.md +2 -0
  28. package/docs/widget/AnimatedBox.md +46 -20
  29. package/docs/widget/AnimatedText.md +13 -7
  30. package/docs/widget/Badge.md +1 -0
  31. package/docs/widget/COMMON_PROPS.md +26 -3
  32. package/docs/widget/Chart.md +1 -0
  33. package/docs/widget/Checkbox.md +7 -1
  34. package/docs/widget/CircularChart.md +2 -0
  35. package/docs/widget/Drawer.md +1 -0
  36. package/docs/widget/GridView.md +4 -1
  37. package/docs/widget/Input.md +2 -0
  38. package/docs/widget/InstallButton.md +1 -0
  39. package/docs/widget/Markdown.md +4 -0
  40. package/docs/widget/Rating.md +2 -0
  41. package/docs/widget/Slider.md +2 -0
  42. package/docs/widget/Stack.md +6 -5
  43. package/docs/widget/Stepper.md +2 -0
  44. package/docs/widget/Tooltip.md +3 -1
  45. package/package.json +3 -2
  46. package/skills/fletbox-design/SKILL.md +390 -14
  47. package/skills/fletbox-motion/SKILL.md +145 -47
  48. package/skills/fletbox-rules/SKILL.md +140 -1
  49. package/src/animations/AnimatedBox.js +321 -226
  50. package/src/animations/MatrixRain.js +7 -1
  51. package/src/animations/ParallaxBox.js +13 -10
  52. package/src/animations/animate.js +269 -106
  53. package/src/animations/color.js +91 -0
  54. package/src/animations/easings.js +275 -0
  55. package/src/animations/index.js +21 -0
  56. package/src/animations/motion.js +204 -0
  57. package/src/animations/transitions.js +327 -0
  58. package/src/core/runApp.js +0 -6
  59. package/src/helpers/boxEdges.js +69 -0
  60. package/src/helpers/html.js +39 -0
  61. package/src/index.d.ts +377 -25
  62. package/src/index.js +77 -0
  63. package/src/navigations/AdaptiveScaffold.js +6 -2
  64. package/src/navigations/AppBar.js +16 -7
  65. package/src/navigations/BottomNavigation.js +24 -1
  66. package/src/navigations/CollapsibleSideBar.js +0 -1
  67. package/src/navigations/Drawer.js +32 -1
  68. package/src/navigations/DrawerItem.js +1 -1
  69. package/src/navigations/Router.js +0 -1
  70. package/src/navigations/Scaffold.js +9 -3
  71. package/src/navigations/Tabs.js +104 -31
  72. package/src/services/RamStore.js +1 -2
  73. package/src/tools/breakpoints.js +113 -0
  74. package/src/tools/createList.js +0 -2
  75. package/src/tools/fonts.js +99 -0
  76. package/src/tools/index.js +74 -1
  77. package/src/tools/margin.js +5 -63
  78. package/src/tools/padding.js +5 -63
  79. package/src/tools/print.js +0 -8
  80. package/src/tools/scale.js +9 -2
  81. package/src/tools/time.js +56 -15
  82. package/src/tools/useState.js +1 -54
  83. package/src/utils/fieldFont.js +77 -0
  84. package/src/utils/focusRing.js +73 -0
  85. package/src/utils/focusScope.js +113 -0
  86. package/src/utils/markdownParser.js +7 -9
  87. package/src/utils/readableInk.js +7 -1
  88. package/src/utils/stackLayout.js +55 -0
  89. package/src/utils/styleInjector.js +25 -0
  90. package/src/utils/syntaxHighlight.js +1 -17
  91. package/src/utils/themes.js +15 -0
  92. package/src/utils/visualEffects.js +49 -47
  93. package/src/widget-factory/createWidget.js +16 -3
  94. package/src/widget-factory/effects.js +76 -3
  95. package/src/widget-factory/reactiveValue.js +0 -7
  96. package/src/widget-factory/tools.js +26 -143
  97. package/src/widget-factory/translateProps.js +5 -0
  98. package/src/widget-factory/widgetFactory.js +1 -1
  99. package/src/widgets/Accordion.js +70 -12
  100. package/src/widgets/AdMob.js +1 -1
  101. package/src/widgets/AdSense.js +1 -1
  102. package/src/widgets/AlertDialog.js +11 -9
  103. package/src/widgets/Avatar.js +10 -1
  104. package/src/widgets/Badge.js +7 -1
  105. package/src/widgets/BottomSheet.js +39 -5
  106. package/src/widgets/Chart.js +41 -9
  107. package/src/widgets/Checkbox.js +59 -9
  108. package/src/widgets/Chip.js +13 -1
  109. package/src/widgets/CircularBar.js +17 -5
  110. package/src/widgets/CircularChart.js +24 -2
  111. package/src/widgets/CodeViewer.js +5 -3
  112. package/src/widgets/Dropdown.js +223 -20
  113. package/src/widgets/GridView.js +17 -1
  114. package/src/widgets/Icon.js +21 -0
  115. package/src/widgets/Input.js +75 -44
  116. package/src/widgets/InstallButton.js +8 -2
  117. package/src/widgets/ListTile.js +6 -5
  118. package/src/widgets/ListView.js +21 -7
  119. package/src/widgets/Markdown.js +25 -44
  120. package/src/widgets/Modal.js +43 -6
  121. package/src/widgets/Pagination.js +35 -3
  122. package/src/widgets/ProgressBar.js +18 -0
  123. package/src/widgets/Radio.js +7 -0
  124. package/src/widgets/Rating.js +104 -16
  125. package/src/widgets/Skeleton.js +9 -5
  126. package/src/widgets/Slider.js +87 -7
  127. package/src/widgets/SnackBar.js +30 -1
  128. package/src/widgets/Stack.js +25 -13
  129. package/src/widgets/Stepper.js +112 -64
  130. package/src/widgets/Switch.js +10 -1
  131. package/src/widgets/Tooltip.js +93 -10
  132. package/src/widgets/TreeView.js +5 -5
  133. package/src/create-index.sh +0 -237
  134. package/src/widget-factory/pixTool.js +0 -40
  135. package/src/widget-factory/stringTool.js +0 -26
@@ -13,6 +13,11 @@ import { colors } from "../utils/themes.js";
13
13
 
14
14
  import { toREM } from "../utils/units.js";
15
15
 
16
+ // Nodes already reported by the shared-content warning below. A screen that
17
+ // reuses one `steps` array steals it once per stepper per render, and a wall of
18
+ // identical warnings is a warning nobody reads.
19
+ const warnedContent = new WeakSet();
20
+
16
21
  export const Stepper = (props) => {
17
22
  const {
18
23
  steps = [],
@@ -41,6 +46,9 @@ export const Stepper = (props) => {
41
46
  } = props;
42
47
 
43
48
  let currentStep = Math.min(Math.max(activeStep, 0), steps.length - 1);
49
+ // The two orientations share the marker, the state and the press, and disagree
50
+ // about everything else — so the branch is decided once, here.
51
+ const vertical = orientation !== "horizontal";
44
52
  let stepsContainer = null;
45
53
  let contentContainer = null;
46
54
  let navContainer = null;
@@ -92,7 +100,7 @@ export const Stepper = (props) => {
92
100
  justifyContent: "center",
93
101
  alignItems: "center",
94
102
  child: isCompleted
95
- ? Icon({ name: "check", size: 18, color: "#fff" })
103
+ ? Icon({ name: "check", size: 18, color: colors.onSecondary })
96
104
  : Text({
97
105
  text: String(idx + 1),
98
106
  size: 14,
@@ -117,59 +125,104 @@ export const Stepper = (props) => {
117
125
  });
118
126
  }
119
127
 
120
- // Label
128
+ // The label, in the ink the stage decides, placed where the orientation
129
+ // puts it: under the marker in the horizontal track, beside it in the
130
+ // vertical one. Both branches used to draw their own, so a vertical step
131
+ // read "Account" twice — once under the circle and once stranded at the far
132
+ // edge of a row the marker column had already stretched across.
133
+ const labelColor = isActive ? colors.primary : colors.textSecondary;
121
134
  const label = showLabels
122
- ? Text({
123
- text: step.label,
124
- size: 12,
125
- color: isActive ? colors.primary : colors.textSecondary,
126
- marginTop: 4,
127
- align: "center",
128
- })
135
+ ? vertical
136
+ ? Text({ text: step.label, size: 14, color: labelColor })
137
+ : Text({
138
+ text: step.label,
139
+ size: 12,
140
+ color: labelColor,
141
+ marginTop: 4,
142
+ align: "center",
143
+ })
129
144
  : null;
130
145
 
131
- const stepColumn = Column({
132
- alignItems: "center",
133
- flex: needsWrap ? "auto" : 1,
134
- minWidth: needsWrap ? "80px" : "0",
146
+ // The number, the check and the accent carry the stage, and all three are
147
+ // drawn: the check is a decorative glyph and the label alone does not say
148
+ // whether the step is behind, ahead or where you are. The state goes in
149
+ // the name, and `aria-current` marks the step you are on. The press lives
150
+ // on this box, so the whole strip answers — not only the circle.
151
+ const press = {
135
152
  cursor: "pointer",
153
+ "aria-label": `${step.label || `Step ${idx + 1}`} (${
154
+ isCompleted ? "completed" : isActive ? "current" : "upcoming"
155
+ })`,
156
+ "aria-current": isActive ? "step" : undefined,
136
157
  onclick: () => goToStep(idx),
137
- children: [indicator, label].filter(Boolean),
138
- });
139
-
140
- if (orientation === "horizontal") {
141
- stepsContainer.appendChild(stepColumn);
142
- if (!isLast && !needsWrap) {
143
- const connector = Container({
144
- flex: 1,
145
- height: "2px",
146
- minWidth: "10px",
147
- backgroundColor:
148
- idx < currentStep ? colors.success : colors.gray300,
149
- });
150
- stepsContainer.appendChild(connector);
151
- } else if (!isLast && needsWrap) {
152
- const spacer = Container({ width: 8 });
153
- stepsContainer.appendChild(spacer);
154
- }
155
- } else {
156
- const verticalStep = Row({
157
- gap: 12,
158
- alignItems: "center",
159
- children: [stepColumn, Text({ text: step.label, size: 14 })],
160
- });
161
- stepsContainer.appendChild(verticalStep);
158
+ };
159
+
160
+ stepsContainer.appendChild(
161
+ vertical
162
+ ? Row({
163
+ alignItems: "center",
164
+ gap: 12,
165
+ width: "100%",
166
+ ...press,
167
+ children: [indicator, label].filter(Boolean),
168
+ })
169
+ : Column({
170
+ alignItems: "center",
171
+ flex: needsWrap ? "auto" : 1,
172
+ minWidth: needsWrap ? "80px" : "0",
173
+ ...press,
174
+ children: [indicator, label].filter(Boolean),
175
+ }),
176
+ );
177
+
178
+ // Only the horizontal track links its markers; a vertical list is already
179
+ // read in order, and the Column's own gap is the separation.
180
+ if (!vertical && !isLast) {
181
+ stepsContainer.appendChild(
182
+ needsWrap
183
+ ? Container({ width: 8 })
184
+ : Container({
185
+ flex: 1,
186
+ height: "2px",
187
+ minWidth: "10px",
188
+ backgroundColor:
189
+ idx < currentStep ? colors.success : colors.gray300,
190
+ }),
191
+ );
162
192
  }
163
193
  });
164
194
 
165
- // Update content
195
+ // Update content. One place owns the slot, and it only rewrites it when the
196
+ // node already there is not this step's: `appendChild` of the node that is
197
+ // already the child is a detach and a re-attach, which throws away the
198
+ // content's layout, the caret of a field being typed in, and any transition
199
+ // it was mid-way through.
166
200
  if (contentContainer) {
167
- while (contentContainer.firstChild) {
168
- contentContainer.removeChild(contentContainer.firstChild);
169
- }
170
201
  const stepContent = steps[currentStep]?.content;
171
- if (stepContent) {
172
- contentContainer.appendChild(stepContent);
202
+ if (contentContainer.firstChild !== stepContent) {
203
+ while (contentContainer.firstChild) {
204
+ contentContainer.removeChild(contentContainer.firstChild);
205
+ }
206
+ if (stepContent) {
207
+ // A DOM node has one parent, so the same `content` cannot show in two
208
+ // steppers: the second `appendChild` moves it and leaves the first with
209
+ // an empty slot that reads as "the widget is invisible". Nothing else
210
+ // says so, and the stepper that lost it looks like it never rendered.
211
+ if (
212
+ stepContent.parentElement &&
213
+ stepContent.parentElement !== contentContainer &&
214
+ !warnedContent.has(stepContent)
215
+ ) {
216
+ warnedContent.add(stepContent);
217
+ console.warn(
218
+ "Stepper: this step's content is already mounted in another tree, " +
219
+ "so that stepper is now empty — a widget node has one parent. Build " +
220
+ "the steps array where the Stepper is built; do not share one array " +
221
+ "between steppers.",
222
+ );
223
+ }
224
+ contentContainer.appendChild(stepContent);
225
+ }
173
226
  }
174
227
  }
175
228
 
@@ -189,24 +242,17 @@ export const Stepper = (props) => {
189
242
  }),
190
243
  );
191
244
  }
192
- if (currentStep === steps.length - 1) {
193
- buttons.push(
194
- Button({
195
- text: finishLabel,
196
- variant: "filled",
197
- bgColor: colors.success,
198
- onPress: finish,
199
- }),
200
- );
201
- } else {
202
- buttons.push(
203
- Button({
204
- text: nextLabel,
205
- variant: "filled",
206
- onPress: next,
207
- }),
208
- );
209
- }
245
+ // One filled call, not a filled call per branch: the last step swaps the
246
+ // label, the plate and the handler, and never paints two primaries.
247
+ const isLast = currentStep === steps.length - 1;
248
+ buttons.push(
249
+ Button({
250
+ text: isLast ? finishLabel : nextLabel,
251
+ variant: "filled",
252
+ bgColor: isLast ? colors.successPlate : colors.primary,
253
+ onPress: isLast ? finish : next,
254
+ }),
255
+ );
210
256
 
211
257
  const navRow = Row({
212
258
  justifyContent: "flex-end",
@@ -271,9 +317,11 @@ export const Stepper = (props) => {
271
317
 
272
318
  stepsWrapper.appendChild(stepsContainer);
273
319
 
274
- // Content container
320
+ // Content container — built empty. `updateUI()` below is the only writer of
321
+ // this slot, so the first paint goes through the same code as every
322
+ // navigation, instead of mounting the node here and detaching it a line later
323
+ // to mount the same node again.
275
324
  contentContainer = Container({
276
- child: steps[currentStep]?.content || null,
277
325
  width: "100%",
278
326
  overflow: "auto",
279
327
  });
@@ -43,6 +43,7 @@ export const Switch = (props) => {
43
43
  let knobRef = null;
44
44
 
45
45
  const updateUI = (newValue) => {
46
+ if (switchContainer) switchContainer.setAttribute("aria-checked", String(Boolean(newValue)));
46
47
  if (switchRef) {
47
48
  switchRef.style.backgroundColor = newValue ? activeColor : colors.gray300;
48
49
  }
@@ -52,10 +53,14 @@ export const Switch = (props) => {
52
53
  };
53
54
 
54
55
  // Createste knob
56
+ // The knob is a white plate in every theme: it sits on a track whose colour
57
+ // the caller picks (`activeColor`), so it cannot borrow `onPrimary` — in dark
58
+ // that token is page ink and the knob would vanish into the grey track.
59
+ // `onSecondary` is the palette's own answer for a white surface.
55
60
  const knob = WidgetFactory({
56
61
  width: sz.knob,
57
62
  height: sz.knob,
58
- backgroundColor: "#ffffff",
63
+ backgroundColor: colors.onSecondary,
59
64
  borderRadius: "50%",
60
65
  transform: internalValue ? `translateX(${travel()})` : "translateX(0)",
61
66
  transition: "transform 0.2s ease",
@@ -89,6 +94,10 @@ export const Switch = (props) => {
89
94
 
90
95
  const switchContainer = WidgetFactory({
91
96
  widgetName: "Switch",
97
+ // A track and a knob say "toggle" to an eye and nothing at all to a screen
98
+ // reader; the role names the control and `aria-checked` is its state.
99
+ role: "switch",
100
+ "aria-checked": String(Boolean(internalValue)),
92
101
  display: "flex",
93
102
  flexDirection: "row",
94
103
  alignItems: "center",
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * @file Tooltip.js
3
3
  * @description Hover/focus tooltip rendered as a fixed-position portal on `document.body`.
4
+ * Hover only where a pointer can hover, and the bubble is placed inside the
5
+ * device's safe area.
4
6
  */
5
7
  import { WidgetFactory } from "../widget-factory/index.js";
6
8
  import { resolveReactive } from "../widget-factory/reactiveValue.js";
@@ -8,6 +10,40 @@ import { colors } from "../utils/themes.js";
8
10
 
9
11
  import { toREM } from "../utils/units.js";
10
12
 
13
+ /** The bubble and the thing it describes have to be able to name each other. */
14
+ let tooltipIdSeed = 0;
15
+
16
+ /**
17
+ * The device's own edge insets, in CSS pixels.
18
+ *
19
+ * `env()` has no JavaScript getter — the only way to read it is to measure an
20
+ * element that uses it — and the placement below needs the number, not just the
21
+ * CSS: a bubble that clears the viewport by 10px still sits on the status bar of
22
+ * a notched phone. The probe is a child of the bubble it measures, so showing a
23
+ * tooltip never puts a second node in `document.body`, and it is re-read on every
24
+ * show because rotation moves the inset from the top edge to the sides.
25
+ */
26
+ const makeInsetProbe = () => {
27
+ const probe = document.createElement("div");
28
+ // Out of the flow, so the bubble keeps the size its text gives it.
29
+ probe.style.cssText =
30
+ "position:absolute;top:0;left:0;width:0;height:0;visibility:hidden;pointer-events:none;" +
31
+ "padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px);" +
32
+ "padding-left:env(safe-area-inset-left,0px);padding-right:env(safe-area-inset-right,0px);";
33
+ return probe;
34
+ };
35
+
36
+ const readInsets = (probe) => {
37
+ if (!probe) return { top: 0, bottom: 0, left: 0, right: 0 };
38
+ const cs = getComputedStyle(probe);
39
+ return {
40
+ top: parseFloat(cs.paddingTop) || 0,
41
+ bottom: parseFloat(cs.paddingBottom) || 0,
42
+ left: parseFloat(cs.paddingLeft) || 0,
43
+ right: parseFloat(cs.paddingRight) || 0,
44
+ };
45
+ };
46
+
11
47
  export const Tooltip = (props) => {
12
48
  let {
13
49
  text,
@@ -37,8 +73,6 @@ export const Tooltip = (props) => {
37
73
  borderColor = "transparent",
38
74
  borderWidth = 0,
39
75
  shadow = "0 2px 8px rgba(0,0,0,0.15)",
40
-
41
- ...rest
42
76
  } = props;
43
77
 
44
78
  if (!child) return null;
@@ -51,6 +85,8 @@ export const Tooltip = (props) => {
51
85
  let timeoutId = null;
52
86
  let isVisible = false;
53
87
 
88
+ const tooltipId = `fletbox-tooltip-${++tooltipIdSeed}`;
89
+
54
90
  let scrollHandler = null;
55
91
  let resizeHandler = null;
56
92
 
@@ -98,6 +134,11 @@ export const Tooltip = (props) => {
98
134
 
99
135
  const tooltip = WidgetFactory({
100
136
  tag: "div",
137
+ // The bubble is what `aria-describedby` on the trigger points at, so the id
138
+ // and the role live on the element that is actually shown and hidden — its
139
+ // subtree is the sentence a reader hears.
140
+ id: tooltipId,
141
+ role: "tooltip",
101
142
  position: "fixed",
102
143
  zIndex: zIndex,
103
144
  opacity: 0,
@@ -106,6 +147,8 @@ export const Tooltip = (props) => {
106
147
  pointerEvents: "none",
107
148
  });
108
149
 
150
+ const probe = makeInsetProbe();
151
+ tooltip.appendChild(probe);
109
152
  tooltip.appendChild(content);
110
153
  if (arrow) tooltip.appendChild(arrow);
111
154
 
@@ -114,6 +157,7 @@ export const Tooltip = (props) => {
114
157
 
115
158
  tooltip._content = content;
116
159
  tooltip._arrow = arrow;
160
+ tooltip._probe = probe;
117
161
 
118
162
  return tooltip;
119
163
  };
@@ -121,10 +165,33 @@ export const Tooltip = (props) => {
121
165
  const positionTooltip = (targetRect, tooltipRect) => {
122
166
  if (!tooltipElement) return;
123
167
 
168
+ // The edge the bubble actually gets: the requested one when it fits, the
169
+ // opposite when it does not. Clamping a `top` bubble into the viewport used
170
+ // to park it at y=10 — over the iOS status bar, above the AppBar it
171
+ // describes — because "there is no room up here" and "put it at the top of
172
+ // the screen" are not the same instruction.
173
+ const MARGIN = 10;
174
+ const inset = readInsets(tooltipElement._probe);
175
+ const boxTop = inset.top + MARGIN;
176
+ const boxBottom = window.innerHeight - inset.bottom - MARGIN;
177
+ const boxLeft = inset.left + MARGIN;
178
+ const boxRight = window.innerWidth - inset.right - MARGIN;
179
+ const needed = tooltipRect.height + offset;
180
+ const wide = tooltipRect.width + offset;
181
+ const room = {
182
+ top: targetRect.top - boxTop,
183
+ bottom: boxBottom - targetRect.bottom,
184
+ left: targetRect.left - boxLeft,
185
+ right: boxRight - targetRect.right,
186
+ };
187
+ const flip = { top: "bottom", bottom: "top", left: "right", right: "left" };
188
+ const fits = { top: room.top >= needed, bottom: room.bottom >= needed, left: room.left >= wide, right: room.right >= wide };
189
+ const side = fits[position] ? position : fits[flip[position]] ? flip[position] : position;
190
+
124
191
  let top, left;
125
192
  let arrowTop, arrowLeft;
126
193
 
127
- switch (position) {
194
+ switch (side) {
128
195
  case "top":
129
196
  top = targetRect.top - tooltipRect.height - offset;
130
197
  left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2;
@@ -175,11 +242,11 @@ export const Tooltip = (props) => {
175
242
  break;
176
243
  }
177
244
 
178
- if (left < 10) left = 10;
179
- if (left + tooltipRect.width > window.innerWidth - 10) {
180
- left = window.innerWidth - tooltipRect.width - 10;
181
- }
182
- if (top < 10) top = 10;
245
+ // Keep the bubble inside the safe area on every axis. `Math.max(boxTop, …)`
246
+ // guards the other end for a bubble taller or wider than the screen, where
247
+ // the two bounds cross.
248
+ left = Math.min(Math.max(left, boxLeft), Math.max(boxLeft, boxRight - tooltipRect.width));
249
+ top = Math.min(Math.max(top, boxTop), Math.max(boxTop, boxBottom - tooltipRect.height));
183
250
 
184
251
  tooltipElement.style.top = `${top}px`;
185
252
  tooltipElement.style.left = `${left}px`;
@@ -205,6 +272,10 @@ export const Tooltip = (props) => {
205
272
  });
206
273
 
207
274
  isVisible = true;
275
+ // The link is made while the bubble is up and broken while it is not: an
276
+ // `aria-describedby` pointing at a hidden node is a control that claims a
277
+ // description it is not being given.
278
+ child.setAttribute("aria-describedby", tooltipId);
208
279
 
209
280
  scrollHandler = hideOnScrollOrResize;
210
281
  resizeHandler = hideOnScrollOrResize;
@@ -223,6 +294,7 @@ export const Tooltip = (props) => {
223
294
  tooltipElement.style.opacity = "0";
224
295
  tooltipElement.style.visibility = "hidden";
225
296
  isVisible = false;
297
+ child.removeAttribute("aria-describedby");
226
298
 
227
299
  if (scrollHandler) {
228
300
  window.removeEventListener("scroll", scrollHandler, true);
@@ -237,8 +309,19 @@ export const Tooltip = (props) => {
237
309
 
238
310
  const wrappedChild = child;
239
311
 
240
- wrappedChild.addEventListener("mouseenter", showTooltip);
241
- wrappedChild.addEventListener("mouseleave", hideTooltip);
312
+ // A device that cannot hover has no state a tooltip can live in. iOS WebKit
313
+ // fires a synthetic `mouseenter` for the first tap and never a matching
314
+ // `mouseleave`, so one tap pinned a bubble to the screen and a second tap on
315
+ // another control stacked a second one on top of it — measured: two bubbles
316
+ // visible at once, neither ever hidden. A pointer that does hover keeps the
317
+ // behaviour, and the keyboard path below stays open for everyone.
318
+ const canHover =
319
+ typeof window === "undefined" || !window.matchMedia || window.matchMedia("(hover: hover)").matches;
320
+
321
+ if (canHover) {
322
+ wrappedChild.addEventListener("mouseenter", showTooltip);
323
+ wrappedChild.addEventListener("mouseleave", hideTooltip);
324
+ }
242
325
  wrappedChild.addEventListener("focus", showTooltip);
243
326
  wrappedChild.addEventListener("blur", hideTooltip);
244
327
 
@@ -6,7 +6,7 @@ import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { colors } from "../utils/themes.js";
7
7
 
8
8
  import { toREM } from "../utils/units.js";
9
- import { TAP_MINIMUM } from "../tools/index.js";
9
+ import { PILL, TAP_MINIMUM } from "../tools/index.js";
10
10
 
11
11
  export const TreeView = (props) => {
12
12
  const {
@@ -219,12 +219,12 @@ export const TreeView = (props) => {
219
219
  // `danger` is the signal tone; the plate is the fill white words can
220
220
  // actually sit on (6.47:1 in the light theme, 10.02:1 in the dark one).
221
221
  backgroundColor: colors.dangerPlate,
222
- borderRadius: "12px",
223
- padding: "2px 8px",
222
+ borderRadius: PILL,
223
+ padding: "4px 8px",
224
224
  child: WidgetFactory({
225
225
  tag: "span",
226
- fontSize: "10px",
227
- color: "#ffffff",
226
+ fontSize: "12px",
227
+ color: colors.onSecondary,
228
228
  fontWeight: "bold",
229
229
  textContent: String(node.badge),
230
230
  }),