@thewhileloop/whileui 4.2.0 → 4.2.3

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 CHANGED
@@ -1490,7 +1490,8 @@ itself to the collision-available viewport, and wraps long option labels inside
1490
1490
  | blurTintToken | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | `'popover'` |
1491
1491
 
1492
1492
  `SelectContent` keeps its listbox inside the safe area by default; pass `insets` only to
1493
- change that margin. Inside a Dialog, Sheet or other WhileUI overlay it renders in that
1493
+ change that margin. A list taller than the screen scrolls inside the listbox on every
1494
+ platform. Inside a Dialog, Sheet or other WhileUI overlay it renders in that
1494
1495
  overlay's layer automatically. Option values must be non-empty strings: the web
1495
1496
  primitive rejects `""`, so model "none" as an explicit value such as `'none'`.
1496
1497
 
@@ -79,7 +79,7 @@ export function Sheet({ open, onOpenChange, children, maxHeight = 'full', maxWid
79
79
  widthStyle,
80
80
  frostedSurface.surfaceStyle,
81
81
  ];
82
- return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: onClose, children: _jsxs(View, { className: "flex-1 justify-end", children: [_jsx(Animated.View, { style: [
82
+ return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: open ? onClose : undefined, children: _jsxs(View, { className: "flex-1 justify-end", children: [_jsx(Animated.View, { style: [
83
83
  StyleSheet.absoluteFill,
84
84
  { backgroundColor: frosted ? 'transparent' : colors.overlay },
85
85
  backdropStyle,
@@ -125,5 +125,5 @@ export function DrawerMenu({ visible, onClose, sections, activeKey, onSelect, he
125
125
  frostedSurface.surfaceStyle,
126
126
  style,
127
127
  ], className: drawerPanelClassName(frosted, className), children: [frostedSurface.overlay, _jsxs(View, { className: "flex-1 pb-8", style: { paddingTop: contentTopPadding }, children: [header && _jsx(View, { className: "px-5 pb-4 mb-2 border-b border-border", children: header }), _jsx(DrawerSectionList, { sections: sections, activeKey: activeKey, onSelect: handleItemPress, interaction: interaction, visual: visual }), footer && _jsx(View, { className: "px-5 py-4 mt-auto border-t border-border", children: footer })] })] })] }));
128
- return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: onClose, children: drawerTree }));
128
+ return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: visible ? onClose : undefined, children: drawerTree }));
129
129
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
- import { Platform, ScrollView, StyleSheet, View, } from 'react-native';
3
+ import { Platform, ScrollView, StyleSheet, useWindowDimensions, View, } from 'react-native';
4
4
  import * as SelectPrimitive from '@rn-primitives/select';
5
5
  import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens';
6
6
  import { Text } from '../text';
@@ -68,6 +68,11 @@ const SelectContent = React.forwardRef(({ className, children, portalHost, posit
68
68
  // without every caller reading the safe area itself.
69
69
  const defaultInsets = useOverlayInsets();
70
70
  const resolvedInsets = { ...defaultInsets, ...insets };
71
+ // Web caps the listbox with the primitive's available-height variable. Native has
72
+ // no equivalent: a list longer than the screen ran under the status bar with
73
+ // nothing to scroll, so the options get a scroll owner bounded by the insets.
74
+ const { height: windowHeight } = useWindowDimensions();
75
+ const nativeMaxHeight = windowHeight - resolvedInsets.top - resolvedInsets.bottom;
71
76
  // Radix applies its collision variables to the listbox wrapper, while
72
77
  // @rn-primitives renders our visual surface one level inside it. Keep the sizing
73
78
  // on that surface as inline web CSS: the Viewport's own `width: 100%` otherwise
@@ -94,7 +99,7 @@ const SelectContent = React.forwardRef(({ className, children, portalHost, posit
94
99
  }, className: cn('border-border relative z-50 overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', Platform.select({
95
100
  web: cn('overflow-y-auto overflow-x-hidden', props.side === 'bottom' && 'translate-y-1', props.side === 'top' && '-translate-y-1'),
96
101
  native: 'p-1',
97
- }), className), ...props, children: [frostedSurface.overlay, _jsx(SelectPrimitive.Viewport, { className: "p-1", children: children })] }) }) }) }));
102
+ }), className), ...props, children: [frostedSurface.overlay, _jsx(SelectPrimitive.Viewport, { className: "p-1", children: Platform.OS === 'web' ? (children) : (_jsx(ScrollView, { style: { maxHeight: nativeMaxHeight }, bounces: false, keyboardShouldPersistTaps: "handled", children: children })) })] }) }) }) }));
98
103
  });
99
104
  SelectContent.displayName = 'SelectContent';
