@oxyhq/bloom 0.35.8 → 0.36.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 (76) hide show
  1. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +148 -0
  2. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -0
  3. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +166 -0
  4. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
  5. package/lib/commonjs/frosted-icon-button/index.js +13 -0
  6. package/lib/commonjs/frosted-icon-button/index.js.map +1 -0
  7. package/lib/commonjs/frosted-icon-button/index.web.js +13 -0
  8. package/lib/commonjs/frosted-icon-button/index.web.js.map +1 -0
  9. package/lib/commonjs/frosted-icon-button/shared.js +112 -0
  10. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -0
  11. package/lib/commonjs/frosted-icon-button/types.js +6 -0
  12. package/lib/commonjs/frosted-icon-button/types.js.map +1 -0
  13. package/lib/commonjs/index.js +10 -1
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +46 -37
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/module/frosted-icon-button/FrostedIconButton.js +143 -0
  18. package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -0
  19. package/lib/module/frosted-icon-button/FrostedIconButton.web.js +161 -0
  20. package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
  21. package/lib/module/frosted-icon-button/index.js +4 -0
  22. package/lib/module/frosted-icon-button/index.js.map +1 -0
  23. package/lib/module/frosted-icon-button/index.web.js +11 -0
  24. package/lib/module/frosted-icon-button/index.web.js.map +1 -0
  25. package/lib/module/frosted-icon-button/shared.js +107 -0
  26. package/lib/module/frosted-icon-button/shared.js.map +1 -0
  27. package/lib/module/frosted-icon-button/types.js +4 -0
  28. package/lib/module/frosted-icon-button/types.js.map +1 -0
  29. package/lib/module/index.js +1 -0
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/index.web.js +1 -0
  32. package/lib/module/index.web.js.map +1 -1
  33. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts +5 -0
  34. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
  36. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/frosted-icon-button/index.d.ts +3 -0
  38. package/lib/typescript/commonjs/frosted-icon-button/index.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts +3 -0
  40. package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +60 -0
  42. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +69 -0
  44. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/index.d.ts +2 -0
  46. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  48. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  49. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts +5 -0
  50. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
  51. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
  52. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
  53. package/lib/typescript/module/frosted-icon-button/index.d.ts +3 -0
  54. package/lib/typescript/module/frosted-icon-button/index.d.ts.map +1 -0
  55. package/lib/typescript/module/frosted-icon-button/index.web.d.ts +3 -0
  56. package/lib/typescript/module/frosted-icon-button/index.web.d.ts.map +1 -0
  57. package/lib/typescript/module/frosted-icon-button/shared.d.ts +60 -0
  58. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -0
  59. package/lib/typescript/module/frosted-icon-button/types.d.ts +69 -0
  60. package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -0
  61. package/lib/typescript/module/index.d.ts +2 -0
  62. package/lib/typescript/module/index.d.ts.map +1 -1
  63. package/lib/typescript/module/index.web.d.ts +2 -0
  64. package/lib/typescript/module/index.web.d.ts.map +1 -1
  65. package/package.json +17 -1
  66. package/src/__tests__/FrostedIconButton.test.tsx +137 -0
  67. package/src/__tests__/FrostedIconButton.web.test.tsx +246 -0
  68. package/src/frosted-icon-button/FrostedIconButton.stories.tsx +65 -0
  69. package/src/frosted-icon-button/FrostedIconButton.tsx +156 -0
  70. package/src/frosted-icon-button/FrostedIconButton.web.tsx +207 -0
  71. package/src/frosted-icon-button/index.ts +2 -0
  72. package/src/frosted-icon-button/index.web.ts +9 -0
  73. package/src/frosted-icon-button/shared.ts +118 -0
  74. package/src/frosted-icon-button/types.ts +81 -0
  75. package/src/index.ts +2 -0
  76. package/src/index.web.ts +2 -0
