@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/styles.mdx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Styles
|
|
3
|
+
description: Atoms, tokens, breakpoints and the platform helpers — for styles a className cannot express.
|
|
4
|
+
order: 74
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Styles
|
|
8
|
+
|
|
9
|
+
`@oxyhq/bloom/styles` holds the style primitives Bloom itself uses.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { atoms as a, tokens, BREAKPOINTS, web, native, select } from '@oxyhq/bloom/styles';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## `atoms`
|
|
16
|
+
|
|
17
|
+
Utility style objects — `a.flex_1`, `a.flex_row`, `a.align_center`, `a.px_sm`.
|
|
18
|
+
They are the inline equivalent of the Tailwind utilities, and they exist
|
|
19
|
+
because **a className is inert on web without the consumer's Tailwind
|
|
20
|
+
pipeline**. Anything Bloom must draw correctly with or without that pipeline
|
|
21
|
+
uses atoms; layout that only apps compose can use classes.
|
|
22
|
+
|
|
23
|
+
## `tokens`
|
|
24
|
+
|
|
25
|
+
The t-shirt view of the scales — `tokens.space.lg`, `tokens.borderRadius.xl`.
|
|
26
|
+
|
|
27
|
+
The numbers themselves live in `design-tokens/scales.ts` and are read from
|
|
28
|
+
there by reference, so a rung cannot exist in one spelling and not the other.
|
|
29
|
+
They did once diverge: `borderRadius.full` was 999 against `radius-max` 9999,
|
|
30
|
+
and each file's comments claimed to match the other.
|
|
31
|
+
|
|
32
|
+
Prefer the numeric names (`radius-20`) when you are naming a rung in an API —
|
|
33
|
+
[`Card`](/docs/bloom/main/card)'s `radius` prop takes those — and the t-shirt
|
|
34
|
+
names in ad-hoc inline style.
|
|
35
|
+
|
|
36
|
+
## `BREAKPOINTS`
|
|
37
|
+
|
|
38
|
+
The shared widths, so a component and the screen around it agree about when the
|
|
39
|
+
layout changes.
|
|
40
|
+
|
|
41
|
+
## `web` / `native` / `ios` / `android` / `platform` / `select`
|
|
42
|
+
|
|
43
|
+
Platform-conditional style helpers. Use them for a genuine platform difference
|
|
44
|
+
— a shadow, a font smoothing, a scrollbar — and not to work around something
|
|
45
|
+
that should be the same on both.
|
|
46
|
+
|
|
47
|
+
## `Z_INDEX`
|
|
48
|
+
|
|
49
|
+
**Within-context values only.** Which overlay paints on top is decided by the
|
|
50
|
+
overlay stack, not by a constant: `overlay/stack.ts` ranks surfaces by when
|
|
51
|
+
they OPENED, and a per-component z-index answers that question by what a
|
|
52
|
+
surface *is*, which leaves some pairings permanently inverted.
|
|
53
|
+
|
|
54
|
+
Never give an overlay surface a `zIndex` of its own.
|
|
55
|
+
|
|
56
|
+
## `flatten`
|
|
57
|
+
|
|
58
|
+
`StyleSheet.flatten` re-exported for code that already depends on the RN
|
|
59
|
+
runtime. A raw-DOM web fork should use `flattenWebStyle()` instead — jest's RN
|
|
60
|
+
mock stubs `StyleSheet.flatten` as identity, so the array form would survive
|
|
61
|
+
and crash the DOM with `Failed to set an indexed property [0]`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SubtleHover
|
|
3
|
+
description: The hover wash behind a row — one tint, so every hoverable row matches.
|
|
4
|
+
order: 64
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SubtleHover
|
|
8
|
+
|
|
9
|
+
A faint fill that appears behind its parent's content on hover. It is what
|
|
10
|
+
makes a list row feel hoverable without a border or a shadow.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { SubtleHover } from '@oxyhq/bloom/subtle-hover';
|
|
14
|
+
|
|
15
|
+
<Pressable onPress={open} onHoverIn={…} onHoverOut={…}>
|
|
16
|
+
<SubtleHover active={hovered} />
|
|
17
|
+
<Row />
|
|
18
|
+
</Pressable>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Which prop decides what
|
|
22
|
+
|
|
23
|
+
| prop | decides |
|
|
24
|
+
| -------- | ------------------------------------------------------------ |
|
|
25
|
+
| `active` | whether the wash is shown — the caller owns the hover state |
|
|
26
|
+
| `native` | whether it also renders on native, where there is no hover |
|
|
27
|
+
|
|
28
|
+
## It does not detect hover
|
|
29
|
+
|
|
30
|
+
The caller does. That is deliberate: what counts as "hovered" depends on the
|
|
31
|
+
row — sometimes it is the row itself, often it is a larger container, and
|
|
32
|
+
occasionally it should stay lit while a menu opened from the row is up. A
|
|
33
|
+
component that owned the detection could not express the third case at all.
|
|
34
|
+
|
|
35
|
+
## `native` defaults off, because hover does not exist there
|
|
36
|
+
|
|
37
|
+
On touch there is no hover state to reflect, and a wash that appears on press
|
|
38
|
+
duplicates the press feedback the row already has. Turn it on only for a native
|
|
39
|
+
surface that genuinely has a pointer — an iPad with a trackpad, a keyboard
|
|
40
|
+
focus ring you are mirroring.
|
|
41
|
+
|
|
42
|
+
## One tint, everywhere
|
|
43
|
+
|
|
44
|
+
The value of this component is not the effect, which is three lines of style —
|
|
45
|
+
it is that every hoverable row in the ecosystem uses the *same* wash. Rolling
|
|
46
|
+
your own is how a menu and a list on the same screen end up hovering in
|
|
47
|
+
different greys.
|
|
48
|
+
|
|
49
|
+
## Not a substitute for focus
|
|
50
|
+
|
|
51
|
+
Hover is for pointers. A keyboard user needs a visible focus state, which is a
|
|
52
|
+
separate thing and must not be styled away — a row that only responds to hover
|
|
53
|
+
is invisible to anyone navigating with a keyboard.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Surfaces
|
|
3
|
+
description: The one imperative overlay API — alert, confirm, prompt, and the stack they present onto.
|
|
4
|
+
order: 36
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Surfaces
|
|
8
|
+
|
|
9
|
+
The surface stack is Bloom's **one** imperative overlay API. `alert()`,
|
|
10
|
+
`confirm()` and `prompt()` present from anywhere — no ref, no local `open`
|
|
11
|
+
state, no second provider — and a single `<SurfaceHost>` renders whatever is on
|
|
12
|
+
the stack.
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { alert, confirm, prompt } from '@oxyhq/bloom/surfaces';
|
|
16
|
+
|
|
17
|
+
alert('Saved', 'Your changes are live.');
|
|
18
|
+
|
|
19
|
+
if (await confirm({ title: 'Leave this page?', confirmLabel: 'Leave' })) {
|
|
20
|
+
router.back();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const name = await prompt({ title: 'Name this list', placeholder: 'Reading list' });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why these are not components
|
|
27
|
+
|
|
28
|
+
They are answers to a **question**, so the call site wants a value back, and a
|
|
29
|
+
component you have to mount cannot be awaited. `confirm()` resolves to a
|
|
30
|
+
boolean and `prompt()` to a string or `null`. That difference is the whole
|
|
31
|
+
reason this API exists beside [`Dialog`](/docs/bloom/main/dialog):
|
|
32
|
+
|
|
33
|
+
- **Surfaces** — a one-shot question from inside an event handler, in code
|
|
34
|
+
that has no render tree of its own (a mutation callback, a service).
|
|
35
|
+
- **Dialog** — a surface with real content that belongs to a screen.
|
|
36
|
+
|
|
37
|
+
## Mounting
|
|
38
|
+
|
|
39
|
+
`<SurfaceProvider>` wraps the app; `<SurfaceHost>` renders the stack. **Both
|
|
40
|
+
stay out of `BloomProvider`** — where an outlet sits is an app decision, and a
|
|
41
|
+
second mount would duplicate every surface.
|
|
42
|
+
|
|
43
|
+
## Two calls in a row STACK, they do not queue
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
alert('First');
|
|
47
|
+
alert('Second'); // paints over the first; dismissing reveals it
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If you want them one after the other, `await` the first. This is deliberate: a
|
|
51
|
+
queue would delay an urgent surface behind a trivial one, and there is no
|
|
52
|
+
general rule for which of two surfaces matters more.
|
|
53
|
+
|
|
54
|
+
## `present()` is the general form
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
const size = await present<string>(
|
|
58
|
+
(surface) => <SizeList onPick={(s) => surface.dismiss(s)} />,
|
|
59
|
+
{ placement: 'bottom', title: 'Pick a size', label: 'Pick a size' },
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The content resolves the promise by calling `surface.dismiss(value)`. Note what
|
|
64
|
+
it does **not** call: `Dialog`'s own `close()`. The value has to reach the
|
|
65
|
+
`present()` promise, and it resolves on the press — a surface closed any other
|
|
66
|
+
way resolves with nothing, which reads as the user having cancelled.
|
|
67
|
+
|
|
68
|
+
## Buttons
|
|
69
|
+
|
|
70
|
+
A built-in surface's buttons carry `shouldCloseOnPress: false` and dismiss
|
|
71
|
+
through `surface.dismiss(result)` for that reason. A `'cancel'` colour on a
|
|
72
|
+
`DialogAction` is purely visual; it does not make the button dismiss.
|
package/docs/switch.mdx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Switch
|
|
3
|
+
description: An immediate on/off toggle — why it must carry `aria-checked` rather than `accessibilityState`, and why it cannot name itself.
|
|
4
|
+
order: 24
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Switch
|
|
8
|
+
|
|
9
|
+
A binary control whose change takes effect **immediately**. That is the whole
|
|
10
|
+
distinction from a checkbox: a switch turns something on, a checkbox records an
|
|
11
|
+
intent you confirm later with a Save button.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { Switch } from '@oxyhq/bloom/switch';
|
|
15
|
+
|
|
16
|
+
<Switch
|
|
17
|
+
value={pushEnabled}
|
|
18
|
+
onValueChange={setPushEnabled}
|
|
19
|
+
accessibilityLabel="Push notifications"
|
|
20
|
+
/>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Which prop decides what
|
|
24
|
+
|
|
25
|
+
| prop | decides |
|
|
26
|
+
| -------------------- | --------------------------------------------- |
|
|
27
|
+
| `value` | on or off — the component is fully controlled |
|
|
28
|
+
| `onValueChange` | receives the **opposite** of the current value |
|
|
29
|
+
| `disabled` | dims it and takes it out of the tab order |
|
|
30
|
+
| `size` | `default` (44×26) or `sm` (36×22) |
|
|
31
|
+
| `accessibilityLabel` | what it announces as — see below |
|
|
32
|
+
|
|
33
|
+
There is no uncontrolled mode. A switch whose state the caller does not own is a
|
|
34
|
+
switch that can disagree with the thing it is supposed to be controlling — the
|
|
35
|
+
classic version being an optimistic toggle that stays on after the request
|
|
36
|
+
failed.
|
|
37
|
+
|
|
38
|
+
## A switch needs a label it does not draw
|
|
39
|
+
|
|
40
|
+
`Switch` renders a track and a thumb and nothing else, so there is no text for
|
|
41
|
+
either platform to compute a name from. **`accessibilityLabel` is the only
|
|
42
|
+
route to one.** Without it the control announces "switch, on" and never says
|
|
43
|
+
which of the settings on the page it belongs to.
|
|
44
|
+
|
|
45
|
+
The caption next to it does not do this job. A `SettingsListItem` title, a
|
|
46
|
+
`Label`, a `Text` in the same row — all of them are SIBLING elements, and
|
|
47
|
+
neither React Native nor react-native-web associates a sibling with a control.
|
|
48
|
+
So the row below needs the label even though the words are already on screen:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
<SettingsListItem
|
|
52
|
+
title="Push notifications"
|
|
53
|
+
rightElement={
|
|
54
|
+
<Switch
|
|
55
|
+
value={push}
|
|
56
|
+
onValueChange={setPush}
|
|
57
|
+
accessibilityLabel="Push notifications"
|
|
58
|
+
/>
|
|
59
|
+
}
|
|
60
|
+
showChevron={false}
|
|
61
|
+
/>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
One spelling covers both platforms, and it is not the same rule as the state
|
|
65
|
+
below: react-native-web emits `aria-label` from `accessibilityLabel` when no
|
|
66
|
+
`aria-label` is given, and React Native reads it directly.
|
|
67
|
+
|
|
68
|
+
Omitting it warns once per component in development. The warning is dev-only
|
|
69
|
+
(`process.env.NODE_ENV`, folded out of production bundles) and never throws — an
|
|
70
|
+
unnamed switch still renders and still toggles. An empty or whitespace-only
|
|
71
|
+
label counts as missing, because `aria-label=""` names nothing.
|
|
72
|
+
|
|
73
|
+
## The accessibility rule this component is the reference for
|
|
74
|
+
|
|
75
|
+
`Switch` sets `role="switch"` and **`aria-checked`**, not
|
|
76
|
+
`accessibilityState={{ checked }}`.
|
|
77
|
+
|
|
78
|
+
react-native-web drops `accessibilityState` entirely and reads only `aria-*`,
|
|
79
|
+
while React Native folds `aria-checked` back into `accessibilityState`. So
|
|
80
|
+
`aria-checked` is the one spelling that works on both platforms, and a control
|
|
81
|
+
that set only `accessibilityState` would render a switch role carrying no
|
|
82
|
+
state — drawn correctly, announced as nothing.
|
|
83
|
+
|
|
84
|
+
The disabled state is the mirror image and goes the other way: it travels on
|
|
85
|
+
the `disabled` **prop**, because react-native-web's `Pressable` appends its own
|
|
86
|
+
`aria-disabled` *after* spreading the caller's props, so a hand-written one is
|
|
87
|
+
silently overwritten.
|
|
88
|
+
|
|
89
|
+
Neither of these is visible to a prop-level test. Assert the rendered
|
|
90
|
+
attribute.
|
package/docs/tab-bar.mdx
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: TabBar
|
|
3
|
+
description: A floating, minimize-on-scroll tab bar with a sliding highlight, liquid glass on iOS 26 and a backdrop-filter on web.
|
|
4
|
+
order: 19
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# TabBar
|
|
8
|
+
|
|
9
|
+
A floating tab bar that sits over your content instead of boxing it in. It minimizes as you scroll down and re-expands as you scroll up, its highlight slides between tabs and can be scrubbed with a finger, and its capsule renders as real liquid glass on iOS 26, a CSS `backdrop-filter` on web, and a solid themed fill everywhere else.
|
|
10
|
+
|
|
11
|
+
It works with or without a router. `@oxyhq/bloom/tab-bar` has no router dependency at all; the expo-router bindings live in a separate `@oxyhq/bloom/tab-bar/expo-router` subpath.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
The tab bar adds three peers on top of Bloom's usual ones:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bunx expo install expo-glass-effect expo-symbols react-native-screens
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Peer | Used by | Needed when |
|
|
22
|
+
|------|---------|-------------|
|
|
23
|
+
| `expo-glass-effect` | `tab-bar/surface.native` | Any native build that renders the bar. Its NATIVE module is Apple-only (`"platforms": ["apple"]`) and provides the iOS 26 liquid-glass capsule; on Android the package is pure JS, its own `isLiquidGlassAvailable()` answers `false`, and the bar paints the solid themed fill instead. A build whose native module is missing altogether — an OTA JS update landing on a binary built before the package was linked — degrades to that same solid fill and logs one `[Bloom]` warning, rather than throwing out of render. |
|
|
24
|
+
| `expo-symbols` | `tab-bar/glyph.native` | Any native build that renders the bar. Apple-only as well: it draws `item.sfSymbol` as a real SF Symbol on iOS, and the bar falls back to `item.icon` everywhere else. |
|
|
25
|
+
| `react-native-screens` | `tab-bar/expo-router` | Only if you use `renderFadingTabScreen`. |
|
|
26
|
+
|
|
27
|
+
The bar also uses Bloom's existing `expo-blur`, `react-native-reanimated`, `react-native-gesture-handler` and `react-native-safe-area-context` peers. Native apps must wrap their root in `GestureHandlerRootView` — the bar is scrubbable, so it needs gesture-handler mounted.
|
|
28
|
+
|
|
29
|
+
<Callout variant="info" title="Web builds pull none of the native packages">
|
|
30
|
+
`expo-glass-effect` and `expo-symbols` are imported only from `.native` files, and the router packages only from the `expo-router` subpath. A web bundle of `@oxyhq/bloom/tab-bar` reaches none of them, so nothing about them ends up in a web app's output.
|
|
31
|
+
|
|
32
|
+
Both are nonetheless declared as **required** peers, deliberately. They are static imports, so omitting them from a native build that imports the tab bar does not degrade to anything — Metro fails to resolve the specifier and the build dies. The only thing marking them optional would buy is silencing the one warning that says so in advance. That applies on Android too: the native module there is Apple-only (`"platforms": ["apple"]`), but the JS package still has to be installed for the bundle to resolve.
|
|
33
|
+
|
|
34
|
+
That degrade-to-nothing only follows once you import the tab bar. An app that never imports `@oxyhq/bloom/tab-bar` at all — on native or web — never reaches these files, so leaving the peer unmet costs nothing; don't install two Apple-only packages just to quiet a warning about a surface you don't use. In practice bun 1.3.14 doesn't even print that warning, so the gap is silent regardless of whether you use the tab bar.
|
|
35
|
+
</Callout>
|
|
36
|
+
|
|
37
|
+
## Usage without a router
|
|
38
|
+
|
|
39
|
+
Drive it like any controlled component: pass `activeIndex`, handle `onIndexChange`.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { useState } from 'react';
|
|
43
|
+
import * as Icons from '@oxyhq/bloom/icons';
|
|
44
|
+
import { TabBar, TabBarButton, TabBarMinimizeProvider } from '@oxyhq/bloom/tab-bar';
|
|
45
|
+
|
|
46
|
+
const TABS = [
|
|
47
|
+
{ name: 'home', label: 'Home', icon: <Icons.Home_Stroke2_Corner0_Rounded /> },
|
|
48
|
+
{ name: 'search', label: 'Search', icon: <Icons.MagnifyingGlass_Stroke2_Corner0_Rounded /> },
|
|
49
|
+
{ name: 'you', label: 'You', icon: <Icons.UserCircle_Stroke2_Corner0_Rounded /> },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export function Shell() {
|
|
53
|
+
const [index, setIndex] = useState(0);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TabBarMinimizeProvider>
|
|
57
|
+
<Screen index={index} />
|
|
58
|
+
<TabBar activeIndex={index} onIndexChange={setIndex}>
|
|
59
|
+
{TABS.map((item, i) => (
|
|
60
|
+
<TabBarButton key={item.name} item={item} index={i} />
|
|
61
|
+
))}
|
|
62
|
+
</TabBar>
|
|
63
|
+
</TabBarMinimizeProvider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`TabBarMinimizeProvider` wraps the whole tab tree once, so any screen that calls `useMinimizeOnScroll()` drives the same bar. It is optional — without it each consumer falls back to its own local progress, which keeps a screen working when it is rendered outside a tab layout.
|
|
69
|
+
|
|
70
|
+
### No selection
|
|
71
|
+
|
|
72
|
+
An `activeIndex` that names no tab — negative, past the last tab, or fractional — means **no selection**: the highlight fades out where it stands and no tab is left tinted. Returning to a real index fades it back in at that tab rather than sliding to it, since there was no visible position to travel from. Scrubbing and tapping still work from there: the finger arms the highlight and a release selects, exactly as when a tab was already picked.
|
|
73
|
+
|
|
74
|
+
This is the state to use whenever the current screen is not a tab. An index derived from the route — `TABS.findIndex((tab) => tab.href === pathname)` — is `-1` on every screen outside the tab set, and passing it straight through is correct; there is nothing to special-case.
|
|
75
|
+
|
|
76
|
+
<Callout variant="warning" title="Omitting `activeIndex` is NOT the same as no selection">
|
|
77
|
+
Leaving the prop off entirely selects the focus-driven path, where each `TabBarButton` supplies `isFocused` and drives the highlight itself (this is what `RouterTabBar` does). That path always shows the highlight. Only a NUMBER that names no tab hides it.
|
|
78
|
+
</Callout>
|
|
79
|
+
|
|
80
|
+
## Usage with expo-router
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import * as Icons from '@oxyhq/bloom/icons';
|
|
84
|
+
import { TabBarMinimizeProvider } from '@oxyhq/bloom/tab-bar';
|
|
85
|
+
import {
|
|
86
|
+
RouterTabBar,
|
|
87
|
+
RouterTabBarButton,
|
|
88
|
+
renderFadingTabScreen,
|
|
89
|
+
} from '@oxyhq/bloom/tab-bar/expo-router';
|
|
90
|
+
import { Tabs, TabList, TabSlot, TabTrigger } from 'expo-router/ui';
|
|
91
|
+
import { router } from 'expo-router';
|
|
92
|
+
|
|
93
|
+
const TABS = [
|
|
94
|
+
{ name: 'home', label: 'Home', href: '/', icon: <Icons.Home_Stroke2_Corner0_Rounded /> },
|
|
95
|
+
{ name: 'search', label: 'Search', href: '/search', icon: <Icons.MagnifyingGlass_Stroke2_Corner0_Rounded /> },
|
|
96
|
+
{ name: 'you', label: 'You', href: '/you', icon: <Icons.UserCircle_Stroke2_Corner0_Rounded /> },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export default function TabLayout() {
|
|
100
|
+
return (
|
|
101
|
+
<TabBarMinimizeProvider>
|
|
102
|
+
<Tabs>
|
|
103
|
+
<TabSlot style={{ height: '100%' }} renderFn={renderFadingTabScreen} />
|
|
104
|
+
<TabList asChild>
|
|
105
|
+
<RouterTabBar onIndexChange={(i) => router.navigate(TABS[i].href)}>
|
|
106
|
+
{TABS.map(({ href, ...item }, index) => (
|
|
107
|
+
<TabTrigger key={item.name} name={item.name} href={href} asChild>
|
|
108
|
+
<RouterTabBarButton item={item} index={index} />
|
|
109
|
+
</TabTrigger>
|
|
110
|
+
))}
|
|
111
|
+
</RouterTabBar>
|
|
112
|
+
</TabList>
|
|
113
|
+
</Tabs>
|
|
114
|
+
</TabBarMinimizeProvider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Two things about this shape are load-bearing:
|
|
120
|
+
|
|
121
|
+
- **Navigation runs through `onIndexChange`, not through the triggers.** The bar's gesture detector consumes the touches — that is what makes scrubbing possible — so a tap never reaches a trigger's own `Pressable`.
|
|
122
|
+
- **`RouterTabBar` takes no `activeIndex`.** With a router the highlight is driven by each trigger's own focus, which is what keeps it correct through deep links, back gestures and any other programmatic navigation. Setting both would put two writers on one shared value.
|
|
123
|
+
|
|
124
|
+
`renderFadingTabScreen` is a drop-in for `TabSlot`'s `renderFn`: identical to expo-router's default render, plus a fade and a micro-scale on the screen becoming focused. It is the only place `react-native-screens` is used.
|
|
125
|
+
|
|
126
|
+
## Minimizing on scroll
|
|
127
|
+
|
|
128
|
+
Screens opt in with `useMinimizeOnScroll()`, which returns a scroll handler for `Animated.ScrollView`. Scrolling down minimizes the bar to icons only; scrolling up, or being near the top, expands it.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import Animated from 'react-native-reanimated';
|
|
132
|
+
import { useMinimizeOnScroll } from '@oxyhq/bloom/tab-bar';
|
|
133
|
+
|
|
134
|
+
export default function Feed() {
|
|
135
|
+
const onScroll = useMinimizeOnScroll();
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16}>
|
|
139
|
+
{/* … */}
|
|
140
|
+
</Animated.ScrollView>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For anything else that should react to the same progress — a header that fades with the bar, say — read the raw shared value:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
import { useTabBarMinimized } from '@oxyhq/bloom/tab-bar';
|
|
149
|
+
|
|
150
|
+
const minimized = useTabBarMinimized(); // SharedValue<number>, 0 = expanded, 1 = minimized
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
<Callout variant="warning" title="List every shared value in your mapper's deps array">
|
|
154
|
+
On web without the react-native-worklets babel plugin — the production reality for every Oxy RN-Web app — reanimated drives a mapper off its dependency array, not off auto-tracked reads. `useAnimatedStyle(() => ({ opacity: minimized.value }), [minimized])` animates; the same mapper with `[]` runs once and freezes at the first frame, with no error anywhere. Native ignores the extra deps, so listing them is correct on both platforms.
|
|
155
|
+
</Callout>
|
|
156
|
+
|
|
157
|
+
## Icons
|
|
158
|
+
|
|
159
|
+
`item.icon` is the primary API and takes any node — a Bloom icon, an SVG, your own component — exactly like `TabsTrigger` and `FrostedIconButton`. The bar renders it twice per tab (an inactive layer with the active layer crossfading on top) and injects the layer's tint as the icon's `fill`. An icon that sets its own `fill` (a brand mark, say) keeps it, so both layers render it identically and it never flickers.
|
|
160
|
+
|
|
161
|
+
`item.sfSymbol` is an **iOS-only enhancement**: when present, iOS renders the real system glyph instead, weight-matched to the platform and tinted natively. Android, web and every non-Metro resolver ignore it and render `item.icon`, so every item still needs one.
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
const TABS = [
|
|
165
|
+
{
|
|
166
|
+
name: 'home',
|
|
167
|
+
label: 'Home',
|
|
168
|
+
icon: <Icons.Home_Stroke2_Corner0_Rounded />,
|
|
169
|
+
sfSymbol: 'house.fill',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'you',
|
|
173
|
+
label: 'You',
|
|
174
|
+
icon: <Icons.UserCircle_Stroke2_Corner0_Rounded />,
|
|
175
|
+
sfSymbol: 'person.crop.circle',
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Icon sets that change SHAPE when selected
|
|
181
|
+
|
|
182
|
+
Tinting says everything an icon set needs to say only when its selected state *is* a different color. Plenty of sets — including Mention's — say it with a different **shape** instead: an outline glyph becomes a filled one, on a different path. A tint crossfade cannot express that, because both layers would draw the same path in two colors.
|
|
183
|
+
|
|
184
|
+
`item.activeIcon` gives the crossfade a second node, so the outline dissolves into the fill:
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
const TABS = [
|
|
188
|
+
{
|
|
189
|
+
name: 'home',
|
|
190
|
+
label: 'Home',
|
|
191
|
+
icon: <Home />, // outline: fill="none" + stroke
|
|
192
|
+
activeIcon: <HomeActive />, // filled, different path
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
It is optional and additive: an item with only `icon` renders it on both layers exactly as before. On iOS an item carrying `sfSymbol` renders the symbol on both layers and ignores `activeIcon` — a symbol is tinted natively, so the tint crossfade is still the right expression there.
|
|
198
|
+
|
|
199
|
+
<Callout variant="warning" title="An icon that paints from a `color` prop never lights up">
|
|
200
|
+
The bar tints a glyph by cloning the element with a `fill` prop. That is how Bloom's own icons (and raw SVG) take paint — they resolve `fill ?? style.color`. An icon set whose components read a **`color` prop** instead simply ignores the injected `fill`, and the result is a glyph that renders fine and never changes between the two layers. Nothing throws and nothing warns.
|
|
201
|
+
|
|
202
|
+
Two ways out, both explicit: pass `activeIcon` so selection is carried by the shape rather than by the tint, or pre-color both nodes yourself (`icon={<Home color={inactive} />}`, `activeIcon={<HomeActive color={active} />}`) — an element that already has a `fill` is never overridden, and one that paints from `color` was never going to be.
|
|
203
|
+
</Callout>
|
|
204
|
+
|
|
205
|
+
## Long press
|
|
206
|
+
|
|
207
|
+
The bar's gesture detector consumes its touches — that is what makes scrubbing possible — so a `TabBarButton`'s own `onLongPress` never fires for a real finger. `onIndexLongPress` on the **bar** is the way to get one:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
<TabBar
|
|
211
|
+
activeIndex={index}
|
|
212
|
+
onIndexChange={setIndex}
|
|
213
|
+
onIndexLongPress={(i) => TABS[i].name === 'you' && openAccountSheet()}
|
|
214
|
+
>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It reports the index under the finger after a 500ms hold, resolved through the same geometry as a tap, and it does **not** select the tab — it is a secondary action (an account switcher on the profile tab, say), so the highlight stays where it was.
|
|
218
|
+
|
|
219
|
+
The gesture joins the bar's gesture race only when you pass the prop. Without it, nothing about a held finger changes: you can still rest a finger and then scrub. With it, a hold that reaches 500ms wins the race and cancels the scrub — the tap is unaffected either way, since it gives up at 400ms, before the long press can activate.
|
|
220
|
+
|
|
221
|
+
## Laying out around the bar
|
|
222
|
+
|
|
223
|
+
The bar floats over your content (`position: absolute`), so nothing accounts for it. `useTabBarFootprint()` returns the space it physically occupies above the bottom of the window — its own height plus its own bottom offset, and nothing else:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { useMinimizeOnScroll, useTabBarFootprint } from '@oxyhq/bloom/tab-bar';
|
|
227
|
+
|
|
228
|
+
export default function Feed() {
|
|
229
|
+
const onScroll = useMinimizeOnScroll();
|
|
230
|
+
const footprint = useTabBarFootprint();
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<>
|
|
234
|
+
<Animated.ScrollView
|
|
235
|
+
onScroll={onScroll}
|
|
236
|
+
scrollEventThrottle={16}
|
|
237
|
+
// The last row clears the pill by 12pt — that gap is yours, not Bloom's.
|
|
238
|
+
contentContainerStyle={{ paddingBottom: footprint + 12 }}
|
|
239
|
+
/>
|
|
240
|
+
{/* A FAB anchored by `bottom` already carries its own gap: sit it right
|
|
241
|
+
on top of the footprint. */}
|
|
242
|
+
<Fab style={{ position: 'absolute', right: 16, bottom: footprint }} />
|
|
243
|
+
</>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**No clearance is baked in.** Clearance is a per-call-site decision — a list wants breathing room above the pill, a FAB anchored by `bottom` supplies its own — and only you know which. Adding a margin inside the hook would place that FAB too high and make one word mean two things in a single file. Bloom returns the one number it actually knows.
|
|
249
|
+
|
|
250
|
+
<Callout variant="warning" title="Never add `insets.bottom` to it">
|
|
251
|
+
The value is measured from the true bottom of the window, because the bar folds the bottom safe-area inset into its own gap. `footprint + insets.bottom` — the reflex for anything anchored to the bottom edge — counts the home indicator twice and accounts 110pt for a 76pt bar, leaving a visible band of dead space under every list. Whatever you add on top is your own clearance, never the inset a second time.
|
|
252
|
+
</Callout>
|
|
253
|
+
|
|
254
|
+
It is the bar's **expanded** height, deliberately: the bar minimizes on the way down and re-expands on the way up, so measuring the minimized height would hide the end of a list the moment the user scrolled back to it.
|
|
255
|
+
|
|
256
|
+
## Constraining the width on a tablet
|
|
257
|
+
|
|
258
|
+
By default the bar spans the window. That is right on a phone and wrong on a tablet: an iPad 11" gives you an 810pt pill in portrait and 1342pt in landscape, with 21pt glyphs adrift in cells hundreds of points wide. `maxWidth` caps it and centres it:
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
<TabBar activeIndex={index} onIndexChange={setIndex} maxWidth={480}>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
There is **no default** — omit the prop and the bar is exactly the bar it has always been. The value is a **ceiling, never a floor**: a window narrower than it keeps the full-bleed bar, so you can pass one number unconditionally from a shared tab layout and phones are untouched.
|
|
265
|
+
|
|
266
|
+
The important part is what comes with it: the item width, the sliding highlight's position and the tap / scrub / long-press hit-testing all derive from the **same** constrained width, so a tap still lands on the tab it is visibly over.
|
|
267
|
+
|
|
268
|
+
<Callout variant="warning" title="Do not narrow the bar with `style` instead">
|
|
269
|
+
`style={{ maxWidth: 480, alignSelf: 'center' }}` on the bar moves the pixels and nothing else. The highlight and the gesture worklet keep dividing the **window** width by the tab count, so the pill is sized and positioned for a bar that is no longer there — and on a wide window a tap near the right edge selects a tab two places to the left. `maxWidth` is a prop rather than something you can express in a style precisely because the geometry has to follow it.
|
|
270
|
+
</Callout>
|
|
271
|
+
|
|
272
|
+
Centring and the minimize animation compose without either knowing about the other: the constrained wrap is centred once by layout, and the pill's minimized inset stays a pair of equal animated margins **inside** it, so the bar shrinks about its own centre on the way down.
|
|
273
|
+
|
|
274
|
+
## The blur behind the bar
|
|
275
|
+
|
|
276
|
+
The bar draws a progressive blur rising from the bottom edge of the screen — the band content dissolves into instead of meeting the pill at a hard line. It is full-bleed and 114pt tall at a zero bottom inset (the bar's bottom gap + its expanded height + 44pt of bleed above it), which is taller than the bar itself.
|
|
277
|
+
|
|
278
|
+
That matters if your screen floats its own chrome down there. A video scrubber, a compose FAB, a QR-scan button — anything inside that band is blurred, and **no `zIndex` gets it out**: z-order only sorts siblings within one stacking context, and your FAB lives inside the screen, which is an earlier sibling of the bar's host. Turn the band off instead:
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
<TabBar activeIndex={index} onIndexChange={setIndex} blur={false}>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`false` renders no blur at all — not an invisible one, and not an empty absolutely-positioned view left behind. `true` (or omitting it) is the default. The object form tunes the strength on the same 0–100 `expo-blur` scale the standalone [`ProgressiveBlur`](#progressiveblur) uses, defaulting to 5:
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
<TabBar activeIndex={index} onIndexChange={setIndex} blur={{ intensity: 2 }}>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The band stays full-bleed under `maxWidth`: it is the screen-edge scrim, not part of the pill, and constraining it would leave a floating blurred rectangle.
|
|
291
|
+
|
|
292
|
+
## Theming
|
|
293
|
+
|
|
294
|
+
Every color comes from the active `BloomThemeProvider` preset and mode — there is no hardcoded palette, so the bar follows a preset or a light/dark switch for free.
|
|
295
|
+
|
|
296
|
+
| Role | Resolves from | Meaning |
|
|
297
|
+
|------|---------------|---------|
|
|
298
|
+
| `activeTint` | `colors.text` | Label and glyph of the tab the highlight is over. |
|
|
299
|
+
| `inactiveTint` | `colors.textSecondary` | Label and glyph of every other tab. |
|
|
300
|
+
| `highlight` | `colors.contrast50` | The sliding highlight pill. |
|
|
301
|
+
| `glassTint` | `colors.background` at 55% | Tint layered over the liquid glass (iOS 26) or `backdrop-filter` (web) material. |
|
|
302
|
+
| `solidFallback` | `colors.backgroundSecondary` at 94% | Near-opaque background used when there is no glass to lens. |
|
|
303
|
+
|
|
304
|
+
Override any subset with the `theme` prop; the rest keep their token-derived values.
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
<TabBar activeIndex={index} onIndexChange={setIndex} theme={{ highlight: 'rgb(255 0 128)' }}>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Overrides are coalesced key by key, so an explicit `undefined` in a partial can never punch a hole in the resolved theme.
|
|
311
|
+
|
|
312
|
+
## Per-platform behaviour
|
|
313
|
+
|
|
314
|
+
| Platform | Capsule surface | Blur behind the bar |
|
|
315
|
+
|----------|-----------------|---------------------|
|
|
316
|
+
| iOS 26 | Real `UIGlassEffect` liquid glass under `glassTint`, with the native corner configuration (true squircle + rim lighting) tracking the animated height. | Ten thin stacked `BlurView`s — iOS has no public variable-blur API, so the falloff is built from layer coverage. |
|
|
317
|
+
| iOS < 26, Android | Solid `solidFallback` fill. `isLiquidGlassAvailable()` reports `false`, so `GlassView` is never mounted. | Same ten-layer stack. |
|
|
318
|
+
| Web | `backdrop-filter: blur(24px) saturate(180%)` under `glassTint`. Browsers without `backdrop-filter` simply skip it and keep the translucent tint. | One `backdrop-filter` faded with a `mask-image` — an order of magnitude cheaper than ten composited backdrop passes, and it traces the same curve as the native stack. |
|
|
319
|
+
|
|
320
|
+
The capsule radius rides on the surface itself rather than on a clipping parent: iOS 26 glass renders its own native corner configuration, which an RN mask would flatten.
|
|
321
|
+
|
|
322
|
+
## ProgressiveBlur
|
|
323
|
+
|
|
324
|
+
The gradient blur behind the bar is also a standalone component. Use it behind any floating chrome — a transparent header, a docked composer — so content dissolves rather than meeting a hard blur line.
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
import { ProgressiveBlur } from '@oxyhq/bloom/progressive-blur';
|
|
328
|
+
|
|
329
|
+
<ProgressiveBlur
|
|
330
|
+
direction="top"
|
|
331
|
+
intensity={5}
|
|
332
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 120 }}
|
|
333
|
+
/>;
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
| Prop | Type | Default | Description |
|
|
337
|
+
|------|------|---------|-------------|
|
|
338
|
+
| `direction?` | `'top' \| 'bottom'` | `'top'` | Which edge the blur is anchored to — strongest there, fading away. |
|
|
339
|
+
| `intensity?` | `number` | `5` | Blur strength at the anchored edge, on `expo-blur`'s 0–100 scale. Applied per layer on native; the web fork maps it onto the single CSS blur radius that approximates the same stack. |
|
|
340
|
+
| `style?` | `StyleProp<ViewStyle>` | — | Position and size it yourself; it has no intrinsic layout. |
|
|
341
|
+
|
|
342
|
+
The tail gradient is derived from the theme's page background, so it works on light and dark alike.
|
|
343
|
+
|
|
344
|
+
## TabBar props
|
|
345
|
+
|
|
346
|
+
| Prop | Type | Default | Description |
|
|
347
|
+
|------|------|---------|-------------|
|
|
348
|
+
| `children` | `ReactNode` | — | The `TabBarButton`s, in bar order. |
|
|
349
|
+
| `activeIndex?` | `number` | — | **Controlled path.** The focused tab; an index that names no tab (`-1`, past the last, fractional) hides the highlight — see [No selection](#no-selection). Omit it with a router — use per-button `isFocused` instead. |
|
|
350
|
+
| `onIndexChange?` | `(index: number) => void` | — | Called with the chosen index on tap, on scrub release, or on keyboard / assistive-technology activation. |
|
|
351
|
+
| `onIndexLongPress?` | `(index: number) => void` | — | Called with the index under the finger after a 500ms hold. Does not select. The gesture is armed only when this is supplied. |
|
|
352
|
+
| `theme?` | `Partial<TabBarTheme>` | — | Partial override of the token-resolved theme. |
|
|
353
|
+
| `haptics?` | `boolean` | `true` | Picker-style tick as the scrub crosses tab boundaries. Already a no-op on web and when `expo-haptics` is absent. |
|
|
354
|
+
| `blur?` | `boolean \| { intensity?: number }` | `true` | The progressive blur band behind the bar. `false` removes it entirely — the escape hatch for a FAB or scrubber that sits inside its 114pt reach. |
|
|
355
|
+
| `maxWidth?` | `number` | — | Caps the pill's width and centres it, with the item geometry and the hit-testing following. A ceiling, never a floor: narrower windows keep the full-bleed bar. |
|
|
356
|
+
|
|
357
|
+
Everything `View` accepts is forwarded.
|
|
358
|
+
|
|
359
|
+
## TabBarButton props
|
|
360
|
+
|
|
361
|
+
| Prop | Type | Default | Description |
|
|
362
|
+
|------|------|---------|-------------|
|
|
363
|
+
| `item` | `TabBarItem` | — | `{ name, label, icon, activeIcon?, sfSymbol? }`. |
|
|
364
|
+
| `index` | `number` | — | Position in the bar. Must match the child order. |
|
|
365
|
+
| `isFocused?` | `boolean` | — | **Focus-driven path**, supplied by the router adapter. When present it wins over the bar's `activeIndex` and this button drives the highlight. |
|
|
366
|
+
|
|
367
|
+
Everything `Pressable` accepts is forwarded, including the function form of `style`.
|
|
368
|
+
|
|
369
|
+
## Exports
|
|
370
|
+
|
|
371
|
+
| Export | From | Description |
|
|
372
|
+
|--------|------|-------------|
|
|
373
|
+
| `TabBar`, `TabBarButton` | `@oxyhq/bloom/tab-bar` | The bar and its trigger. |
|
|
374
|
+
| `TabBarMinimizeProvider` | `@oxyhq/bloom/tab-bar` | Shares one minimize progress across the tab tree. |
|
|
375
|
+
| `useMinimizeOnScroll` | `@oxyhq/bloom/tab-bar` | Scroll handler for `Animated.ScrollView`. |
|
|
376
|
+
| `useTabBarMinimized` | `@oxyhq/bloom/tab-bar` | The raw `SharedValue<number>`, 0…1. |
|
|
377
|
+
| `useTabBarFootprint` | `@oxyhq/bloom/tab-bar` | Space the bar occupies at the bottom, safe-area included, no clearance added. |
|
|
378
|
+
| `useMinimizeState`, `setMinimized`, `MINIMIZE_SPRING` | `@oxyhq/bloom/tab-bar` | Lower-level control of the same progress. |
|
|
379
|
+
| `RouterTabBar`, `RouterTabBarButton`, `renderFadingTabScreen` | `@oxyhq/bloom/tab-bar/expo-router` | expo-router bindings. |
|
|
380
|
+
| `ProgressiveBlur` | `@oxyhq/bloom/progressive-blur` | The gradient blur, standalone. |
|
|
381
|
+
|
|
382
|
+
Types: `TabBarItem`, `TabBarProps`, `TabBarButtonProps`, `TabBarTheme`, `MinimizeState`, `ProgressiveBlurProps`.
|
|
383
|
+
|
|
384
|
+
<Callout variant="info" title="TabBar vs Tabs">
|
|
385
|
+
`TabBar` is app-level navigation chrome: floating, minimize-on-scroll, one per screen tree. For switching between panels *inside* a screen, reach for `Tabs` from `@oxyhq/bloom/tabs` instead — an inline, underlined control with no gestures and no surface of its own.
|
|
386
|
+
</Callout>
|