@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,469 @@
1
+ import React, {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ type CSSProperties,
8
+ type MouseEvent,
9
+ type ReactElement,
10
+ } from 'react';
11
+
12
+ import { useTheme } from '../theme/use-theme';
13
+ import type { Theme } from '../theme/types';
14
+ import { SpinnerIcon } from '../loading/SpinnerIcon.web';
15
+ import type { ButtonProps, ButtonSize, ButtonVariant } from './types';
16
+
17
+ export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Geometry — mirrors the native (`Button.tsx`) impl exactly so the web button
21
+ // is pixel-consistent with native. Sizes below are the resolved primitives;
22
+ // the shadcn-style aliases (`sm | md | lg | icon`) are normalized first.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type NativeSize = 'small' | 'medium' | 'large';
26
+
27
+ interface SizeConfig {
28
+ paddingVertical: number;
29
+ paddingHorizontal: number;
30
+ fontSize: number;
31
+ minHeight: number;
32
+ }
33
+
34
+ const SIZE_CONFIG: Record<NativeSize, SizeConfig> = {
35
+ small: { paddingVertical: 6, paddingHorizontal: 12, fontSize: 14, minHeight: 32 },
36
+ medium: { paddingVertical: 8, paddingHorizontal: 16, fontSize: 15, minHeight: 40 },
37
+ large: { paddingVertical: 12, paddingHorizontal: 20, fontSize: 16, minHeight: 48 },
38
+ };
39
+
40
+ const SIZE_ALIAS: Record<ButtonSize, NativeSize> = {
41
+ small: 'small',
42
+ medium: 'medium',
43
+ large: 'large',
44
+ sm: 'small',
45
+ md: 'medium',
46
+ lg: 'large',
47
+ icon: 'medium',
48
+ };
49
+
50
+ const PILL_RADIUS = 20;
51
+ const GHOST_RADIUS = 8;
52
+ const ICON_RADIUS = 100;
53
+ const PRESS_SCALE = 0.97;
54
+
55
+ /** Variants that get a tactile press-scale (matches native `SCALE_VARIANTS`). */
56
+ const SCALE_VARIANTS = new Set<ButtonVariant>(['primary', 'secondary', 'inverse', 'destructive']);
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Per-state CSS injection
60
+ //
61
+ // Inline styles cannot express `:hover` / `:focus-visible` / `:active` /
62
+ // `:disabled`, and the keyboard-focus ring MUST be a `:focus-visible` rule
63
+ // (not always-on) for correct mouse-vs-keyboard behavior. We inject one static
64
+ // stylesheet (keyed by id, once) that defines the interaction behavior for the
65
+ // base `bloom-btn` class; per-instance resolved colors stay inline.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const STYLE_ID = 'bloom-button-web-css';
69
+
70
+ /**
71
+ * The focus ring color is read from a CSS custom property the component sets
72
+ * inline per-instance (`--bloom-btn-ring`), so the static rule can theme itself
73
+ * from the resolved token without a per-instance stylesheet.
74
+ */
75
+ const BLOOM_BUTTON_CSS = `
76
+ .bloom-btn {
77
+ appearance: none;
78
+ -webkit-appearance: none;
79
+ box-sizing: border-box;
80
+ display: inline-flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ flex-direction: row;
84
+ gap: 8px;
85
+ position: relative;
86
+ margin: 0;
87
+ border: none;
88
+ background: transparent;
89
+ font-family: inherit;
90
+ text-decoration: none;
91
+ cursor: pointer;
92
+ user-select: none;
93
+ outline: none;
94
+ transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease, border-color 120ms ease;
95
+ }
96
+ .bloom-btn:disabled,
97
+ .bloom-btn[aria-disabled="true"] {
98
+ cursor: default;
99
+ opacity: 0.5;
100
+ }
101
+ .bloom-btn:not(:disabled):not([aria-disabled="true"]):hover {
102
+ opacity: 0.9;
103
+ }
104
+ .bloom-btn:not(:disabled):not([aria-disabled="true"]):active {
105
+ transform: scale(var(--bloom-btn-press-scale, 1));
106
+ }
107
+ .bloom-btn:focus-visible {
108
+ outline: 2px solid var(--bloom-btn-ring, currentColor);
109
+ outline-offset: 2px;
110
+ }
111
+ .bloom-btn--link:not(:disabled):not([aria-disabled="true"]):hover {
112
+ opacity: 1;
113
+ text-decoration: underline;
114
+ }
115
+ `;
116
+
117
+ function useButtonCss(): void {
118
+ useEffect(() => {
119
+ if (typeof document === 'undefined') return;
120
+ if (document.getElementById(STYLE_ID)) return;
121
+ const style = document.createElement('style');
122
+ style.id = STYLE_ID;
123
+ style.textContent = BLOOM_BUTTON_CSS;
124
+ document.head.appendChild(style);
125
+ }, []);
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Variant styling — all colors come from the SAME Bloom theme tokens native
130
+ // uses, so primary/secondary/etc. look identical across platforms. The added
131
+ // web variants (`outline | link | destructive`) are styled from those tokens
132
+ // too (no new palette).
133
+ // ---------------------------------------------------------------------------
134
+
135
+ interface VariantStyle {
136
+ /** Container CSS (background, border, radius). */
137
+ container: CSSProperties;
138
+ /** Resolved text/icon color. */
139
+ textColor: string;
140
+ /** The `:focus-visible` ring color. */
141
+ ringColor: string;
142
+ }
143
+
144
+ function resolveVariantStyle(
145
+ variant: ButtonVariant,
146
+ size: NativeSize,
147
+ theme: Theme,
148
+ ): VariantStyle {
149
+ const c = theme.colors;
150
+ const sizeConfig = SIZE_CONFIG[size];
151
+
152
+ switch (variant) {
153
+ case 'primary':
154
+ return {
155
+ container: { backgroundColor: c.primary, borderRadius: PILL_RADIUS },
156
+ textColor: c.primaryForeground,
157
+ ringColor: c.primary,
158
+ };
159
+ case 'destructive':
160
+ return {
161
+ container: { backgroundColor: c.negative, borderRadius: PILL_RADIUS },
162
+ textColor: c.negativeForeground,
163
+ ringColor: c.negative,
164
+ };
165
+ case 'inverse':
166
+ return {
167
+ container: { backgroundColor: '#FFFFFF', borderRadius: PILL_RADIUS },
168
+ textColor: '#000000',
169
+ ringColor: c.primary,
170
+ };
171
+ case 'secondary':
172
+ case 'outline':
173
+ return {
174
+ container: {
175
+ backgroundColor: 'transparent',
176
+ borderWidth: 1,
177
+ borderStyle: 'solid',
178
+ borderColor: c.border,
179
+ borderRadius: PILL_RADIUS,
180
+ },
181
+ textColor: c.text,
182
+ ringColor: c.primary,
183
+ };
184
+ case 'ghost':
185
+ return {
186
+ container: { backgroundColor: 'transparent', borderRadius: GHOST_RADIUS },
187
+ textColor: c.primary,
188
+ ringColor: c.primary,
189
+ };
190
+ case 'icon':
191
+ return {
192
+ container: {
193
+ backgroundColor: c.background,
194
+ borderWidth: 1,
195
+ borderStyle: 'solid',
196
+ borderColor: c.border,
197
+ borderRadius: ICON_RADIUS,
198
+ padding: 8,
199
+ width: sizeConfig.minHeight,
200
+ height: sizeConfig.minHeight,
201
+ },
202
+ textColor: c.text,
203
+ ringColor: c.primary,
204
+ };
205
+ case 'text':
206
+ case 'link':
207
+ default:
208
+ return {
209
+ container: { backgroundColor: 'transparent', borderRadius: 0 },
210
+ textColor: c.primary,
211
+ ringColor: c.primary,
212
+ };
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Component
218
+ // ---------------------------------------------------------------------------
219
+
220
+ const ButtonWebComponent: React.FC<ButtonProps> = ({
221
+ onPress,
222
+ onClick,
223
+ children,
224
+ disabled = false,
225
+ variant = 'primary',
226
+ size: sizeProp = 'medium',
227
+ style,
228
+ icon,
229
+ iconPosition = 'left',
230
+ loading = false,
231
+ loadingColor,
232
+ accessibilityLabel,
233
+ 'aria-label': ariaLabelProp,
234
+ accessibilityHint,
235
+ testID,
236
+ className,
237
+ type = 'button',
238
+ asChild = false,
239
+ id,
240
+ name,
241
+ value,
242
+ title,
243
+ autoFocus,
244
+ tabIndex,
245
+ fullWidth = false,
246
+ }) => {
247
+ useButtonCss();
248
+ const theme = useTheme();
249
+ const reactId = useId();
250
+ const resolvedId = id ?? `bloom-btn-${reactId}`;
251
+
252
+ // `size="icon"` shorthand also selects the icon variant unless the caller
253
+ // explicitly picked one — mirrors native + shadcn behavior.
254
+ const resolvedVariant: ButtonVariant =
255
+ sizeProp === 'icon' && variant === 'primary' ? 'icon' : variant;
256
+ const size: NativeSize = SIZE_ALIAS[sizeProp];
257
+ const isIcon = resolvedVariant === 'icon';
258
+ const isInteractionBlocked = disabled || loading;
259
+
260
+ const variantStyle = useMemo(
261
+ () => resolveVariantStyle(resolvedVariant, size, theme),
262
+ [resolvedVariant, size, theme],
263
+ );
264
+
265
+ const sizeConfig = SIZE_CONFIG[size];
266
+
267
+ const containerStyle = useMemo((): CSSProperties => {
268
+ const base: CSSProperties = {
269
+ fontSize: sizeConfig.fontSize,
270
+ fontWeight: 'bold',
271
+ color: variantStyle.textColor,
272
+ width: fullWidth ? '100%' : undefined,
273
+ // CSS custom props consumed by the static stylesheet.
274
+ ['--bloom-btn-ring' as string]: variantStyle.ringColor,
275
+ ['--bloom-btn-press-scale' as string]: SCALE_VARIANTS.has(resolvedVariant)
276
+ ? PRESS_SCALE
277
+ : 1,
278
+ ...variantStyle.container,
279
+ };
280
+ if (!isIcon) {
281
+ base.paddingTop = sizeConfig.paddingVertical;
282
+ base.paddingBottom = sizeConfig.paddingVertical;
283
+ base.paddingLeft = sizeConfig.paddingHorizontal;
284
+ base.paddingRight = sizeConfig.paddingHorizontal;
285
+ base.minHeight = sizeConfig.minHeight;
286
+ }
287
+ if (resolvedVariant === 'text' || resolvedVariant === 'link') {
288
+ base.paddingTop = 4;
289
+ base.paddingBottom = 4;
290
+ base.paddingLeft = 8;
291
+ base.paddingRight = 8;
292
+ base.minHeight = undefined;
293
+ }
294
+ return base;
295
+ }, [sizeConfig, variantStyle, fullWidth, isIcon, resolvedVariant]);
296
+
297
+ const handleClick = useCallback(
298
+ (event: MouseEvent<HTMLButtonElement>) => {
299
+ if (isInteractionBlocked) {
300
+ event.preventDefault();
301
+ return;
302
+ }
303
+ onClick?.(event);
304
+ onPress?.();
305
+ },
306
+ [isInteractionBlocked, onClick, onPress],
307
+ );
308
+
309
+ const ariaLabel = ariaLabelProp ?? accessibilityLabel;
310
+ const composedClassName = ['bloom-btn']
311
+ .concat(resolvedVariant === 'link' ? ['bloom-btn--link'] : [])
312
+ .concat(className ? [className] : [])
313
+ .join(' ');
314
+
315
+ const spinnerColor = loadingColor ?? variantStyle.textColor;
316
+
317
+ const content = (
318
+ <>
319
+ {iconPosition === 'left' && icon}
320
+ {children != null &&
321
+ (typeof children === 'string' || typeof children === 'number' ? (
322
+ <span>{children}</span>
323
+ ) : (
324
+ children
325
+ ))}
326
+ {iconPosition === 'right' && icon}
327
+ </>
328
+ );
329
+
330
+ const body = loading ? (
331
+ <>
332
+ {/* Keep children mounted (hidden) so the button preserves its width. */}
333
+ <span
334
+ aria-hidden="true"
335
+ style={{
336
+ display: 'inline-flex',
337
+ alignItems: 'center',
338
+ justifyContent: 'center',
339
+ gap: 8,
340
+ opacity: 0,
341
+ pointerEvents: 'none',
342
+ }}
343
+ >
344
+ {content}
345
+ </span>
346
+ <span
347
+ aria-hidden="true"
348
+ style={{
349
+ position: 'absolute',
350
+ inset: 0,
351
+ display: 'inline-flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'center',
354
+ pointerEvents: 'none',
355
+ }}
356
+ >
357
+ <SpinnerIcon size={Math.round(sizeConfig.fontSize * 1.2)} color={spinnerColor} />
358
+ </span>
359
+ </>
360
+ ) : (
361
+ content
362
+ );
363
+
364
+ // asChild: render the provided child element (e.g. <a> / router <Link>) with
365
+ // the button styling and handlers merged in. Used for link-buttons.
366
+ if (asChild && React.isValidElement(children)) {
367
+ const child = children as ReactElement<{
368
+ className?: string;
369
+ style?: CSSProperties;
370
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
371
+ 'aria-disabled'?: boolean;
372
+ 'aria-busy'?: boolean;
373
+ 'aria-label'?: string;
374
+ title?: string;
375
+ id?: string;
376
+ tabIndex?: number;
377
+ }>;
378
+ const childProps = child.props;
379
+ return React.cloneElement(child, {
380
+ className: [composedClassName, childProps.className].filter(Boolean).join(' '),
381
+ style: { ...containerStyle, ...(style as CSSProperties), ...childProps.style },
382
+ onClick: (event: MouseEvent<HTMLElement>) => {
383
+ if (isInteractionBlocked) {
384
+ event.preventDefault();
385
+ return;
386
+ }
387
+ childProps.onClick?.(event);
388
+ onClick?.(event as MouseEvent<HTMLButtonElement>);
389
+ onPress?.();
390
+ },
391
+ 'aria-disabled': isInteractionBlocked || undefined,
392
+ 'aria-busy': loading || undefined,
393
+ 'aria-label': ariaLabel ?? childProps['aria-label'],
394
+ title: title ?? childProps.title,
395
+ id: childProps.id ?? resolvedId,
396
+ tabIndex: isInteractionBlocked ? -1 : childProps.tabIndex,
397
+ });
398
+ }
399
+
400
+ return (
401
+ <button
402
+ id={resolvedId}
403
+ type={type}
404
+ name={name}
405
+ value={value}
406
+ className={composedClassName}
407
+ style={{ ...containerStyle, ...(style as CSSProperties) }}
408
+ onClick={handleClick}
409
+ disabled={disabled && !loading}
410
+ aria-disabled={isInteractionBlocked || undefined}
411
+ aria-busy={loading || undefined}
412
+ aria-label={ariaLabel}
413
+ title={title ?? accessibilityHint}
414
+ autoFocus={autoFocus}
415
+ tabIndex={tabIndex}
416
+ data-testid={testID}
417
+ >
418
+ {body}
419
+ </button>
420
+ );
421
+ };
422
+
423
+ export const Button = memo(ButtonWebComponent);
424
+ Button.displayName = 'Button';
425
+
426
+ export const PrimaryButton = memo((props: Omit<ButtonProps, 'variant'>) => (
427
+ <Button {...props} variant="primary" />
428
+ ));
429
+ PrimaryButton.displayName = 'PrimaryButton';
430
+
431
+ export const SecondaryButton = memo((props: Omit<ButtonProps, 'variant'>) => (
432
+ <Button {...props} variant="secondary" />
433
+ ));
434
+ SecondaryButton.displayName = 'SecondaryButton';
435
+
436
+ export const IconButton = memo((props: Omit<ButtonProps, 'variant'>) => (
437
+ <Button {...props} variant="icon" />
438
+ ));
439
+ IconButton.displayName = 'IconButton';
440
+
441
+ export const GhostButton = memo((props: Omit<ButtonProps, 'variant'>) => (
442
+ <Button {...props} variant="ghost" />
443
+ ));
444
+ GhostButton.displayName = 'GhostButton';
445
+
446
+ export const InverseButton = memo((props: Omit<ButtonProps, 'variant'>) => (
447
+ <Button {...props} variant="inverse" />
448
+ ));
449
+ InverseButton.displayName = 'InverseButton';
450
+
451
+ export const TextButton = memo((props: Omit<ButtonProps, 'variant'>) => (
452
+ <Button {...props} variant="text" />
453
+ ));
454
+ TextButton.displayName = 'TextButton';
455
+
456
+ export const OutlineButton = memo((props: Omit<ButtonProps, 'variant'>) => (
457
+ <Button {...props} variant="outline" />
458
+ ));
459
+ OutlineButton.displayName = 'OutlineButton';
460
+
461
+ export const LinkButton = memo((props: Omit<ButtonProps, 'variant'>) => (
462
+ <Button {...props} variant="link" />
463
+ ));
464
+ LinkButton.displayName = 'LinkButton';
465
+
466
+ export const DestructiveButton = memo((props: Omit<ButtonProps, 'variant'>) => (
467
+ <Button {...props} variant="destructive" />
468
+ ));
469
+ DestructiveButton.displayName = 'DestructiveButton';
@@ -5,6 +5,10 @@ export {
5
5
  IconButton,
6
6
  GhostButton,
7
7
  TextButton,
8
+ InverseButton,
9
+ OutlineButton,
10
+ LinkButton,
11
+ DestructiveButton,
8
12
  } from './Button';
9
13
 
10
14
  export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
@@ -0,0 +1,27 @@
1
+ // Web variant of the `./button` barrel.
2
+ //
3
+ // The default barrel (`./index.ts`) re-exports the React Native (`Pressable`)
4
+ // implementation. Via react-native-web that renders `<button type="button">`,
5
+ // which breaks form submits, HTML5 validation, Enter-to-submit, `className`
6
+ // layout passthrough, `asChild`/links, and lacks the `outline`/`link`/
7
+ // `destructive` variants web apps use. This fork (`./Button.web`) renders a real
8
+ // HTML `<button>` with full web semantics while keeping the SAME Bloom design
9
+ // tokens, variants, and sizes so it is visually identical to the native button.
10
+ //
11
+ // Web bundlers select this file via the `"browser"` export condition in
12
+ // `package.json`'s `exports['./button']`; native bundlers fall through to the
13
+ // React Native build. Types are platform-agnostic, so they come from `./types`.
14
+ export {
15
+ Button,
16
+ PrimaryButton,
17
+ SecondaryButton,
18
+ IconButton,
19
+ GhostButton,
20
+ TextButton,
21
+ InverseButton,
22
+ OutlineButton,
23
+ LinkButton,
24
+ DestructiveButton,
25
+ } from './Button.web';
26
+
27
+ export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
@@ -1,8 +1,42 @@
1
1
  import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
2
2
 
3
- export type ButtonVariant = 'primary' | 'secondary' | 'inverse' | 'icon' | 'ghost' | 'text';
3
+ /**
4
+ * Bloom's canonical button variants (shared across native + web) plus the
5
+ * web/shadcn set that DOM apps (auth, console, website) rely on:
6
+ *
7
+ * - `primary | secondary | inverse | icon | ghost | text` — the original
8
+ * Bloom set, rendered identically on both platforms.
9
+ * - `outline | link | destructive` — web additions. They are styled from the
10
+ * SAME Bloom design tokens so they stay visually consistent with the rest of
11
+ * the set. On native they fall back to a sensible equivalent
12
+ * (`outline → secondary`, `link → text`, `destructive → primary` tinted with
13
+ * the negative token) so passing them never crashes a native consumer.
14
+ */
15
+ export type ButtonVariant =
16
+ | 'primary'
17
+ | 'secondary'
18
+ | 'inverse'
19
+ | 'icon'
20
+ | 'ghost'
21
+ | 'text'
22
+ | 'outline'
23
+ | 'link'
24
+ | 'destructive';
4
25
 
5
- export type ButtonSize = 'small' | 'medium' | 'large';
26
+ /**
27
+ * Bloom sizes plus shadcn-style aliases. `sm | md | lg` map onto
28
+ * `small | medium | large`; `icon` maps onto a square icon button at the medium
29
+ * height. The aliases exist so web consumers migrating from shadcn keep their
30
+ * call sites unchanged.
31
+ */
32
+ export type ButtonSize =
33
+ | 'small'
34
+ | 'medium'
35
+ | 'large'
36
+ | 'sm'
37
+ | 'md'
38
+ | 'lg'
39
+ | 'icon';
6
40
 
7
41
  export interface ButtonProps {
8
42
  onPress?: () => void;
@@ -35,4 +69,66 @@ export interface ButtonProps {
35
69
  activeOpacity?: number;
36
70
  testID?: string;
37
71
  className?: string;
72
+
73
+ // -------------------------------------------------------------------------
74
+ // Web-only props
75
+ //
76
+ // These are no-ops in the native (`Pressable`) implementation — they exist
77
+ // on the shared type so a single `<Button>` call site type-checks on both
78
+ // platforms. The web fork (`Button.web.tsx`) renders a real HTML `<button>`
79
+ // and consumes them.
80
+ // -------------------------------------------------------------------------
81
+
82
+ /**
83
+ * HTML button `type` (web only). Defaults to `'button'`. Use `'submit'` to
84
+ * participate in `<form>` submission / HTML5 validation / Enter-to-submit,
85
+ * and `'reset'` to reset a form.
86
+ */
87
+ type?: 'button' | 'submit' | 'reset';
88
+ /**
89
+ * Web click handler. Aliased to the same logical action as `onPress` — either
90
+ * (or both) may be provided; both fire on click. `onPress` is kept for
91
+ * cross-platform parity.
92
+ */
93
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
94
+ /**
95
+ * Render as the child element instead of a `<button>` (web only). Use for
96
+ * link-buttons: `<Button asChild><a href="…">Go</a></Button>` applies the
97
+ * button styling to the `<a>` (or a router `<Link>`). The child receives the
98
+ * merged `className`, `style`, and the button's event handlers.
99
+ */
100
+ asChild?: boolean;
101
+ /**
102
+ * Inline DOM `id` (web only).
103
+ */
104
+ id?: string;
105
+ /**
106
+ * Native HTML `name` for form participation (web only).
107
+ */
108
+ name?: string;
109
+ /**
110
+ * Native HTML `value` for form participation (web only).
111
+ */
112
+ value?: string;
113
+ /**
114
+ * `aria-label` (web only). Falls back to `accessibilityLabel`.
115
+ */
116
+ 'aria-label'?: string;
117
+ /**
118
+ * `title` tooltip attribute (web only).
119
+ */
120
+ title?: string;
121
+ /**
122
+ * `autoFocus` the button on mount (web only).
123
+ */
124
+ autoFocus?: boolean;
125
+ /**
126
+ * `tabIndex` override (web only).
127
+ */
128
+ tabIndex?: number;
129
+ /**
130
+ * Stretch the button to fill its container's main axis (web only). Equivalent
131
+ * to passing `className="w-full"`; provided as a prop for ergonomics.
132
+ */
133
+ fullWidth?: boolean;
38
134
  }
package/src/index.web.ts CHANGED
@@ -42,7 +42,7 @@ export type {
42
42
  DialogControlProps,
43
43
  DialogProps,
44
44
  } from './dialog/index.web';
45
- export * from './button';
45
+ export * from './button/index.web';
46
46
  export * from './grouped-buttons';
47
47
  export * from './divider';
48
48
  export * from './radio-indicator';