@@ -0,0 +1,156 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import {
3
+ Animated,
4
+ Pressable,
5
+ StyleSheet,
6
+ View,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+ import { BlurView } from 'expo-blur';
10
+
11
+ import { useTheme } from '../theme/use-theme';
12
+ import { usePressAnimation } from '../hooks/usePressAnimation';
13
+ import { useInteractionState } from '../hooks/useInteractionState';
14
+ import {
15
+ applyIconColor,
16
+ resolveFrostedPalette,
17
+ resolveFrostedSize,
18
+ } from './shared';
19
+ import type { FrostedIconButtonProps } from './types';
20
+
21
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './types';
22
+
23
+ const RADIUS = 999;
24
+ const PRESS_SCALE = 0.94;
25
+ const DEFAULT_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
26
+
27
+ // BlurView intensity (0–100). A gentle frost so the chip reads over an image
28
+ // without smearing the content behind it. The `active` (solid) state has none.
29
+ const BLUR_INTENSITY = 24;
30
+
31
+ const FrostedIconButtonComponent: React.FC<FrostedIconButtonProps> = ({
32
+ onPress,
33
+ icon,
34
+ children,
35
+ active = false,
36
+ disabled = false,
37
+ size = 'md',
38
+ accessibilityLabel,
39
+ accessibilityHint,
40
+ style,
41
+ className,
42
+ testID,
43
+ hitSlop,
44
+ }) => {
45
+ const theme = useTheme();
46
+ const geo = useMemo(() => resolveFrostedSize(size), [size]);
47
+ const palette = useMemo(
48
+ () => resolveFrostedPalette(theme.colors, theme.isDark),
49
+ [theme.colors, theme.isDark],
50
+ );
51
+
52
+ const { scaleAnim, onPressIn: onScalePressIn, onPressOut: onScalePressOut } =
53
+ usePressAnimation(disabled ? undefined : PRESS_SCALE);
54
+ const { state: pressed, onIn: onPressedIn, onOut: onPressedOut } =
55
+ useInteractionState();
56
+
57
+ const handlePressIn = disabled
58
+ ? undefined
59
+ : () => {
60
+ onScalePressIn();
61
+ onPressedIn();
62
+ };
63
+ const handlePressOut = disabled
64
+ ? undefined
65
+ : () => {
66
+ onScalePressOut();
67
+ onPressedOut();
68
+ };
69
+
70
+ const highlighted = pressed && !disabled;
71
+ const surface = active
72
+ ? palette.activeSurface
73
+ : highlighted
74
+ ? palette.surfaceHover
75
+ : palette.surface;
76
+ const ring = highlighted ? palette.ringHover : palette.ring;
77
+ const iconColor = active ? palette.activeIcon : palette.icon;
78
+
79
+ const containerStyle = useMemo((): ViewStyle => {
80
+ return {
81
+ width: geo.diameter,
82
+ height: geo.diameter,
83
+ borderRadius: RADIUS,
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ borderWidth: StyleSheet.hairlineWidth * 2,
87
+ borderColor: ring,
88
+ // Soft shadow lives on the (non-clipping) Pressable so it renders on iOS —
89
+ // the blur/tint are clipped by an inner overflow:hidden layer instead.
90
+ shadowColor: palette.shadow,
91
+ shadowOffset: { width: 0, height: 2 },
92
+ shadowOpacity: 1,
93
+ shadowRadius: 8,
94
+ elevation: 4,
95
+ };
96
+ }, [geo.diameter, ring, palette.shadow]);
97
+
98
+ const content = icon ?? children;
99
+
100
+ return (
101
+ <Animated.View style={[{ transform: [{ scale: scaleAnim }] }, style]}>
102
+ <Pressable
103
+ {...(className ? ({ className } as Record<string, string>) : {})}
104
+ style={[containerStyle, disabled && styles.disabled]}
105
+ onPress={disabled ? undefined : onPress}
106
+ onPressIn={handlePressIn}
107
+ onPressOut={handlePressOut}
108
+ disabled={disabled}
109
+ hitSlop={hitSlop ?? DEFAULT_HIT_SLOP}
110
+ accessibilityRole="button"
111
+ accessibilityLabel={accessibilityLabel}
112
+ accessibilityHint={accessibilityHint}
113
+ accessibilityState={{ disabled, selected: active }}
114
+ testID={testID}
115
+ >
116
+ {/* Clip layer: blur (frosted only) + translucent/solid tint, rounded. */}
117
+ <View style={[StyleSheet.absoluteFill, styles.clip]}>
118
+ {!active && (
119
+ <BlurView
120
+ intensity={BLUR_INTENSITY}
121
+ tint={theme.isDark ? 'dark' : 'light'}
122
+ experimentalBlurMethod="dimezisBlurView"
123
+ style={StyleSheet.absoluteFill}
124
+ />
125
+ )}
126
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: surface }]} />
127
+ </View>
128
+ {content != null && (
129
+ <View
130
+ style={{
131
+ width: geo.iconBox,
132
+ height: geo.iconBox,
133
+ alignItems: 'center',
134
+ justifyContent: 'center',
135
+ }}
136
+ >
137
+ {applyIconColor(content, iconColor)}
138
+ </View>
139
+ )}
140
+ </Pressable>
141
+ </Animated.View>
142
+ );
143
+ };
144
+
145
+ const styles = StyleSheet.create({
146
+ clip: {
147
+ borderRadius: RADIUS,
148
+ overflow: 'hidden',
149
+ },
150
+ disabled: {
151
+ opacity: 0.5,
152
+ },
153
+ });
154
+
155
+ export const FrostedIconButton = memo(FrostedIconButtonComponent);
156
+ FrostedIconButton.displayName = 'FrostedIconButton';
@@ -0,0 +1,207 @@
1
+ import React, {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ type CSSProperties,
8
+ type MouseEvent,
9
+ } from 'react';
10
+
11
+ import { useTheme } from '../theme/use-theme';
12
+ import { flattenWebStyle } from '../styles/flatten-web-style';
13
+ import {
14
+ applyIconColor,
15
+ resolveFrostedPalette,
16
+ resolveFrostedSize,
17
+ } from './shared';
18
+ import type { FrostedIconButtonProps } from './types';
19
+
20
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './types';
21
+
22
+ const RADIUS = 999;
23
+ const PRESS_SCALE = 0.94;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Per-state CSS injection
27
+ //
28
+ // Inline styles cannot express `:hover` / `:focus-visible` / `:active`. We
29
+ // inject one static stylesheet (keyed by id, once) defining the interaction
30
+ // behavior for the base `bloom-frosted-icon-btn` class; per-instance resolved
31
+ // colors are passed in as inline CSS custom properties. Self-injecting the CSS
32
+ // (rather than exporting a string for consumers to paste) is the Bloom web-fork
33
+ // convention — an unresolvable rule would fail silently otherwise.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const STYLE_ID = 'bloom-frosted-icon-button-web-css';
37
+
38
+ const BLOOM_FROSTED_ICON_BUTTON_CSS = `
39
+ .bloom-frosted-icon-btn {
40
+ appearance: none;
41
+ -webkit-appearance: none;
42
+ box-sizing: border-box;
43
+ display: inline-flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ padding: 0;
47
+ margin: 0;
48
+ border-style: solid;
49
+ border-width: 1px;
50
+ font-family: inherit;
51
+ cursor: pointer;
52
+ user-select: none;
53
+ outline: none;
54
+ transition: background-color 140ms ease, border-color 140ms ease,
55
+ box-shadow 160ms ease, transform 120ms ease;
56
+ }
57
+ .bloom-frosted-icon-btn:disabled,
58
+ .bloom-frosted-icon-btn[aria-disabled="true"] {
59
+ cursor: default;
60
+ opacity: 0.5;
61
+ }
62
+ .bloom-frosted-icon-btn:not(:disabled):not([aria-disabled="true"]):not([data-active="true"]):hover {
63
+ background-color: var(--bloom-frosted-hover-bg);
64
+ border-color: var(--bloom-frosted-hover-ring);
65
+ }
66
+ .bloom-frosted-icon-btn:not(:disabled):not([aria-disabled="true"]):active {
67
+ transform: scale(var(--bloom-frosted-press-scale, 1));
68
+ }
69
+ .bloom-frosted-icon-btn:focus-visible {
70
+ outline: 2px solid var(--bloom-frosted-ring-focus, currentColor);
71
+ outline-offset: 2px;
72
+ }
73
+ `;
74
+
75
+ function useFrostedIconButtonCss(): void {
76
+ useEffect(() => {
77
+ if (typeof document === 'undefined') return;
78
+ if (document.getElementById(STYLE_ID)) return;
79
+ const style = document.createElement('style');
80
+ style.id = STYLE_ID;
81
+ style.textContent = BLOOM_FROSTED_ICON_BUTTON_CSS;
82
+ document.head.appendChild(style);
83
+ }, []);
84
+ }
85
+
86
+ const FrostedIconButtonWebComponent: React.FC<FrostedIconButtonProps> = ({
87
+ onPress,
88
+ onClick,
89
+ icon,
90
+ children,
91
+ active = false,
92
+ disabled = false,
93
+ size = 'md',
94
+ accessibilityLabel,
95
+ 'aria-label': ariaLabelProp,
96
+ accessibilityHint,
97
+ style,
98
+ className,
99
+ testID,
100
+ id,
101
+ title,
102
+ type = 'button',
103
+ }) => {
104
+ useFrostedIconButtonCss();
105
+ const theme = useTheme();
106
+ const reactId = useId();
107
+ const resolvedId = id ?? `bloom-frosted-icon-btn-${reactId}`;
108
+
109
+ const geo = useMemo(() => resolveFrostedSize(size), [size]);
110
+ const palette = useMemo(
111
+ () => resolveFrostedPalette(theme.colors, theme.isDark),
112
+ [theme.colors, theme.isDark],
113
+ );
114
+
115
+ const iconColor = active ? palette.activeIcon : palette.icon;
116
+ const blur = `blur(${geo.blur}px)`;
117
+
118
+ const containerStyle = useMemo((): CSSProperties => {
119
+ return {
120
+ width: geo.diameter,
121
+ height: geo.diameter,
122
+ borderRadius: RADIUS,
123
+ backgroundColor: active ? palette.activeSurface : palette.surface,
124
+ borderColor: palette.ring,
125
+ color: iconColor,
126
+ // Real CSS backdrop blur so the chip frosts over content behind it. The
127
+ // solid `active` state drops the blur. Both prefixed forms for Safari.
128
+ backdropFilter: active ? 'none' : blur,
129
+ WebkitBackdropFilter: active ? 'none' : blur,
130
+ // Soft shadow kept in BOTH modes so the chip has an edge on a solid bg.
131
+ boxShadow: `0 2px 8px ${palette.shadow}`,
132
+ ['--bloom-frosted-hover-bg' as string]: palette.surfaceHover,
133
+ ['--bloom-frosted-hover-ring' as string]: palette.ringHover,
134
+ ['--bloom-frosted-ring-focus' as string]: palette.focusRing,
135
+ ['--bloom-frosted-press-scale' as string]: PRESS_SCALE,
136
+ };
137
+ }, [
138
+ geo.diameter,
139
+ active,
140
+ palette.activeSurface,
141
+ palette.surface,
142
+ palette.ring,
143
+ palette.surfaceHover,
144
+ palette.ringHover,
145
+ palette.focusRing,
146
+ palette.shadow,
147
+ iconColor,
148
+ blur,
149
+ ]);
150
+
151
+ const handleClick = useCallback(
152
+ (event: MouseEvent<HTMLButtonElement>) => {
153
+ if (disabled) {
154
+ event.preventDefault();
155
+ return;
156
+ }
157
+ onClick?.(event);
158
+ onPress?.();
159
+ },
160
+ [disabled, onClick, onPress],
161
+ );
162
+
163
+ const ariaLabel = ariaLabelProp ?? accessibilityLabel;
164
+ const composedClassName = ['bloom-frosted-icon-btn']
165
+ .concat(className ? [className] : [])
166
+ .join(' ');
167
+
168
+ // `style` is an RN `StyleProp` (array-capable). Flatten before spreading into
169
+ // the raw DOM `style` object so an array form never leaks numeric keys onto
170
+ // the element's CSSStyleDeclaration. See `flattenWebStyle` for the rationale.
171
+ const resolvedStyle = flattenWebStyle(style);
172
+ const content = icon ?? children;
173
+
174
+ return (
175
+ <button
176
+ id={resolvedId}
177
+ type={type}
178
+ className={composedClassName}
179
+ style={{ ...containerStyle, ...resolvedStyle }}
180
+ onClick={handleClick}
181
+ disabled={disabled}
182
+ aria-disabled={disabled || undefined}
183
+ aria-label={ariaLabel}
184
+ aria-pressed={active}
185
+ data-active={active || undefined}
186
+ title={title ?? accessibilityHint}
187
+ data-testid={testID}
188
+ >
189
+ {content != null && (
190
+ <span
191
+ style={{
192
+ display: 'inline-flex',
193
+ alignItems: 'center',
194
+ justifyContent: 'center',
195
+ width: geo.iconBox,
196
+ height: geo.iconBox,
197
+ }}
198
+ >
199
+ {applyIconColor(content, iconColor)}
200
+ </span>
201
+ )}
202
+ </button>
203
+ );
204
+ };
205
+
206
+ export const FrostedIconButton = memo(FrostedIconButtonWebComponent);
207
+ FrostedIconButton.displayName = 'FrostedIconButton';
@@ -0,0 +1,2 @@
1
+ export { FrostedIconButton } from './FrostedIconButton';
2
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './types';
@@ -0,0 +1,9 @@
1
+ // Web variant of the `./frosted-icon-button` barrel.
2
+ //
3
+ // The default barrel (`./index.ts`) re-exports the React Native implementation,
4
+ // which frosts via `expo-blur`'s `BlurView`. The web fork (`./FrostedIconButton.web`)
5
+ // renders a real HTML `<button>` and frosts via a real CSS `backdrop-filter` —
6
+ // no `expo-blur` import ever reaches a web bundler. Web bundlers select this
7
+ // file via the `"browser"` export condition in `package.json`.
8
+ export { FrostedIconButton } from './FrostedIconButton.web';
9
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './types';
@@ -0,0 +1,118 @@
1
+ import { cloneElement, isValidElement, type ReactNode, type ReactElement } from 'react';
2
+
3
+ import { withAlpha } from '../theme/color-utils';
4
+ import type { ThemeColors } from '../theme/types';
5
+ import type { FrostedIconButtonSize } from './types';
6
+
7
+ /** Resolved pixel geometry for a given size prop. */
8
+ export interface FrostedGeometry {
9
+ /** Circle diameter (width === height). */
10
+ diameter: number;
11
+ /** Centered box the icon renders inside. */
12
+ iconBox: number;
13
+ /** Backdrop-blur radius in px (web) — scaled for BlurView intensity on native. */
14
+ blur: number;
15
+ }
16
+
17
+ const SIZE_CONFIG: Record<FrostedIconButtonSize, FrostedGeometry> = {
18
+ // 32px (`h-8`) — dense.
19
+ sm: { diameter: 32, iconBox: 18, blur: 10 },
20
+ // 36px (`h-9`) — the default comfortable header/overlay action.
21
+ md: { diameter: 36, iconBox: 20, blur: 12 },
22
+ };
23
+
24
+ /**
25
+ * Resolve a `size` prop (preset name or raw pixel diameter) to concrete pixel
26
+ * geometry. A numeric size sets the diameter directly, derives the icon box as
27
+ * `round(size * 0.56)` (min 16px) and scales the blur radius with the diameter.
28
+ * Mirrored byte-for-byte between the native and web forks via this one module.
29
+ */
30
+ export function resolveFrostedSize(size: FrostedIconButtonSize | number): FrostedGeometry {
31
+ if (typeof size === 'number') {
32
+ return {
33
+ diameter: size,
34
+ iconBox: Math.max(16, Math.round(size * 0.56)),
35
+ blur: Math.max(8, Math.round(size * 0.34)),
36
+ };
37
+ }
38
+ return SIZE_CONFIG[size];
39
+ }
40
+
41
+ /**
42
+ * Per-mode alpha weights for the translucent surface + hairline ring.
43
+ *
44
+ * These are calibrated so the chip READS on a SOLID background independently of
45
+ * any content behind it: on a dark theme the surface is a LOW-OPACITY LIGHT tint
46
+ * of the foreground token (lighter than the near-black page — NOT a dark `card`
47
+ * fill that sinks into the background), and on a light theme it is a subtle dark
48
+ * tint. E.g. on a `rgb(11 11 15)` page a `rgba(237,237,237,0.14)` surface
49
+ * composites to ≈`rgb(43 43 47)` — a clearly visible frosted circle.
50
+ */
51
+ const SURFACE_ALPHA = { light: 0.06, dark: 0.14 } as const;
52
+ const SURFACE_ALPHA_HOVER = { light: 0.1, dark: 0.22 } as const;
53
+ const RING_ALPHA = { light: 0.12, dark: 0.22 } as const;
54
+ const RING_ALPHA_HOVER = { light: 0.18, dark: 0.3 } as const;
55
+
56
+ /** Theme-aware colors for every frosted-button surface state. */
57
+ export interface FrostedPalette {
58
+ /** Translucent surface at rest. */
59
+ surface: string;
60
+ /** Translucent surface on hover / press (slightly lighter). */
61
+ surfaceHover: string;
62
+ /** Hairline ring/border at rest. */
63
+ ring: string;
64
+ /** Hairline ring/border on hover / press. */
65
+ ringHover: string;
66
+ /** Icon color when frosted (rest). */
67
+ icon: string;
68
+ /** Opaque surface for the solid `active` state. */
69
+ activeSurface: string;
70
+ /** Icon color when `active`. */
71
+ activeIcon: string;
72
+ /** Focus-ring color (web `:focus-visible` outline). */
73
+ focusRing: string;
74
+ /**
75
+ * Soft drop shadow color. Intentionally kept in BOTH modes (never
76
+ * `dark:shadow-none`) so the chip has an edge over an image and reads as a
77
+ * distinct button. The token already carries its own alpha.
78
+ */
79
+ shadow: string;
80
+ }
81
+
82
+ /**
83
+ * Build the frosted palette from the resolved theme colors. Everything is
84
+ * derived from Bloom tokens (`text`/foreground, `primary`, `shadow`) so it is
85
+ * fully theme-aware and Oxy-agnostic — no hardcoded colors.
86
+ */
87
+ export function resolveFrostedPalette(colors: ThemeColors, isDark: boolean): FrostedPalette {
88
+ const k = isDark ? 'dark' : 'light';
89
+ return {
90
+ surface: withAlpha(colors.text, SURFACE_ALPHA[k]),
91
+ surfaceHover: withAlpha(colors.text, SURFACE_ALPHA_HOVER[k]),
92
+ ring: withAlpha(colors.text, RING_ALPHA[k]),
93
+ ringHover: withAlpha(colors.text, RING_ALPHA_HOVER[k]),
94
+ icon: colors.text,
95
+ activeSurface: colors.primary,
96
+ activeIcon: colors.primaryForeground,
97
+ focusRing: colors.primary,
98
+ shadow: colors.shadow,
99
+ };
100
+ }
101
+
102
+ /** A React element that accepts a `fill` prop (Bloom icons, raw SVG). */
103
+ type FillableElement = ReactElement<{ fill?: unknown }>;
104
+
105
+ /**
106
+ * Inject `color` as the icon's `fill` — but only as a FALLBACK: if the caller
107
+ * already set an explicit `fill` on the icon element, it wins and the node is
108
+ * returned untouched. Bloom icons resolve their paint as `fill ?? style.color`,
109
+ * so a bare `<Icons.X />` gets the theme-aware color for free while an
110
+ * explicitly-colored icon is never overridden. Non-element children (plain
111
+ * text/strings) pass through unchanged.
112
+ */
113
+ export function applyIconColor(node: ReactNode, color: string): ReactNode {
114
+ if (!isValidElement(node)) return node;
115
+ const el = node as FillableElement;
116
+ if (el.props.fill != null) return node;
117
+ return cloneElement(el, { fill: color });
118
+ }
@@ -0,0 +1,81 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ /**
4
+ * Size of the {@link FrostedIconButton}.
5
+ *
6
+ * - `sm` — 32px circle (`h-8`), 18px icon box. Dense toolbars / stacked chips.
7
+ * - `md` — 36px circle (`h-9`), 20px icon box. The default; a comfortable
8
+ * header/overlay action.
9
+ *
10
+ * A raw pixel `number` is also accepted for an exact diameter; the icon box is
11
+ * derived as `round(size * 0.56)` (min 16px) and the blur radius scales with it.
12
+ */
13
+ export type FrostedIconButtonSize = 'sm' | 'md';
14
+
15
+ export interface FrostedIconButtonProps {
16
+ /** Press handler. */
17
+ onPress?: () => void;
18
+
19
+ /**
20
+ * The icon to render, centered inside the circle. Typically a Bloom icon
21
+ * element (`<Icons.ArrowLeft… />`). If the icon does not set its own `fill`,
22
+ * the button injects the theme-aware icon color (foreground when frosted,
23
+ * `primaryForeground` when `active`) as a fallback so a bare icon is colored
24
+ * correctly out of the box; an explicit `fill` on the icon always wins.
25
+ *
26
+ * `children` is accepted as an alias — if both are given, `icon` wins.
27
+ */
28
+ icon?: React.ReactNode;
29
+ children?: React.ReactNode;
30
+
31
+ /**
32
+ * Solid "on" state for toggles. When true the button drops its translucency
33
+ * and backdrop blur and fills with the brand `primary` token (icon tinted
34
+ * `primaryForeground`), so an enabled toggle reads unmistakably as active.
35
+ * Exposed to assistive tech as `aria-pressed` (web) / the selected a11y state.
36
+ */
37
+ active?: boolean;
38
+
39
+ disabled?: boolean;
40
+
41
+ /**
42
+ * A preset (`'sm' | 'md'`) or a raw pixel diameter (`number`). Defaults to
43
+ * `'md'` (36px). See {@link FrostedIconButtonSize}.
44
+ */
45
+ size?: FrostedIconButtonSize | number;
46
+
47
+ /** Required for icon-only buttons so screen readers announce the action. */
48
+ accessibilityLabel?: string;
49
+ accessibilityHint?: string;
50
+
51
+ /** Container style override (merged last). */
52
+ style?: StyleProp<ViewStyle>;
53
+ /** NativeWind / DOM className passthrough. */
54
+ className?: string;
55
+ testID?: string;
56
+
57
+ /**
58
+ * Native press hit target padding. Defaults to a generous slop so the compact
59
+ * circle is comfortable to tap. No-op on web.
60
+ */
61
+ hitSlop?: { top: number; bottom: number; left: number; right: number };
62
+
63
+ // -------------------------------------------------------------------------
64
+ // Web-only props
65
+ //
66
+ // No-ops in the native (`Pressable`) implementation — present on the shared
67
+ // type so a single `<FrostedIconButton>` call site type-checks on both
68
+ // platforms. The web fork renders a real DOM `<button>` and consumes them.
69
+ // -------------------------------------------------------------------------
70
+
71
+ /** Web click handler. Fires together with `onPress`. */
72
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
73
+ /** `aria-label` (web only). Falls back to `accessibilityLabel`. */
74
+ 'aria-label'?: string;
75
+ /** `title` tooltip attribute (web only). */
76
+ title?: string;
77
+ /** Inline DOM `id` (web only). */
78
+ id?: string;
79
+ /** HTML button `type` (web only). Defaults to `'button'`. */
80
+ type?: 'button' | 'submit' | 'reset';
81
+ }
package/src/index.ts CHANGED
@@ -75,6 +75,8 @@ export type {
75
75
  export * from './button';
76
76
  export { Fab } from './fab';
77
77
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab';
78
+ export { FrostedIconButton } from './frosted-icon-button';
79
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './frosted-icon-button';
78
80
  export * from './grouped-buttons';
79
81
  export * from './divider';
80
82
  export * from './radio-indicator';
package/src/index.web.ts CHANGED
@@ -80,6 +80,8 @@ export type {
80
80
  export * from './button/index.web';
81
81
  export { Fab } from './fab/index.web';
82
82
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab/index.web';
83
+ export { FrostedIconButton } from './frosted-icon-button/index.web';
84
+ export type { FrostedIconButtonProps, FrostedIconButtonSize } from './frosted-icon-button/index.web';
83
85
  export * from './grouped-buttons';
84
86
  export * from './divider';
85
87
  export * from './radio-indicator';