@oxyhq/bloom 1.0.0 → 1.0.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 -4
- package/docs/accordion.mdx +69 -0
- package/docs/activity-heatmap.mdx +71 -0
- package/docs/admonition.mdx +77 -0
- package/docs/alert-dialog.mdx +75 -0
- package/docs/alert.mdx +93 -0
- package/docs/animated-check.mdx +53 -0
- package/docs/aspect-ratio.mdx +45 -0
- package/docs/avatar-group.mdx +91 -0
- package/docs/avatar.mdx +126 -0
- package/docs/badge.mdx +74 -0
- package/docs/benefit-list.mdx +56 -0
- package/docs/bottom-sheet.mdx +103 -0
- package/docs/button.mdx +218 -0
- package/docs/card.mdx +106 -0
- package/docs/checkbox.mdx +59 -0
- package/docs/chip.mdx +53 -0
- package/docs/code.mdx +50 -0
- package/docs/combobox.mdx +64 -0
- package/docs/command.mdx +85 -0
- package/docs/composition-bar.mdx +63 -0
- package/docs/connection-dots.mdx +50 -0
- package/docs/connection-status.mdx +56 -0
- package/docs/content-panel.mdx +62 -0
- package/docs/context-menu.mdx +65 -0
- package/docs/design-tokens.mdx +254 -0
- package/docs/dialog.mdx +277 -0
- package/docs/divider.mdx +56 -0
- package/docs/dot-grid-meter.mdx +66 -0
- package/docs/dropdown-menu.mdx +161 -0
- package/docs/error-boundary.mdx +72 -0
- package/docs/fab.mdx +67 -0
- package/docs/field.mdx +70 -0
- package/docs/fill.mdx +58 -0
- package/docs/fonts.mdx +144 -0
- package/docs/frosted-icon-button.mdx +60 -0
- package/docs/getting-started.mdx +75 -0
- package/docs/grid.mdx +64 -0
- package/docs/hooks.mdx +81 -0
- package/docs/icon-circle.mdx +66 -0
- package/docs/icons.mdx +69 -0
- package/docs/image-aspect-ratio-cache.mdx +63 -0
- package/docs/image-resolver.mdx +66 -0
- package/docs/index.mdx +44 -0
- package/docs/input-group.mdx +58 -0
- package/docs/item.mdx +69 -0
- package/docs/kbd.mdx +57 -0
- package/docs/label.mdx +50 -0
- package/docs/link-preview.mdx +66 -0
- package/docs/list.mdx +72 -0
- package/docs/loading.mdx +129 -0
- package/docs/media-inset-border.mdx +52 -0
- package/docs/menubar.mdx +86 -0
- package/docs/migrating-to-1.0.mdx +434 -0
- package/docs/motion.mdx +83 -0
- package/docs/overlay.mdx +75 -0
- package/docs/popover.mdx +73 -0
- package/docs/portal.mdx +63 -0
- package/docs/pressable-scale.mdx +51 -0
- package/docs/profile-card.mdx +66 -0
- package/docs/progressive-blur.mdx +54 -0
- package/docs/prompt-input.mdx +183 -0
- package/docs/provider.mdx +64 -0
- package/docs/radio-indicator.mdx +60 -0
- package/docs/radio.mdx +65 -0
- package/docs/scroll.mdx +66 -0
- package/docs/search.mdx +67 -0
- package/docs/segmented-control.mdx +76 -0
- package/docs/select.mdx +93 -0
- package/docs/settings-list.mdx +186 -0
- package/docs/skeleton.mdx +66 -0
- package/docs/slider.mdx +73 -0
- package/docs/stat-bar.mdx +59 -0
- package/docs/stat-widgets.mdx +33 -0
- package/docs/styles.mdx +61 -0
- package/docs/subtle-hover.mdx +53 -0
- package/docs/surfaces.mdx +72 -0
- package/docs/switch.mdx +90 -0
- package/docs/tab-bar.mdx +386 -0
- package/docs/tabs.mdx +75 -0
- package/docs/text-field.mdx +187 -0
- package/docs/theme.mdx +67 -0
- package/docs/toast.mdx +285 -0
- package/docs/tooltip.mdx +67 -0
- package/docs/typography.mdx +88 -0
- package/docs/user-hover-card.mdx +140 -0
- package/docs/zoomable-image-gallery.mdx +66 -0
- package/lib/commonjs/design-tokens/color-roles.js +21 -1
- package/lib/commonjs/design-tokens/color-roles.js.map +1 -1
- package/lib/commonjs/design-tokens/index.js +6 -0
- package/lib/commonjs/design-tokens/index.js.map +1 -1
- package/lib/commonjs/design-tokens/tailwind-preset.js +3 -1
- package/lib/commonjs/design-tokens/tailwind-preset.js.map +1 -1
- package/lib/commonjs/design-tokens/theme-css.js +1 -0
- package/lib/commonjs/design-tokens/theme-css.js.map +1 -1
- package/lib/commonjs/design-tokens/theme.css +3 -0
- package/lib/commonjs/design-tokens/tokens-json.js +13 -4
- package/lib/commonjs/design-tokens/tokens-json.js.map +1 -1
- package/lib/commonjs/design-tokens/tokens.json +6227 -265
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +7 -0
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/theme/ambient-store.js +16 -1
- package/lib/commonjs/theme/ambient-store.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +30 -33
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +413 -166
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js +1 -0
- package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/commonjs/theme/glass-colors.js +18 -21
- package/lib/commonjs/theme/glass-colors.js.map +1 -1
- package/lib/commonjs/theme/index.js +30 -0
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/index.web.js +30 -0
- package/lib/commonjs/theme/index.web.js.map +1 -1
- package/lib/module/design-tokens/color-roles.js +20 -0
- package/lib/module/design-tokens/color-roles.js.map +1 -1
- package/lib/module/design-tokens/index.js +2 -2
- package/lib/module/design-tokens/index.js.map +1 -1
- package/lib/module/design-tokens/tailwind-preset.js +4 -2
- package/lib/module/design-tokens/tailwind-preset.js.map +1 -1
- package/lib/module/design-tokens/theme-css.js +2 -1
- package/lib/module/design-tokens/theme-css.js.map +1 -1
- package/lib/module/design-tokens/theme.css +3 -0
- package/lib/module/design-tokens/tokens-json.js +14 -5
- package/lib/module/design-tokens/tokens-json.js.map +1 -1
- package/lib/module/design-tokens/tokens.json +6227 -265
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/theme/ambient-store.js +16 -1
- package/lib/module/theme/ambient-store.js.map +1 -1
- package/lib/module/theme/color-policy.js +30 -33
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/module/theme/color-presets.js +412 -165
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/color-scope/seed-scope.js +2 -1
- package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/module/theme/glass-colors.js +18 -21
- package/lib/module/theme/glass-colors.js.map +1 -1
- package/lib/module/theme/index.js +1 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/index.web.js +1 -1
- package/lib/module/theme/index.web.js.map +1 -1
- package/lib/typescript/commonjs/design-tokens/color-roles.d.ts +20 -0
- package/lib/typescript/commonjs/design-tokens/color-roles.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/index.d.ts +3 -3
- package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/tailwind-preset.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/theme-css.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +12 -3
- package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -2
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/ambient-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +386 -62
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/glass-colors.d.ts +18 -21
- package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/index.d.ts +2 -2
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/index.web.d.ts +2 -2
- package/lib/typescript/commonjs/theme/index.web.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/color-roles.d.ts +20 -0
- package/lib/typescript/module/design-tokens/color-roles.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/index.d.ts +3 -3
- package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/tailwind-preset.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/theme-css.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/tokens-json.d.ts +12 -3
- package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -2
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/theme/ambient-store.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +386 -62
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/lib/typescript/module/theme/glass-colors.d.ts +18 -21
- package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
- package/lib/typescript/module/theme/index.d.ts +2 -2
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/index.web.d.ts +2 -2
- package/lib/typescript/module/theme/index.web.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/design-tokens/color-roles.ts +21 -0
- package/src/design-tokens/index.ts +3 -2
- package/src/design-tokens/tailwind-preset.ts +3 -2
- package/src/design-tokens/theme-css.ts +2 -1
- package/src/design-tokens/theme.css +3 -0
- package/src/design-tokens/tokens-json.ts +29 -9
- package/src/design-tokens/tokens.json +6227 -265
- package/src/glass/GlassSurface.tsx +2 -2
- package/src/index.ts +2 -0
- package/src/index.web.ts +2 -0
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +4082 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
- package/src/theme/ambient-store.ts +21 -1
- package/src/theme/color-policy.ts +33 -38
- package/src/theme/color-presets.ts +473 -146
- package/src/theme/color-scope/seed-scope.ts +2 -1
- package/src/theme/glass-colors.ts +18 -21
- package/src/theme/index.ts +16 -1
- package/src/theme/index.web.ts +16 -1
package/docs/portal.mdx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Portal
|
|
3
|
+
description: Rendering somewhere else in the tree — and why the provider and outlet are native-only.
|
|
4
|
+
order: 38
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Portal
|
|
8
|
+
|
|
9
|
+
`Portal` renders its children at an outlet instead of where they were written.
|
|
10
|
+
It is what lets a menu escape a parent's `overflow: hidden`, a clipping
|
|
11
|
+
scroll view, or a stacking context that would otherwise trap it.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { Portal } from '@oxyhq/bloom/portal';
|
|
15
|
+
|
|
16
|
+
<View style={{ overflow: 'hidden' }}>
|
|
17
|
+
<Trigger />
|
|
18
|
+
{open ? (
|
|
19
|
+
<Portal>
|
|
20
|
+
<OverlayRoot>
|
|
21
|
+
<Panel />
|
|
22
|
+
</OverlayRoot>
|
|
23
|
+
</Portal>
|
|
24
|
+
) : null}
|
|
25
|
+
</View>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## `PortalProvider` and `PortalOutlet` are NATIVE-ONLY
|
|
29
|
+
|
|
30
|
+
On web the fork portals directly to `document.body`, so both are explicit no-op
|
|
31
|
+
exports there. You still render them — the API is the same on both platforms —
|
|
32
|
+
they simply do nothing in a browser.
|
|
33
|
+
|
|
34
|
+
This matters because "this app doesn't mount a Portal Outlet" is **never** a
|
|
35
|
+
valid reason to fork a web-facing component. That reasoning once produced a
|
|
36
|
+
742-line fork that diverged for two months and shipped strictly worse than the
|
|
37
|
+
thing it replaced.
|
|
38
|
+
|
|
39
|
+
## Where the outlet goes
|
|
40
|
+
|
|
41
|
+
Near the app root, after the content it must paint over. Mounting it too early
|
|
42
|
+
puts every portaled surface behind the app; mounting two duplicates every
|
|
43
|
+
surface.
|
|
44
|
+
|
|
45
|
+
Outlets deliberately stay **out** of `BloomProvider`: their tree position is an
|
|
46
|
+
app decision, and a second mount is silent — you get two of everything, which
|
|
47
|
+
reads as a render loop rather than as a duplicated outlet.
|
|
48
|
+
|
|
49
|
+
## Portal is not enough on its own
|
|
50
|
+
|
|
51
|
+
A portaled surface still needs:
|
|
52
|
+
|
|
53
|
+
- an [`OverlayRoot`](/docs/bloom/main/overlay) for its stack position and its
|
|
54
|
+
interactive root — the web portal root is `pointer-events: none` and that
|
|
55
|
+
**inherits**, so a surface without one is entirely click-through;
|
|
56
|
+
- a `Backdrop` if it should dismiss on an outside press.
|
|
57
|
+
|
|
58
|
+
## Several portals coexist
|
|
59
|
+
|
|
60
|
+
Each portal is tracked by its own id, so opening a second does not replace the
|
|
61
|
+
first, and unmounting one removes only that one. Order at the outlet follows
|
|
62
|
+
mount order — which is also why the overlay stack, not the outlet, decides what
|
|
63
|
+
paints on top.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: PressableScale
|
|
3
|
+
description: A press that shrinks slightly — the feedback for something that is not already a button.
|
|
4
|
+
order: 63
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PressableScale
|
|
8
|
+
|
|
9
|
+
A `Pressable` that scales down while held.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { PressableScale } from '@oxyhq/bloom/pressable-scale';
|
|
13
|
+
|
|
14
|
+
<PressableScale onPress={open} targetScale={0.97} accessibilityLabel="Open photo">
|
|
15
|
+
<Image source={photo} style={{ width: 120, height: 120, borderRadius: 12 }} />
|
|
16
|
+
</PressableScale>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Which prop decides what
|
|
20
|
+
|
|
21
|
+
| prop | decides |
|
|
22
|
+
| ------------- | ------------------------------------------------------------ |
|
|
23
|
+
| `targetScale` | how far it shrinks while held — smaller is more emphatic |
|
|
24
|
+
|
|
25
|
+
Everything else is `Pressable`'s.
|
|
26
|
+
|
|
27
|
+
## What it is for
|
|
28
|
+
|
|
29
|
+
Feedback on things that are not shaped like buttons: a photo, a card, a tile, a
|
|
30
|
+
row that opens something. Those have no border or fill to darken, so without
|
|
31
|
+
motion a press produces no acknowledgement at all and the user presses again.
|
|
32
|
+
|
|
33
|
+
`Button`, `Fab` and `FrostedIconButton` already have their own press feedback —
|
|
34
|
+
wrapping one adds a second animation on top of the first.
|
|
35
|
+
|
|
36
|
+
## Keep the scale small
|
|
37
|
+
|
|
38
|
+
Around 0.96–0.98 for anything sizeable. A large element shrinking to 0.9 moves
|
|
39
|
+
its edges a long way, which reads as the layout jumping rather than as a press.
|
|
40
|
+
Small targets can take more, because the absolute movement is smaller.
|
|
41
|
+
|
|
42
|
+
## It is a Pressable, so it still needs a name
|
|
43
|
+
|
|
44
|
+
An image inside a `PressableScale` announces as a button with no label.
|
|
45
|
+
`accessibilityLabel` is not optional here.
|
|
46
|
+
|
|
47
|
+
## Native vs web
|
|
48
|
+
|
|
49
|
+
The press animation runs on the native driver, and jest can see the structure
|
|
50
|
+
but never whether the animation ran. A device build is the only place it is
|
|
51
|
+
actually verified.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: ProfileCard
|
|
3
|
+
description: A profile widget with one headline number and one metric — four flavours, one shape.
|
|
4
|
+
order: 62
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# ProfileCard
|
|
8
|
+
|
|
9
|
+
A compact profile widget: an avatar with an optional ring and badge, a headline
|
|
10
|
+
value, and one metric underneath.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ProfileCard } from '@oxyhq/bloom/profile-card';
|
|
14
|
+
|
|
15
|
+
<ProfileCard
|
|
16
|
+
variant="wallet"
|
|
17
|
+
avatar={{ source: user.avatar, name: user.displayName }}
|
|
18
|
+
value="$167,395"
|
|
19
|
+
subtitle="*5bF5"
|
|
20
|
+
metric={{ kind: 'dots', label: 'Token diversity', filled: 34, total: 50 }}
|
|
21
|
+
footer={{ label: 'Top tokens', items: topTokens }}
|
|
22
|
+
/>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Which prop decides what
|
|
26
|
+
|
|
27
|
+
| prop | decides |
|
|
28
|
+
| ---------- | --------------------------------------------------------------- |
|
|
29
|
+
| `variant` | `wallet` · `social` · `shopping` · `stat` — the flavour |
|
|
30
|
+
| `layout` | `widget` (a fixed-width card) or `wide` (fills its container) |
|
|
31
|
+
| `avatar` | the image, its ring, a badge, and the fallback name |
|
|
32
|
+
| `value` | the headline number |
|
|
33
|
+
| `metric` | the thing underneath — a discriminated union, see below |
|
|
34
|
+
| `footer` | a labelled row of avatars |
|
|
35
|
+
|
|
36
|
+
## `metric` is a discriminated union
|
|
37
|
+
|
|
38
|
+
`kind` picks both the widget and the fields it needs, so a `dots` metric cannot
|
|
39
|
+
be given a `max` and a `progress` metric cannot be given a `total`:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
metric={{ kind: 'dots', filled: 34, total: 50 }}
|
|
43
|
+
metric={{ kind: 'progress', label: 'Level 4', value: 620, max: 1000 }}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That is deliberate: the alternative — a flat prop bag with everything optional
|
|
47
|
+
— makes an incomplete metric a runtime surprise rather than a type error.
|
|
48
|
+
|
|
49
|
+
## `avatar.source`, not a URL
|
|
50
|
+
|
|
51
|
+
The avatar goes through [`ImageResolver`](/docs/bloom/main/image-resolver), so
|
|
52
|
+
pass the bare Oxy file id. `name` is the fallback used to draw an initial when
|
|
53
|
+
there is no image — give it even when you have one, since that is exactly the
|
|
54
|
+
state you get when the image fails.
|
|
55
|
+
|
|
56
|
+
## One headline number
|
|
57
|
+
|
|
58
|
+
`value` is the number the card exists to show. If a card needs two, it is two
|
|
59
|
+
cards — a widget with competing headlines has no headline, and the eye has
|
|
60
|
+
nowhere to land.
|
|
61
|
+
|
|
62
|
+
## The background is deliberately not the `card` role
|
|
63
|
+
|
|
64
|
+
This widget sits **on** a card surface in its reference design, so it takes the
|
|
65
|
+
page background and reads as a cut-out rather than as a stacked card. If you
|
|
66
|
+
place it on the page background instead, give it a surface of its own.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: ProgressiveBlur
|
|
3
|
+
description: A blur that fades out, so content dissolves into the chrome instead of meeting a line.
|
|
4
|
+
order: 72
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# ProgressiveBlur
|
|
8
|
+
|
|
9
|
+
A blur that is strongest at one edge and fades to nothing. Put it behind a
|
|
10
|
+
floating tab bar or a transparent header so scrolling content dissolves rather
|
|
11
|
+
than hitting a hard blur boundary halfway up a word.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { ProgressiveBlur } from '@oxyhq/bloom/progressive-blur';
|
|
15
|
+
|
|
16
|
+
<ProgressiveBlur
|
|
17
|
+
direction="bottom"
|
|
18
|
+
intensity={5}
|
|
19
|
+
style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 96 }}
|
|
20
|
+
/>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Which prop decides what
|
|
24
|
+
|
|
25
|
+
| prop | decides |
|
|
26
|
+
| ----------- | ----------------------------------------------------- |
|
|
27
|
+
| `direction` | which edge the blur is anchored to — `top` or `bottom` |
|
|
28
|
+
| `intensity` | the strength at that edge, on `expo-blur`'s 0–100 scale |
|
|
29
|
+
|
|
30
|
+
It takes no size of its own: position and size it with `style`.
|
|
31
|
+
|
|
32
|
+
## Two implementations, one call site
|
|
33
|
+
|
|
34
|
+
iOS has no public variable-blur API, so the **native** fork stacks ten thin
|
|
35
|
+
blur layers with a small per-layer intensity — each layer's edge adds an
|
|
36
|
+
imperceptible step, so the falloff reads as continuous. The **web** fork
|
|
37
|
+
replaces the whole stack with one masked `backdrop-filter`, because ten stacked
|
|
38
|
+
backdrop passes would cost far more than the single pass a browser can do
|
|
39
|
+
natively.
|
|
40
|
+
|
|
41
|
+
Nothing at the call site changes, and `intensity` means the same thing in both
|
|
42
|
+
— per layer on native, mapped onto the equivalent CSS radius on web.
|
|
43
|
+
|
|
44
|
+
## Be conservative with `intensity`
|
|
45
|
+
|
|
46
|
+
The blur exists so text stays legible while passing under the chrome. A high
|
|
47
|
+
value turns the strip into an opaque band, which reads as a rendering bug
|
|
48
|
+
rather than as a design.
|
|
49
|
+
|
|
50
|
+
## It needs something behind it
|
|
51
|
+
|
|
52
|
+
A backdrop blur with nothing scrolling under it is a slightly grey rectangle
|
|
53
|
+
that costs a compositing pass. If the area behind is a flat background, use a
|
|
54
|
+
gradient — or nothing.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: PromptInput
|
|
3
|
+
description: AI chat input — textarea, actions, attachments, submit, stop, fullscreen.
|
|
4
|
+
order: 17
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PromptInput
|
|
8
|
+
|
|
9
|
+
The AI chat input component. Auto-resizing textarea, send / stop button, optional attachments, optional fullscreen toggle, configurable left-side actions, image paste on web, and a controlled or simple mode.
|
|
10
|
+
|
|
11
|
+
## Simple mode
|
|
12
|
+
|
|
13
|
+
The fastest path to a working prompt input:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { PromptInput } from '@oxyhq/bloom';
|
|
17
|
+
|
|
18
|
+
const [value, setValue] = useState('');
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
|
|
21
|
+
<PromptInput
|
|
22
|
+
value={value}
|
|
23
|
+
onValueChange={setValue}
|
|
24
|
+
placeholder="Ask anything…"
|
|
25
|
+
isLoading={busy}
|
|
26
|
+
onSubmit={async () => {
|
|
27
|
+
setBusy(true);
|
|
28
|
+
try { await ask(value); setValue(''); } finally { setBusy(false); }
|
|
29
|
+
}}
|
|
30
|
+
onStop={() => abortController.abort()}
|
|
31
|
+
/>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it — textarea, submit button, stop button, all wired.
|
|
35
|
+
|
|
36
|
+
## Compound mode
|
|
37
|
+
|
|
38
|
+
For full control over layout, opt into the compound API:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import * as Prompt from '@oxyhq/bloom/prompt-input';
|
|
42
|
+
|
|
43
|
+
<Prompt.Root value={value} onValueChange={setValue} isLoading={busy} onSubmit={ask}>
|
|
44
|
+
<Prompt.Textarea placeholder="Ask anything…" />
|
|
45
|
+
<Prompt.Actions>
|
|
46
|
+
<Prompt.AttachmentButton onPress={pickFile} />
|
|
47
|
+
<View style={{ flex: 1 }} />
|
|
48
|
+
<Prompt.SubmitButton />
|
|
49
|
+
</Prompt.Actions>
|
|
50
|
+
<Prompt.Attachments />
|
|
51
|
+
</Prompt.Root>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Compound mode lets you reorder actions, render extra buttons, swap icons, or render attachments somewhere else entirely.
|
|
55
|
+
|
|
56
|
+
## Attachments
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
60
|
+
|
|
61
|
+
<PromptInput
|
|
62
|
+
value={value}
|
|
63
|
+
onValueChange={setValue}
|
|
64
|
+
attachments={attachments}
|
|
65
|
+
onAddAttachment={(a) => setAttachments((prev) => [...prev, a])}
|
|
66
|
+
onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((p) => p.id !== id))}
|
|
67
|
+
onUpdateAttachment={(id, u) => setAttachments((prev) => prev.map((p) => p.id === id ? { ...p, ...u } : p))}
|
|
68
|
+
onImagePaste={(files) => {
|
|
69
|
+
/* turn each File into an Attachment and call onAddAttachment */
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`onImagePaste` is web-only — the textarea swallows `paste` events and forwards image clipboard content.
|
|
75
|
+
|
|
76
|
+
## Loading and stop
|
|
77
|
+
|
|
78
|
+
`isLoading` toggles the right-side button between "submit" and "stop". Wire `onStop` to abort the in-flight request:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
const controllerRef = useRef<AbortController | null>(null);
|
|
82
|
+
|
|
83
|
+
<PromptInput
|
|
84
|
+
isLoading={busy}
|
|
85
|
+
onSubmit={async () => {
|
|
86
|
+
controllerRef.current = new AbortController();
|
|
87
|
+
setBusy(true);
|
|
88
|
+
try {
|
|
89
|
+
await ask(value, { signal: controllerRef.current.signal });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if ((err as Error).name === 'AbortError') return; // user pressed stop
|
|
92
|
+
throw err;
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false);
|
|
95
|
+
}
|
|
96
|
+
}}
|
|
97
|
+
onStop={() => controllerRef.current?.abort()}
|
|
98
|
+
/>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Empty action
|
|
102
|
+
|
|
103
|
+
The right-side button defaults to the submit/stop pair. When the textarea is empty and not loading, you can render an alternate action (e.g. a mic for voice input):
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
<PromptInput
|
|
107
|
+
value={value}
|
|
108
|
+
onValueChange={setValue}
|
|
109
|
+
emptyAction={<MicButton onPress={() => startListening()} />}
|
|
110
|
+
/>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`emptyAction` only renders when `value` is empty and `isLoading` is false.
|
|
114
|
+
|
|
115
|
+
## Fullscreen
|
|
116
|
+
|
|
117
|
+
Tap the expand icon to swap the input into a fullscreen modal. Override the icons:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
<PromptInput
|
|
121
|
+
value={value}
|
|
122
|
+
onValueChange={setValue}
|
|
123
|
+
expandIcon={<MaximizeIcon />}
|
|
124
|
+
collapseIcon={<MinimizeIcon />}
|
|
125
|
+
/>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Props (simple mode)
|
|
129
|
+
|
|
130
|
+
| Prop | Type | Description |
|
|
131
|
+
|------|------|-------------|
|
|
132
|
+
| `value?` | `string` | Controlled text value. |
|
|
133
|
+
| `onValueChange?` | `(value: string) => void` | |
|
|
134
|
+
| `isLoading?` | `boolean` | Show stop button instead of submit. |
|
|
135
|
+
| `onSubmit?` | `() => void` | Submit handler. |
|
|
136
|
+
| `onStop?` | `() => void` | Abort handler. |
|
|
137
|
+
| `placeholder?` | `string` | Placeholder text. |
|
|
138
|
+
| `maxHeight?` | `number` | Max textarea height before it scrolls. Default `240`. |
|
|
139
|
+
| `disabled?` | `boolean` | |
|
|
140
|
+
| `actionsLeft?` | `React.ReactNode` | Render extra buttons on the left of the actions bar. |
|
|
141
|
+
| `emptyAction?` | `React.ReactNode` | Replaces the right-side button when empty + idle. |
|
|
142
|
+
| `attachments?` | `Attachment[]` | Controlled attachments. |
|
|
143
|
+
| `onAddAttachment?` | `(a: Attachment) => void` | |
|
|
144
|
+
| `onRemoveAttachment?` | `(id: string) => void` | |
|
|
145
|
+
| `onUpdateAttachment?` | `(id, updates) => void` | |
|
|
146
|
+
| `onImagePaste?` | `(files: File[]) => void` | Web-only image paste. |
|
|
147
|
+
| `disableKeyboardAvoidance?` | `boolean` | Skip the inner `KeyboardAvoidingView`. |
|
|
148
|
+
| `expandIcon?` | `React.ReactNode` | Custom expand icon. |
|
|
149
|
+
| `collapseIcon?` | `React.ReactNode` | Custom collapse icon. |
|
|
150
|
+
| `testID?` | `string` | |
|
|
151
|
+
|
|
152
|
+
## Recipes
|
|
153
|
+
|
|
154
|
+
### Send on Enter, newline on Shift+Enter
|
|
155
|
+
|
|
156
|
+
The textarea handles this on web automatically. On native, Enter inserts a newline (no software keyboard convention for "send" exists across iOS/Android). Drive submission via the submit button or an explicit toolbar action.
|
|
157
|
+
|
|
158
|
+
### Disable submit while empty
|
|
159
|
+
|
|
160
|
+
The submit button is disabled automatically when `value.trim()` is empty. No extra wiring required.
|
|
161
|
+
|
|
162
|
+
### Custom left action (model picker)
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
<PromptInput
|
|
166
|
+
value={value}
|
|
167
|
+
onValueChange={setValue}
|
|
168
|
+
actionsLeft={
|
|
169
|
+
<Select.Root value={model} onValueChange={setModel}>
|
|
170
|
+
<Select.Trigger label="Model">{({ props }) => <Button {...props}>{model}</Button>}</Select.Trigger>
|
|
171
|
+
<Select.Content
|
|
172
|
+
items={['gpt-4', 'gpt-3.5']}
|
|
173
|
+
renderItem={(m, _i, selected) => (
|
|
174
|
+
<Select.Item value={m} label={m}>
|
|
175
|
+
<Select.ItemText>{m}</Select.ItemText>
|
|
176
|
+
{selected === m ? <Select.ItemIndicator /> : null}
|
|
177
|
+
</Select.Item>
|
|
178
|
+
)}
|
|
179
|
+
/>
|
|
180
|
+
</Select.Root>
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: BloomProvider
|
|
3
|
+
description: The one root a consuming app mounts — and why the outlets are deliberately not in it.
|
|
4
|
+
order: 56
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# BloomProvider
|
|
8
|
+
|
|
9
|
+
The single root that composes Bloom's app-wide **state** providers, so none of
|
|
10
|
+
them lands at the wrong depth.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { BloomProvider } from '@oxyhq/bloom/provider';
|
|
14
|
+
|
|
15
|
+
export default function RootLayout() {
|
|
16
|
+
return (
|
|
17
|
+
<BloomProvider>
|
|
18
|
+
<Stack />
|
|
19
|
+
</BloomProvider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why one root rather than several
|
|
25
|
+
|
|
26
|
+
Depth is not a detail for these. Mounted too low:
|
|
27
|
+
|
|
28
|
+
- `useScrollRestoration()` **throws** for anything rendered beside it;
|
|
29
|
+
- `useMinimizeState()` silently hands out a private fallback, so two parts of
|
|
30
|
+
the app disagree about the same state and neither errors.
|
|
31
|
+
|
|
32
|
+
One root removes the chance to get that wrong.
|
|
33
|
+
|
|
34
|
+
## Expo/expo-router apps only
|
|
35
|
+
|
|
36
|
+
`BloomProvider` binds the scroll store to `expoRouterScrollAdapter`, so it is
|
|
37
|
+
expo-router-only **by construction** — which is also why the family is not on
|
|
38
|
+
the root barrel: importing it would drag `expo-router` into every consumer's
|
|
39
|
+
graph, and Metro does not tree-shake, so an unmet peer is a build failure
|
|
40
|
+
rather than a degradation.
|
|
41
|
+
|
|
42
|
+
A Vite/SPA consumer mounts the pieces instead: `BloomThemeProvider` plus
|
|
43
|
+
`<ScrollRestorationProvider adapter={…}>` with its own router adapter.
|
|
44
|
+
|
|
45
|
+
## The OUTLETS are deliberately not in it
|
|
46
|
+
|
|
47
|
+
`ToastOutlet`, `PortalProvider`/`PortalOutlet`, `SurfaceHost` and the dialog
|
|
48
|
+
hosts stay out. Where an outlet sits in the tree is an app decision — it has to
|
|
49
|
+
paint over the app's own chrome — and **a second mount duplicates every
|
|
50
|
+
surface**. Adding one here would silently double the toasts of any app that
|
|
51
|
+
already mounts its own.
|
|
52
|
+
|
|
53
|
+
That failure is quiet: two identical toasts read as a double-fire in your own
|
|
54
|
+
code, not as two outlets.
|
|
55
|
+
|
|
56
|
+
## Where the theme provider goes
|
|
57
|
+
|
|
58
|
+
Above **every** render branch, including the splash/loading one.
|
|
59
|
+
|
|
60
|
+
`useTheme()` throws outside `BloomThemeProvider`, and the classic way to hit
|
|
61
|
+
that is an `appIsReady` early-return in `app/_layout.tsx` that renders a splash
|
|
62
|
+
screen *before* the providers mount. It fails at runtime only — `tsc` and the
|
|
63
|
+
build stay green — and only on a cold start. Verify in a real browser after any
|
|
64
|
+
theming migration.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: RadioIndicator
|
|
3
|
+
description: The radio dot on its own — presentational, for rows that are already controls.
|
|
4
|
+
order: 69
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# RadioIndicator
|
|
8
|
+
|
|
9
|
+
The ring-and-dot glyph, with no label, no press target and no group.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { RadioIndicator } from '@oxyhq/bloom/radio-indicator';
|
|
13
|
+
|
|
14
|
+
<Item
|
|
15
|
+
title={plan.name}
|
|
16
|
+
subtitle={plan.price}
|
|
17
|
+
leading={<RadioIndicator selected={plan.id === chosen} />}
|
|
18
|
+
role="radio"
|
|
19
|
+
onPress={() => setChosen(plan.id)}
|
|
20
|
+
/>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Which prop decides what
|
|
24
|
+
|
|
25
|
+
| prop | decides |
|
|
26
|
+
| --------------- | ----------------------------------------------- |
|
|
27
|
+
| `selected` | filled or hollow |
|
|
28
|
+
| `size` | the diameter; the dot scales with it |
|
|
29
|
+
| `selectedColor` | the fill when selected |
|
|
30
|
+
| `borderColor` | the ring when not |
|
|
31
|
+
|
|
32
|
+
## Use it only where the row is already the control
|
|
33
|
+
|
|
34
|
+
`RadioIndicator` is **presentational**. It has no press handler and contributes
|
|
35
|
+
no role or state, so a group built out of these announces as decoration unless
|
|
36
|
+
the surrounding row supplies both — `role="radio"` and the selected state.
|
|
37
|
+
|
|
38
|
+
If you are building a plain list of options, use
|
|
39
|
+
[`Radio` / `RadioGroup`](/docs/bloom/main/radio) instead: they render the label,
|
|
40
|
+
make the whole row the target, and name the group. Reaching for the indicator
|
|
41
|
+
where you meant the control is the usual way a "radio group" ends up
|
|
42
|
+
unreachable by keyboard and silent to a screen reader.
|
|
43
|
+
|
|
44
|
+
The legitimate cases are rows that are already something else: a card selector,
|
|
45
|
+
a table row, a settings row with its own affordances.
|
|
46
|
+
|
|
47
|
+
## The dot colour is derived, not white
|
|
48
|
+
|
|
49
|
+
When `selectedColor` is the theme primary, the dot uses the preset's readable
|
|
50
|
+
foreground — dark on a yellow primary, light on a blue one. A hardcoded white
|
|
51
|
+
dot disappears on a light accent, which is the same class of failure as
|
|
52
|
+
appending alpha to a token: correct markup, invisible control.
|
|
53
|
+
|
|
54
|
+
A caller-supplied `selectedColor` falls back to white, because Bloom cannot
|
|
55
|
+
know the right contrast for an arbitrary colour. If you pass one, check it.
|
|
56
|
+
|
|
57
|
+
## Selected drops the border
|
|
58
|
+
|
|
59
|
+
A filled circle with a ring around it reads as two states at once. When
|
|
60
|
+
`selected` is true the border goes to zero and the fill carries the state.
|
package/docs/radio.mdx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Radio
|
|
3
|
+
description: One choice from a small set — the control, not just the dot.
|
|
4
|
+
order: 30
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Radio
|
|
8
|
+
|
|
9
|
+
A radio is a **control**: the label is part of the target, the group announces
|
|
10
|
+
itself as a group, and exactly one option is selected. `RadioGroup` is the form
|
|
11
|
+
you almost always want; `Radio` is the single row, for when the options are not
|
|
12
|
+
a flat list.
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { RadioGroup } from '@oxyhq/bloom/radio';
|
|
16
|
+
|
|
17
|
+
<RadioGroup
|
|
18
|
+
label="Delivery speed"
|
|
19
|
+
value={speed}
|
|
20
|
+
onValueChange={setSpeed}
|
|
21
|
+
options={[
|
|
22
|
+
{ value: 'standard', label: 'Standard', description: 'Two to four days' },
|
|
23
|
+
{ value: 'express', label: 'Express', description: 'Next working day' },
|
|
24
|
+
{ value: 'pickup', label: 'Collect in store', disabled: !storeNearby },
|
|
25
|
+
]}
|
|
26
|
+
/>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Which prop decides what
|
|
30
|
+
|
|
31
|
+
| prop | decides |
|
|
32
|
+
| ------------------------- | --------------------------------------------------- |
|
|
33
|
+
| `label` (group) | the accessible name of the whole group — required |
|
|
34
|
+
| `value` / `onValueChange` | the selection; the group is fully controlled |
|
|
35
|
+
| `options` | the choices, each with its own `label`/`description` |
|
|
36
|
+
| `size` | `small` · `medium` · `large` |
|
|
37
|
+
| `disabled` | the whole group, or one option via its `disabled` |
|
|
38
|
+
|
|
39
|
+
## The label belongs to the radio, not next to it
|
|
40
|
+
|
|
41
|
+
`Radio` renders its own `label` and `description` and makes the whole row the
|
|
42
|
+
press target. A dot with a separate `<Text>` beside it is a smaller target, and
|
|
43
|
+
tapping the words does nothing — which reads as an unresponsive control.
|
|
44
|
+
|
|
45
|
+
If you need the dot alone — inside a custom row, a card selector, a table cell —
|
|
46
|
+
that is [`RadioIndicator`](/docs/bloom/main/radio-indicator), which is
|
|
47
|
+
presentational and takes `selected`. Using the indicator where you meant the
|
|
48
|
+
control is how a "radio group" ends up announcing as decoration.
|
|
49
|
+
|
|
50
|
+
## Radio, Switch or Checkbox
|
|
51
|
+
|
|
52
|
+
- **Radio** — one of several named options, all visible, mutually exclusive.
|
|
53
|
+
- **[Switch](/docs/bloom/main/switch)** — a single thing that is on or off,
|
|
54
|
+
taking effect immediately.
|
|
55
|
+
- **Checkbox** — several independent options, or an intent confirmed later.
|
|
56
|
+
|
|
57
|
+
Two radios is usually a sign the choice is really a switch — unless both
|
|
58
|
+
options need naming ("Metric" / "Imperial"), which is exactly when radios are
|
|
59
|
+
right.
|
|
60
|
+
|
|
61
|
+
## A group with nothing selected
|
|
62
|
+
|
|
63
|
+
`value` may be `undefined`. Prefer that over pre-selecting an option the user
|
|
64
|
+
did not choose: a default that happens to be first is indistinguishable from an
|
|
65
|
+
answer, and it silently becomes the submitted value.
|
package/docs/scroll.mdx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scroll restoration
|
|
3
|
+
description: Returning to content where the user left it — and the decisions that make it feel right.
|
|
4
|
+
order: 55
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Scroll restoration
|
|
8
|
+
|
|
9
|
+
`@oxyhq/bloom/scroll` remembers where the user was and puts them back. The core
|
|
10
|
+
is router-agnostic; one adapter binds it to a router.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ScrollRestorationProvider } from '@oxyhq/bloom/scroll';
|
|
14
|
+
import { expoRouterScrollAdapter } from '@oxyhq/bloom/scroll/expo-router';
|
|
15
|
+
|
|
16
|
+
<ScrollRestorationProvider adapter={expoRouterScrollAdapter}>
|
|
17
|
+
<App />
|
|
18
|
+
</ScrollRestorationProvider>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
An expo-router app gets this from [`BloomProvider`](/docs/bloom/main/provider)
|
|
22
|
+
already. A Vite/SPA consumer mounts it directly with its own adapter.
|
|
23
|
+
|
|
24
|
+
**The adapter must be a module-level constant** — the core calls its members as
|
|
25
|
+
hooks, so a value built inside a component changes the hook identity every
|
|
26
|
+
render.
|
|
27
|
+
|
|
28
|
+
## The key is CONTENT, not the navigation entry
|
|
29
|
+
|
|
30
|
+
An offset is stored against the content's identity (route name plus sorted
|
|
31
|
+
params) and the caller's optional `key` — deliberately **not** against the
|
|
32
|
+
history entry.
|
|
33
|
+
|
|
34
|
+
So content the user has already seen restores however they arrived at it, and
|
|
35
|
+
only unseen content opens at the top. The accepted trade-off: the same content
|
|
36
|
+
open in two live entries shares one offset.
|
|
37
|
+
|
|
38
|
+
`contentId` comes from the adapter via `useRoute()`, never `usePathname()` —
|
|
39
|
+
`usePathname` reads the globally focused route, so a background screen would
|
|
40
|
+
adopt the foreground one's path and restore to a stranger's offset.
|
|
41
|
+
|
|
42
|
+
## A miss RESETS to 0; it is not a no-op
|
|
43
|
+
|
|
44
|
+
On a document-scrolled web app, one window scroller serves every route. If
|
|
45
|
+
arriving at unseen content did nothing, it would inherit the previous screen's
|
|
46
|
+
offset — you would open a new page halfway down.
|
|
47
|
+
|
|
48
|
+
## Native is deliberately narrower
|
|
49
|
+
|
|
50
|
+
The native hook is keyed on the storage key, never on focus, and it **returns**
|
|
51
|
+
`{ onScroll, restorePending }` for the caller to wire up rather than attaching
|
|
52
|
+
itself.
|
|
53
|
+
|
|
54
|
+
`enabled` is how the caller says *its rows exist yet* — restoring to an offset a
|
|
55
|
+
list cannot reach yet lands at the bottom of a short list. It is not a feature
|
|
56
|
+
flag.
|
|
57
|
+
|
|
58
|
+
## What to expect on web
|
|
59
|
+
|
|
60
|
+
- The restore re-applies across a bounded run of frames, because a re-shown
|
|
61
|
+
virtualized list reaches full height over several.
|
|
62
|
+
- It **aborts on user input** (`wheel`, `touchstart`, `pointerdown`, `keydown`).
|
|
63
|
+
If the user starts scrolling, they have taken over.
|
|
64
|
+
- `history.scrollRestoration` is set to `'manual'` only while the provider is
|
|
65
|
+
mounted, and handed back on `pagehide` — so an app that unmounts it does not
|
|
66
|
+
leave the browser's own restoration disabled.
|