@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.
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +148 -0
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -0
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +166 -0
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
- package/lib/commonjs/frosted-icon-button/index.js +13 -0
- package/lib/commonjs/frosted-icon-button/index.js.map +1 -0
- package/lib/commonjs/frosted-icon-button/index.web.js +13 -0
- package/lib/commonjs/frosted-icon-button/index.web.js.map +1 -0
- package/lib/commonjs/frosted-icon-button/shared.js +112 -0
- package/lib/commonjs/frosted-icon-button/shared.js.map +1 -0
- package/lib/commonjs/frosted-icon-button/types.js +6 -0
- package/lib/commonjs/frosted-icon-button/types.js.map +1 -0
- package/lib/commonjs/index.js +10 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +46 -37
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/module/frosted-icon-button/FrostedIconButton.js +143 -0
- package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -0
- package/lib/module/frosted-icon-button/FrostedIconButton.web.js +161 -0
- package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
- package/lib/module/frosted-icon-button/index.js +4 -0
- package/lib/module/frosted-icon-button/index.js.map +1 -0
- package/lib/module/frosted-icon-button/index.web.js +11 -0
- package/lib/module/frosted-icon-button/index.web.js.map +1 -0
- package/lib/module/frosted-icon-button/shared.js +107 -0
- package/lib/module/frosted-icon-button/shared.js.map +1 -0
- package/lib/module/frosted-icon-button/types.js +4 -0
- package/lib/module/frosted-icon-button/types.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts +5 -0
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/index.d.ts +3 -0
- package/lib/typescript/commonjs/frosted-icon-button/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts +3 -0
- package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +60 -0
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +69 -0
- package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts +5 -0
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/index.d.ts +3 -0
- package/lib/typescript/module/frosted-icon-button/index.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/index.web.d.ts +3 -0
- package/lib/typescript/module/frosted-icon-button/index.web.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/shared.d.ts +60 -0
- package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/types.d.ts +69 -0
- package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/package.json +17 -1
- package/src/__tests__/FrostedIconButton.test.tsx +137 -0
- package/src/__tests__/FrostedIconButton.web.test.tsx +246 -0
- package/src/frosted-icon-button/FrostedIconButton.stories.tsx +65 -0
- package/src/frosted-icon-button/FrostedIconButton.tsx +156 -0
- package/src/frosted-icon-button/FrostedIconButton.web.tsx +207 -0
- package/src/frosted-icon-button/index.ts +2 -0
- package/src/frosted-icon-button/index.web.ts +9 -0
- package/src/frosted-icon-button/shared.ts +118 -0
- package/src/frosted-icon-button/types.ts +81 -0
- package/src/index.ts +2 -0
- 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,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';
|