@oxyhq/bloom 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/frosted-icon-button/shared.js +11 -1
- package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.js +9 -4
- package/lib/commonjs/tab-bar/glyph.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.native.js +7 -2
- package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
- package/lib/commonjs/tab-bar/index.js +7 -0
- package/lib/commonjs/tab-bar/index.js.map +1 -1
- package/lib/commonjs/tab-bar/index.web.js +7 -0
- package/lib/commonjs/tab-bar/index.web.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +32 -1
- package/lib/commonjs/tab-bar/shared.js.map +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/commonjs/tab-bar/use-reserved-space.js +50 -0
- package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
- package/lib/module/frosted-icon-button/shared.js +11 -1
- package/lib/module/frosted-icon-button/shared.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +31 -6
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/glyph.js +9 -4
- package/lib/module/tab-bar/glyph.js.map +1 -1
- package/lib/module/tab-bar/glyph.native.js +7 -2
- package/lib/module/tab-bar/glyph.native.js.map +1 -1
- package/lib/module/tab-bar/index.js +1 -0
- package/lib/module/tab-bar/index.js.map +1 -1
- package/lib/module/tab-bar/index.web.js +1 -0
- package/lib/module/tab-bar/index.web.js.map +1 -1
- package/lib/module/tab-bar/shared.js +30 -0
- package/lib/module/tab-bar/shared.js.map +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/module/tab-bar/use-reserved-space.js +46 -0
- package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
- 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 +11 -1
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +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/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/commonjs/tab-bar/shared.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 +43 -4
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
- package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
- 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 +11 -1
- package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +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/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/module/tab-bar/shared.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 +43 -4
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
- package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
- package/package.json +2 -8
- package/src/__tests__/TabBar.test.tsx +82 -2
- package/src/__tests__/TabBarGlassFallback.test.tsx +234 -0
- package/src/__tests__/TabBarLongPress.test.tsx +258 -0
- package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
- package/src/__tests__/TabBarWebFork.test.ts +47 -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 +11 -1
- package/src/frosted-icon-button/types.ts +2 -1
- package/src/tab-bar/TabBarBase.tsx +39 -5
- package/src/tab-bar/glyph.native.tsx +5 -2
- package/src/tab-bar/glyph.tsx +8 -4
- package/src/tab-bar/index.ts +1 -0
- package/src/tab-bar/index.web.tsx +1 -0
- package/src/tab-bar/shared.ts +38 -0
- 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 +43 -4
- package/src/tab-bar/use-reserved-space.ts +44 -0
|
@@ -49,7 +49,9 @@ const NEUTRAL_AND_WEB_FILES = [
|
|
|
49
49
|
'tab-bar/shared.ts',
|
|
50
50
|
'tab-bar/TabBarBase.tsx',
|
|
51
51
|
'tab-bar/minimize-context.tsx',
|
|
52
|
+
'tab-bar/use-reserved-space.ts',
|
|
52
53
|
'tab-bar/surface.tsx',
|
|
54
|
+
'tab-bar/surface-solid.tsx',
|
|
53
55
|
'tab-bar/surface.web.tsx',
|
|
54
56
|
'tab-bar/glyph.tsx',
|
|
55
57
|
'progressive-blur/index.web.tsx',
|
|
@@ -128,6 +130,17 @@ describe('tab-bar platform split', () => {
|
|
|
128
130
|
expect(moduleSpecifiers(read('tab-bar/glyph.native.tsx'))).toContain('expo-symbols');
|
|
129
131
|
});
|
|
130
132
|
|
|
133
|
+
it('the native surface takes its fallback from surface-solid, never ./surface', () => {
|
|
134
|
+
// Metro resolves a bare `./surface` to `surface.native.tsx` from ANY caller,
|
|
135
|
+
// so importing the neutral sibling by that name would be a self-import — which
|
|
136
|
+
// is the entire reason `surface-solid.tsx` exists as a separate name.
|
|
137
|
+
const specifiers = moduleSpecifiers(read('tab-bar/surface.native.tsx'));
|
|
138
|
+
expect(specifiers).toContain('./surface-solid');
|
|
139
|
+
expect(specifiers).not.toContain('./surface');
|
|
140
|
+
// And the neutral variant must be that same component, not a second copy.
|
|
141
|
+
expect(moduleSpecifiers(read('tab-bar/surface.tsx'))).toContain('./surface-solid');
|
|
142
|
+
});
|
|
143
|
+
|
|
131
144
|
it('the router packages live only under tab-bar/expo-router', () => {
|
|
132
145
|
const router = moduleSpecifiers(read('tab-bar/expo-router/RouterTabBar.tsx'));
|
|
133
146
|
expect(router.some((s) => isFrom(s, 'expo-router'))).toBe(true);
|
|
@@ -160,6 +173,8 @@ describe('tab-bar platform split', () => {
|
|
|
160
173
|
describe('tab-bar published surface', () => {
|
|
161
174
|
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as {
|
|
162
175
|
exports: Record<string, { browser?: { import?: string; require?: string } }>;
|
|
176
|
+
peerDependencies: Record<string, string>;
|
|
177
|
+
peerDependenciesMeta: Record<string, { optional?: boolean }>;
|
|
163
178
|
};
|
|
164
179
|
|
|
165
180
|
it('exports ./tab-bar with a browser condition → index.web.js', () => {
|
|
@@ -184,6 +199,38 @@ describe('tab-bar published surface', () => {
|
|
|
184
199
|
expect(entry?.browser).toBeUndefined();
|
|
185
200
|
});
|
|
186
201
|
|
|
202
|
+
it('declares the two Apple-only native peers REQUIRED, keeping their ranges', () => {
|
|
203
|
+
// `expo-glass-effect` and `expo-symbols` are imported STATICALLY, by
|
|
204
|
+
// `tab-bar/surface.native` and `tab-bar/glyph.native`. That is what settles
|
|
205
|
+
// this: optional means "you may omit this", but omitting it does not degrade
|
|
206
|
+
// — the native bundle fails to RESOLVE the specifier and the build dies. An
|
|
207
|
+
// optional peer plus a static import is an incoherent pair, and the only
|
|
208
|
+
// thing marking them optional would achieve is silencing the one warning
|
|
209
|
+
// that tells a consumer their build is about to break. Declared the same way
|
|
210
|
+
// `expo-blur` already is, for the same reason.
|
|
211
|
+
//
|
|
212
|
+
// The RANGE carries the second half: both files import symbols that arrived
|
|
213
|
+
// after 1.0 — `isGlassEffectAPIAvailable` first shipped in
|
|
214
|
+
// expo-glass-effect 0.1.9 — so an older installed copy is a real
|
|
215
|
+
// incompatibility, not merely an absent one.
|
|
216
|
+
for (const peer of ['expo-glass-effect', 'expo-symbols']) {
|
|
217
|
+
expect(pkg.peerDependencies[peer]).toMatch(/^>=\d/);
|
|
218
|
+
expect(pkg.peerDependenciesMeta[peer]).toBeUndefined();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps the cross-platform native peers REQUIRED', () => {
|
|
223
|
+
// `expo-blur` and `expo-image` are apple+android native modules imported from
|
|
224
|
+
// platform-NEUTRAL files (`progressive-blur/index.tsx`,
|
|
225
|
+
// `frosted-icon-button/FrostedIconButton.tsx`, `zoomable-image-gallery/`), so
|
|
226
|
+
// every native platform genuinely needs them. Marking them optional would
|
|
227
|
+
// silence the one warning that tells a consumer their build is about to fail.
|
|
228
|
+
for (const peer of ['expo-blur', 'expo-image']) {
|
|
229
|
+
expect(pkg.peerDependencies[peer]).toBeDefined();
|
|
230
|
+
expect(pkg.peerDependenciesMeta[peer]).toBeUndefined();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
187
234
|
it('the generator lists exactly the right subpaths as web-forked', () => {
|
|
188
235
|
const gen = readFileSync(join(REPO_ROOT, 'scripts/generate-platform-exports.mjs'), 'utf8');
|
|
189
236
|
const block = /const WEB_FORKED_SUBPATHS = new Set\(\[([\s\S]*?)\]\);/.exec(gen)?.[1];
|
|
@@ -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,9 +106,19 @@ 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
|
+
*
|
|
113
|
+
* KNOWN LIMIT, deliberately not papered over: an icon component that paints from
|
|
114
|
+
* a `color` PROP rather than from `fill`/`style.color` ignores this entirely, and
|
|
115
|
+
* does so SILENTLY — nothing throws, the glyph renders, it simply never takes the
|
|
116
|
+
* tint. `color` is not injected alongside `fill` because the prop is not ours to
|
|
117
|
+
* claim: components use it for semantic variants (`color="danger"`), so writing an
|
|
118
|
+
* `rgb(…)` string into it can produce a worse failure than the one it fixes, and
|
|
119
|
+
* an icon already carrying its own `color` would be clobbered. Consumers with such
|
|
120
|
+
* a set pre-color the element themselves; the tab bar additionally offers
|
|
121
|
+
* `TabBarItem.activeIcon`, which carries selection by shape instead of by tint.
|
|
112
122
|
*/
|
|
113
123
|
export function applyIconColor(node: ReactNode, color: string): ReactNode {
|
|
114
124
|
if (!isValidElement(node)) return node;
|
|
@@ -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.
|
|
@@ -49,10 +49,12 @@ import {
|
|
|
49
49
|
ITEM_GAP,
|
|
50
50
|
ITEM_PAD_V,
|
|
51
51
|
LABEL_FONT_SIZE,
|
|
52
|
+
LONG_PRESS_MIN_DURATION,
|
|
52
53
|
MINIMIZED_HEIGHT,
|
|
53
54
|
MINIMIZED_INSET,
|
|
54
55
|
ROW_PAD_H,
|
|
55
56
|
SLIDE_SPRING,
|
|
57
|
+
tabBarBottomGap,
|
|
56
58
|
useTabBarTheme,
|
|
57
59
|
type TabBarGlyphProps,
|
|
58
60
|
type TabBarSurfaceProps,
|
|
@@ -93,6 +95,7 @@ function TabBarBody({
|
|
|
93
95
|
children,
|
|
94
96
|
activeIndex,
|
|
95
97
|
onIndexChange,
|
|
98
|
+
onIndexLongPress,
|
|
96
99
|
theme: themeOverrides,
|
|
97
100
|
haptics = true,
|
|
98
101
|
style,
|
|
@@ -121,6 +124,14 @@ function TabBarBody({
|
|
|
121
124
|
// scrubbing makes the content jump under the finger.
|
|
122
125
|
const selectIndex = useCallback((index: number) => onIndexChange?.(index), [onIndexChange]);
|
|
123
126
|
|
|
127
|
+
// Long press is reported through the same shape, and deliberately does NOT
|
|
128
|
+
// select: it is a secondary action on the tab under the finger.
|
|
129
|
+
const longPressIndex = useCallback(
|
|
130
|
+
(index: number) => onIndexLongPress?.(index),
|
|
131
|
+
[onIndexLongPress],
|
|
132
|
+
);
|
|
133
|
+
const hasLongPress = onIndexLongPress !== undefined;
|
|
134
|
+
|
|
124
135
|
// CONTROLLED path: the bar owns the highlight when `activeIndex` is provided,
|
|
125
136
|
// so it tracks the prop even when a consumer renders custom children instead
|
|
126
137
|
// of `TabBarButton`s. Syncing an imperative animation to a controlled prop is
|
|
@@ -200,11 +211,30 @@ function TabBarBody({
|
|
|
200
211
|
runOnJS(selectIndex)(index);
|
|
201
212
|
});
|
|
202
213
|
|
|
203
|
-
return Gesture.Race(pan, tap);
|
|
214
|
+
if (!hasLongPress) return Gesture.Race(pan, tap);
|
|
215
|
+
|
|
216
|
+
// Long press: the ONLY path to one, because the detector consumes the
|
|
217
|
+
// bar's touches and a button's inner `Pressable` therefore never sees a
|
|
218
|
+
// real finger (see `TabBarButtonBody`). Added to the race only when a
|
|
219
|
+
// handler was supplied — an always-armed long press would cancel the pan
|
|
220
|
+
// for anyone who rests a finger before scrubbing, and no existing consumer
|
|
221
|
+
// asked for that. It cannot cost a tap either: the tap gesture gives up at
|
|
222
|
+
// 400ms, before this one can activate at 500ms, so the two are never
|
|
223
|
+
// candidates at the same instant. Resolves its index through the same
|
|
224
|
+
// `indexAtX` worklet as tap and pan, so all three agree on the geometry.
|
|
225
|
+
const longPress = Gesture.LongPress()
|
|
226
|
+
.minDuration(LONG_PRESS_MIN_DURATION)
|
|
227
|
+
.onStart((event) => {
|
|
228
|
+
runOnJS(longPressIndex)(Math.round(indexAtX(event.x, progress.value)));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return Gesture.Race(pan, tap, longPress);
|
|
204
232
|
}, [
|
|
205
233
|
windowWidth,
|
|
206
234
|
tabCount,
|
|
207
235
|
selectIndex,
|
|
236
|
+
hasLongPress,
|
|
237
|
+
longPressIndex,
|
|
208
238
|
tick,
|
|
209
239
|
isDragging,
|
|
210
240
|
lastTicked,
|
|
@@ -287,7 +317,9 @@ function TabBarBody({
|
|
|
287
317
|
};
|
|
288
318
|
}, [progress, slideIndex, windowWidth, tabCount]);
|
|
289
319
|
|
|
290
|
-
|
|
320
|
+
// Shared with `useTabBarReservedSpace`, so a consumer reserving layout space
|
|
321
|
+
// for the bar can never drift from where the bar actually sits.
|
|
322
|
+
const bottomOffset = tabBarBottomGap(insets.bottom);
|
|
291
323
|
const barContext = useMemo(
|
|
292
324
|
() => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
|
|
293
325
|
[slideIndex, isDragging, theme, activeIndex, selectIndex],
|
|
@@ -430,11 +462,13 @@ function TabBarButtonBody({
|
|
|
430
462
|
}
|
|
431
463
|
>
|
|
432
464
|
<Animated.View style={[styles.itemBox, boxStyle]}>
|
|
433
|
-
{/* Inactive glyph underneath, active glyph crossfading on top.
|
|
465
|
+
{/* Inactive glyph underneath, active glyph crossfading on top. `active`
|
|
466
|
+
tells each layer which one it is, so an item with an `activeIcon`
|
|
467
|
+
crossfades between two different nodes and not just two tints. */}
|
|
434
468
|
<View>
|
|
435
|
-
<Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} />
|
|
469
|
+
<Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} active={false} />
|
|
436
470
|
<Animated.View style={[StyleSheet.absoluteFill, styles.glyphLayer, activeGlyphStyle]}>
|
|
437
|
-
<Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} />
|
|
471
|
+
<Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} active />
|
|
438
472
|
</Animated.View>
|
|
439
473
|
</View>
|
|
440
474
|
{/* Fades out and is clipped by the shrinking box — no layout anim. */}
|
|
@@ -17,7 +17,7 @@ import { Platform, View } from 'react-native';
|
|
|
17
17
|
import { applyIconColor } from '../frosted-icon-button/shared';
|
|
18
18
|
import type { TabBarGlyphProps } from './shared';
|
|
19
19
|
|
|
20
|
-
export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
|
|
20
|
+
export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
|
|
21
21
|
if (item.sfSymbol && Platform.OS === 'ios') {
|
|
22
22
|
// `TabBarItem.sfSymbol` is a plain `string` because `types.ts` may import
|
|
23
23
|
// from react/react-native only — pulling `expo-symbols`' `SFSymbol` union
|
|
@@ -36,9 +36,12 @@ export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Same node-swapping crossfade as the neutral variant: the active layer takes
|
|
40
|
+
// `activeIcon` when the item has one. An SF Symbol never reaches here, and is
|
|
41
|
+
// tinted natively above, so the tint crossfade remains its correct expression.
|
|
39
42
|
return (
|
|
40
43
|
<View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
|
|
41
|
-
{applyIconColor(item.icon, tint)}
|
|
44
|
+
{applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
|
|
42
45
|
</View>
|
|
43
46
|
);
|
|
44
47
|
}
|
package/src/tab-bar/glyph.tsx
CHANGED
|
@@ -2,8 +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
|
-
* NEUTRAL default variant of a tab glyph: renders the item's own `icon` node
|
|
6
|
-
* tinted for this crossfade layer.
|
|
5
|
+
* NEUTRAL default variant of a tab glyph: renders the item's own `icon` node —
|
|
6
|
+
* or its `activeIcon` on the active layer — tinted for this crossfade layer.
|
|
7
7
|
*
|
|
8
8
|
* `item.icon` is the primary icon API for the whole family — a Bloom icon
|
|
9
9
|
* element, exactly like `TabsTrigger` and `FrostedIconButton` take. `sfSymbol`
|
|
@@ -17,13 +17,17 @@ import { View } from 'react-native';
|
|
|
17
17
|
import { applyIconColor } from '../frosted-icon-button/shared';
|
|
18
18
|
import type { TabBarGlyphProps } from './shared';
|
|
19
19
|
|
|
20
|
-
export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
|
|
20
|
+
export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
|
|
21
21
|
// The tint is injected as the icon's `fill` FALLBACK — an icon that sets its
|
|
22
22
|
// own `fill` (a brand mark, say) keeps it, so the crossfade layers simply
|
|
23
23
|
// render it twice at full opacity and it never flickers.
|
|
24
|
+
//
|
|
25
|
+
// `activeIcon` is what makes the crossfade able to swap NODES: an icon set
|
|
26
|
+
// whose selected state is a different SHAPE (outline → filled) cannot be
|
|
27
|
+
// expressed by tinting one node two ways. Absent, the layer renders `icon`.
|
|
24
28
|
return (
|
|
25
29
|
<View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
|
|
26
|
-
{applyIconColor(item.icon, tint)}
|
|
30
|
+
{applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
|
|
27
31
|
</View>
|
|
28
32
|
);
|
|
29
33
|
}
|
package/src/tab-bar/index.ts
CHANGED
|
@@ -29,4 +29,5 @@ export {
|
|
|
29
29
|
useTabBarMinimized,
|
|
30
30
|
} from './minimize-context';
|
|
31
31
|
export type { MinimizeState } from './minimize-context';
|
|
32
|
+
export { useTabBarReservedSpace } from './use-reserved-space';
|
|
32
33
|
export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
|
|
@@ -31,4 +31,5 @@ export {
|
|
|
31
31
|
useTabBarMinimized,
|
|
32
32
|
} from './minimize-context';
|
|
33
33
|
export type { MinimizeState } from './minimize-context';
|
|
34
|
+
export { useTabBarReservedSpace } from './use-reserved-space';
|
|
34
35
|
export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
|
package/src/tab-bar/shared.ts
CHANGED
|
@@ -51,6 +51,27 @@ export const BLUR_BLEED = 44;
|
|
|
51
51
|
/** Label type size. Small by design — the icon carries the meaning. */
|
|
52
52
|
export const LABEL_FONT_SIZE = 9.5;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* How much of the bottom safe-area inset the bar's own gap absorbs. The pill
|
|
56
|
+
* already floats clear of the content, so sitting the FULL inset below it
|
|
57
|
+
* strands it in the middle of the screen on a home-indicator device.
|
|
58
|
+
*/
|
|
59
|
+
const SAFE_AREA_ABSORBED = 16;
|
|
60
|
+
/** Gap floor, for a device (or a browser) reporting no bottom inset at all. */
|
|
61
|
+
const MIN_BOTTOM_GAP = 12;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Vertical gap between the bottom of the pill and the bottom of the window.
|
|
65
|
+
*
|
|
66
|
+
* The safe-area inset is folded in HERE, which is why anything reserving layout
|
|
67
|
+
* space for the bar must go through `useTabBarReservedSpace` (which derives its
|
|
68
|
+
* number from this same function) rather than adding `insets.bottom` to a
|
|
69
|
+
* hardcoded height — that double-counts the inset.
|
|
70
|
+
*/
|
|
71
|
+
export function tabBarBottomGap(bottomInset: number): number {
|
|
72
|
+
return Math.max(bottomInset - SAFE_AREA_ABSORBED, MIN_BOTTOM_GAP);
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
/**
|
|
55
76
|
* Slide spring: interruptible by design — rapid tab-hopping retargets with
|
|
56
77
|
* preserved velocity. Slight under-damping gives the pill a tiny settle, safe
|
|
@@ -58,6 +79,15 @@ export const LABEL_FONT_SIZE = 9.5;
|
|
|
58
79
|
*/
|
|
59
80
|
export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
|
|
60
81
|
|
|
82
|
+
/**
|
|
83
|
+
* How long a press must be held before `onIndexLongPress` fires.
|
|
84
|
+
*
|
|
85
|
+
* Comfortably above the tap gesture's own 400ms ceiling: the two can never both
|
|
86
|
+
* be candidates at the same instant, so arming the long press cannot cost the
|
|
87
|
+
* bar a tap. Matches the platform long-press feel (iOS ~0.5s).
|
|
88
|
+
*/
|
|
89
|
+
export const LONG_PRESS_MIN_DURATION = 500;
|
|
90
|
+
|
|
61
91
|
// ---------------------------------------------------------------------------
|
|
62
92
|
// Theme
|
|
63
93
|
// ---------------------------------------------------------------------------
|
|
@@ -156,4 +186,12 @@ export interface TabBarGlyphProps {
|
|
|
156
186
|
tint: string;
|
|
157
187
|
/** Rendered icon size in px ({@link ICON_SIZE}). */
|
|
158
188
|
size: number;
|
|
189
|
+
/**
|
|
190
|
+
* Which crossfade layer this is: `false` for the inactive one underneath,
|
|
191
|
+
* `true` for the one fading in on top. The active layer renders
|
|
192
|
+
* `item.activeIcon` when the item has one, so a set that expresses selection
|
|
193
|
+
* by shape (outline → filled) crossfades between two different nodes rather
|
|
194
|
+
* than between two tints of the same node.
|
|
195
|
+
*/
|
|
196
|
+
active: boolean;
|
|
159
197
|
}
|
|
@@ -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
|
+
});
|