@oxyhq/bloom 0.28.0 → 0.29.1

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 (30) hide show
  1. package/lib/commonjs/content-panel/index.web.js +6 -18
  2. package/lib/commonjs/content-panel/index.web.js.map +1 -1
  3. package/lib/commonjs/pressable-scale/PressableScale.js +15 -12
  4. package/lib/commonjs/pressable-scale/PressableScale.js.map +1 -1
  5. package/lib/commonjs/subtle-hover/SubtleHover.js +34 -29
  6. package/lib/commonjs/subtle-hover/SubtleHover.js.map +1 -1
  7. package/lib/module/content-panel/index.web.js +6 -18
  8. package/lib/module/content-panel/index.web.js.map +1 -1
  9. package/lib/module/pressable-scale/PressableScale.js +15 -12
  10. package/lib/module/pressable-scale/PressableScale.js.map +1 -1
  11. package/lib/module/subtle-hover/SubtleHover.js +34 -29
  12. package/lib/module/subtle-hover/SubtleHover.js.map +1 -1
  13. package/lib/typescript/commonjs/content-panel/index.web.d.ts.map +1 -1
  14. package/lib/typescript/commonjs/pressable-scale/PressableScale.d.ts +8 -8
  15. package/lib/typescript/commonjs/pressable-scale/PressableScale.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/subtle-hover/SubtleHover.d.ts +20 -15
  17. package/lib/typescript/commonjs/subtle-hover/SubtleHover.d.ts.map +1 -1
  18. package/lib/typescript/module/content-panel/index.web.d.ts.map +1 -1
  19. package/lib/typescript/module/pressable-scale/PressableScale.d.ts +8 -8
  20. package/lib/typescript/module/pressable-scale/PressableScale.d.ts.map +1 -1
  21. package/lib/typescript/module/subtle-hover/SubtleHover.d.ts +20 -15
  22. package/lib/typescript/module/subtle-hover/SubtleHover.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/__tests__/PressableScale.test.tsx +9 -0
  25. package/src/__tests__/SubtleHover.test.tsx +24 -16
  26. package/src/content-panel/index.web.tsx +26 -37
  27. package/src/pressable-scale/PressableScale.stories.tsx +23 -2
  28. package/src/pressable-scale/PressableScale.tsx +18 -28
  29. package/src/subtle-hover/SubtleHover.stories.tsx +42 -29
  30. package/src/subtle-hover/SubtleHover.tsx +46 -38
@@ -1,19 +1,19 @@
1
1
  import React from 'react';
2
2
  import { type PressableProps, type StyleProp, type View, type ViewStyle } from 'react-native';