100
105
  const SelectItem = React.forwardRef(({ className, children, style: styleProp, ...props }, ref) => {
@@ -1,8 +1,7 @@
1
1
  import { type ThemeColors } from './theme-contract';
2
2
  export type { ThemeColors } from './theme-contract';
3
3
  export declare function useThemeColors(): ThemeColors;
4
- /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
5
- export declare function useIconColors(): {
4
+ export interface IconColors {
6
5
  foreground: string;
7
6
  muted: string;
8
7
  primary: string;
@@ -12,6 +11,8 @@ export declare function useIconColors(): {
12
11
  success: string;
13
12
  info: string;
14
13
  destructive: string;
15
- };
14
+ }
15
+ /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
16
+ export declare function useIconColors(): IconColors;
16
17
  /** Alias for useThemeColors. Returns RN-safe color strings (hex/rgb/rgba) for RefreshControl, LinearGradient, charts. */
17
18
  export declare const useThemeTokens: typeof useThemeColors;
@@ -41,10 +41,29 @@ function resolveThemeToken(cssValue, appValue) {
41
41
  }
42
42
  return parseThemeColor(cssValue) ?? parseThemeColor(appValue);
43
43
  }
44
+ // Every themed component calls this hook on every render, and parsing 104 colour
45
+ // strings plus deriving the palette cost about 0.16 ms per call in Node, several times
46
+ // that under Hermes. The inputs only change with the theme, so the result is derived
47
+ // once per distinct snapshot and shared: the same object comes back until the CSS
48
+ // variables change, which also keeps it stable for downstream memoisation.
49
+ const resolvedThemeColors = new Map();
50
+ const RESOLVED_THEME_LIMIT = 8;
44
51
  export function useThemeColors() {
45
52
  const { theme } = useUniwind();
46
53
  const values = useCSSVariable(THEME_TOKEN_VARIABLES);
47
54
  const scheme = theme === 'dark' ? 'dark' : 'light';
55
+ const key = `${scheme}\u0001${values.join('\u0001')}`;
56
+ const cached = resolvedThemeColors.get(key);
57
+ if (cached)
58
+ return cached;
59
+ const resolved = resolveThemeColors(values, scheme);
60
+ if (resolvedThemeColors.size >= RESOLVED_THEME_LIMIT) {
61
+ resolvedThemeColors.delete(resolvedThemeColors.keys().next().value);
62
+ }
63
+ resolvedThemeColors.set(key, resolved);
64
+ return resolved;
65
+ }
66
+ function resolveThemeColors(values, scheme) {
48
67
  const fallbackPalette = scheme === 'dark' ? WHILEUI_DARK_THEME_COLORS : WHILEUI_LIGHT_THEME_COLORS;
49
68
  const defined = {};
50
69
  let index = 0;
@@ -61,10 +80,14 @@ export function useThemeColors() {
61
80
  const derived = deriveThemeColors(base, scheme);
62
81
  return { ...derived, ...defined, placeholder: derived.placeholder };
63
82
  }
83
+ const iconColorsByTheme = new WeakMap();
64
84
  /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
65
85
  export function useIconColors() {
66
86
  const c = useThemeColors();
67
- return {
87
+ const cached = iconColorsByTheme.get(c);
88
+ if (cached)
89
+ return cached;
90
+ const icons = {
68
91
  foreground: c.foreground,
69
92
  muted: c.mutedForeground,
70
93
  primary: c.primary,
@@ -75,6 +98,8 @@ export function useIconColors() {
75
98
  info: c.info,
76
99
  destructive: c.destructive,
77
100
  };
101
+ iconColorsByTheme.set(c, icons);
102
+ return icons;
78
103
  }
79
104
  /** Alias for useThemeColors. Returns RN-safe color strings (hex/rgb/rgba) for RefreshControl, LinearGradient, charts. */
80
105
  export const useThemeTokens = useThemeColors;
@@ -28,11 +28,24 @@ function resolveVisualToken(spec, value) {
28
28
  }
29
29
  return parseRatio(value, spec.fallback, spec.min ?? 0, spec.max ?? 1);
30
30
  }
31
+ // Resolved once per distinct CSS snapshot and shared, for the same reason as
32
+ // `useThemeColors`: 83 parses per component per render add up on a list screen, and a
33
+ // stable object lets callers memoise on it.
34
+ const resolvedVisualTokens = new Map();
35
+ const RESOLVED_VISUAL_LIMIT = 8;
31
36
  export function useVisualTokens() {
32
37
  const values = useCSSVariable(VISUAL_TOKEN_VARIABLES);
38
+ const key = values.join('\u0001');
39
+ const cached = resolvedVisualTokens.get(key);
40
+ if (cached)
41
+ return cached;
33
42
  const resolved = {};
34
43
  VISUAL_TOKEN_SPECS.forEach((spec, index) => {
35
44
  resolved[spec.key] = resolveVisualToken(spec, values[index]);
36
45
  });
46
+ if (resolvedVisualTokens.size >= RESOLVED_VISUAL_LIMIT) {
47
+ resolvedVisualTokens.delete(resolvedVisualTokens.keys().next().value);
48
+ }
49
+ resolvedVisualTokens.set(key, resolved);
37
50
  return resolved;
38
51
  }
package/dist/metro.cjs CHANGED
@@ -12,6 +12,11 @@ const { withUniwindConfig } = require('uniwind/metro');
12
12
  const NEEDS_REAL_REACT_NATIVE = ['expo-asset', 'expo-font', '@expo/vector-icons'];
13
13
 
14
14
  function withWhileUIMetro(config, options) {
15
+ if (!options || typeof options.cssEntryFile !== 'string') {
16
+ throw new Error(
17
+ "withWhileUIMetro(config, options): pass Uniwind's options, at least { cssEntryFile: './global.css' }"
18
+ );
19
+ }
15
20
  const { exemptFromUniwindRewrite = [], ...uniwindOptions } = options;
16
21
  const wrapped = withUniwindConfig(config, uniwindOptions);
17
22
  const exempt = [...NEEDS_REAL_REACT_NATIVE, ...exemptFromUniwindRewrite];
@@ -19,7 +24,8 @@ function withWhileUIMetro(config, options) {
19
24
 
20
25
  wrapped.resolver.resolveRequest = (context, moduleName, platform) => {
21
26
  if (moduleName === 'react-native') {
22
- const origin = context.originModulePath ?? '';
27
+ // Metro reports Windows paths with backslashes; match either separator.
28
+ const origin = (context.originModulePath ?? '').replace(/\\/g, '/');
23
29
  if (exempt.some((pkg) => origin.includes(`node_modules/${pkg}/`))) {
24
30
  return context.resolveRequest(context, moduleName, platform);
25
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "4.2.0",
3
+ "version": "4.2.3",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {