@oxyhq/bloom 0.70.0 → 0.72.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.
Files changed (68) hide show
  1. package/lib/commonjs/fonts/apply-font-faces.js +5 -4
  2. package/lib/commonjs/fonts/apply-font-faces.js.map +1 -1
  3. package/lib/commonjs/fonts/apply-font-faces.web.js +14 -16
  4. package/lib/commonjs/fonts/apply-font-faces.web.js.map +1 -1
  5. package/lib/commonjs/fonts/assets/BlomusModernus-Bold.woff2 +0 -0
  6. package/lib/commonjs/fonts/assets/BlomusModernus-Regular.woff2 +0 -0
  7. package/lib/commonjs/fonts/assets/GeistMono-Variable.woff2 +0 -0
  8. package/lib/commonjs/fonts/assets/InterVariable.woff2 +0 -0
  9. package/lib/commonjs/fonts/font-assets.web.js +3 -2
  10. package/lib/commonjs/fonts/font-assets.web.js.map +1 -1
  11. package/lib/commonjs/fonts/font-urls.web.js +35 -0
  12. package/lib/commonjs/fonts/font-urls.web.js.map +1 -0
  13. package/lib/commonjs/select/index.web.js +77 -4
  14. package/lib/commonjs/select/index.web.js.map +1 -1
  15. package/lib/module/fonts/apply-font-faces.js +5 -4
  16. package/lib/module/fonts/apply-font-faces.js.map +1 -1
  17. package/lib/module/fonts/apply-font-faces.web.js +14 -16
  18. package/lib/module/fonts/apply-font-faces.web.js.map +1 -1
  19. package/lib/module/fonts/assets/BlomusModernus-Bold.woff2 +0 -0
  20. package/lib/module/fonts/assets/BlomusModernus-Regular.woff2 +0 -0
  21. package/lib/module/fonts/assets/GeistMono-Variable.woff2 +0 -0
  22. package/lib/module/fonts/assets/InterVariable.woff2 +0 -0
  23. package/lib/module/fonts/font-assets.web.js +3 -2
  24. package/lib/module/fonts/font-assets.web.js.map +1 -1
  25. package/lib/module/fonts/font-urls.web.js +32 -0
  26. package/lib/module/fonts/font-urls.web.js.map +1 -0
  27. package/lib/module/fonts/index.web.js +3 -3
  28. package/lib/module/select/index.web.js +78 -5
  29. package/lib/module/select/index.web.js.map +1 -1
  30. package/lib/typescript/commonjs/fonts/apply-font-faces.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts +9 -11
  32. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/fonts/font-assets.web.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/fonts/font-urls.web.d.ts +6 -0
  35. package/lib/typescript/commonjs/fonts/font-urls.web.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/select/index.web.d.ts +1 -1
  37. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/select/types.d.ts +6 -0
  39. package/lib/typescript/commonjs/select/types.d.ts.map +1 -1
  40. package/lib/typescript/module/fonts/apply-font-faces.d.ts.map +1 -1
  41. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts +9 -11
  42. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts.map +1 -1
  43. package/lib/typescript/module/fonts/font-assets.web.d.ts.map +1 -1
  44. package/lib/typescript/module/fonts/font-urls.web.d.ts +6 -0
  45. package/lib/typescript/module/fonts/font-urls.web.d.ts.map +1 -0
  46. package/lib/typescript/module/select/index.web.d.ts +1 -1
  47. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  48. package/lib/typescript/module/select/types.d.ts +6 -0
  49. package/lib/typescript/module/select/types.d.ts.map +1 -1
  50. package/package.json +4 -7
  51. package/src/__tests__/SelectItemLabel.test.tsx +46 -0
  52. package/src/__tests__/apply-font-faces.test.ts +32 -0
  53. package/src/fonts/apply-font-faces.ts +5 -4
  54. package/src/fonts/apply-font-faces.web.ts +14 -16
  55. package/src/fonts/font-assets.web.ts +3 -2
  56. package/src/fonts/font-urls.web.ts +30 -0
  57. package/src/fonts/index.web.ts +3 -3
  58. package/src/select/index.web.tsx +87 -4
  59. package/src/select/types.ts +6 -0
  60. package/lib/commonjs/fonts/font-data.web.js +0 -22
  61. package/lib/commonjs/fonts/font-data.web.js.map +0 -1
  62. package/lib/module/fonts/font-data.web.js +0 -18
  63. package/lib/module/fonts/font-data.web.js.map +0 -1
  64. package/lib/typescript/commonjs/fonts/font-data.web.d.ts +0 -5
  65. package/lib/typescript/commonjs/fonts/font-data.web.d.ts.map +0 -1
  66. package/lib/typescript/module/fonts/font-data.web.d.ts +0 -5
  67. package/lib/typescript/module/fonts/font-data.web.d.ts.map +0 -1
  68. package/src/fonts/font-data.web.ts +0 -15
@@ -3,7 +3,7 @@ import {
3
3
  blomusModernusBold,
4
4
  interVariable,
5
5
  geistMonoVariable,
6
- } from './font-data.web';
6
+ } from './font-urls.web';
7
7
  import { fontFamilies } from './tokens';
8
8
 
9
9
  const STYLE_ID = 'bloom-fonts';
@@ -12,18 +12,16 @@ const STYLE_ID = 'bloom-fonts';
12
12
  * Inject @font-face rules and font CSS variables onto :root.
13
13
  *
14
14
  * Web-only. The native counterpart in `apply-font-faces.ts` is a no-op
15
- * stub — Metro cannot parse `.woff2` imports, so the file split keeps
16
- * the font payload out of the native bundle entirely. Idempotent: safe to
17
- * call multiple times; subsequent calls early-return after the
18
- * `<style id="bloom-fonts">` tag has been mounted. SSR-safe via the
19
- * `typeof document === 'undefined'` guard.
15
+ * stub — native loads its fonts through `useFonts(FONT_ASSETS)`, so the file
16
+ * split keeps the web font payload out of the native bundle entirely.
17
+ * Idempotent: safe to call multiple times; subsequent calls early-return
18
+ * after the `<style id="bloom-fonts">` tag has been mounted. SSR-safe via
19
+ * the `typeof document === 'undefined'` guard.
20
20
  *
21
- * The font URLs come from `./font-data.web`, a generated module that
22
- * embeds the woff2 bytes as base64 data URLs. Inlining (rather than
23
- * relying on bundler asset loaders for `.woff2`) means consumers do not
24
- * have to extend Metro's `assetExts` or add a webpack/Vite asset rule —
25
- * the published JS is self-contained. See `scripts/generate-font-data.mjs`
26
- * for the rationale and trade-offs.
21
+ * The URLs come from `./font-urls.web`, which imports the `.woff2` files so
22
+ * the consuming bundler emits them as separate, cacheable assets. See that
23
+ * module for the per-bundler details and for the one thing a consumer has to
24
+ * provide (Metro needs `woff2` in `assetExts`).
27
25
  */
