@oxyhq/bloom 0.9.1 → 0.10.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 +60 -7
  6. package/lib/commonjs/button/Button.js.map +1 -1
  7. package/lib/commonjs/button/Button.web.js +427 -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 +59 -6
  28. package/lib/module/button/Button.js.map +1 -1
  29. package/lib/module/button/Button.web.js +422 -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 +66 -7
  72. package/src/button/Button.web.tsx +469 -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,
@@ -48,8 +68,8 @@ const ButtonComponent: React.FC<ButtonProps> = ({
48
68
  onPress,
49
69
  children,
50
70
  disabled = false,
51
- variant = 'primary',
52
- size = 'medium',
71
+ variant: variantProp = 'primary',
72
+ size: sizeProp = 'medium',
53
73
  style,
54
74
  textStyle,
55
75
  icon,
@@ -64,6 +84,23 @@ const ButtonComponent: React.FC<ButtonProps> = ({
64
84
  className,
65
85
  }) => {
66
86
  const theme = useTheme();
87
+ // The shadcn `size="icon"` shorthand also selects the icon variant unless an
88
+ // explicit variant was given by the caller.
89
+ const variant: NativeVariant = useMemo(() => {
90
+ if (sizeProp === 'icon' && variantProp === 'primary') return 'icon';
91
+ switch (variantProp) {
92
+ case 'outline':
93
+ return 'secondary';
94
+ case 'link':
95
+ return 'text';
96
+ case 'destructive':
97
+ return 'primary';
98
+ default:
99
+ return variantProp;
100
+ }
101
+ }, [variantProp, sizeProp]);
102
+ const isDestructive = variantProp === 'destructive';
103
+ const size: NativeSize = SIZE_ALIAS[sizeProp];
67
104
  const hasScaleFeedback = SCALE_VARIANTS.has(variant);
68
105
  const isInteractionBlocked = disabled || loading;
69
106
  const { scaleAnim, onPressIn: onScalePressIn, onPressOut: onScalePressOut } =
@@ -112,7 +149,9 @@ const ButtonComponent: React.FC<ButtonProps> = ({
112
149
 
113
150
  switch (variant) {
114
151
  case 'primary':
115
- styles.backgroundColor = theme.colors.primary;
152
+ styles.backgroundColor = isDestructive
153
+ ? theme.colors.negative
154
+ : theme.colors.primary;
116
155
  styles.borderRadius = 20;
117
156
  break;
118
157
  case 'secondary':
@@ -143,12 +182,14 @@ const ButtonComponent: React.FC<ButtonProps> = ({
143
182
  }
144
183
 
145
184
  return styles;
146
- }, [variant, size, theme]);
185
+ }, [variant, size, theme, isDestructive]);
147
186
 
148
187
  const resolvedTextColor = useMemo((): string => {
149
188
  switch (variant) {
150
189
  case 'primary':
151
- return theme.colors.primaryForeground;
190
+ return isDestructive
191
+ ? theme.colors.negativeForeground
192
+ : theme.colors.primaryForeground;
152
193
  case 'secondary':
153
194
  return theme.colors.text;
154
195
  case 'inverse':
@@ -160,7 +201,7 @@ const ButtonComponent: React.FC<ButtonProps> = ({
160
201
  default:
161
202
  return theme.colors.text;
162
203
  }
163
- }, [variant, theme]);
204
+ }, [variant, theme, isDestructive]);
164
205
 
165
206
  const computedTextStyle = useMemo((): TextStyle => {
166
207
  const sizeConfig = SIZE_CONFIG[size];
@@ -275,3 +316,21 @@ export const TextButton = memo((props: Omit<ButtonProps, 'variant'>) => (
275
316
  <Button {...props} variant="text" />
276
317
  ));
277
318
  TextButton.displayName = 'TextButton';
319
+
320
+ // Web/shadcn-aligned variants. On native they normalize to existing primitives
321
+ // (`outline → secondary`, `link → text`, `destructive → primary` tinted with the
322
+ // negative token) inside `Button`, so these stay API-parallel with the web fork.
323
+ export const OutlineButton = memo((props: Omit<ButtonProps, 'variant'>) => (
324
+ <Button {...props} variant="outline" />
325
+ ));
326
+ OutlineButton.displayName = 'OutlineButton';
327
+
328
+ export const LinkButton = memo((props: Omit<ButtonProps, 'variant'>) => (
329
+ <Button {...props} variant="link" />
330
+ ));
331
+ LinkButton.displayName = 'LinkButton';
332
+
333
+ export const DestructiveButton = memo((props: Omit<ButtonProps, 'variant'>) => (
334
+ <Button {...props} variant="destructive" />
335
+ ));
336
+ DestructiveButton.displayName = 'DestructiveButton';