@oxyhq/bloom 1.11.0 → 1.12.0
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/docs/layout.mdx +27 -22
- package/lib/commonjs/fab/Fab.js +46 -37
- package/lib/commonjs/fab/Fab.js.map +1 -1
- package/lib/commonjs/fab/Fab.web.js +21 -8
- package/lib/commonjs/fab/Fab.web.js.map +1 -1
- package/lib/commonjs/layout/bottom-edge.js +63 -44
- package/lib/commonjs/layout/bottom-edge.js.map +1 -1
- package/lib/commonjs/layout/index.js +4 -4
- package/lib/commonjs/tab-bar/TabBarBase.js +6 -6
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/fab/Fab.js +47 -38
- package/lib/module/fab/Fab.js.map +1 -1
- package/lib/module/fab/Fab.web.js +22 -9
- package/lib/module/fab/Fab.web.js.map +1 -1
- package/lib/module/layout/bottom-edge.js +62 -43
- package/lib/module/layout/bottom-edge.js.map +1 -1
- package/lib/module/layout/index.js +1 -1
- package/lib/module/layout/index.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +6 -6
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts +42 -28
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/index.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/module/layout/bottom-edge.d.ts +42 -28
- package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/module/layout/index.d.ts +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/fab/Fab.tsx +47 -32
- package/src/fab/Fab.web.tsx +23 -9
- package/src/layout/bottom-edge.tsx +67 -48
- package/src/layout/index.ts +1 -1
- package/src/tab-bar/TabBarBase.tsx +6 -9
package/src/fab/Fab.web.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import React, {
|
|
|
7
7
|
type MouseEvent,
|
|
8
8
|
} from 'react';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { useBottomEdgeCollapsed, useBottomEdgeInset } from '../layout/bottom-edge';
|
|
11
11
|
import { useTheme } from '../theme/use-theme';
|
|
12
12
|
import { animation, borderRadius } from '../styles/tokens';
|
|
13
13
|
import { pressedSurface } from '../theme/press-colors';
|
|
@@ -83,11 +83,11 @@ const BLOOM_FAB_CSS = interactiveWebCss({
|
|
|
83
83
|
box-shadow: var(--bloom-fab-shadow);
|
|
84
84
|
font-family: inherit;
|
|
85
85
|
`,
|
|
86
|
-
// `
|
|
87
|
-
// the
|
|
88
|
-
//
|
|
86
|
+
// `opacity` carries two things: the disabled dim and the collapse fade. 200ms
|
|
87
|
+
// is the collapse's number — the dim is imperceptibly slower for it, and one
|
|
88
|
+
// property cannot hold two durations.
|
|
89
89
|
transition:
|
|
90
|
-
'opacity
|
|
90
|
+
'opacity 200ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease',
|
|
91
91
|
// A FAB lifts on hover rather than dimming: it floats over the content, so a
|
|
92
92
|
// deeper shadow is the affordance an opacity dip cannot express.
|
|
93
93
|
hover: { declarations: 'box-shadow: var(--bloom-fab-shadow-hover);' },
|
|
@@ -210,9 +210,14 @@ const FabWebComponent: React.FC<FabProps> = ({
|
|
|
210
210
|
type = 'button',
|
|
211
211
|
}) => {
|
|
212
212
|
useInteractiveWebCss(STYLE_ID, BLOOM_FAB_CSS);
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
|
|
213
|
+
const bottomEdgeInset = useBottomEdgeInset();
|
|
214
|
+
// The FAB belongs to the chrome: when the chrome retracts, so does it. Only a
|
|
215
|
+
// BOTTOM-anchored FAB is affected by the bottom edge collapsing.
|
|
216
|
+
const bottomEdgeCollapsed = useBottomEdgeCollapsed();
|
|
217
|
+
// A faded-out FAB must not be tabbable, clickable or announced — an invisible
|
|
218
|
+
// target that still takes a click is worse than a visible one.
|
|
219
|
+
const hidden =
|
|
220
|
+
bottomEdgeCollapsed && (placement === 'bottom-right' || placement === 'bottom-left');
|
|
216
221
|
const theme = useTheme();
|
|
217
222
|
const reactId = useId();
|
|
218
223
|
const resolvedId = id ?? `bloom-fab-${reactId}`;
|
|
@@ -247,8 +252,15 @@ const FabWebComponent: React.FC<FabProps> = ({
|
|
|
247
252
|
variantColors.foreground,
|
|
248
253
|
),
|
|
249
254
|
['--bloom-fab-press-scale' as string]: animation.pressScale,
|
|
250
|
-
...placementStyle(placement, offset,
|
|
255
|
+
...placementStyle(placement, offset, bottomEdgeInset),
|
|
251
256
|
};
|
|
257
|
+
// Only while HIDDEN, so the `:disabled { opacity }` rule still owns the
|
|
258
|
+
// dim the rest of the time — an inline opacity would beat that stylesheet
|
|
259
|
+
// rule unconditionally and a disabled FAB would stop dimming.
|
|
260
|
+
if (hidden) {
|
|
261
|
+
base.opacity = 0;
|
|
262
|
+
base.pointerEvents = 'none';
|
|
263
|
+
}
|
|
252
264
|
if (isExtended) {
|
|
253
265
|
const pad = sizeConfig.diameter <= 44 ? 14 : 20;
|
|
254
266
|
base.paddingLeft = pad;
|
|
@@ -302,6 +314,8 @@ const FabWebComponent: React.FC<FabProps> = ({
|
|
|
302
314
|
onClick={handleClick}
|
|
303
315
|
disabled={disabled}
|
|
304
316
|
aria-disabled={disabled || undefined}
|
|
317
|
+
aria-hidden={hidden || undefined}
|
|
318
|
+
tabIndex={hidden ? -1 : undefined}
|
|
305
319
|
aria-label={ariaLabel}
|
|
306
320
|
title={title ?? accessibilityHint}
|
|
307
321
|
data-testid={testID}
|
|
@@ -27,20 +27,32 @@
|
|
|
27
27
|
* edge, so two surfaces at that edge overlap rather than stack; the tallest is
|
|
28
28
|
* what a new surface has to clear. Summing would strand it at twice the height.
|
|
29
29
|
*
|
|
30
|
-
* ##
|
|
30
|
+
* ## A size and a state, because they travel differently
|
|
31
31
|
*
|
|
32
|
-
* A surface that collapses — the tab bar minimizes 58 -> 44 on scroll —
|
|
33
|
-
*
|
|
32
|
+
* A surface that collapses — the tab bar minimizes 58 -> 44 on scroll — publishes
|
|
33
|
+
* two things, and only one of them is a measurement:
|
|
34
34
|
*
|
|
35
35
|
* - RESERVED (`useBottomEdgeInset`) never shrinks while the claimant is
|
|
36
36
|
* mounted. It is what must be kept permanently free: a list's bottom
|
|
37
|
-
* padding, a toast stack's offset.
|
|
38
|
-
* list's content size on every scroll and jump a toast 14px mid-display
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
37
|
+
* padding, a toast stack's offset. Shrinking it on collapse would jitter a
|
|
38
|
+
* list's content size on every scroll and jump a toast 14px mid-display,
|
|
39
|
+
* because the bar re-expands the instant the user scrolls back up.
|
|
40
|
+
* - COLLAPSED (`useBottomEdgeCollapsed`) is a boolean, deliberately, and this
|
|
41
|
+
* is the part that was learned the hard way. It first shipped as a live
|
|
42
|
+
* PIXEL height so a FAB could ride down with the bar — and it felt broken on
|
|
43
|
+
* a device. The bar animates on the UI thread; a React consumer learns about
|
|
44
|
+
* the change through `runOnJS` plus two render passes, so its motion STARTS
|
|
45
|
+
* one to three frames late, worst exactly while scrolling because that is
|
|
46
|
+
* when the JS thread is busiest. No spring config fixes a variable start
|
|
47
|
+
* delay.
|
|
48
|
+
*
|
|
49
|
+
* Two things moving together betray that lag; a state change does not. A reader
|
|
50
|
+
* that FADES on this boolean has no spatial relationship to violate, so the same
|
|
51
|
+
* few frames of latency are imperceptible. That is why the channel is a state and
|
|
52
|
+
* not a geometry: it is the shape of signal that survives the trip to the JS
|
|
53
|
+
* thread. Anything that genuinely must track the collapse per-frame has to read
|
|
54
|
+
* the tab bar's own shared value on the UI thread instead — React state is the
|
|
55
|
+
* wrong transport for it, at any width.
|
|
44
56
|
*
|
|
45
57
|
* ## Why an external store
|
|
46
58
|
*
|
|
@@ -63,8 +75,8 @@ import {
|
|
|
63
75
|
interface Claim {
|
|
64
76
|
/** What the surface keeps permanently free, collapsed or not. */
|
|
65
77
|
reserved: number;
|
|
66
|
-
/**
|
|
67
|
-
|
|
78
|
+
/** Whether it is currently in its collapsed state. */
|
|
79
|
+
collapsed: boolean;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
interface BottomEdgeStore {
|
|
@@ -76,8 +88,8 @@ interface BottomEdgeStore {
|
|
|
76
88
|
* only the other moved.
|
|
77
89
|
*/
|
|
78
90
|
getReserved: () => number;
|
|
79
|
-
|
|
80
|
-
claim: (id: string, reserved: number,
|
|
91
|
+
getCollapsed: () => boolean;
|
|
92
|
+
claim: (id: string, reserved: number, collapsed: boolean) => void;
|
|
81
93
|
release: (id: string) => void;
|
|
82
94
|
}
|
|
83
95
|
|
|
@@ -85,21 +97,25 @@ function createBottomEdgeStore(): BottomEdgeStore {
|
|
|
85
97
|
const claims = new Map<string, Claim>();
|
|
86
98
|
const listeners = new Set<() => void>();
|
|
87
99
|
let reserved = 0;
|
|
88
|
-
let
|
|
100
|
+
let collapsed = false;
|
|
89
101
|
|
|
90
102
|
const recompute = () => {
|
|
91
103
|
let nextReserved = 0;
|
|
92
|
-
|
|
104
|
+
// ANY claimant collapsing collapses the edge. With the one floating bar this
|
|
105
|
+
// is written for the two readings coincide; where they would not, "something
|
|
106
|
+
// at this edge just retracted" is still the signal a reader wants, and it is
|
|
107
|
+
// the safe direction — a FAB that fades when it did not strictly have to
|
|
108
|
+
// beats one that stays put over a bar that left.
|
|
109
|
+
let nextCollapsed = false;
|
|
93
110
|
for (const claim of claims.values()) {
|
|
94
111
|
if (claim.reserved > nextReserved) nextReserved = claim.reserved;
|
|
95
|
-
if (claim.
|
|
112
|
+
if (claim.collapsed) nextCollapsed = true;
|
|
96
113
|
}
|
|
97
|
-
// Bail before notifying: a re-registration at unchanged
|
|
98
|
-
// render of a claimant
|
|
99
|
-
|
|
100
|
-
if (nextReserved === reserved && nextLive === live) return;
|
|
114
|
+
// Bail before notifying: a re-registration at an unchanged footprint (every
|
|
115
|
+
// render of a claimant that did not move) must not re-render every reader.
|
|
116
|
+
if (nextReserved === reserved && nextCollapsed === collapsed) return;
|
|
101
117
|
reserved = nextReserved;
|
|
102
|
-
|
|
118
|
+
collapsed = nextCollapsed;
|
|
103
119
|
for (const listener of listeners) listener();
|
|
104
120
|
};
|
|
105
121
|
|
|
@@ -111,11 +127,11 @@ function createBottomEdgeStore(): BottomEdgeStore {
|
|
|
111
127
|
};
|
|
112
128
|
},
|
|
113
129
|
getReserved: () => reserved,
|
|
114
|
-
|
|
115
|
-
claim(id, nextReserved,
|
|
130
|
+
getCollapsed: () => collapsed,
|
|
131
|
+
claim(id, nextReserved, nextCollapsed) {
|
|
116
132
|
const existing = claims.get(id);
|
|
117
|
-
if (existing?.reserved === nextReserved && existing.
|
|
118
|
-
claims.set(id, { reserved: nextReserved,
|
|
133
|
+
if (existing?.reserved === nextReserved && existing.collapsed === nextCollapsed) return;
|
|
134
|
+
claims.set(id, { reserved: nextReserved, collapsed: nextCollapsed });
|
|
119
135
|
recompute();
|
|
120
136
|
},
|
|
121
137
|
release(id) {
|
|
@@ -144,6 +160,7 @@ BottomEdgeProvider.displayName = 'BottomEdgeProvider';
|
|
|
144
160
|
// would resubscribe `useSyncExternalStore` on every render.
|
|
145
161
|
const NO_SUBSCRIPTION = () => () => {};
|
|
146
162
|
const NO_INSET = () => 0;
|
|
163
|
+
const NO_COLLAPSE = () => false;
|
|
147
164
|
|
|
148
165
|
/**
|
|
149
166
|
* How much of the bottom edge is permanently RESERVED, in px.
|
|
@@ -172,23 +189,28 @@ export function useBottomEdgeInset(): number {
|
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
192
|
+
* Whether the surface owning the bottom edge is currently COLLAPSED — the tab
|
|
193
|
+
* bar minimized on scroll.
|
|
194
|
+
*
|
|
195
|
+
* A boolean rather than a live height, on purpose: see the note at the top of
|
|
196
|
+
* this file. A reader should FADE or otherwise change state on it, never try to
|
|
197
|
+
* stay geometrically locked to the collapsing surface — that lock is what a
|
|
198
|
+
* React-state channel cannot deliver, because the surface animates on the UI
|
|
199
|
+
* thread and this arrives one to three frames later.
|
|
200
|
+
*
|
|
201
|
+
* ```tsx
|
|
202
|
+
* const collapsed = useBottomEdgeCollapsed();
|
|
203
|
+
* // fade out; do not chase the bar's new top edge
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* `false` outside a provider.
|
|
185
207
|
*/
|
|
186
|
-
export function
|
|
208
|
+
export function useBottomEdgeCollapsed(): boolean {
|
|
187
209
|
const store = useContext(BottomEdgeContext);
|
|
188
210
|
return useSyncExternalStore(
|
|
189
211
|
store?.subscribe ?? NO_SUBSCRIPTION,
|
|
190
|
-
store?.
|
|
191
|
-
store?.
|
|
212
|
+
store?.getCollapsed ?? NO_COLLAPSE,
|
|
213
|
+
store?.getCollapsed ?? NO_COLLAPSE,
|
|
192
214
|
);
|
|
193
215
|
}
|
|
194
216
|
|
|
@@ -198,22 +220,19 @@ export function useBottomEdgeLiveInset(): number {
|
|
|
198
220
|
* The claimant owns its own placement — claiming does not move it. It declares
|
|
199
221
|
* the space it occupies so that everything reading the edge stays off it. Pass
|
|
200
222
|
* the FULL footprint (the surface's height plus the gap it holds off the window
|
|
201
|
-
* edge), which is the same number the surface positions itself with
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* which is correct for anything that does not collapse. A surface that shrinks
|
|
205
|
-
* on scroll passes its live footprint as `current` and keeps `reserved` at its
|
|
206
|
-
* full size — see the two channels above for why both are needed.
|
|
223
|
+
* edge), which is the same number the surface positions itself with, and keep it
|
|
224
|
+
* at the surface's FULL size even while collapsed: `reserved` is what must stay
|
|
225
|
+
* permanently free, and the collapse is reported separately by `collapsed`.
|
|
207
226
|
*
|
|
208
227
|
* A no-op outside a provider, so a surface stays usable standalone.
|
|
209
228
|
*/
|
|
210
|
-
export function useClaimBottomEdge(reserved: number,
|
|
229
|
+
export function useClaimBottomEdge(reserved: number, collapsed = false): void {
|
|
211
230
|
const store = useContext(BottomEdgeContext);
|
|
212
231
|
const id = useId();
|
|
213
232
|
|
|
214
233
|
useEffect(() => {
|
|
215
234
|
if (!store) return;
|
|
216
|
-
store.claim(id, reserved,
|
|
235
|
+
store.claim(id, reserved, collapsed);
|
|
217
236
|
return () => store.release(id);
|
|
218
|
-
}, [store, id, reserved,
|
|
237
|
+
}, [store, id, reserved, collapsed]);
|
|
219
238
|
}
|
package/src/layout/index.ts
CHANGED
|
@@ -445,15 +445,12 @@ function TabBarBody({
|
|
|
445
445
|
// from the same geometry, so a consumer reading either can never disagree with
|
|
446
446
|
// where the bar actually sits.
|
|
447
447
|
//
|
|
448
|
-
// RESERVED stays the EXPANDED height: the bar re-expands
|
|
449
|
-
// scrolls back up, so anything that permanently reserves
|
|
450
|
-
// bottom padding, a toast) must keep room for the full pill or
|
|
451
|
-
// on every scroll.
|
|
452
|
-
//
|
|
453
|
-
useClaimBottomEdge(
|
|
454
|
-
bottomOffset + EXPANDED_HEIGHT,
|
|
455
|
-
bottomOffset + (isMinimized ? MINIMIZED_HEIGHT : EXPANDED_HEIGHT),
|
|
456
|
-
);
|
|
448
|
+
// RESERVED stays the EXPANDED height even while minimized: the bar re-expands
|
|
449
|
+
// the moment the user scrolls back up, so anything that permanently reserves
|
|
450
|
+
// space (a list's bottom padding, a toast) must keep room for the full pill or
|
|
451
|
+
// it would jitter on every scroll. The collapse travels as its own STATE — see
|
|
452
|
+
// `layout/bottom-edge` for why it is a boolean and not the live height.
|
|
453
|
+
useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT, isMinimized);
|
|
457
454
|
|
|
458
455
|
// How centring and the animated inset compose: centring is STATIC and belongs
|
|
459
456
|
// to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
|