@oxyhq/bloom 0.9.1 → 0.11.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/admonition/index.js +2 -2
  2. package/lib/commonjs/admonition/index.js.map +1 -1
  3. package/lib/commonjs/alert-dialog/AlertDialog.js +5 -5
  4. package/lib/commonjs/alert-dialog/AlertDialog.js.map +1 -1
  5. package/lib/commonjs/button/Button.js +71 -12
  6. package/lib/commonjs/button/Button.js.map +1 -1
  7. package/lib/commonjs/button/Button.web.js +431 -0
  8. package/lib/commonjs/button/Button.web.js.map +1 -0
  9. package/lib/commonjs/button/index.js +25 -1
  10. package/lib/commonjs/button/index.js.map +1 -1
  11. package/lib/commonjs/button/index.web.js +67 -0
  12. package/lib/commonjs/button/index.web.js.map +1 -0
  13. package/lib/commonjs/index.js +70 -70
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +84 -84
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/menu/index.js +2 -2
  18. package/lib/commonjs/menu/index.js.map +1 -1
  19. package/lib/commonjs/search-input/index.js +2 -2
  20. package/lib/commonjs/search-input/index.js.map +1 -1
  21. package/lib/commonjs/select/index.js +4 -4
  22. package/lib/commonjs/select/index.js.map +1 -1
  23. package/lib/module/admonition/index.js +1 -1
  24. package/lib/module/admonition/index.js.map +1 -1
  25. package/lib/module/alert-dialog/AlertDialog.js +1 -1
  26. package/lib/module/alert-dialog/AlertDialog.js.map +1 -1
  27. package/lib/module/button/Button.js +70 -11
  28. package/lib/module/button/Button.js.map +1 -1
  29. package/lib/module/button/Button.web.js +426 -0
  30. package/lib/module/button/Button.web.js.map +1 -0
  31. package/lib/module/button/index.js +1 -1
  32. package/lib/module/button/index.js.map +1 -1
  33. package/lib/module/button/index.web.js +17 -0
  34. package/lib/module/button/index.web.js.map +1 -0
  35. package/lib/module/index.js +1 -1
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/index.web.js +1 -1
  38. package/lib/module/index.web.js.map +1 -1
  39. package/lib/module/menu/index.js +1 -1
  40. package/lib/module/menu/index.js.map +1 -1
  41. package/lib/module/search-input/index.js +1 -1
  42. package/lib/module/search-input/index.js.map +1 -1
  43. package/lib/module/select/index.js +1 -1
  44. package/lib/module/select/index.js.map +1 -1
  45. package/lib/typescript/commonjs/button/Button.d.ts +3 -0
  46. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/button/Button.web.d.ts +14 -0
  48. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/button/index.d.ts +1 -1
  50. package/lib/typescript/commonjs/button/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/button/index.web.d.ts +3 -0
  52. package/lib/typescript/commonjs/button/index.web.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/button/types.d.ts +72 -2
  54. package/lib/typescript/commonjs/button/types.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.web.d.ts +1 -1
  56. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  57. package/lib/typescript/module/button/Button.d.ts +3 -0
  58. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  59. package/lib/typescript/module/button/Button.web.d.ts +14 -0
  60. package/lib/typescript/module/button/Button.web.d.ts.map +1 -0
  61. package/lib/typescript/module/button/index.d.ts +1 -1
  62. package/lib/typescript/module/button/index.d.ts.map +1 -1
  63. package/lib/typescript/module/button/index.web.d.ts +3 -0
  64. package/lib/typescript/module/button/index.web.d.ts.map +1 -0
  65. package/lib/typescript/module/button/types.d.ts +72 -2
  66. package/lib/typescript/module/button/types.d.ts.map +1 -1
  67. package/lib/typescript/module/index.web.d.ts +1 -1
  68. package/lib/typescript/module/index.web.d.ts.map +1 -1
  69. package/package.json +8 -1
  70. package/src/__tests__/Button.web.test.tsx +201 -0
  71. package/src/button/Button.tsx +77 -12
  72. package/src/button/Button.web.tsx +472 -0
  73. package/src/button/index.ts +4 -0
  74. package/src/button/index.web.ts +27 -0
  75. package/src/button/types.ts +98 -2
  76. package/src/index.web.ts +1 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react';