28
26
  export function applyFontFaces(): void {
29
27
  if (typeof document === 'undefined') return;
@@ -32,10 +30,10 @@ export function applyFontFaces(): void {
32
30
  const style = document.createElement('style');
33
31
  style.id = STYLE_ID;
34
32
  style.textContent = `
35
- @font-face { font-family: 'BlomusModernus'; src: url(${blomusModernusRegular}) format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
36
- @font-face { font-family: 'BlomusModernus'; src: url(${blomusModernusBold}) format('woff2'); font-weight: 700; font-style: normal; font-display: swap; }
37
- @font-face { font-family: 'Inter'; src: url(${interVariable}) format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
38
- @font-face { font-family: 'Geist Mono'; src: url(${geistMonoVariable}) format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
33
+ @font-face { font-family: 'BlomusModernus'; src: url("${blomusModernusRegular}") format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
34
+ @font-face { font-family: 'BlomusModernus'; src: url("${blomusModernusBold}") format('woff2'); font-weight: 700; font-style: normal; font-display: swap; }
35
+ @font-face { font-family: 'Inter'; src: url("${interVariable}") format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
36
+ @font-face { font-family: 'Geist Mono'; src: url("${geistMonoVariable}") format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
39
37
  :root {
40
38
  --bloom-font-display: ${fontFamilies.display};
41
39
  --bloom-font-sans: ${fontFamilies.sans};
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // `useFonts()` is native-only — the web `FontLoader` (`FontLoader.tsx`) calls
4
4
  // `applyFontFaces()` instead, which injects the same four families as
5
- // `@font-face` rules from base64 data URLs (`apply-font-faces.web.ts` /
6
- // `font-data.web.ts`). So the map is empty by design, not by omission.
5
+ // `@font-face` rules pointing at bundler-emitted `.woff2` URLs
6
+ // (`apply-font-faces.web.ts` / `font-urls.web.ts`). So the map is empty by
7
+ // design, not by omission.
7
8
  //
8
9
  // This file exists so `index.web.ts` can name its font-asset source
9
10
  // explicitly, exactly as it already does for `./apply-font-faces.web`. The
@@ -0,0 +1,30 @@
1
+ // Web font URLs, resolved by whatever bundler is building the consuming app.
2
+ //
3
+ // WEB-ONLY. Each import below hands the `.woff2` to the app's asset pipeline,
4
+ // which emits it as a standalone file and gives us back its URL:
5
+ //
6
+ // - Metro (Expo web) rewrites the import to the exported asset path — its
7
+ // asset transformer emits a bare string module whenever the platform is
8
+ // `web`, so the imported value is the URL itself, not an asset id.
9
+ // - Vite, Rollup and webpack 5 resolve it through their own asset handling
10
+ // and return a content-hashed URL.
11
+ //
12
+ // Either way the browser fetches the four families alongside the JS and caches
13
+ // them under their own URLs, instead of re-parsing ~310 KB of base64 out of the
14
+ // entry bundle on every cold load. No consumer ever writes an asset filename
15
+ // down, so a font swap here cannot leave a downstream app pointing at a file
16
+ // that no longer exists.
17
+ //
18
+ // Metro does not carry `woff2` in its default `assetExts`, so a consumer
19
+ // bundling for web with Metro has to register it; `@oxyhq/app-preset`'s Metro
20
+ // base already does that for every Oxy app.
21
+ //
22
+ // Native never reaches this module. The default `apply-font-faces.ts` is a
23
+ // no-op stub and `FontLoader.native.tsx` loads the `.ttf` variants through
24
+ // `useFonts` instead, so no web font bytes enter a native bundle.
25
+ import blomusModernusBold from './assets/BlomusModernus-Bold.woff2';
26
+ import blomusModernusRegular from './assets/BlomusModernus-Regular.woff2';
27
+ import geistMonoVariable from './assets/GeistMono-Variable.woff2';
28
+ import interVariable from './assets/InterVariable.woff2';
29
+
30
+ export { blomusModernusRegular, blomusModernusBold, interVariable, geistMonoVariable };
@@ -6,9 +6,9 @@
6
6
  // of the box), so an extensionless specifier here would quietly fall through
7
7
  // to the default file.
8
8
  //
9
- // - `./apply-font-faces.web` performs the real `@font-face` injection using
10
- // inlined base64 data URLs (see `font-data.web.ts`); the default
11
- // `./apply-font-faces` is a no-op stub for native.
9
+ // - `./apply-font-faces.web` performs the real `@font-face` injection against
10
+ // the `.woff2` URLs the consuming bundler emits (see `font-urls.web.ts`);
11
+ // the default `./apply-font-faces` is a no-op stub for native.
12
12
  // - `./font-assets.web` is an empty map. The native map is built from Metro
13
13
  // asset `require()` calls, and importing it from an ES module throws
14
14
  // `ReferenceError: require is not defined` during module evaluation — which
@@ -1,5 +1,14 @@
1
- import React, { createContext, useContext, useId, useMemo, useState } from 'react';
2
- import { Pressable, StyleSheet, View } from 'react-native';
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native';
3
12
 
4
13
  import { useTheme } from '../theme/use-theme';
5
14
  import { Text } from '../typography';
@@ -7,6 +16,7 @@ import { Backdrop } from '../overlay';
7
16
  import { Portal } from '../portal/index.web';
8
17
  import { createOverlayZIndex } from '../styles/z-index';
9
18
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
19
+ import { resolveDropdownPlacement } from '../overlay/dropdown-placement';
10
20
  import { bloomShadowStyle } from '../design-tokens/shadows';
11
21
  import { RadioIndicator } from '../radio-indicator';
12
22
  import { useInteractionState } from '../hooks/useInteractionState';
@@ -30,6 +40,8 @@ import type {
30
40
  export { useSelectItemContext };
31
41
 
32
42
  const selectZIndex = createOverlayZIndex();
43
+ const VIEWPORT_GUTTER = 8;
44
+ const SELECT_OFFSET = 6;
33
45
 
34
46
  // ---------------------------------------------------------------------------
35
47
  // Context
@@ -39,6 +51,8 @@ type SelectContextValue = Pick<SelectProps, 'value' | 'onValueChange' | 'disable
39
51
  isOpen: boolean;
40
52
  open: () => void;
41
53
  close: () => void;
54
+ /** The trigger element, so `SelectContent` can position itself against it. */
55
+ triggerRef: React.RefObject<unknown>;
42
56
  };
43
57
 
44
58
  const SelectContext = createContext<SelectContextValue | null>(null);
@@ -58,6 +72,7 @@ function useSelectContext(): SelectContextValue {
58
72
 
59
73
  export function Select({ children, value, onValueChange, disabled }: SelectProps) {
60
74
  const [isOpen, setIsOpen] = useState(false);
75
+ const triggerRef = useRef<unknown>(null);
61
76
 
62
77
  const ctx = useMemo<SelectContextValue>(
63
78
  () => ({
@@ -67,6 +82,7 @@ export function Select({ children, value, onValueChange, disabled }: SelectProps
67
82
  isOpen,
68
83
  open: () => setIsOpen(true),
69
84
  close: () => setIsOpen(false),
85
+ triggerRef,
70
86
  }),
71
87
  [value, onValueChange, disabled, isOpen],
72
88
  );
@@ -103,6 +119,7 @@ export function SelectTrigger({ children, label }: SelectTriggerProps) {
103
119
  pressed: false,
104
120
  },
105
121
  props: {
122
+ ref: ctx.triggerRef,
106
123
  onPress: ctx.open,
107
124
  onFocus,
108
125
  onBlur,
@@ -113,6 +130,7 @@ export function SelectTrigger({ children, label }: SelectTriggerProps) {
113
130
 
114
131
  return (
115
132
  <Pressable
133
+ ref={ctx.triggerRef as React.Ref<View>}
116
134
  onPress={ctx.open}
117
135
  onFocus={onFocus}
118
136
  onBlur={onBlur}
@@ -184,6 +202,58 @@ export function SelectContent<T>({
184
202
  }: SelectContentProps<T>) {
185
203
  const ctx = useSelectContext();
186
204
  const theme = useTheme();
205
+ const [position, setPosition] = useState<ViewStyle | null>(null);
206
+ // The mounted dropdown node, as STATE rather than a bare ref: positioning has
207
+ // to measure it, and `Portal` renders null on its first pass (it resolves its
208
+ // host in its own layout effect), so the node lands one render after `isOpen`
209
+ // flips. An effect keyed only on `isOpen` would measure nothing.
210
+ const [dropdownNode, setDropdownNode] = useState<HTMLElement | null>(null);
211
+
212
+ const attachDropdown = useCallback((node: View | null) => {
213
+ setDropdownNode(node as unknown as HTMLElement | null);
214
+ }, []);
215
+
216
+ // Anchored to the trigger, left edges aligned, and never off-screen: sit below
217
+ // when it fits, flip above when it doesn't, clamp when neither. Before this the
218
+ // dropdown had no `top`/`left` at all, so it rendered at the `Portal` root's
219
+ // origin — the viewport's top-left corner — however far from its trigger.
220
+ useLayoutEffect(() => {
221
+ if (!ctx.isOpen || typeof window === 'undefined') return;
222
+
223
+ const triggerNode = ctx.triggerRef.current as HTMLElement | null;
224
+ if (!triggerNode?.getBoundingClientRect || !dropdownNode) return;
225
+
226
+ const updatePosition = () => {
227
+ const rect = triggerNode.getBoundingClientRect();
228
+ // Measured BEFORE `minWidth` is applied, so the final surface can only be
229
+ // wider than this — and a wider surface wraps less, so the measured height
230
+ // is an upper bound. Erring that way flips early in a tie, never late.
231
+ const surface = dropdownNode.getBoundingClientRect();
232
+ const width = Math.max(surface.width, rect.width);
233
+
234
+ setPosition({
235
+ position: WEB_POSITION_FIXED,
236
+ ...resolveDropdownPlacement({
237
+ anchor: rect,
238
+ size: { width, height: surface.height },
239
+ viewport: { width: window.innerWidth, height: window.innerHeight },
240
+ offset: SELECT_OFFSET,
241
+ gutter: VIEWPORT_GUTTER,
242
+ align: 'start',
243
+ }),
244
+ minWidth: width,
245
+ });
246
+ };
247
+
248
+ updatePosition();
249
+ window.addEventListener('resize', updatePosition);
250
+ window.addEventListener('scroll', updatePosition, true);
251
+
252
+ return () => {
253
+ window.removeEventListener('resize', updatePosition);
254
+ window.removeEventListener('scroll', updatePosition, true);
255
+ };
256
+ }, [ctx.isOpen, ctx.triggerRef, dropdownNode]);
187
257
 
188
258
  if (!ctx.isOpen) return null;
189
259
 
@@ -195,6 +265,7 @@ export function SelectContent<T>({
195
265
  accessibilityLabel="Close selection"
196
266
  />
197
267
  <View
268
+ ref={attachDropdown}
198
269
  accessibilityRole="list"
199
270
  accessibilityLabel={label}
200
271
  style={[
@@ -206,6 +277,7 @@ export function SelectContent<T>({
206
277
  borderColor: theme.colors.borderLight,
207
278
  ...bloomShadowStyle('m'),
208
279
  },
280
+ position,
209
281
  ]}
210
282
  >
211
283
  {items.map((item, index) => (
@@ -222,7 +294,7 @@ export function SelectContent<T>({
222
294
  // SelectItem
223
295
  // ---------------------------------------------------------------------------
224
296
 
225
- export function SelectItem({ ref, value, children, style }: SelectItemProps) {
297
+ export function SelectItem({ ref, value, label, children, style }: SelectItemProps) {
226
298
  const theme = useTheme();
227
299
  const ctx = useSelectContext();
228
300
  const {
@@ -243,6 +315,10 @@ export function SelectItem({ ref, value, children, style }: SelectItemProps) {
243
315
  <Pressable
244
316
  ref={ref}
245
317
  accessibilityRole="radio"
318
+ // The native fork has always applied this; web declared `label` and then
319
+ // dropped it, leaving every option a `role="radio"` with no accessible
320
+ // name for a screen reader to announce.
321
+ accessibilityLabel={label}
246
322
  accessibilityState={{ checked: isSelected }}
247
323
  onPress={() => {
248
324
  ctx.onValueChange?.(value);
@@ -344,7 +420,14 @@ const styles = StyleSheet.create({
344
420
  pointerEvents: 'auto',
345
421
  },
346
422
  dropdown: {
347
- position: 'absolute',
423
+ // Fixed from the outset, not only once positioned: the `Portal` root is a
424
+ // block container, so a static child would stretch to the full viewport
425
+ // width and the pre-position measurement would under-read the height (less
426
+ // wrapping at a width the surface never actually has). A fixed box
427
+ // shrink-wraps to the same width it ends up with.
428
+ position: WEB_POSITION_FIXED,
429
+ top: 0,
430
+ left: 0,
348
431
  zIndex: selectZIndex.surface,
349
432
  borderRadius: 8,
350
433
  borderWidth: 1,
@@ -23,6 +23,12 @@ export type TriggerChildProps = {
23
23
  pressed: boolean;
24
24
  };
25
25
  props: {
26
+ /**
27
+ * Attach this to the element the trigger actually renders. On web the
28
+ * dropdown is positioned against its measured rect; a trigger that drops
29
+ * it falls back to the viewport's top-left corner.
30
+ */
31
+ ref?: React.Ref<unknown>;
26
32
  onPress: () => void;
27
33
  onFocus: () => void;
28
34
  onBlur: () => void;