@oxyhq/bloom 0.51.0 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/frosted-icon-button/shared.js +1 -1
- package/lib/commonjs/tab-bar/surface-solid.js +43 -0
- package/lib/commonjs/tab-bar/surface-solid.js.map +1 -0
- package/lib/commonjs/tab-bar/surface.js +5 -42
- package/lib/commonjs/tab-bar/surface.js.map +1 -1
- package/lib/commonjs/tab-bar/surface.native.js +65 -8
- package/lib/commonjs/tab-bar/surface.native.js.map +1 -1
- package/lib/module/frosted-icon-button/shared.js +1 -1
- package/lib/module/tab-bar/surface-solid.js +37 -0
- package/lib/module/tab-bar/surface-solid.js.map +1 -0
- package/lib/module/tab-bar/surface.js +10 -23
- package/lib/module/tab-bar/surface.js.map +1 -1
- package/lib/module/tab-bar/surface.native.js +68 -9
- package/lib/module/tab-bar/surface.native.js.map +1 -1
- package/lib/typescript/commonjs/benefit-list/types.d.ts +2 -1
- package/lib/typescript/commonjs/benefit-list/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/types.d.ts +4 -2
- package/lib/typescript/commonjs/fab/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +2 -1
- package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts +6 -0
- package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts.map +1 -0
- package/lib/typescript/commonjs/tab-bar/surface.d.ts +28 -5
- package/lib/typescript/commonjs/tab-bar/surface.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/surface.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +6 -3
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/benefit-list/types.d.ts +2 -1
- package/lib/typescript/module/benefit-list/types.d.ts.map +1 -1
- package/lib/typescript/module/fab/types.d.ts +4 -2
- package/lib/typescript/module/fab/types.d.ts.map +1 -1
- package/lib/typescript/module/frosted-icon-button/shared.d.ts +1 -1
- package/lib/typescript/module/frosted-icon-button/types.d.ts +2 -1
- package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/surface-solid.d.ts +6 -0
- package/lib/typescript/module/tab-bar/surface-solid.d.ts.map +1 -0
- package/lib/typescript/module/tab-bar/surface.d.ts +28 -5
- package/lib/typescript/module/tab-bar/surface.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/surface.native.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +6 -3
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +2 -8
- package/src/__tests__/TabBarGlassFallback.test.tsx +234 -0
- package/src/__tests__/TabBarWebFork.test.ts +46 -0
- package/src/__tests__/icon-references.test.ts +243 -0
- package/src/benefit-list/types.ts +2 -1
- package/src/fab/types.ts +4 -2
- package/src/frosted-icon-button/shared.ts +1 -1
- package/src/frosted-icon-button/types.ts +2 -1
- package/src/tab-bar/surface-solid.tsx +40 -0
- package/src/tab-bar/surface.native.tsx +77 -15
- package/src/tab-bar/surface.tsx +10 -29
- package/src/tab-bar/types.ts +6 -3
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every `Icons.*` reference in this repo must name an identifier `src/icons`
|
|
6
|
+
* actually exports.
|
|
7
|
+
*
|
|
8
|
+
* Icon exports carry their full style and corner suffix
|
|
9
|
+
* (`Home_Stroke2_Corner0_Rounded`), there are 250-odd of them, and a shortened
|
|
10
|
+
* name is not a compile error anywhere it usually appears: in a DOC COMMENT or an
|
|
11
|
+
* `.mdx` example, `tsc` never sees it. A reader copies it, gets `undefined`, and
|
|
12
|
+
* React reports "Element type is invalid … but got: undefined" against the
|
|
13
|
+
* consumer's own file — which is exactly how a phantom release blocker was filed
|
|
14
|
+
* against the tab bar's native surface.
|
|
15
|
+
*
|
|
16
|
+
* Two shapes are checked, because the real incident needed both:
|
|
17
|
+
*
|
|
18
|
+
* 1. `Icons.Home` — a member that does not exist.
|
|
19
|
+
* 2. `import { Icons } from '@oxyhq/bloom/icons'` — the NAMESPACE imported as a
|
|
20
|
+
* named export. `Icons` is assembled by the ROOT barrel
|
|
21
|
+
* (`export * as Icons from './icons'` in `src/index.ts`); the `./icons`
|
|
22
|
+
* subpath exports the components flat, so that import is `undefined` too.
|
|
23
|
+
* `import * as Icons from '@oxyhq/bloom/icons'` and
|
|
24
|
+
* `import { Icons } from '@oxyhq/bloom'` are both correct and must stay
|
|
25
|
+
* unflagged.
|
|
26
|
+
*
|
|
27
|
+
* Comments are deliberately NOT stripped here — the inverse of
|
|
28
|
+
* `TabBarWebFork.test.ts`, which blanks them so a doc comment naming a package is
|
|
29
|
+
* not mistaken for an import of it. Here the doc comments ARE the surface under
|
|
30
|
+
* test: four of the five instances this test was written for lived in JSDoc.
|
|
31
|
+
*
|
|
32
|
+
* Identifiers are matched against the UNION of everything exported anywhere under
|
|
33
|
+
* `src/icons`, because references legitimately target the barrel
|
|
34
|
+
* (`../icons`), a single icon file (`../icons/Check`) and `../icons/common` alike.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const SRC = join(__dirname, '..');
|
|
38
|
+
const REPO_ROOT = join(SRC, '..');
|
|
39
|
+
const ICONS_DIR = join(SRC, 'icons');
|
|
40
|
+
|
|
41
|
+
const SCANNED_EXTENSIONS = ['.ts', '.tsx', '.mdx', '.md'];
|
|
42
|
+
|
|
43
|
+
/** True for a specifier that resolves to an icons module, package or relative. */
|
|
44
|
+
const ICONS_MODULE = /(?:^|\/)icons(?:\/|$)/;
|
|
45
|
+
|
|
46
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
47
|
+
for (const entry of readdirSync(dir)) {
|
|
48
|
+
// Agent worktrees under `.claude/` are full copies of this repo — scanning
|
|
49
|
+
// them would double every file and report another checkout's problems.
|
|
50
|
+
if (entry === 'node_modules' || entry === 'lib' || entry.startsWith('.')) continue;
|
|
51
|
+
const full = join(dir, entry);
|
|
52
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
53
|
+
else if (SCANNED_EXTENSIONS.includes(extname(full))) out.push(full);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every identifier a module exports, across every export shape in use here. */
|
|
59
|
+
function exportedNames(source: string): string[] {
|
|
60
|
+
const found = new Set<string>();
|
|
61
|
+
|
|
62
|
+
// `export const X` / `export function X` / `export class X` /
|
|
63
|
+
// `export type X =` / `export interface X`
|
|
64
|
+
for (const m of source.matchAll(
|
|
65
|
+
/export\s+(?:declare\s+)?(?:const|let|var|function|class|type|interface)\s+([A-Za-z0-9_$]+)/g,
|
|
66
|
+
)) {
|
|
67
|
+
if (m[1]) found.add(m[1]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `export { A, B as C }` and `export type { A, B } from './x'` — the form that
|
|
71
|
+
// carries `IconStyle` / `Props` out of `icons/common.tsx` through the barrel.
|
|
72
|
+
// Missing it is what made an earlier pass of this check report two phantom
|
|
73
|
+
// failures, and a check with known noise is a check somebody disables.
|
|
74
|
+
for (const m of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
|
|
75
|
+
for (const name of namedBindings(m[1] ?? '', 'export')) found.add(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [...found];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The bindings a brace list introduces.
|
|
83
|
+
*
|
|
84
|
+
* `as` means opposite things in the two directions: `export { A as B }` publishes
|
|
85
|
+
* `B`, while `import { A as B }` reads `A`. Getting that backwards would make the
|
|
86
|
+
* check blind to exactly the imports it exists to catch.
|
|
87
|
+
*/
|
|
88
|
+
function namedBindings(list: string, direction: 'import' | 'export'): string[] {
|
|
89
|
+
const names: string[] = [];
|
|
90
|
+
for (const raw of list.split(',')) {
|
|
91
|
+
const part = raw.trim().replace(/^type\s+/, '');
|
|
92
|
+
if (!part) continue;
|
|
93
|
+
const sides = part.split(/\s+as\s+/).map((s) => s.trim());
|
|
94
|
+
const name = direction === 'export' ? sides[sides.length - 1] : sides[0];
|
|
95
|
+
if (name && /^[A-Za-z0-9_$]+$/.test(name) && name !== 'default') names.push(name);
|
|
96
|
+
}
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The union of every identifier exported anywhere under `src/icons`. */
|
|
101
|
+
function iconExportUnion(): Set<string> {
|
|
102
|
+
const union = new Set<string>();
|
|
103
|
+
for (const file of walk(ICONS_DIR)) {
|
|
104
|
+
for (const name of exportedNames(readFileSync(file, 'utf8'))) union.add(name);
|
|
105
|
+
}
|
|
106
|
+
return union;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Unresolved {
|
|
110
|
+
line: number;
|
|
111
|
+
ref: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every icon reference in `source` that `exported` cannot satisfy. */
|
|
115
|
+
function unresolvedIconRefs(source: string, exported: Set<string>): Unresolved[] {
|
|
116
|
+
const problems: Unresolved[] = [];
|
|
117
|
+
|
|
118
|
+
source.split('\n').forEach((text, index) => {
|
|
119
|
+
const line = index + 1;
|
|
120
|
+
|
|
121
|
+
for (const m of text.matchAll(/\bIcons\.([A-Za-z0-9_$]+)/g)) {
|
|
122
|
+
if (m[1] && !exported.has(m[1])) problems.push({ line, ref: `Icons.${m[1]}` });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Braces only: `import * as Icons from …` is the correct namespace form, and a
|
|
126
|
+
// bare `import '…/icons'` binds nothing.
|
|
127
|
+
const imported = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/.exec(text);
|
|
128
|
+
const specifier = imported?.[2];
|
|
129
|
+
if (imported && specifier && ICONS_MODULE.test(specifier)) {
|
|
130
|
+
for (const name of namedBindings(imported[1] ?? '', 'import')) {
|
|
131
|
+
if (!exported.has(name)) problems.push({ line, ref: `{ ${name} } from '${specifier}'` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return problems;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ICON_EXPORTS = iconExportUnion();
|
|
140
|
+
|
|
141
|
+
const SCANNED = [
|
|
142
|
+
...walk(SRC),
|
|
143
|
+
...walk(join(REPO_ROOT, 'docs')),
|
|
144
|
+
join(REPO_ROOT, 'README.md'),
|
|
145
|
+
join(REPO_ROOT, 'AGENTS.md'),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* This file is the one exclusion, and it has to be: it SPELLS the mistakes it
|
|
150
|
+
* forbids — in its probes, in the doc comment above, and in the `Icons.${…}`
|
|
151
|
+
* template that formats a finding. Nothing outside a test copies an example out of
|
|
152
|
+
* a test. The list is pinned to exactly one path in the first assertion below, so
|
|
153
|
+
* it can neither grow quietly nor become a silent no-op through a typo.
|
|
154
|
+
*/
|
|
155
|
+
const EXCLUDED = [join(__dirname, 'icon-references.test.ts')];
|
|
156
|
+
const CHECKED = SCANNED.filter((file) => !EXCLUDED.includes(file));
|
|
157
|
+
|
|
158
|
+
describe('icon reference integrity', () => {
|
|
159
|
+
it('reads a plausible repo — neither list may be empty or truncated', () => {
|
|
160
|
+
// Without these floors a broken traversal or a broken extractor makes every
|
|
161
|
+
// assertion below pass by finding nothing at all.
|
|
162
|
+
expect(ICON_EXPORTS.size).toBeGreaterThan(200);
|
|
163
|
+
expect(CHECKED.length).toBeGreaterThan(300);
|
|
164
|
+
expect(CHECKED).toContain(join(SRC, 'tab-bar', 'types.ts'));
|
|
165
|
+
expect(CHECKED).toContain(join(REPO_ROOT, 'docs', 'tab-bar.mdx'));
|
|
166
|
+
|
|
167
|
+
// Exactly one file is exempt, and it really is in the scan — a misspelled path
|
|
168
|
+
// here would make the exemption a no-op and the suite fail on itself instead.
|
|
169
|
+
expect(EXCLUDED).toHaveLength(1);
|
|
170
|
+
expect(SCANNED).toContain(EXCLUDED[0]);
|
|
171
|
+
expect(CHECKED).not.toContain(EXCLUDED[0]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('collects every export shape src/icons uses, types included', () => {
|
|
175
|
+
// The suffixed component itself, the helpers, and the two TYPE exports that an
|
|
176
|
+
// earlier version of this check mistook for phantom references.
|
|
177
|
+
for (const name of [
|
|
178
|
+
'Home_Stroke2_Corner0_Rounded',
|
|
179
|
+
'PlusLarge_Stroke2_Corner0_Rounded',
|
|
180
|
+
'sizes',
|
|
181
|
+
'useCommonSVGProps',
|
|
182
|
+
'createSinglePathSVG',
|
|
183
|
+
'IconStyle',
|
|
184
|
+
'Props',
|
|
185
|
+
]) {
|
|
186
|
+
expect([...ICON_EXPORTS]).toContain(name);
|
|
187
|
+
}
|
|
188
|
+
// And nothing shortened is in there, which is the premise of the whole test.
|
|
189
|
+
expect(ICON_EXPORTS.has('Home')).toBe(false);
|
|
190
|
+
expect(ICON_EXPORTS.has('Icons')).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('resolves the export/import directions of `as` correctly', () => {
|
|
194
|
+
// `export { A as B }` publishes B; `import { A as B }` reads A.
|
|
195
|
+
expect(exportedNames("export { TimesLarge_Stroke2_Corner0_Rounded as X } from './Times';")).toEqual(
|
|
196
|
+
['X'],
|
|
197
|
+
);
|
|
198
|
+
expect(namedBindings('Check_Stroke2_Corner0_Rounded as CheckIcon', 'import')).toEqual([
|
|
199
|
+
'Check_Stroke2_Corner0_Rounded',
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('flags the two mistakes that have actually been made', () => {
|
|
204
|
+
const probe = [
|
|
205
|
+
"import { Icons } from '@oxyhq/bloom/icons';",
|
|
206
|
+
'const a = <Icons.Home />;',
|
|
207
|
+
"import { Bell, Home } from '@oxyhq/bloom/icons';",
|
|
208
|
+
" * A doc comment saying `<Icons.Lock size=\"sm\" />`, which is where four of these lived.",
|
|
209
|
+
].join('\n');
|
|
210
|
+
|
|
211
|
+
expect(unresolvedIconRefs(probe, ICON_EXPORTS)).toEqual([
|
|
212
|
+
{ line: 1, ref: "{ Icons } from '@oxyhq/bloom/icons'" },
|
|
213
|
+
{ line: 2, ref: 'Icons.Home' },
|
|
214
|
+
{ line: 3, ref: "{ Bell } from '@oxyhq/bloom/icons'" },
|
|
215
|
+
{ line: 3, ref: "{ Home } from '@oxyhq/bloom/icons'" },
|
|
216
|
+
{ line: 4, ref: 'Icons.Lock' },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('leaves every correct form alone', () => {
|
|
221
|
+
const probe = [
|
|
222
|
+
"import * as Icons from '@oxyhq/bloom/icons';",
|
|
223
|
+
"import { Icons } from '@oxyhq/bloom';",
|
|
224
|
+
'const a = <Icons.Home_Stroke2_Corner0_Rounded />;',
|
|
225
|
+
"import { Check_Stroke2_Corner0_Rounded as CheckIcon } from '../icons/Check';",
|
|
226
|
+
"import { type IconStyle, type Props as SVGIconProps } from '../icons/common';",
|
|
227
|
+
'| `Icons` | `export * as Icons` | keep namespace | `Icons.*` |',
|
|
228
|
+
"import { useState } from 'react';",
|
|
229
|
+
].join('\n');
|
|
230
|
+
|
|
231
|
+
expect(unresolvedIconRefs(probe, ICON_EXPORTS)).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('every icon reference in src, docs, README and AGENTS resolves', () => {
|
|
235
|
+
const problems = CHECKED.flatMap((file) =>
|
|
236
|
+
unresolvedIconRefs(readFileSync(file, 'utf8'), ICON_EXPORTS).map(
|
|
237
|
+
({ line, ref }) => `${relative(REPO_ROOT, file)}:${line} ${ref}`,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(problems).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -4,7 +4,8 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
|
|
|
4
4
|
export interface BenefitRowProps {
|
|
5
5
|
/**
|
|
6
6
|
* Leading icon — rendered inside a rounded `fill-secondary` square. Pass a
|
|
7
|
-
* Bloom icon element (e.g. `<Icons.
|
|
7
|
+
* Bloom icon element (e.g. `<Icons.Lock_Stroke2_Corner0_Rounded size="sm" />`,
|
|
8
|
+
* suffix included — there is no shortened alias) or any node.
|
|
8
9
|
*/
|
|
9
10
|
icon: ReactNode;
|
|
10
11
|
/**
|
package/src/fab/types.ts
CHANGED
|
@@ -63,8 +63,10 @@ export interface FabProps {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* The icon to render inside the FAB. Typically a Bloom icon element
|
|
66
|
-
* (`<Icons.
|
|
67
|
-
*
|
|
66
|
+
* (`<Icons.PlusLarge_Stroke2_Corner0_Rounded />` — icon exports carry their full
|
|
67
|
+
* style and corner suffix, and there is no shortened alias). For an icon-only
|
|
68
|
+
* FAB this is the whole content; for the extended variant it sits to the left of
|
|
69
|
+
* the `label`.
|
|
68
70
|
*
|
|
69
71
|
* `children` is accepted as an alias/override of `icon` for ergonomics — if
|
|
70
72
|
* both are given, `icon` wins and `children` is ignored.
|
|
@@ -106,7 +106,7 @@ type FillableElement = ReactElement<{ fill?: unknown }>;
|
|
|
106
106
|
* Inject `color` as the icon's `fill` — but only as a FALLBACK: if the caller
|
|
107
107
|
* already set an explicit `fill` on the icon element, it wins and the node is
|
|
108
108
|
* returned untouched. Bloom icons resolve their paint as `fill ?? style.color`,
|
|
109
|
-
* so
|
|
109
|
+
* so any bare Bloom icon element gets the theme-aware color for free while an
|
|
110
110
|
* explicitly-colored icon is never overridden. Non-element children (plain
|
|
111
111
|
* text/strings) pass through unchanged.
|
|
112
112
|
*/
|
|
@@ -18,7 +18,8 @@ export interface FrostedIconButtonProps {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The icon to render, centered inside the circle. Typically a Bloom icon
|
|
21
|
-
* element (`<Icons.
|
|
21
|
+
* element (`<Icons.ArrowLeft_Stroke2_Corner0_Rounded />` — icon exports carry
|
|
22
|
+
* their full style and corner suffix). If the icon does not set its own `fill`,
|
|
22
23
|
* the button injects the theme-aware icon color (foreground when frosted,
|
|
23
24
|
* `primaryForeground` when `active`) as a fallback so a bare icon is colored
|
|
24
25
|
* correctly out of the box; an explicit `fill` on the icon always wins.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
|
|
3
|
+
* (MIT © 2026 David Mokos). See the top-level NOTICE.
|
|
4
|
+
*
|
|
5
|
+
* The tab bar's NON-GLASS capsule surface: a plain animated view filled with the
|
|
6
|
+
* theme's near-opaque fallback. Android, pre-iOS-26 devices and any build without
|
|
7
|
+
* a usable `expo-glass-effect` all paint this.
|
|
8
|
+
*
|
|
9
|
+
* It lives in its own module because TWO variants render it and one of them
|
|
10
|
+
* cannot reach the other: Metro resolves a bare `./surface` to
|
|
11
|
+
* `surface.native.tsx` no matter which file asks, so `surface.native.tsx`
|
|
12
|
+
* importing its neutral sibling for the fallback would import ITSELF. A name with
|
|
13
|
+
* no platform variants resolves to this one file everywhere — which is the whole
|
|
14
|
+
* point, so please do not fold it back into `surface.tsx`.
|
|
15
|
+
*/
|
|
16
|
+
import { StyleSheet } from 'react-native';
|
|
17
|
+
import Animated from 'react-native-reanimated';
|
|
18
|
+
|
|
19
|
+
import type { TabBarSurfaceProps } from './shared';
|
|
20
|
+
|
|
21
|
+
export function SolidTabBarSurface({ theme, style }: TabBarSurfaceProps) {
|
|
22
|
+
return (
|
|
23
|
+
<Animated.View
|
|
24
|
+
style={[
|
|
25
|
+
StyleSheet.absoluteFill,
|
|
26
|
+
styles.surface,
|
|
27
|
+
{ backgroundColor: theme.solidFallback },
|
|
28
|
+
style,
|
|
29
|
+
]}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
SolidTabBarSurface.displayName = 'SolidTabBarSurface';
|
|
35
|
+
|
|
36
|
+
const styles = StyleSheet.create({
|
|
37
|
+
surface: {
|
|
38
|
+
borderCurve: 'continuous',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
|
|
3
3
|
* (MIT © 2026 David Mokos). See the top-level NOTICE.
|
|
4
4
|
*
|
|
5
|
-
* NATIVE variant of the tab bar's capsule surface — real liquid glass
|
|
5
|
+
* NATIVE variant of the tab bar's capsule surface — real liquid glass, degrading
|
|
6
|
+
* to the solid surface whenever the glass is not actually usable.
|
|
6
7
|
*
|
|
7
8
|
* `expo-glass-effect` is imported STATICALLY and this file is the only place it
|
|
8
9
|
* appears: Metro selects `.native` on iOS/Android, so the import never reaches
|
|
@@ -14,18 +15,88 @@
|
|
|
14
15
|
*
|
|
15
16
|
* `isLiquidGlassAvailable()` is false on Android and pre-iOS-26, where the
|
|
16
17
|
* package's `GlassView` has no material to render; those platforms get the same
|
|
17
|
-
* opaque fallback the neutral variant paints.
|
|
18
|
+
* opaque fallback the neutral variant paints. "Installed" is not "usable"
|
|
19
|
+
* though, and BOTH remaining hazards sit at MODULE scope, which is why neither
|
|
20
|
+
* can be handled by a branch inside the component:
|
|
21
|
+
*
|
|
22
|
+
* 1. THE NATIVE MODULE. On iOS `isLiquidGlassAvailable()` resolves
|
|
23
|
+
* `ExpoGlassEffect` through `requireNativeModule`, which THROWS
|
|
24
|
+
* `Cannot find native module …` by contract when that module is not in the
|
|
25
|
+
* binary. For these apps it is a normal deploy state rather than a
|
|
26
|
+
* misconfiguration: an OTA JS update can land on a binary built before the
|
|
27
|
+
* package was linked, while the JS side always resolves because expo-router
|
|
28
|
+
* depends on it. Unguarded, the throw escapes render and takes down the whole
|
|
29
|
+
* screen the bar is on.
|
|
30
|
+
* 2. THE EXPORTS. `Animated.createAnimatedComponent(GlassView)` runs at IMPORT
|
|
31
|
+
* time, so a tree that resolves the specifier to something without
|
|
32
|
+
* `GlassView` hands React an `undefined` element type before any render
|
|
33
|
+
* happens.
|
|
34
|
+
*
|
|
35
|
+
* `isGlassEffectAPIAvailable()` is upstream's own third condition: on some iOS 26
|
|
36
|
+
* betas `UIGlassEffect` is missing and touching `GlassView` crashes NATIVELY,
|
|
37
|
+
* below JS (expo/expo#40911). It first shipped in expo-glass-effect 0.1.9, which is
|
|
38
|
+
* exactly why the peer floor is `>=0.1.9`: this file imports the symbol STATICALLY,
|
|
39
|
+
* and a floor predating it would be the same incoherence as declaring a
|
|
40
|
+
* statically-imported package optional. The `typeof` guard below is still not
|
|
41
|
+
* redundant — a peer range only WARNS, so a consumer can resolve an older copy
|
|
42
|
+
* anyway. A missing probe therefore means "not applicable", never "unavailable";
|
|
43
|
+
* reading it the other way would delete the glass look from every working device.
|
|
44
|
+
*
|
|
45
|
+
* Everything that fails routes to the SAME solid surface Android already paints —
|
|
46
|
+
* the intended fallback path, not a new one.
|
|
18
47
|
*/
|
|
19
|
-
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
|
48
|
+
import { GlassView, isGlassEffectAPIAvailable, isLiquidGlassAvailable } from 'expo-glass-effect';
|
|
20
49
|
import { StyleSheet } from 'react-native';
|
|
21
50
|
import Animated from 'react-native-reanimated';
|
|
22
51
|
|
|
23
52
|
import type { TabBarSurfaceProps } from './shared';
|
|
53
|
+
import { SolidTabBarSurface } from './surface-solid';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The animated glass view, or `null` when the package did not deliver the two
|
|
57
|
+
* exports this file needs. Built once, and guarded HERE rather than in the
|
|
58
|
+
* component because `createAnimatedComponent` runs on import (hazard 2 above).
|
|
59
|
+
*/
|
|
60
|
+
const AnimatedGlassView =
|
|
61
|
+
Boolean(GlassView) && typeof isLiquidGlassAvailable === 'function'
|
|
62
|
+
? Animated.createAnimatedComponent(GlassView)
|
|
63
|
+
: null;
|
|
24
64
|
|
|
25
|
-
|
|
65
|
+
/** Memo for {@link glassIsUsable} — the answer cannot change within a session. */
|
|
66
|
+
let glassUsable: boolean | undefined;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether this build can really render glass. Probed once: the native lookup
|
|
70
|
+
* behind it is not free, and on the failing path it throws.
|
|
71
|
+
*
|
|
72
|
+
* The `catch` reports rather than swallows. A bar that silently painted itself
|
|
73
|
+
* opaque on every iOS device would be far more expensive to find than one warning
|
|
74
|
+
* line, and reaching this branch always means a real, fixable packaging mistake.
|
|
75
|
+
* Warning exactly once falls out of the memo — the body runs at most one time.
|
|
76
|
+
*/
|
|
77
|
+
function glassIsUsable(): boolean {
|
|
78
|
+
if (glassUsable !== undefined) return glassUsable;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
glassUsable =
|
|
82
|
+
isLiquidGlassAvailable() &&
|
|
83
|
+
(typeof isGlassEffectAPIAvailable !== 'function' || isGlassEffectAPIAvailable());
|
|
84
|
+
} catch (error) {
|
|
85
|
+
glassUsable = false;
|
|
86
|
+
// eslint-disable-next-line no-console
|
|
87
|
+
console.warn(
|
|
88
|
+
'[Bloom] TabBar: expo-glass-effect could not report liquid-glass availability, ' +
|
|
89
|
+
'so the bar is painting its solid surface. Install expo-glass-effect and ' +
|
|
90
|
+
'rebuild the native app to get the glass capsule.',
|
|
91
|
+
error,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return glassUsable;
|
|
96
|
+
}
|
|
26
97
|
|
|
27
98
|
export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
|
|
28
|
-
if (
|
|
99
|
+
if (AnimatedGlassView !== null && glassIsUsable()) {
|
|
29
100
|
return (
|
|
30
101
|
<AnimatedGlassView
|
|
31
102
|
glassEffectStyle="regular"
|
|
@@ -39,16 +110,7 @@ export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
|
|
|
39
110
|
);
|
|
40
111
|
}
|
|
41
112
|
|
|
42
|
-
return
|
|
43
|
-
<Animated.View
|
|
44
|
-
style={[
|
|
45
|
-
StyleSheet.absoluteFill,
|
|
46
|
-
styles.surface,
|
|
47
|
-
{ backgroundColor: theme.solidFallback },
|
|
48
|
-
style,
|
|
49
|
-
]}
|
|
50
|
-
/>
|
|
51
|
-
);
|
|
113
|
+
return <SolidTabBarSurface theme={theme} style={style} />;
|
|
52
114
|
}
|
|
53
115
|
|
|
54
116
|
TabBarSurface.displayName = 'TabBarSurface';
|
package/src/tab-bar/surface.tsx
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
|
|
3
3
|
* (MIT © 2026 David Mokos). See the top-level NOTICE.
|
|
4
4
|
*
|
|
5
|
-
* NEUTRAL default variant of the tab bar's capsule surface:
|
|
6
|
-
* view filled with the theme's solid fallback.
|
|
5
|
+
* NEUTRAL default variant of the tab bar's capsule surface: the non-glass one.
|
|
7
6
|
*
|
|
8
7
|
* This is the file resolved by everything that does NOT do React Native's
|
|
9
8
|
* platform-extension resolution — a consumer's `tsc` (which follows Bloom's
|
|
@@ -14,34 +13,16 @@
|
|
|
14
13
|
* resolution outright. Same three-way split, for the same reason, as
|
|
15
14
|
* `theme/native-root-vars{,.native,.web}.ts`.
|
|
16
15
|
*
|
|
16
|
+
* The surface itself lives in `surface-solid.tsx`, because `surface.native.tsx`
|
|
17
|
+
* renders it as its own fallback and cannot import this file to get it — Metro
|
|
18
|
+
* resolves `./surface` back to `surface.native.tsx`, i.e. to itself. This module's
|
|
19
|
+
* job is to give that one implementation the name every entry module imports.
|
|
20
|
+
*
|
|
17
21
|
* The variants — all three implement `TabBarSurfaceProps` exactly, so the entry
|
|
18
22
|
* modules can swap them freely:
|
|
19
|
-
* - `surface.native.tsx` — iOS/Android (Metro), real `UIGlassEffect` glass
|
|
23
|
+
* - `surface.native.tsx` — iOS/Android (Metro), real `UIGlassEffect` glass, with
|
|
24
|
+
* the solid surface as its fallback;
|
|
20
25
|
* - `surface.web.tsx` — web, a CSS `backdrop-filter`;
|
|
21
|
-
* - this file — everywhere else, the opaque fallback.
|
|
26
|
+
* - this file — everywhere else, the opaque fallback alone.
|
|
22
27
|
*/
|
|
23
|
-
|
|
24
|
-
import Animated from 'react-native-reanimated';
|
|
25
|
-
|
|
26
|
-
import type { TabBarSurfaceProps } from './shared';
|
|
27
|
-
|
|
28
|
-
export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
|
|
29
|
-
return (
|
|
30
|
-
<Animated.View
|
|
31
|
-
style={[
|
|
32
|
-
StyleSheet.absoluteFill,
|
|
33
|
-
styles.surface,
|
|
34
|
-
{ backgroundColor: theme.solidFallback },
|
|
35
|
-
style,
|
|
36
|
-
]}
|
|
37
|
-
/>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
TabBarSurface.displayName = 'TabBarSurface';
|
|
42
|
-
|
|
43
|
-
const styles = StyleSheet.create({
|
|
44
|
-
surface: {
|
|
45
|
-
borderCurve: 'continuous',
|
|
46
|
-
},
|
|
47
|
-
});
|
|
28
|
+
export { SolidTabBarSurface as TabBarSurface } from './surface-solid';
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -19,9 +19,12 @@ export type TabBarItem = {
|
|
|
19
19
|
/** Text under the icon; fades out and is clipped when the bar minimizes. */
|
|
20
20
|
label: string;
|
|
21
21
|
/**
|
|
22
|
-
* The glyph, as a Bloom icon element (`<Icons.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* The glyph, as a Bloom icon element (`<Icons.Home_Stroke2_Corner0_Rounded />`)
|
|
23
|
+
* or any node. Every `@oxyhq/bloom/icons` export carries its full style and
|
|
24
|
+
* corner suffix; a shortened `Home` is not an export, and a name that does not
|
|
25
|
+
* exist is simply `undefined`, which React then rejects as an invalid element
|
|
26
|
+
* type. This is the primary icon API — same convention as `Tabs`/`TabsTrigger`
|
|
27
|
+
* and `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
|
|
25
28
|
* active layer crossfading on top), injecting the layer's tint as the icon's
|
|
26
29
|
* `fill` unless the element already sets one.
|
|
27
30
|
*/
|