6
+ import { act } from 'react';
7
+ import { createRoot, type Root } from 'react-dom/client';
8
+ import { getByRole, getByText, getByLabelText, fireEvent } from '@testing-library/dom';
9
+ import '@testing-library/jest-dom';
10
+
11
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
12
+ import {
13
+ Button,
14
+ OutlineButton,
15
+ LinkButton,
16
+ DestructiveButton,
17
+ } from '../button/Button.web';
18
+
19
+ // react-dom 19 logs a guard unless this flag is set in test environments.
20
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ let container: HTMLDivElement;
23
+ let root: Root;
24
+
25
+ function mount(ui: React.ReactElement): HTMLElement {
26
+ act(() => {
27
+ root.render(
28
+ <BloomThemeProvider mode="light" colorPreset="teal">
29
+ {ui}
30
+ </BloomThemeProvider>,
31
+ );
32
+ });
33
+ return container;
34
+ }
35
+
36
+ beforeEach(() => {
37
+ container = document.createElement('div');
38
+ document.body.appendChild(container);
39
+ root = createRoot(container);
40
+ });
41
+
42
+ afterEach(() => {
43
+ act(() => root.unmount());
44
+ container.remove();
45
+ });
46
+
47
+ describe('Button.web', () => {
48
+ it('renders a real <button> element', () => {
49
+ const c = mount(<Button>Click me</Button>);
50
+ const btn = getByRole(c, 'button', { name: 'Click me' });
51
+ expect(btn.tagName).toBe('BUTTON');
52
+ });
53
+
54
+ it('defaults to type="button"', () => {
55
+ const c = mount(<Button>Default</Button>);
56
+ expect(getByRole(c, 'button')).toHaveAttribute('type', 'button');
57
+ });
58
+
59
+ it('supports type="submit" for form participation', () => {
60
+ const onSubmit = jest.fn((e: Event) => e.preventDefault());
61
+ const c = mount(
62
+ <form onSubmit={onSubmit as unknown as React.FormEventHandler}>
63
+ <Button type="submit">Submit</Button>
64
+ </form>,
65
+ );
66
+ const btn = getByRole(c, 'button', { name: 'Submit' });
67
+ expect(btn).toHaveAttribute('type', 'submit');
68
+ act(() => {
69
+ fireEvent.click(btn);
70
+ });
71
+ expect(onSubmit).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('fires both onClick and onPress on click', () => {
75
+ const onClick = jest.fn();
76
+ const onPress = jest.fn();
77
+ const c = mount(
78
+ <Button onClick={onClick} onPress={onPress}>
79
+ Go
80
+ </Button>,
81
+ );
82
+ act(() => {
83
+ fireEvent.click(getByRole(c, 'button'));
84
+ });
85
+ expect(onClick).toHaveBeenCalledTimes(1);
86
+ expect(onPress).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it('does not fire handlers when disabled', () => {
90
+ const onPress = jest.fn();
91
+ const c = mount(
92
+ <Button disabled onPress={onPress}>
93
+ Nope
94
+ </Button>,
95
+ );
96
+ const btn = getByRole(c, 'button');
97
+ expect(btn).toBeDisabled();
98
+ act(() => {
99
+ fireEvent.click(btn);
100
+ });
101
+ expect(onPress).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('does not fire onPress when loading and marks aria-busy', () => {
105
+ const onPress = jest.fn();
106
+ const c = mount(
107
+ <Button loading onPress={onPress}>
108
+ Saving
109
+ </Button>,
110
+ );
111
+ const btn = getByRole(c, 'button');
112
+ expect(btn).toHaveAttribute('aria-busy', 'true');
113
+ act(() => {
114
+ fireEvent.click(btn);
115
+ });
116
+ expect(onPress).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('keeps children mounted while loading so width is preserved', () => {
120
+ const c = mount(<Button loading>Submit</Button>);
121
+ expect(getByText(c, 'Submit')).toBeTruthy();
122
+ });
123
+
124
+ it('passes className through so consumer layout classes win', () => {
125
+ const c = mount(<Button className="w-full custom">Wide</Button>);
126
+ const btn = getByRole(c, 'button');
127
+ expect(btn).toHaveClass('bloom-btn');
128
+ expect(btn).toHaveClass('w-full');
129
+ expect(btn).toHaveClass('custom');
130
+ });
131
+
132
+ it('applies aria-label from accessibilityLabel', () => {
133
+ const c = mount(<Button accessibilityLabel="Save changes">Save</Button>);
134
+ expect(getByLabelText(c, 'Save changes')).toBeTruthy();
135
+ });
136
+
137
+ it('exposes testID as data-testid', () => {
138
+ const c = mount(<Button testID="my-btn">Test</Button>);
139
+ expect(c.querySelector('[data-testid="my-btn"]')?.tagName).toBe('BUTTON');
140
+ });
141
+
142
+ describe('asChild', () => {
143
+ it('renders the child element (anchor) instead of a button', () => {
144
+ const c = mount(
145
+ <Button asChild>
146
+ <a href="/go">Go</a>
147
+ </Button>,
148
+ );
149
+ const link = getByRole(c, 'link', { name: 'Go' });
150
+ expect(link.tagName).toBe('A');
151
+ expect(link).toHaveAttribute('href', '/go');
152
+ expect(link).toHaveClass('bloom-btn');
153
+ });
154
+
155
+ it('merges onPress onto the child', () => {
156
+ const onPress = jest.fn();
157
+ const c = mount(
158
+ <Button asChild onPress={onPress}>
159
+ <a href="/go">Go</a>
160
+ </Button>,
161
+ );
162
+ act(() => {
163
+ fireEvent.click(getByRole(c, 'link'));
164
+ });
165
+ expect(onPress).toHaveBeenCalledTimes(1);
166
+ });
167
+ });
168
+
169
+ describe('web variants', () => {
170
+ it('OutlineButton renders a button', () => {
171
+ const c = mount(<OutlineButton>Outline</OutlineButton>);
172
+ expect(getByRole(c, 'button', { name: 'Outline' }).tagName).toBe('BUTTON');
173
+ });
174
+
175
+ it('LinkButton renders with the link modifier class', () => {
176
+ const c = mount(<LinkButton>Link</LinkButton>);
177
+ expect(getByRole(c, 'button', { name: 'Link' })).toHaveClass('bloom-btn--link');
178
+ });
179
+
180
+ it('DestructiveButton renders a button', () => {
181
+ const c = mount(<DestructiveButton>Delete</DestructiveButton>);
182
+ expect(getByRole(c, 'button', { name: 'Delete' }).tagName).toBe('BUTTON');
183
+ });
184
+ });
185
+
186
+ describe('size aliases', () => {
187
+ it('accepts shadcn-style sm size', () => {
188
+ const c = mount(<Button size="sm">Small</Button>);
189
+ expect(getByRole(c, 'button', { name: 'Small' }).tagName).toBe('BUTTON');
190
+ });
191
+
192
+ it('size="icon" selects the icon variant', () => {
193
+ const c = mount(
194
+ <Button size="icon" accessibilityLabel="icon">
195
+ x
196
+ </Button>,
197
+ );
198
+ expect(getByRole(c, 'button', { name: 'icon' }).tagName).toBe('BUTTON');
199
+ });
200
+ });
201
+ });
@@ -14,10 +14,30 @@ import {
14
14
  import { useTheme } from '../theme/use-theme';
15
15
  import { usePressAnimation } from '../hooks/usePressAnimation';
16
16
  import { useInteractionState } from '../hooks/useInteractionState';
17
- import type { ButtonProps } from './types';
17
+ import type { ButtonProps, ButtonSize, ButtonVariant } from './types';
18
18
 
19
19
  export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
20
20
 
21
+ /**
22
+ * The shared {@link ButtonVariant}/{@link ButtonSize} types include web-only
23
+ * additions (shadcn variants + size aliases) so a single call site type-checks
24
+ * on both platforms. Native normalizes them to its own primitives:
25
+ * - `outline → secondary`, `link → text`, `destructive → primary` (tinted)
26
+ * - `sm|md|lg|icon` size aliases → `small|medium|large`
27
+ */
28
+ type NativeVariant = 'primary' | 'secondary' | 'inverse' | 'icon' | 'ghost' | 'text';
29
+ type NativeSize = 'small' | 'medium' | 'large';
30
+
31
+ const SIZE_ALIAS: Record<ButtonSize, NativeSize> = {
32
+ small: 'small',
33
+ medium: 'medium',
34
+ large: 'large',
35
+ sm: 'small',
36
+ md: 'medium',
37
+ lg: 'large',
38
+ icon: 'medium',
39
+ };
40
+
21
41
  const SIZE_CONFIG = {
22
42
  small: {
23
43
  paddingVertical: 6,
@@ -41,6 +61,11 @@ const SIZE_CONFIG = {
41
61
 
42
62
  const ICON_HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } as const;
43
63
 
64
+ // Fully-rounded (pill/capsule) corner radius. Large enough to stay a perfect
65
+ // capsule at any button height. Shared by every text button variant so they all
66
+ // read as pills; the icon variant keeps its own large radius to stay a circle.
67
+ const PILL_RADIUS = 999;
68
+
44
69
  const PRESS_SCALE = 0.97;
45
70
  const SCALE_VARIANTS = new Set<string>(['primary', 'secondary', 'inverse']);
46
71
 
@@ -48,8 +73,8 @@ const ButtonComponent: React.FC<ButtonProps> = ({
48
73
  onPress,
49
74
  children,
50
75
  disabled = false,
51
- variant = 'primary',
52
- size = 'medium',
76
+ variant: variantProp = 'primary',
77
+ size: sizeProp = 'medium',
53
78
  style,
54
79
  textStyle,
55
80
  icon,
@@ -64,6 +89,23 @@ const ButtonComponent: React.FC<ButtonProps> = ({
64
89
  className,
65
90
  }) => {
66
91
  const theme = useTheme();
92
+ // The shadcn `size="icon"` shorthand also selects the icon variant unless an
93
+ // explicit variant was given by the caller.
94
+ const variant: NativeVariant = useMemo(() => {
95
+ if (sizeProp === 'icon' && variantProp === 'primary') return 'icon';
96
+ switch (variantProp) {
97
+ case 'outline':
98
+ return 'secondary';
99
+ case 'link':
100
+ return 'text';
101
+ case 'destructive':
102
+ return 'primary';
103
+ default:
104
+ return variantProp;
105
+ }
106
+ }, [variantProp, sizeProp]);
107
+ const isDestructive = variantProp === 'destructive';
108
+ const size: NativeSize = SIZE_ALIAS[sizeProp];
67
109
  const hasScaleFeedback = SCALE_VARIANTS.has(variant);
68
110
  const isInteractionBlocked = disabled || loading;
69
111
  const { scaleAnim, onPressIn: onScalePressIn, onPressOut: onScalePressOut } =
@@ -112,43 +154,48 @@ const ButtonComponent: React.FC<ButtonProps> = ({
112
154
 
113
155
  switch (variant) {
114
156
  case 'primary':
115
- styles.backgroundColor = theme.colors.primary;
116
- styles.borderRadius = 20;
157
+ styles.backgroundColor = isDestructive
158
+ ? theme.colors.negative
159
+ : theme.colors.primary;
160
+ styles.borderRadius = PILL_RADIUS;
117
161
  break;
118
162
  case 'secondary':
119
163
  styles.backgroundColor = 'transparent';
120
164
  styles.borderWidth = 1;
121
165
  styles.borderColor = theme.colors.border;
122
- styles.borderRadius = 20;
166
+ styles.borderRadius = PILL_RADIUS;
123
167
  break;
124
168
  case 'inverse':
125
169
  styles.backgroundColor = '#FFFFFF';
126
- styles.borderRadius = 20;
170
+ styles.borderRadius = PILL_RADIUS;
127
171
  break;
128
172
  case 'icon':
129
- styles.borderRadius = 100;
173
+ styles.borderRadius = PILL_RADIUS;
130
174
  styles.padding = 8;
131
175
  styles.width = sizeConfig.minHeight;
132
176
  styles.height = sizeConfig.minHeight;
133
177
  break;
134
178
  case 'ghost':
135
179
  styles.backgroundColor = 'transparent';
136
- styles.borderRadius = 8;
180
+ styles.borderRadius = PILL_RADIUS;
137
181
  break;
138
182
  case 'text':
139
183
  styles.backgroundColor = 'transparent';
184
+ styles.borderRadius = PILL_RADIUS;
140
185
  styles.paddingVertical = 4;
141
186
  styles.paddingHorizontal = 8;
142
187
  break;
143
188
  }
144
189
 
145
190
  return styles;
146
- }, [variant, size, theme]);
191
+ }, [variant, size, theme, isDestructive]);
147
192
 
148
193
  const resolvedTextColor = useMemo((): string => {
149
194
  switch (variant) {
150
195
  case 'primary':
151
- return theme.colors.primaryForeground;
196
+ return isDestructive
197
+ ? theme.colors.negativeForeground
198
+ : theme.colors.primaryForeground;
152
199
  case 'secondary':
153
200
  return theme.colors.text;
154
201
  case 'inverse':
@@ -160,7 +207,7 @@ const ButtonComponent: React.FC<ButtonProps> = ({
160
207
  default:
161
208
  return theme.colors.text;
162
209
  }
163
- }, [variant, theme]);
210
+ }, [variant, theme, isDestructive]);
164
211
 
165
212
  const computedTextStyle = useMemo((): TextStyle => {
166
213
  const sizeConfig = SIZE_CONFIG[size];
@@ -275,3 +322,21 @@ export const TextButton = memo((props: Omit<ButtonProps, 'variant'>) => (
275
322
  <Button {...props} variant="text" />
276
323
  ));
277
324
  TextButton.displayName = 'TextButton';
325
+
326
+ // Web/shadcn-aligned variants. On native they normalize to existing primitives
327
+ // (`outline → secondary`, `link → text`, `destructive → primary` tinted with the
328
+ // negative token) inside `Button`, so these stay API-parallel with the web fork.
329
+ export const OutlineButton = memo((props: Omit<ButtonProps, 'variant'>) => (
330
+ <Button {...props} variant="outline" />
331
+ ));
332
+ OutlineButton.displayName = 'OutlineButton';
333
+
334
+ export const LinkButton = memo((props: Omit<ButtonProps, 'variant'>) => (
335
+ <Button {...props} variant="link" />
336
+ ));
337
+ LinkButton.displayName = 'LinkButton';
338
+
339
+ export const DestructiveButton = memo((props: Omit<ButtonProps, 'variant'>) => (
340
+ <Button {...props} variant="destructive" />
341
+ ));
342
+ DestructiveButton.displayName = 'DestructiveButton';