3
- export interface PressableScaleProps extends Omit<PressableProps, 'children' | 'style'> {
3
+ export interface PressableScaleProps extends Omit<PressableProps, 'style'> {
4
4
  /** Scale applied while pressed. Defaults to `0.98`. */
5
5
  targetScale?: number;
6
- /** Style for the outer, full-size touch target (stays put while pressing). */
6
+ /** Style applied to the pressable itself the node that scales. */
7
7
  style?: StyleProp<ViewStyle>;
8
- /** Style for the inner content wrapper that actually scales. */
9
- contentContainerStyle?: StyleProp<ViewStyle>;
10
- children?: React.ReactNode;
11
8
  }
12
9
  /**
13
- * A drop-in {@link Pressable} whose content springs to a smaller scale on
14
- * press-in and back on release. The outer pressable stays full size (so the
15
- * touch target never shrinks) while an inner wrapper scales.
10
+ * A drop-in {@link Pressable} that springs to a smaller scale on press-in and
11
+ * back on release. The scale is applied to the pressable node itself, so a
12
+ * consumer's `className` / layout styles (e.g. `flex-row items-center`) land on
13
+ * the same node that animates.
16
14
  *
15
+ * The touch target scales with the content — acceptable at the default ~0.98
16
+ * (the earlier two-node touch-target-preservation was dropped for simplicity).
17
17
  * Honours the OS "reduce motion" setting and disables the effect on non-touch
18
18
  * web pointers.
19
19
  */
@@ -1 +1 @@
1
- {"version":3,"file":"PressableScale.d.ts","sourceRoot":"","sources":["../../../../src/pressable-scale/PressableScale.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAqB,MAAM,OAAO,CAAC;AAC1C,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AA0BtB,MAAM,WAAW,mBACf,SAAQ,IAAI,CAAC,cAAc,EAAE,UAAU,GAAG,OAAO,CAAC;IAClD,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,gEAAgE;IAChE,qBAAqB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7C,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,kFA+C1B,CAAC"}
1
+ {"version":3,"file":"PressableScale.d.ts","sourceRoot":"","sources":["../../../../src/pressable-scale/PressableScale.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAqB,MAAM,OAAO,CAAC;AAC1C,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAgCtB,MAAM,WAAW,mBAAoB,SAAQ,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC;IACxE,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,kFAgC1B,CAAC"}
@@ -1,31 +1,36 @@
1
1
  import { type StyleProp, type ViewStyle } from 'react-native';
2
2
  export interface SubtleHoverProps {
3
- /** Whether the parent is currently hovered. Drives the overlay opacity. */
4
- hover: boolean;
5
3
  /**
6
- * Overlay opacity when hovered. Defaults to a mode-tuned value (0.4 dark /
7
- * 0.5 light) that reads as a gentle contrast wash.
4
+ * JS-driven visibility for a coordinated highlight e.g. every post of one
5
+ * thread lighting up together. When omitted, the wash instead follows CSS
6
+ * `group-hover` on web with ZERO React state: the parent element must carry
7
+ * `className="group"`.
8
8
  */
9
- opacity?: number;
9
+ active?: boolean;
10
10
  /** Extra style merged last — e.g. `borderRadius` to match the parent. */
11
11
  style?: StyleProp<ViewStyle>;
12
12
  /**
13
- * Opt in to rendering on native. Off by default: hover is a pointer
14
- * affordance, so this is a web-only overlay unless a native surface wants the
15
- * same wash driven by another interaction (e.g. press).
13
+ * Render on native too (default `false`). Hover is a web pointer affordance,
14
+ * so the CSS `group-hover` mode is web-only; native only shows the wash in the
15
+ * JS `active` mode, and only when this is set.
16
16
  */
17
17
  native?: boolean;
18
18
  }
19
19
  /**
20
- * An absolutely-positioned, non-interactive contrast wash that fades in while
21
- * its parent is hovered. Ported from Bluesky's `SubtleHover`. Pairs naturally
22
- * with {@link useInteractionState} — pass its `state` as `hover`.
20
+ * An absolutely-positioned, non-interactive contrast wash. Two modes:
23
21
  *
24
- * Renders nothing on touch-web (no hovering pointer) and on native unless
25
- * `native` is set. Color comes from the theme's `contrast50` token so the wash
26
- * tracks the active preset and light/dark mode.
22
+ * - **CSS `group-hover`** (`active` omitted): the wash fades in on hover purely
23
+ * through NativeWind classes no React state. The parent must carry
24
+ * `className="group"`. Web-only (native and touch-web have no hover).
25
+ * - **JS `active`**: the wash opacity follows the boolean, so several elements
26
+ * can light up together (e.g. every post of a thread). Web always; native
27
+ * only when `native` is set.
28
+ *
29
+ * The wash color comes from the theme's `contrast50` token so it tracks the
30
+ * active preset and light/dark mode. The web cross-fade is a NativeWind
31
+ * `transition-opacity` class — no manual `willChange` / web-style casts.
27
32
  */
28
- export declare function SubtleHover({ hover, opacity, style, native }: SubtleHoverProps): import("react/jsx-runtime").JSX.Element | null;
33
+ export declare function SubtleHover({ active, style, native }: SubtleHoverProps): import("react/jsx-runtime").JSX.Element | null;
29
34
  export declare namespace SubtleHover {
30
35
  var displayName: string;
31
36
  }
@@ -1 +1 @@
1
- {"version":3,"file":"SubtleHover.d.ts","sourceRoot":"","sources":["../../../../src/subtle-hover/SubtleHover.tsx"],"names":[],"mappings":"AACA,OAAO,EAA8B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AA4B1F,MAAM,WAAW,gBAAgB;IAC/B,2EAA2E;IAC3E,KAAK,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAc,EAAE,EAAE,gBAAgB,kDAoBtF;yBApBe,WAAW"}
1
+ {"version":3,"file":"SubtleHover.d.ts","sourceRoot":"","sources":["../../../../src/subtle-hover/SubtleHover.tsx"],"names":[],"mappings":"AACA,OAAO,EAA8B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAe1F,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAc,EAAE,EAAE,gBAAgB,kDAoC9E;yBApCe,WAAW"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -26,4 +26,13 @@ describe('PressableScale', () => {
26
26
  );
27
27
  expect(getByText('Scaled')).toBeTruthy();
28
28
  });
29
+
30
+ it('applies style to the pressable node that scales', () => {
31
+ const { getByText } = render(
32
+ <PressableScale style={{ flexDirection: 'row' }} accessibilityLabel="Row">
33
+ <Text>Row content</Text>
34
+ </PressableScale>,
35
+ );
36
+ expect(getByText('Row content')).toBeTruthy();
37
+ });
29
38
  });
@@ -5,7 +5,8 @@ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
5
5
  import { SubtleHover } from '../subtle-hover';
6
6
 
7
7
  // Default test Platform.OS is 'ios' (see __mocks__/react-native.ts), so the
8
- // web-only overlay is suppressed unless `native` is opted in.
8
+ // web-only hover wash is suppressed unless the JS `active` mode opts in via
9
+ // `native`.
9
10
  function renderWithTheme(ui: React.ReactElement) {
10
11
  return render(
11
12
  <BloomThemeProvider mode="light" colorPreset="teal">
@@ -14,29 +15,36 @@ function renderWithTheme(ui: React.ReactElement) {
14
15
  );
15
16
  }
16
17
 
18
+ // The provider wraps the subject in ONE flex View, so `toJSON` is a single node;
19
+ // narrowing away the array/null branches lets `children` read the wash cleanly.
20
+ function renderWrapper(ui: React.ReactElement) {
21
+ const tree = renderWithTheme(ui).toJSON();
22
+ if (tree === null || Array.isArray(tree)) {
23
+ throw new Error('expected a single wrapper node');
24
+ }
25
+ return tree;
26
+ }
27
+
17
28
  describe('SubtleHover', () => {
18
29
  it('carries a displayName', () => {
19
30
  expect(SubtleHover.displayName).toBe('SubtleHover');
20
31
  });
21
32
 
22
- // `renderWithTheme` wraps the subject in the provider's flex View, so the
23
- // overlay's presence is read from that wrapper's `children` rather than the
24
- // root being null.
25
- it('renders nothing on native by default', () => {
26
- const tree = renderWithTheme(<SubtleHover hover />).toJSON();
27
- expect(tree).not.toBeNull();
28
- expect(tree?.children).toBeNull();
33
+ it('renders nothing on native in the CSS group-hover mode', () => {
34
+ expect(renderWrapper(<SubtleHover />).children).toBeNull();
35
+ });
36
+
37
+ it('renders nothing on native in the JS active mode by default', () => {
38
+ expect(renderWrapper(<SubtleHover active />).children).toBeNull();
29
39
  });
30
40
 
31
- it('renders the overlay on native when opted in', () => {
32
- const tree = renderWithTheme(<SubtleHover hover native />).toJSON();
33
- expect(tree?.children).not.toBeNull();
41
+ it('renders the wash on native in the JS active mode when opted in', () => {
42
+ expect(renderWrapper(<SubtleHover active native />).children).not.toBeNull();
34
43
  });
35
44
 
36
- it('accepts an opacity override and a style', () => {
37
- const tree = renderWithTheme(
38
- <SubtleHover hover native opacity={0.9} style={{ borderRadius: 12 }} />,
39
- ).toJSON();
40
- expect(tree?.children).not.toBeNull();
45
+ it('accepts a style override in the native active mode', () => {
46
+ expect(
47
+ renderWrapper(<SubtleHover active native style={{ borderRadius: 12 }} />).children,
48
+ ).not.toBeNull();
41
49
  });
42
50
  });
@@ -88,31 +88,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
88
88
  useContentPanelNestingGuard();
89
89
  const { colors } = useTheme();
90
90
 
91
- if (!framed) {
92
- // Full-bleed: a single flat surface. No rounding, no sticky overlays.
93
- const fullBleedClass = [
94
- 'flex-1',
95
- surfaceClassName ?? 'bg-card',
96
- contentClassName,
97
- ]
98
- .filter(Boolean)
99
- .join(' ');
100
- return (
101
- <ContentPanelNestingContext.Provider value={true}>
102
- <View
103
- {...({ className: fullBleedClass } as Record<string, string>)}
104
- style={[surfaceStyle, contentStyle]}
105
- >
106
- {children}
107
- </View>
108
- </ContentPanelNestingContext.Provider>
109
- );
110
- }
111
-
112
- const surfaceClass = ['flex-1', 'rounded-radius-28', surfaceClassName ?? 'bg-card'].join(' ');
113
- const contentClass = ['flex-1', 'rounded-radius-28 web:overflow-x-clip', contentClassName]
114
- .filter(Boolean)
115
- .join(' ');
91
+ const surfaceClass = framed
92
+ ? ['flex-1 rounded-radius-28', surfaceClassName ?? 'bg-card'].join(' ')
93
+ : ['flex-1', surfaceClassName ?? 'bg-card'].join(' ');
94
+ const contentClass = framed
95
+ ? ['flex-1 rounded-radius-28 web:overflow-x-clip', contentClassName].filter(Boolean).join(' ')
96
+ : ['flex-1', contentClassName].filter(Boolean).join(' ');
116
97
 
117
98
  return (
118
99
  <ContentPanelNestingContext.Provider value={true}>
@@ -120,18 +101,22 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
120
101
  {...({ className: surfaceClass } as Record<string, string>)}
121
102
  style={surfaceStyle}
122
103
  >
123
- {/* (1) Bleed-mask overlay — gutter box-shadow ring, below chrome. */}
124
- <View
125
- pointerEvents="none"
126
- {...({
127
- className:
128
- 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]',
129
- } as Record<string, string>)}
130
- style={{ boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
131
- />
132
- {/* (2) Border-frame overlay one continuous rounded border, above all. */}
133
- {showStickyFrame !== false && (
104
+ {/* (1) Bleed-mask overlay — gutter box-shadow ring, below chrome. Framed only. */}
105
+ {framed && (
106
+ <View
107
+ key="bleed-mask"
108
+ pointerEvents="none"
109
+ {...({
110
+ className:
111
+ 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]',
112
+ } as Record<string, string>)}
113
+ style={{ boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
114
+ />
115
+ )}
116
+ {/* (2) Border-frame overlay — one continuous rounded border, above all. Framed only. */}
117
+ {framed && showStickyFrame !== false && (
134
118
  <View
119
+ key="border-frame"
135
120
  pointerEvents="none"
136
121
  {...({
137
122
  className:
@@ -139,8 +124,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
139
124
  } as Record<string, string>)}
140
125
  />
141
126
  )}
142
- {/* Content wrapper — clipped to the rounded panel shape on web. */}
127
+ {/* Content wrapper — STABLE position + key so toggling `framed` reconciles
128
+ in place instead of remounting `{children}` (which would reset feed
129
+ scroll/virtualizer + refetch on a breakpoint cross). Clipped to the
130
+ rounded panel shape on web when framed. */}
143
131
  <View
132
+ key="content"
144
133
  {...({ className: contentClass } as Record<string, string>)}
145
134
  style={contentStyle}
146
135
  >
@@ -21,7 +21,7 @@ export const Basic: Story = {
21
21
  <PressableScale
22
22
  {...args}
23
23
  accessibilityLabel="Press me"
24
- contentContainerStyle={{
24
+ style={{
25
25
  paddingVertical: 12,
26
26
  paddingHorizontal: 20,
27
27
  borderRadius: 12,
@@ -39,7 +39,7 @@ export const StrongScale: Story = {
39
39
  <PressableScale
40
40
  {...args}
41
41
  accessibilityLabel="Press me harder"
42
- contentContainerStyle={{
42
+ style={{
43
43
  paddingVertical: 12,
44
44
  paddingHorizontal: 20,
45
45
  borderRadius: 12,
@@ -51,6 +51,27 @@ export const StrongScale: Story = {
51
51
  ),
52
52
  };
53
53
 
54
+ /**
55
+ * NativeWind layout classes apply to the SAME node that scales — the whole row
56
+ * (avatar + label) springs as one. In a NativeWind consumer you pass `className`
57
+ * directly; the spread-cast here just satisfies Bloom's own typecheck (which has
58
+ * no NativeWind ambient types).
59
+ */
60
+ export const InlineRow: Story = {
61
+ render: (args) => (
62
+ <PressableScale
63
+ {...args}
64
+ accessibilityLabel="Open profile"
65
+ {...({
66
+ className: 'flex-row items-center gap-3 px-4 py-3 rounded-xl bg-muted',
67
+ } as Record<string, string>)}
68
+ >
69
+ <View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: '#4f46e5' }} />
70
+ <Text style={{ fontWeight: '600' }}>Row that scales as one node</Text>
71
+ </PressableScale>
72
+ ),
73
+ };
74
+
54
75
  export const Card: Story = {
55
76
  render: (args) => (
56
77
  <PressableScale {...args} accessibilityLabel="Open card">
@@ -32,38 +32,32 @@ const IS_WEB_TOUCH_DEVICE =
32
32
 
33
33
  const SCALE_SUPPORTED = Platform.OS !== 'web' || IS_WEB_TOUCH_DEVICE;
34
34
 
35
- export interface PressableScaleProps
36
- extends Omit<PressableProps, 'children' | 'style'> {
35
+ /**
36
+ * A single animated `Pressable`, built once at module scope so the animated
37
+ * node identity is stable across renders.
38
+ */
39
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
40
+
41
+ export interface PressableScaleProps extends Omit<PressableProps, 'style'> {
37
42
  /** Scale applied while pressed. Defaults to `0.98`. */
38
43
  targetScale?: number;
39
- /** Style for the outer, full-size touch target (stays put while pressing). */
44
+ /** Style applied to the pressable itself the node that scales. */
40
45
  style?: StyleProp<ViewStyle>;
41
- /** Style for the inner content wrapper that actually scales. */
42
- contentContainerStyle?: StyleProp<ViewStyle>;
43
- children?: React.ReactNode;
44
46
  }
45
47
 
46
48
  /**
47
- * A drop-in {@link Pressable} whose content springs to a smaller scale on
48
- * press-in and back on release. The outer pressable stays full size (so the
49
- * touch target never shrinks) while an inner wrapper scales.
49
+ * A drop-in {@link Pressable} that springs to a smaller scale on press-in and
50
+ * back on release. The scale is applied to the pressable node itself, so a
51
+ * consumer's `className` / layout styles (e.g. `flex-row items-center`) land on
52
+ * the same node that animates.
50
53
  *
54
+ * The touch target scales with the content — acceptable at the default ~0.98
55
+ * (the earlier two-node touch-target-preservation was dropped for simplicity).
51
56
  * Honours the OS "reduce motion" setting and disables the effect on non-touch
52
57
  * web pointers.
53
58
  */
54
59
  export const PressableScale = forwardRef<View, PressableScaleProps>(
55
- function PressableScale(
56
- {
57
- targetScale = 0.98,
58
- style,
59
- contentContainerStyle,
60
- children,
61
- onPressIn,
62
- onPressOut,
63
- ...rest
64
- },
65
- ref,
66
- ) {
60
+ function PressableScale({ targetScale = 0.98, style, onPressIn, onPressOut, ...rest }, ref) {
67
61
  const reducedMotion = useReducedMotion();
68
62
  const animate = SCALE_SUPPORTED && !reducedMotion;
69
63
 
@@ -74,7 +68,7 @@ export const PressableScale = forwardRef<View, PressableScaleProps>(
74
68
  }));
75
69
 
76
70
  return (
77
- <Pressable
71
+ <AnimatedPressable
78
72
  ref={ref}
79
73
  accessibilityRole="button"
80
74
  onPressIn={(e) => {
@@ -89,13 +83,9 @@ export const PressableScale = forwardRef<View, PressableScaleProps>(
89
83
  cancelAnimation(scale);
90
84
  scale.value = withTiming(1, { duration: DURATION });
91
85
  }}
92
- style={style}
86
+ style={[animate ? animatedStyle : null, style]}
93
87
  {...rest}
94
- >
95
- <Animated.View style={[animate ? animatedStyle : null, contentContainerStyle]}>
96
- {children}
97
- </Animated.View>
98
- </Pressable>
88
+ />
99
89
  );
100
90
  },
101
91
  );
@@ -14,15 +14,15 @@ export default meta;
14
14
 
15
15
  type Story = StoryObj<typeof SubtleHover>;
16
16
 
17
- /** A row that washes its background as the pointer enters (web). */
18
- function HoverRow({ label, native }: { label: string; native?: boolean }) {
19
- const { state: hovered, onIn, onOut } = useInteractionState();
20
-
17
+ /**
18
+ * Pure-CSS `group-hover`: the parent carries `className="group"` and the wash
19
+ * fades in on hover with ZERO React state (web only).
20
+ */
21
+ function HoverRow({ label }: { label: string }) {
21
22
  return (
22
23
  <Pressable
24
+ {...({ className: 'group' } as Record<string, string>)}
23
25
  accessibilityLabel={label}
24
- onHoverIn={onIn}
25
- onHoverOut={onOut}
26
26
  style={{
27
27
  width: 280,
28
28
  paddingVertical: 14,
@@ -31,34 +31,14 @@ function HoverRow({ label, native }: { label: string; native?: boolean }) {
31
31
  overflow: 'hidden',
32
32
  }}
33
33
  >
34
- <SubtleHover hover={hovered} native={native} style={{ borderRadius: 12 }} />
34
+ <SubtleHover style={{ borderRadius: 12 }} />
35
35
  <Text style={{ fontWeight: '600' }}>{label}</Text>
36
36
  </Pressable>
37
37
  );
38
38
  }
39
39
 
40
- export const ListRow: Story = {
41
- render: () => <HoverRow label="Hover me (web)" />,
42
- };
43
-
44
- export const StrongerWash: Story = {
45
- render: () => {
46
- function Row() {
47
- const { state: hovered, onIn, onOut } = useInteractionState();
48
- return (
49
- <Pressable
50
- accessibilityLabel="Stronger wash"
51
- onHoverIn={onIn}
52
- onHoverOut={onOut}
53
- style={{ width: 280, padding: 16, borderRadius: 12, overflow: 'hidden' }}
54
- >
55
- <SubtleHover hover={hovered} opacity={0.85} style={{ borderRadius: 12 }} />
56
- <Text style={{ fontWeight: '600' }}>Higher opacity override</Text>
57
- </Pressable>
58
- );
59
- }
60
- return <Row />;
61
- },
40
+ export const GroupHover: Story = {
41
+ render: () => <HoverRow label="Hover me (web, group-hover)" />,
62
42
  };
63
43
 
64
44
  export const Stack: Story = {
@@ -70,3 +50,36 @@ export const Stack: Story = {
70
50
  </View>
71
51
  ),
72
52
  };
53
+
54
+ /**
55
+ * JS `active` mode: hovering anywhere in the thread lights EVERY row together
56
+ * (a coordinated highlight a per-element `group-hover` can't express). `native`
57
+ * is opted in so the same wash shows on native surfaces too.
58
+ */
59
+ export const CoordinatedThread: Story = {
60
+ render: () => {
61
+ function Thread() {
62
+ const { state: hovered, onIn, onOut } = useInteractionState();
63
+ return (
64
+ <Pressable onHoverIn={onIn} onHoverOut={onOut} style={{ gap: 4 }}>
65
+ {['First reply', 'Second reply', 'Third reply'].map((label) => (
66
+ <View
67
+ key={label}
68
+ style={{
69
+ width: 280,
70
+ paddingVertical: 14,
71
+ paddingHorizontal: 16,
72
+ borderRadius: 12,
73
+ overflow: 'hidden',
74
+ }}
75
+ >
76
+ <SubtleHover active={hovered} native style={{ borderRadius: 12 }} />
77
+ <Text style={{ fontWeight: '600' }}>{label}</Text>
78
+ </View>
79
+ ))}
80
+ </Pressable>
81
+ );
82
+ }
83
+ return <Thread />;
84
+ },
85
+ };
@@ -4,9 +4,9 @@ import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from 'reac
4
4
  import { useTheme } from '../theme';
5
5
 
6
6
  /**
7
- * True on touch-capable web browsers (coarse pointer). A hover wash only makes
8
- * sense with a hovering pointer, so it is suppressed on touch web to match
9
- * native (which has no hover at all).
7
+ * True on touch-capable web browsers (coarse pointer). The CSS `group-hover`
8
+ * wash only makes sense with a hovering pointer, so it is suppressed on touch
9
+ * web to match native (which has no hover at all).
10
10
  */
11
11
  const IS_WEB_TOUCH_DEVICE =
12
12
  Platform.OS === 'web' &&
@@ -14,62 +14,70 @@ const IS_WEB_TOUCH_DEVICE =
14
14
  typeof window.matchMedia === 'function' &&
15
15
  window.matchMedia('(pointer: coarse)').matches;
16
16
 
17
- /**
18
- * Web-only style that (a) cross-fades the overlay opacity and (b) promotes it to
19
- * its own GPU layer so Safari doesn't nudge the layout by a subpixel when the
20
- * overlay composites on hover. Cast because RN's `ViewStyle` has no `willChange`
21
- * / `transition*` keys; react-native-web forwards them to the DOM node.
22
- */
23
- const WEB_TRANSITION = {
24
- transitionProperty: 'opacity',
25
- transitionDuration: '0.1s',
26
- transitionTimingFunction: 'ease',
27
- willChange: 'opacity',
28
- } as ViewStyle;
29
-
30
17
  export interface SubtleHoverProps {
31
- /** Whether the parent is currently hovered. Drives the overlay opacity. */
32
- hover: boolean;
33
18
  /**
34
- * Overlay opacity when hovered. Defaults to a mode-tuned value (0.4 dark /
35
- * 0.5 light) that reads as a gentle contrast wash.
19
+ * JS-driven visibility for a coordinated highlight e.g. every post of one
20
+ * thread lighting up together. When omitted, the wash instead follows CSS
21
+ * `group-hover` on web with ZERO React state: the parent element must carry
22
+ * `className="group"`.
36
23
  */
37
- opacity?: number;
24
+ active?: boolean;
38
25
  /** Extra style merged last — e.g. `borderRadius` to match the parent. */
39
26
  style?: StyleProp<ViewStyle>;
40
27
  /**
41
- * Opt in to rendering on native. Off by default: hover is a pointer
42
- * affordance, so this is a web-only overlay unless a native surface wants the
43
- * same wash driven by another interaction (e.g. press).
28
+ * Render on native too (default `false`). Hover is a web pointer affordance,
29
+ * so the CSS `group-hover` mode is web-only; native only shows the wash in the
30
+ * JS `active` mode, and only when this is set.
44
31
  */
45
32
  native?: boolean;
46
33
  }
47
34
 
48
35
  /**
49
- * An absolutely-positioned, non-interactive contrast wash that fades in while
50
- * its parent is hovered. Ported from Bluesky's `SubtleHover`. Pairs naturally
51
- * with {@link useInteractionState} pass its `state` as `hover`.
36
+ * An absolutely-positioned, non-interactive contrast wash. Two modes:
37
+ *
38
+ * - **CSS `group-hover`** (`active` omitted): the wash fades in on hover purely
39
+ * through NativeWind classes — no React state. The parent must carry
40
+ * `className="group"`. Web-only (native and touch-web have no hover).
41
+ * - **JS `active`**: the wash opacity follows the boolean, so several elements
42
+ * can light up together (e.g. every post of a thread). Web always; native
43
+ * only when `native` is set.
52
44
  *
53
- * Renders nothing on touch-web (no hovering pointer) and on native unless
54
- * `native` is set. Color comes from the theme's `contrast50` token so the wash
55
- * tracks the active preset and light/dark mode.
45
+ * The wash color comes from the theme's `contrast50` token so it tracks the
46
+ * active preset and light/dark mode. The web cross-fade is a NativeWind
47
+ * `transition-opacity` class no manual `willChange` / web-style casts.
56
48
  */
57
- export function SubtleHover({ hover, opacity, style, native = false }: SubtleHoverProps) {
49
+ export function SubtleHover({ active, style, native = false }: SubtleHoverProps) {
58
50
  const theme = useTheme();
59
-
60
51
  const isWeb = Platform.OS === 'web';
61
- if (isWeb ? IS_WEB_TOUCH_DEVICE : !native) {
62
- return null;
63
- }
64
52
 
65
- const targetOpacity = opacity ?? (theme.isDark ? 0.4 : 0.5);
53
+ // Full literal class strings (never concatenated fragments) so NativeWind's
54
+ // compiler statically extracts every variant.
55
+ if (active === undefined) {
56
+ // CSS group-hover: a web pointer affordance. Native has no hover, and
57
+ // neither does touch web — render nothing there.
58
+ if (!isWeb || IS_WEB_TOUCH_DEVICE) return null;
59
+ return (
60
+ <View
61
+ {...({
62
+ className:
63
+ 'opacity-0 group-hover:opacity-50 dark:group-hover:opacity-40 transition-opacity duration-150',
64
+ } as Record<string, string>)}
65
+ style={[styles.overlay, { backgroundColor: theme.colors.contrast50 }, style]}
66
+ />
67
+ );
68
+ }
66
69
 
70
+ // JS-driven coordinated mode: web always; native only when opted in.
71
+ if (!isWeb && !native) return null;
67
72
  return (
68
73
  <View
74
+ {...({ className: 'transition-opacity duration-150' } as Record<string, string>)}
69
75
  style={[
70
76
  styles.overlay,
71
- { backgroundColor: theme.colors.contrast50, opacity: hover ? targetOpacity : 0 },
72
- isWeb ? WEB_TRANSITION : null,
77
+ {
78
+ backgroundColor: theme.colors.contrast50,
79
+ opacity: active ? (theme.isDark ? 0.4 : 0.5) : 0,
80
+ },
73
81
  style,
74
82
  ]}
75
83
  />