@4alldigital/foundation-ui--core 3.6.4 → 3.7.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.
@@ -1,117 +1,264 @@
1
- import cx from 'classnames';
2
- import { twMerge } from 'tailwind-merge';
3
- import { BTN_SIZES, BTN_TYPES, BTN_VARIANTS, Props } from './Button.types';
1
+ import * as React from 'react';
2
+ import { Slot } from '@radix-ui/react-slot';
3
+ import { cva } from 'class-variance-authority';
4
+ import { cn } from '../../utils';
4
5
  import Icon from '../Icon';
5
6
  import Loader from '../Loader';
7
+ import { BTN_VARIANTS, BTN_SIZES, type ButtonProps } from './Button.types';
6
8
 
7
- const Button = ({
8
- variant = BTN_VARIANTS.PRIMARY,
9
- size = BTN_SIZES.MEDIUM,
10
- type = BTN_TYPES.BUTTON,
11
- wide = false,
12
- rounded = false,
13
- raised = false,
14
- uppercase,
15
- children,
16
- id,
17
- disabled,
18
- ariaLabel,
19
- onClick,
20
- icon,
21
- external,
22
- iconFirst,
23
- outline,
24
- testID,
25
- className,
26
- isLoading,
27
- offsetFont = false,
28
- }: Props) => {
29
- const smallX = wide ? 'px-5' : 'px-3';
30
- const mediumX = wide ? 'px-8' : 'px-4';
31
- const largeX = wide ? 'px-11' : 'px-5';
32
-
33
- const sizes = {
34
- small: {
35
- x: children && !rounded ? smallX : children ? 'px-3' : 'px-1',
36
- y: 'py-1',
37
- space: 'space-x-2',
38
- text: 'text-sm',
39
- icon: 'w-3 h-3',
40
- },
41
- medium: {
42
- x: children && !rounded ? mediumX : children ? 'px-4' : 'px-2',
43
- y: 'py-2',
44
- space: 'space-x-3',
45
- text: 'text-base',
46
- icon: 'w-5 h-5',
47
- },
48
- large: {
49
- x: children && !rounded ? largeX : children ? 'px-5' : 'px-3',
50
- y: 'py-3',
51
- space: 'space-x-4',
52
- text: 'text-lg',
53
- icon: 'w-6 h-6',
9
+ const buttonVariants = cva(
10
+ 'inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
11
+ {
12
+ variants: {
13
+ variant: {
14
+ // New variants matching old Button theme colors
15
+ primary:
16
+ 'text-white border border-primary bg-primary hover:bg-primary-darker',
17
+ secondary:
18
+ 'text-white border border-secondary bg-secondary hover:bg-secondary-darker',
19
+ tertiary:
20
+ 'text-black border border-tertiary bg-tertiary hover:bg-tertiary-darker',
21
+ link: 'px-0 text-body-text dark:text-body-text-dark border-transparent bg-transparent hover:bg-transparent shadow-none group-hover:invert',
22
+
23
+ // Shadcn variants
24
+ default:
25
+ 'bg-white/20 text-primary-foreground shadow hover:bg-primary/90 border-2 border-red',
26
+ destructive:
27
+ 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
28
+ ghost: 'hover:bg-transparent hover:bg-transparent',
29
+
30
+ // Outline is handled as compound variant
31
+ outline:
32
+ 'border-primary bg-transparent text-body-text dark:text-body-text-dark hover:bg-opacity-30 border-primary-300 dark:border-primary-400 hover:border-primary-400 dark:hover:border-primary-500',
33
+ },
34
+ size: {
35
+ sm: 'h-8 px-3 py-1 text-xs',
36
+ default: 'h-9 px-6 py-2 text-base',
37
+ lg: 'h-10 px-5 py-3 text-lg',
38
+ icon: 'h-9 w-9',
39
+ },
40
+ wide: {
41
+ true: '',
42
+ false: '',
43
+ },
44
+ rounded: {
45
+ true: 'rounded-full leading-none',
46
+ false: '',
47
+ },
48
+ raised: {
49
+ true: 'shadow',
50
+ false: '',
51
+ },
52
+ uppercase: {
53
+ true: 'uppercase',
54
+ false: '',
55
+ },
54
56
  },
55
- };
56
-
57
- const classes = twMerge(
58
- cx(
59
- 'button flex rounded-sm items-center cursor-pointer',
60
- { shadow: raised },
61
- { uppercase: uppercase },
62
- { 'auto-cols-auto grid-cols-2 gap-4': isLoading || icon || external },
63
- { 'flex-row-reverse': iconFirst },
64
- { 'opacity-50 cursor-not-allowed': disabled },
65
- `${sizes?.[size]?.y} ${sizes?.[size]?.x} ${sizes?.[size]?.text}`,
66
- { 'text-white border-primary bg-primary hover:bg-primary-darker': variant === BTN_VARIANTS.PRIMARY },
67
- { 'text-white border-secondary bg-secondary hover:bg-secondary-darker': variant === BTN_VARIANTS.SECONDARY },
68
- { 'text-black border-tertiary bg-tertiary hover:bg-tertiary-darker': variant === BTN_VARIANTS.TERTIARY },
69
- { 'border-primary bg-transparent text-body-text dark:text-body-text-dark hover:bg-opacity-30': outline },
70
- { 'border-primary-300 dark:border-primary-400 hover:border-primary-400 dark:hover:border-primary-500': outline },
57
+ compoundVariants: [
58
+ // Wide variants adjust padding
71
59
  {
72
- 'px-0 text-body-text dark:text-body-text-dark border-transparent bg-transparent hover:bg-transparent shadow-none group-hover:invert':
73
- variant === BTN_VARIANTS.LINK,
60
+ size: 'sm',
61
+ wide: true,
62
+ className: 'px-5',
74
63
  },
75
- { 'rounded-full leading-none': rounded },
64
+ {
65
+ size: 'default',
66
+ wide: true,
67
+ className: 'px-8',
68
+ },
69
+ {
70
+ size: 'lg',
71
+ wide: true,
72
+ className: 'px-11',
73
+ },
74
+ ],
75
+ defaultVariants: {
76
+ variant: 'primary',
77
+ size: 'default',
78
+ wide: false,
79
+ rounded: false,
80
+ raised: false,
81
+ uppercase: false,
82
+ },
83
+ }
84
+ );
85
+
86
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
87
+ (
88
+ {
76
89
  className,
77
- ),
78
- );
79
-
80
- const iconClasses = cx(sizes?.[size]?.icon);
81
-
82
- return (
83
- <button
84
- id={id}
85
- data-testid={testID || id || 'Button'}
86
- onClick={onClick}
87
- className={classes}
88
- type={type}
89
- disabled={disabled}
90
- aria-label={ariaLabel}
91
- role="button">
92
- <>
93
- {offsetFont ? <span className="brand-font-offset">{children}</span>: children}
94
- {icon && !external && (
95
- <span className="flex items-center">
96
- <Icon name={icon} className={iconClasses} />
97
- </span>
98
- )}
99
- {external && (
100
- <span className="flex items-center">
101
- <Icon name="carbon:launch" className={iconClasses} />
102
- </span>
103
- )}
104
- {isLoading && (
105
- <div className="flex items-center">
106
- <Loader size={4} />
107
- </div>
90
+ variant: variantProp,
91
+ size: sizeProp,
92
+ wide = false,
93
+ rounded = false,
94
+ raised = false,
95
+ uppercase = false,
96
+ outline = false,
97
+ asChild = false,
98
+ children,
99
+ icon,
100
+ external,
101
+ iconFirst,
102
+ isLoading,
103
+ offsetFont = false,
104
+ ariaLabel,
105
+ testID,
106
+ id,
107
+ disabled,
108
+ type = 'button',
109
+ ...props
110
+ },
111
+ ref
112
+ ) => {
113
+ // Map legacy enum values to new string values
114
+ const mapVariant = (v: ButtonProps['variant']) => {
115
+ if (typeof v === 'object' && 'PRIMARY' in (v as typeof BTN_VARIANTS)) {
116
+ // Handle enum values
117
+ const enumValue = v as BTN_VARIANTS;
118
+ switch (enumValue) {
119
+ case BTN_VARIANTS.PRIMARY:
120
+ return 'primary';
121
+ case BTN_VARIANTS.SECONDARY:
122
+ return 'secondary';
123
+ case BTN_VARIANTS.TERTIARY:
124
+ return 'tertiary';
125
+ case BTN_VARIANTS.LINK:
126
+ return 'link';
127
+ default:
128
+ return 'primary';
129
+ }
130
+ }
131
+ // Handle string enum values
132
+ if (v === 'primary' || v === 'secondary' || v === 'tertiary' || v === 'link') {
133
+ return v;
134
+ }
135
+ // If outline prop is set, use outline variant
136
+ if (outline) {
137
+ return 'outline';
138
+ }
139
+ return v || 'primary';
140
+ };
141
+
142
+ const mapSize = (s: ButtonProps['size']) => {
143
+ if (typeof s === 'object' && 'SMALL' in (s as typeof BTN_SIZES)) {
144
+ // Handle enum values
145
+ const enumValue = s as BTN_SIZES;
146
+ switch (enumValue) {
147
+ case BTN_SIZES.SMALL:
148
+ return 'sm';
149
+ case BTN_SIZES.MEDIUM:
150
+ return 'default';
151
+ case BTN_SIZES.LARGE:
152
+ return 'lg';
153
+ default:
154
+ return 'default';
155
+ }
156
+ }
157
+ // Handle string size mapping
158
+ if (s === 'small') return 'sm';
159
+ if (s === 'medium') return 'default';
160
+ if (s === 'large') return 'lg';
161
+ return s || 'default';
162
+ };
163
+
164
+ const variant = mapVariant(variantProp);
165
+ const size = mapSize(sizeProp);
166
+
167
+ // Determine icon size based on button size
168
+ const getIconSize = () => {
169
+ switch (size) {
170
+ case 'sm':
171
+ return 'w-3 h-3';
172
+ case 'lg':
173
+ return 'w-6 h-6';
174
+ default:
175
+ return 'w-5 h-5';
176
+ }
177
+ };
178
+
179
+ const iconClasses = getIconSize();
180
+
181
+ const Comp = asChild ? Slot : 'button';
182
+
183
+ // Build content with proper ordering for icons
184
+ const renderContent = () => {
185
+ const iconElement = icon ? (
186
+ <span className="flex items-center">
187
+ <Icon name={icon} className={iconClasses} />
188
+ </span>
189
+ ) : null;
190
+
191
+ const externalIcon = external ? (
192
+ <span className="flex items-center">
193
+ <Icon name="carbon:launch" className={iconClasses} />
194
+ </span>
195
+ ) : null;
196
+
197
+ const loader = isLoading ? (
198
+ <div className="flex items-center">
199
+ <Loader size={4} />
200
+ </div>
201
+ ) : null;
202
+
203
+ const childContent = offsetFont && children ? (
204
+ <span className="brand-font-offset">{children}</span>
205
+ ) : (
206
+ children
207
+ );
208
+
209
+ // If iconFirst is true, render icon before children
210
+ if (iconFirst) {
211
+ return (
212
+ <>
213
+ {iconElement}
214
+ {externalIcon}
215
+ {childContent}
216
+ {loader}
217
+ </>
218
+ );
219
+ }
220
+
221
+ // Default: children first, then icon
222
+ return (
223
+ <>
224
+ {childContent}
225
+ {iconElement}
226
+ {externalIcon}
227
+ {loader}
228
+ </>
229
+ );
230
+ };
231
+
232
+ return (
233
+ <Comp
234
+ ref={ref}
235
+ id={id}
236
+ data-testid={testID || id || 'Button'}
237
+ className={cn(
238
+ buttonVariants({
239
+ variant,
240
+ size,
241
+ wide,
242
+ rounded,
243
+ raised,
244
+ uppercase,
245
+ className,
246
+ })
108
247
  )}
109
- </>
110
- </button>
111
- );
112
- };
248
+ type={type as 'button' | 'submit' | 'reset'}
249
+ disabled={disabled}
250
+ aria-label={ariaLabel}
251
+ role="button"
252
+ {...props}
253
+ >
254
+ {renderContent()}
255
+ </Comp>
256
+ );
257
+ }
258
+ );
113
259
 
114
260
  Button.displayName = 'Button';
115
261
 
116
262
  export default Button;
117
- export type { Props };
263
+ export { Button, buttonVariants };
264
+ export type { ButtonProps };
@@ -1,11 +1,19 @@
1
+ import { type VariantProps } from 'class-variance-authority';
2
+ import { buttonVariants } from './Button';
1
3
  import { MouseEventHandler, ReactNode } from 'react';
2
4
 
5
+ /**
6
+ * @deprecated Use string literals instead: 'submit' | 'reset' | 'button'
7
+ */
3
8
  export enum BTN_TYPES {
4
9
  SUBMIT = 'submit',
5
10
  RESET = 'reset',
6
11
  BUTTON = 'button',
7
12
  }
8
13
 
14
+ /**
15
+ * @deprecated Use ButtonProps['variant'] string literals instead: 'primary' | 'secondary' | 'tertiary' | 'link'
16
+ */
9
17
  export enum BTN_VARIANTS {
10
18
  PRIMARY = 'primary',
11
19
  SECONDARY = 'secondary',
@@ -13,20 +21,33 @@ export enum BTN_VARIANTS {
13
21
  LINK = 'link',
14
22
  }
15
23
 
24
+ /**
25
+ * @deprecated Use ButtonProps['size'] string literals instead: 'sm' | 'default' | 'lg'
26
+ */
16
27
  export enum BTN_SIZES {
17
28
  SMALL = 'small',
18
29
  MEDIUM = 'medium',
19
30
  LARGE = 'large',
20
31
  }
21
- export interface Props {
22
- variant?: BTN_VARIANTS;
23
- size?: BTN_SIZES;
32
+
33
+ // New CVA-based button props
34
+ export interface ButtonProps
35
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
36
+ VariantProps<typeof buttonVariants> {
37
+ // Radix UI Slot support
38
+ asChild?: boolean;
39
+
40
+ /**
41
+ * Button variant. Accepts string literals or legacy BTN_VARIANTS enum values for backward compatibility.
42
+ */
43
+ variant?: 'primary' | 'secondary' | 'tertiary' | 'link' | 'outline' | 'ghost' | 'destructive' | 'default' | BTN_VARIANTS;
44
+
45
+ /**
46
+ * Size of the button. Accepts both new ('sm', 'default', 'lg') and legacy ('small', 'medium', 'large') values
47
+ */
48
+ size?: 'sm' | 'default' | 'lg' | 'icon' | 'small' | 'medium' | 'large' | BTN_SIZES;
49
+
24
50
  uppercase?: boolean;
25
- children?: ReactNode;
26
- type?: BTN_TYPES;
27
- disabled?: boolean;
28
- ariaLabel?: string;
29
- onClick?: MouseEventHandler<HTMLButtonElement>;
30
51
  icon?: string;
31
52
  external?: boolean;
32
53
  iconFirst?: boolean;
@@ -34,10 +55,21 @@ export interface Props {
34
55
  outline?: boolean;
35
56
  wide?: boolean;
36
57
  rounded?: boolean;
37
- id?: string;
38
- testID?: string;
39
- className?: string;
40
58
  isLoading?: boolean;
41
- iconOnly?: boolean;
42
59
  offsetFont?: boolean;
60
+ ariaLabel?: string;
61
+ testID?: string;
62
+ }
63
+
64
+ /**
65
+ * @deprecated Use ButtonProps instead
66
+ */
67
+ export interface Props extends Omit<ButtonProps, 'variant' | 'size'> {
68
+ variant?: BTN_VARIANTS;
69
+ size?: BTN_SIZES;
70
+ type?: BTN_TYPES;
71
+ children?: ReactNode;
72
+ disabled?: boolean;
73
+ onClick?: MouseEventHandler<HTMLButtonElement>;
74
+ id?: string;
43
75
  }
@@ -1 +1,8 @@
1
- export { default } from './Button';
1
+ import ButtonComponent from './Button';
2
+ export { Button, buttonVariants } from './Button';
3
+ export type { ButtonProps } from './Button.types';
4
+ export { BTN_VARIANTS, BTN_SIZES, BTN_TYPES } from './Button.types';
5
+ export type { Props } from './Button.types';
6
+
7
+ // Default export for backward compatibility
8
+ export default ButtonComponent;
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs';
2
+ import Button from '.';
3
+ import { BTN_VARIANTS } from './Button.types';
4
+
5
+ type Story = StoryObj<typeof Button>;
6
+
7
+ const meta: Meta<typeof Button> = {
8
+ title: 'DEPRECATED/ButtonDeprecated',
9
+ component: Button,
10
+ tags: ['deprecated'],
11
+ };
12
+
13
+ export default meta;
14
+
15
+ /* STORIES */
16
+ export const Default: Story = {
17
+ args: {
18
+ children: <>Default Btn</>,
19
+ },
20
+ };
21
+
22
+ export const WithIcon: Story = {
23
+ args: {
24
+ variant: BTN_VARIANTS.PRIMARY,
25
+ children: <>Example Btn</>,
26
+ icon: 'carbon:arrow-right',
27
+ },
28
+ };
29
+
30
+ export const IconOnly: Story = {
31
+ args: {
32
+ variant: BTN_VARIANTS.PRIMARY,
33
+ icon: 'carbon:arrow-right',
34
+ },
35
+ };
36
+
37
+ export const AsLink: Story = {
38
+ args: {
39
+ variant: BTN_VARIANTS.LINK,
40
+ children: <>Example Link</>,
41
+ icon: 'carbon:arrow-right',
42
+ },
43
+ };
44
+
45
+ export const LoadingButton: Story = {
46
+ args: {
47
+ variant: BTN_VARIANTS.PRIMARY,
48
+ children: <>Example Btn</>,
49
+ isLoading: true,
50
+ },
51
+ };
@@ -0,0 +1,138 @@
1
+ import cx from 'classnames';
2
+ import { twMerge } from 'tailwind-merge';
3
+ import { BTN_SIZES, BTN_TYPES, BTN_VARIANTS, Props } from './Button.types';
4
+ import Icon from '../Icon';
5
+ import Loader from '../Loader';
6
+ import { useEffect } from 'react';
7
+
8
+ /**
9
+ * @deprecated ButtonDeprecated is deprecated and will be removed in a future version.
10
+ * Please migrate to the new Button component from '@foundation-ui/core'.
11
+ *
12
+ * Migration guide:
13
+ * - The new Button component is backward compatible with all existing props
14
+ * - No immediate changes required, but consider updating to use the new component
15
+ * - See BUTTON_MIGRATION.md for detailed migration instructions
16
+ */
17
+ const Button = ({
18
+ variant = BTN_VARIANTS.PRIMARY,
19
+ size = BTN_SIZES.MEDIUM,
20
+ type = BTN_TYPES.BUTTON,
21
+ wide = false,
22
+ rounded = false,
23
+ raised = false,
24
+ uppercase,
25
+ children,
26
+ id,
27
+ disabled,
28
+ ariaLabel,
29
+ onClick,
30
+ icon,
31
+ external,
32
+ iconFirst,
33
+ outline,
34
+ testID,
35
+ className,
36
+ isLoading,
37
+ offsetFont = false,
38
+ }: Props) => {
39
+ // Deprecation warning
40
+ useEffect(() => {
41
+ if (process.env.NODE_ENV !== 'production') {
42
+ console.warn(
43
+ 'ButtonDeprecated is deprecated and will be removed in a future version. ' +
44
+ 'Please migrate to the new Button component. The new Button is fully backward compatible. ' +
45
+ 'See BUTTON_MIGRATION.md for migration instructions.'
46
+ );
47
+ }
48
+ }, []);
49
+
50
+ const smallX = wide ? 'px-5' : 'px-3';
51
+ const mediumX = wide ? 'px-8' : 'px-4';
52
+ const largeX = wide ? 'px-11' : 'px-5';
53
+
54
+ const sizes = {
55
+ small: {
56
+ x: children && !rounded ? smallX : children ? 'px-3' : 'px-1',
57
+ y: 'py-1',
58
+ space: 'space-x-2',
59
+ text: 'text-sm',
60
+ icon: 'w-3 h-3',
61
+ },
62
+ medium: {
63
+ x: children && !rounded ? mediumX : children ? 'px-4' : 'px-2',
64
+ y: 'py-2',
65
+ space: 'space-x-3',
66
+ text: 'text-base',
67
+ icon: 'w-5 h-5',
68
+ },
69
+ large: {
70
+ x: children && !rounded ? largeX : children ? 'px-5' : 'px-3',
71
+ y: 'py-3',
72
+ space: 'space-x-4',
73
+ text: 'text-lg',
74
+ icon: 'w-6 h-6',
75
+ },
76
+ };
77
+
78
+ const classes = twMerge(
79
+ cx(
80
+ 'button flex rounded-sm items-center cursor-pointer',
81
+ { shadow: raised },
82
+ { uppercase: uppercase },
83
+ { 'auto-cols-auto grid-cols-2 gap-4': isLoading || icon || external },
84
+ { 'flex-row-reverse': iconFirst },
85
+ { 'opacity-50 cursor-not-allowed': disabled },
86
+ `${sizes?.[size]?.y} ${sizes?.[size]?.x} ${sizes?.[size]?.text}`,
87
+ { 'text-white border-primary bg-primary hover:bg-primary-darker': variant === BTN_VARIANTS.PRIMARY },
88
+ { 'text-white border-secondary bg-secondary hover:bg-secondary-darker': variant === BTN_VARIANTS.SECONDARY },
89
+ { 'text-black border-tertiary bg-tertiary hover:bg-tertiary-darker': variant === BTN_VARIANTS.TERTIARY },
90
+ { 'border-primary bg-transparent text-body-text dark:text-body-text-dark hover:bg-opacity-30': outline },
91
+ { 'border-primary-300 dark:border-primary-400 hover:border-primary-400 dark:hover:border-primary-500': outline },
92
+ {
93
+ 'px-0 text-body-text dark:text-body-text-dark border-transparent bg-transparent hover:bg-transparent shadow-none group-hover:invert':
94
+ variant === BTN_VARIANTS.LINK,
95
+ },
96
+ { 'rounded-full leading-none': rounded },
97
+ className,
98
+ ),
99
+ );
100
+
101
+ const iconClasses = cx(sizes?.[size]?.icon);
102
+
103
+ return (
104
+ <button
105
+ id={id}
106
+ data-testid={testID || id || 'Button'}
107
+ onClick={onClick}
108
+ className={classes}
109
+ type={type}
110
+ disabled={disabled}
111
+ aria-label={ariaLabel}
112
+ role="button">
113
+ <>
114
+ {offsetFont ? <span className="brand-font-offset">{children}</span>: children}
115
+ {icon && !external && (
116
+ <span className="flex items-center">
117
+ <Icon name={icon} className={iconClasses} />
118
+ </span>
119
+ )}
120
+ {external && (
121
+ <span className="flex items-center">
122
+ <Icon name="carbon:launch" className={iconClasses} />
123
+ </span>
124
+ )}
125
+ {isLoading && (
126
+ <div className="flex items-center">
127
+ <Loader size={4} />
128
+ </div>
129
+ )}
130
+ </>
131
+ </button>
132
+ );
133
+ };
134
+
135
+ Button.displayName = 'Button';
136
+
137
+ export default Button;
138
+ export type { Props };