@ideasui/button 0.0.2-beta.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 IdeasUI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @ideasui/button
2
+
3
+ A versatile button component with multiple variants, sizes, and states.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @ideasui/button
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { Button } from '@ideasui/button';
15
+
16
+ function App() {
17
+ return (
18
+ <Button variant="solid" color="primary" size="md">
19
+ Click me
20
+ </Button>
21
+ );
22
+ }
23
+ ```
24
+
25
+ ## API Reference
26
+
27
+ ### ButtonProps
28
+
29
+ | Prop | Type | Default | Description |
30
+ | ------------ | ------------------------------------------------------------------------------------- | --------- | -------------------------------------- |
31
+ | variant | 'solid' \| 'outline' \| 'ghost' | 'solid' | Visual style variant |
32
+ | color | 'default' \| 'primary' \| 'secondary' \| 'success' \| 'warning' \| 'danger' \| 'info' | 'default' | Color variant based on semantic intent |
33
+ | size | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' | 'md' | Size of the button |
34
+ | radius | 'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full' | 'md' | Border radius variant |
35
+ | fullWidth | boolean | false | Whether button should take full width |
36
+ | loading | boolean | false | Whether button is in loading state |
37
+ | loadingText | string | - | Text to show when loading |
38
+ | startContent | ReactNode | - | Content to show at start of button |
39
+ | endContent | ReactNode | - | Content to show at end of button |
40
+ | disabled | boolean | false | Whether button is disabled |
41
+
42
+ ## Examples
43
+
44
+ ### Basic Usage
45
+
46
+ ```tsx
47
+ <Button>Default Button</Button>
48
+ <Button variant="outline">Outline Button</Button>
49
+ <Button variant="ghost">Ghost Button</Button>
50
+ ```
51
+
52
+ ### Colors
53
+
54
+ ```tsx
55
+ <Button color="primary">Primary</Button>
56
+ <Button color="success">Success</Button>
57
+ <Button color="warning">Warning</Button>
58
+ <Button color="danger">Danger</Button>
59
+ ```
60
+
61
+ ### Sizes
62
+
63
+ ```tsx
64
+ <Button size="xs">Extra Small</Button>
65
+ <Button size="sm">Small</Button>
66
+ <Button size="md">Medium</Button>
67
+ <Button size="lg">Large</Button>
68
+ <Button size="xl">Extra Large</Button>
69
+ ```
70
+
71
+ ### With Icons
72
+
73
+ ```tsx
74
+ import { Heart, Download } from 'lucide-react'
75
+
76
+ <Button startContent={<Heart />}>Like</Button>
77
+ <Button endContent={<Download />}>Download</Button>
78
+ ```
79
+
80
+ ### Loading State
81
+
82
+ ```tsx
83
+ <Button loading>Loading</Button>
84
+ <Button loading loadingText="Saving...">Save</Button>
85
+ ```
86
+
87
+ ### States
88
+
89
+ ```tsx
90
+ <Button disabled>Disabled</Button>
91
+ <Button fullWidth>Full Width</Button>
92
+ ```
93
+
94
+ ## Accessibility
95
+
96
+ The Button component follows WAI-ARIA guidelines:
97
+
98
+ - Uses semantic `button` element
99
+ - Supports keyboard navigation (Enter/Space)
100
+ - Proper `disabled` and `aria-disabled` attributes
101
+ - Loading state is communicated to screen readers
102
+ - Focus management with visible focus indicators
103
+
104
+ ## Styling
105
+
106
+ The component uses Tailwind CSS classes and can be customized via:
107
+
108
+ - CSS custom properties
109
+ - Tailwind configuration
110
+ - Custom className prop
111
+ - Component variants system
112
+
113
+ ## TypeScript
114
+
115
+ Full TypeScript support with exported types:
116
+
117
+ ```tsx
118
+ import type { ButtonProps } from '@ideasui/button';
119
+
120
+ const MyButton: React.FC<ButtonProps> = (props) => {
121
+ return <Button {...props} />;
122
+ };
123
+ ```
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import {Button}from'react-aria-components';import {cn,mergePropsWithContext,getAccessibleName}from'@ideasui/utils';import {createContext,forwardRef,useContext}from'react';import {button}from'@ideasui/theme/recipes';import {jsx,jsxs,Fragment}from'react/jsx-runtime';var C=createContext(null);function E(){return useContext(C)}var v=forwardRef(({children:o,className:c,size:t,color:s,variant:u,isDisabled:a,isAttached:n=true,isVertical:l=false,radius:d,fullWidth:i,disableAnimation:p=true,showDivider:m,...B},h)=>{let e={size:t,color:s,variant:u,isDisabled:a,isAttached:n,isVertical:l,radius:d,fullWidth:i,disableAnimation:p,showDivider:m},I=cn(i?"flex":"inline-flex",l?"flex-col":"flex-row",n?"isolate":l?"gap-y-2":"gap-x-2",i&&"w-full",c);return jsx(C.Provider,{value:e,children:jsx("div",{ref:h,className:I,"data-attached":n,"data-full-width":i,"data-slot":"button-group","data-vertical":l,role:"group",...B,children:o})})});v.displayName="IdeasUI.Button.Group";var T=createContext(null);function P(){let o=useContext(T);if(!o)throw new Error("Button sub-components must be rendered within a Button component");return o}var f=forwardRef(({children:o,className:c,placement:t="start",...s},u)=>{let{styles:a,classNames:n}=P(),{icon:l}=a,d=t==="start"?n?.startIcon:n?.endIcon;return jsx("span",{ref:u,"aria-hidden":"true",className:cn(l(),t==="end"?"order-last":"order-first",d,c),"data-slot":"button-icon",...s,children:o})});f.displayName="IdeasUI.Button.Icon";var g=forwardRef(({className:o,label:c="Loading",...t},s)=>{let{styles:u,classNames:a}=P(),{loader:n,icon:l}=u;return jsxs("span",{ref:s,className:cn(n(),l(),"inline-flex shrink-0 items-center justify-center",a?.spinner,o),"data-slot":"button-spinner",role:"status",...t,children:[jsx("svg",{className:"size-full animate-spin",fill:"none",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:"2.5",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:jsx("path",{d:"M21 12a9 9 0 1 1-6.219-8.56"})}),jsx("span",{className:"sr-only",children:c})]})});g.displayName="IdeasUI.Button.Spinner";var S=forwardRef(({children:o,className:c,...t},s)=>{let{styles:u,classNames:a}=P(),{shortcut:n}=u;return jsx("kbd",{ref:s,className:cn(n(),a?.shortcut,c),"data-slot":"button-shortcut",...t,children:o})});S.displayName="IdeasUI.Button.Shortcut";var L=forwardRef(({children:o,className:c,...t},s)=>{let{styles:u,classNames:a}=P(),{label:n}=u;return jsx("span",{ref:s,className:cn(n(),a?.label,c),"data-slot":"button-label",...t,children:o})});L.displayName="IdeasUI.Button.Label";var j=({isLoading:o,loadingIndicator:c,loadingPosition:t="start",isIconOnly:s,startIcon:u,endIcon:a,shortcut:n,children:l,renderProps:d})=>{let i=typeof l=="function"?l(d):l,p=c||jsx(g,{});if(s||o&&t==="center")return o?p:i;let m=o&&t==="start",B=o&&t==="end";return jsxs(Fragment,{children:[m?jsx(f,{placement:"start",children:p}):null,u&&!m?jsx(f,{placement:"start",children:u}):null,typeof i=="string"||typeof i=="number"?jsx(L,{children:i}):i,a&&!B?jsx(f,{placement:"end",children:a}):null,B?jsx(f,{placement:"end",children:p}):null,n?jsx(S,{children:n}):null]})},D=forwardRef((o,c)=>{let{isLoading:t,loadingIndicator:s,loadingPosition:u,isIconOnly:a,startIcon:n,endIcon:l,shortcut:d,children:i,className:p,classNames:m,...B}=o,h=E(),e=mergePropsWithContext(o,h),I=e.isDisabled||t,w=button({variant:e.variant,size:e.size,color:e.color,radius:e.radius,fullWidth:e.fullWidth,isIconOnly:a,isDisabled:I,isLoading:t,disableAnimation:e.disableAnimation,isAttached:e.isAttached,isVertical:e.isVertical,elevation:e.elevation,showDivider:e.showDivider??e.isAttached}),R=getAccessibleName(e,typeof i=="function"?null:i);return jsx(Button,{ref:c,"aria-busy":t,"aria-label":t&&!R?typeof s=="string"?s:"Loading":R,className:N=>w.base({className:cn(typeof p=="function"?p(N):p,m?.base)}),"data-attached":e.isAttached,"data-slot":"button","data-vertical":e.isVertical,isDisabled:I,isPending:t,...B,children:N=>jsx(T.Provider,{value:{styles:w,classNames:m},children:jsx(j,{endIcon:l,isIconOnly:a,isLoading:t,loadingIndicator:s,loadingPosition:u,renderProps:N,shortcut:d,startIcon:n,children:i})})})});D.displayName="IdeasUI.Button";var x=D;x.Icon=f;x.Shortcut=S;x.Spinner=g;x.Label=L;x.Group=v;
2
+ export{x as Button,v as ButtonGroup,C as ButtonGroupContext,E as useButtonGroupContext};
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@ideasui/button",
3
+ "version": "0.0.2-beta.0",
4
+ "description": "A high-performance, accessible Button and ButtonGroup component library for React, built with Tailwind CSS v4 and React Aria.",
5
+ "author": "IdeasUI<support@ideasui.com>",
6
+ "homepage": "https://ideasui.com",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "ideasui",
10
+ "react",
11
+ "button",
12
+ "button-group",
13
+ "button-component",
14
+ "accessible-components",
15
+ "ui-components",
16
+ "design-system",
17
+ "tailwindcss"
18
+ ],
19
+ "main": "dist/index.mjs",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "import": "./dist/index.mjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "sideEffects": false,
31
+ "clean-package": "../../../clean-package.config.json",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/ideas2logic-lab/ideasui.git",
38
+ "directory": "packages/components/button"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/ideas2logic-lab/ideasui/issues"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18",
45
+ "react-dom": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "react-aria-components": "^1.16.0",
49
+ "@ideasui/theme": "0.0.5-beta.0",
50
+ "@ideasui/utils": "0.0.2-beta.0"
51
+ },
52
+ "module": "dist/index.mjs",
53
+ "types": "dist/index.d.mts"
54
+ }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import type { ButtonProps } from './button.types';
4
+
5
+ import { createContext, useContext } from 'react';
6
+
7
+ export interface ButtonGroupContextType {
8
+ size?: ButtonProps['size'];
9
+ color?: ButtonProps['color'];
10
+ variant?: ButtonProps['variant'];
11
+ isDisabled?: boolean;
12
+ isAttached?: boolean;
13
+ isVertical?: boolean;
14
+ radius?: ButtonProps['radius'];
15
+ fullWidth?: boolean;
16
+ disableAnimation?: boolean;
17
+ showDivider?: boolean;
18
+ }
19
+
20
+ export const ButtonGroupContext = createContext<ButtonGroupContextType | null>(null);
21
+
22
+ /**
23
+ * Hook to consume the ButtonGroupContext.
24
+ * @returns The button group context or null if not within a group.
25
+ */
26
+ export function useButtonGroupContext(): ButtonGroupContextType | null {
27
+ return useContext(ButtonGroupContext);
28
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import type { HTMLAttributes, ReactNode, JSX } from 'react';
4
+ import type { ButtonGroupContextType } from './button-group-context';
5
+
6
+ import { forwardRef } from 'react';
7
+ import { cn } from '@ideasui/utils';
8
+
9
+ import { ButtonGroupContext } from './button-group-context';
10
+
11
+ /* -----------------------------------------------------------------------------------------------
12
+ * ButtonGroup Component
13
+ * ---------------------------------------------------------------------------------------------*/
14
+
15
+ export interface ButtonGroupProps
16
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'color'>, ButtonGroupContextType {
17
+ /**
18
+ * Whether the buttons in the group should be vertical.
19
+ * @default false
20
+ */
21
+ isVertical?: boolean;
22
+ /**
23
+ * Whether the buttons in the group should be joined together without gaps.
24
+ * @default true
25
+ */
26
+ isAttached?: boolean;
27
+ /**
28
+ * Whether to show a divider between the buttons in the group.
29
+ * @default true (when isAttached is true)
30
+ */
31
+ showDivider?: boolean;
32
+ /**
33
+ * The content of the button group.
34
+ */
35
+ children: ReactNode;
36
+ }
37
+
38
+ export const ButtonGroup = forwardRef<HTMLDivElement, ButtonGroupProps>(
39
+ (
40
+ {
41
+ children,
42
+ className,
43
+ size,
44
+ color,
45
+ variant,
46
+ isDisabled,
47
+ isAttached = true,
48
+ isVertical = false,
49
+ radius,
50
+ fullWidth,
51
+ disableAnimation = true,
52
+ showDivider,
53
+ ...props
54
+ },
55
+ ref,
56
+ ): JSX.Element => {
57
+ const contextValue = {
58
+ size,
59
+ color,
60
+ variant,
61
+ isDisabled,
62
+ isAttached,
63
+ isVertical,
64
+ radius,
65
+ fullWidth,
66
+ disableAnimation,
67
+ showDivider,
68
+ };
69
+
70
+ const groupClasses = cn(
71
+ fullWidth ? 'flex' : 'inline-flex',
72
+ isVertical ? 'flex-col' : 'flex-row',
73
+ isAttached ? 'isolate' : isVertical ? 'gap-y-2' : 'gap-x-2',
74
+ fullWidth && 'w-full',
75
+ className,
76
+ );
77
+
78
+ return (
79
+ <ButtonGroupContext.Provider value={contextValue}>
80
+ <div
81
+ ref={ref}
82
+ className={groupClasses}
83
+ data-attached={isAttached}
84
+ data-full-width={fullWidth}
85
+ data-slot="button-group"
86
+ data-vertical={isVertical}
87
+ role="group"
88
+ {...props}
89
+ >
90
+ {children}
91
+ </div>
92
+ </ButtonGroupContext.Provider>
93
+ );
94
+ },
95
+ );
96
+
97
+ ButtonGroup.displayName = 'IdeasUI.Button.Group';
package/src/button.tsx ADDED
@@ -0,0 +1,320 @@
1
+ 'use client';
2
+
3
+ import type {
4
+ ButtonProps,
5
+ ButtonComponent,
6
+ ButtonLabelProps,
7
+ ButtonIconProps,
8
+ ButtonSpinnerProps,
9
+ ButtonShortcutProps,
10
+ } from './button.types';
11
+ import type { ButtonReturnType } from '@ideasui/theme/recipes';
12
+ import type { ButtonRenderProps } from 'react-aria-components';
13
+ import type { ReactNode, JSX } from 'react';
14
+
15
+ import { Button as ButtonPrimitive } from 'react-aria-components';
16
+ import { cn, getAccessibleName, mergePropsWithContext } from '@ideasui/utils';
17
+ import { createContext, forwardRef, useContext } from 'react';
18
+ import { button } from '@ideasui/theme/recipes';
19
+
20
+ import { useButtonGroupContext } from './button-group-context';
21
+ import { ButtonGroup } from './button-group';
22
+
23
+ /* -----------------------------------------------------------------------------------------------
24
+ * Button Context
25
+ * ---------------------------------------------------------------------------------------------*/
26
+
27
+ /**
28
+ * Internal context for the Button component to share styles with slots.
29
+ */
30
+ interface ButtonContextValue {
31
+ styles: ButtonReturnType;
32
+ classNames?: ButtonProps['classNames'];
33
+ }
34
+
35
+ const ButtonContext = createContext<ButtonContextValue | null>(null);
36
+
37
+ /**
38
+ * Hook to consume the ButtonContext.
39
+ * @internal
40
+ * @throws Error if used outside of a Button component.
41
+ */
42
+ function useButtonContext(): ButtonContextValue {
43
+ const context = useContext(ButtonContext);
44
+
45
+ if (!context) {
46
+ throw new Error('Button sub-components must be rendered within a Button component');
47
+ }
48
+
49
+ return context;
50
+ }
51
+
52
+ /* -----------------------------------------------------------------------------------------------
53
+ * Button Slots
54
+ * ---------------------------------------------------------------------------------------------*/
55
+
56
+ const ButtonIcon = forwardRef<HTMLElement, ButtonIconProps>(
57
+ ({ children, className, placement = 'start', ...props }, ref): JSX.Element => {
58
+ const { styles, classNames } = useButtonContext();
59
+ const { icon } = styles;
60
+
61
+ const slotClass = placement === 'start' ? classNames?.startIcon : classNames?.endIcon;
62
+
63
+ return (
64
+ <span
65
+ ref={ref}
66
+ aria-hidden="true"
67
+ className={cn(
68
+ icon(),
69
+ placement === 'end' ? 'order-last' : 'order-first',
70
+ slotClass,
71
+ className,
72
+ )}
73
+ data-slot="button-icon"
74
+ {...props}
75
+ >
76
+ {children}
77
+ </span>
78
+ );
79
+ },
80
+ );
81
+
82
+ ButtonIcon.displayName = 'IdeasUI.Button.Icon';
83
+
84
+ const ButtonSpinner = forwardRef<HTMLSpanElement, ButtonSpinnerProps>(
85
+ ({ className, label = 'Loading', ...props }, ref): JSX.Element => {
86
+ const { styles, classNames } = useButtonContext();
87
+ const { loader, icon } = styles;
88
+
89
+ return (
90
+ <span
91
+ ref={ref}
92
+ className={cn(
93
+ loader(),
94
+ icon(),
95
+ 'inline-flex shrink-0 items-center justify-center',
96
+ classNames?.spinner,
97
+ className,
98
+ )}
99
+ data-slot="button-spinner"
100
+ role="status"
101
+ {...props}
102
+ >
103
+ <svg
104
+ className="size-full animate-spin"
105
+ fill="none"
106
+ stroke="currentColor"
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ strokeWidth="2.5"
110
+ viewBox="0 0 24 24"
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ >
113
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
114
+ </svg>
115
+ <span className="sr-only">{label}</span>
116
+ </span>
117
+ );
118
+ },
119
+ );
120
+
121
+ ButtonSpinner.displayName = 'IdeasUI.Button.Spinner';
122
+
123
+ const ButtonShortcut = forwardRef<HTMLSpanElement, ButtonShortcutProps>(
124
+ ({ children, className, ...props }, ref): JSX.Element => {
125
+ const { styles, classNames } = useButtonContext();
126
+ const { shortcut } = styles;
127
+
128
+ return (
129
+ <kbd
130
+ ref={ref}
131
+ className={cn(shortcut(), classNames?.shortcut, className)}
132
+ data-slot="button-shortcut"
133
+ {...props}
134
+ >
135
+ {children}
136
+ </kbd>
137
+ );
138
+ },
139
+ );
140
+
141
+ ButtonShortcut.displayName = 'IdeasUI.Button.Shortcut';
142
+
143
+ const ButtonLabel = forwardRef<HTMLSpanElement, ButtonLabelProps>(
144
+ ({ children, className, ...props }, ref): JSX.Element => {
145
+ const { styles, classNames } = useButtonContext();
146
+ const { label } = styles;
147
+
148
+ return (
149
+ <span
150
+ ref={ref}
151
+ className={cn(label(), classNames?.label, className)}
152
+ data-slot="button-label"
153
+ {...props}
154
+ >
155
+ {children}
156
+ </span>
157
+ );
158
+ },
159
+ );
160
+
161
+ ButtonLabel.displayName = 'IdeasUI.Button.Label';
162
+
163
+ /* -----------------------------------------------------------------------------------------------
164
+ * Button Render Helpers
165
+ * ---------------------------------------------------------------------------------------------*/
166
+
167
+ interface ButtonContentProps {
168
+ isLoading?: boolean;
169
+ loadingIndicator?: React.ReactNode;
170
+ loadingPosition?: 'start' | 'end' | 'center';
171
+ isIconOnly?: boolean;
172
+ startIcon?: React.ReactNode;
173
+ endIcon?: React.ReactNode;
174
+ shortcut?: React.ReactNode;
175
+ children: ButtonProps['children'];
176
+ renderProps: ButtonRenderProps;
177
+ }
178
+
179
+ const ButtonContent = ({
180
+ isLoading,
181
+ loadingIndicator,
182
+ loadingPosition = 'start',
183
+ isIconOnly,
184
+ startIcon,
185
+ endIcon,
186
+ shortcut,
187
+ children,
188
+ renderProps,
189
+ }: ButtonContentProps): ReactNode => {
190
+ const content = typeof children === 'function' ? children(renderProps) : children;
191
+ const loader = loadingIndicator || <ButtonSpinner />;
192
+
193
+ if (isIconOnly || (isLoading && loadingPosition === 'center')) {
194
+ return isLoading ? loader : content;
195
+ }
196
+
197
+ const showStartLoader = isLoading && loadingPosition === 'start';
198
+ const showEndLoader = isLoading && loadingPosition === 'end';
199
+ const showStartIcon = startIcon && !showStartLoader;
200
+ const showEndIcon = endIcon && !showEndLoader;
201
+
202
+ return (
203
+ <>
204
+ {showStartLoader ? <ButtonIcon placement="start">{loader}</ButtonIcon> : null}
205
+
206
+ {showStartIcon ? <ButtonIcon placement="start">{startIcon}</ButtonIcon> : null}
207
+
208
+ {typeof content === 'string' || typeof content === 'number' ? (
209
+ <ButtonLabel>{content}</ButtonLabel>
210
+ ) : (
211
+ content
212
+ )}
213
+
214
+ {showEndIcon ? <ButtonIcon placement="end">{endIcon}</ButtonIcon> : null}
215
+
216
+ {showEndLoader ? <ButtonIcon placement="end">{loader}</ButtonIcon> : null}
217
+
218
+ {shortcut ? <ButtonShortcut>{shortcut}</ButtonShortcut> : null}
219
+ </>
220
+ );
221
+ };
222
+
223
+ /* -----------------------------------------------------------------------------------------------
224
+ * Button Base
225
+ * ---------------------------------------------------------------------------------------------*/
226
+
227
+ const ButtonBase = forwardRef<HTMLButtonElement, ButtonProps>((originalProps, ref): JSX.Element => {
228
+ const {
229
+ isLoading,
230
+ loadingIndicator,
231
+ loadingPosition,
232
+ isIconOnly,
233
+ startIcon,
234
+ endIcon,
235
+ shortcut,
236
+ children,
237
+ className,
238
+ classNames,
239
+ ...props
240
+ } = originalProps;
241
+
242
+ const groupContext = useButtonGroupContext();
243
+ const merged = mergePropsWithContext(originalProps, groupContext);
244
+
245
+ // Specific logic for Button
246
+ const mergedDisabled = merged.isDisabled || isLoading;
247
+
248
+ const styles = button({
249
+ variant: merged.variant,
250
+ size: merged.size,
251
+ color: merged.color,
252
+ radius: merged.radius,
253
+ fullWidth: merged.fullWidth,
254
+ isIconOnly,
255
+ isDisabled: mergedDisabled,
256
+ isLoading,
257
+ disableAnimation: merged.disableAnimation,
258
+ isAttached: merged.isAttached,
259
+ isVertical: merged.isVertical,
260
+ elevation: merged.elevation,
261
+ showDivider: merged.showDivider ?? merged.isAttached,
262
+ });
263
+
264
+ // Ensure we have an accessible name when loading or icon-only
265
+ const ariaLabel = getAccessibleName(merged, typeof children === 'function' ? null : children);
266
+ const loadingLabel = typeof loadingIndicator === 'string' ? loadingIndicator : 'Loading';
267
+
268
+ return (
269
+ <ButtonPrimitive
270
+ ref={ref}
271
+ aria-busy={isLoading}
272
+ aria-label={isLoading && !ariaLabel ? loadingLabel : ariaLabel}
273
+ className={(renderProps) =>
274
+ styles.base({
275
+ className: cn(
276
+ typeof className === 'function' ? className(renderProps) : className,
277
+ classNames?.base,
278
+ ),
279
+ })
280
+ }
281
+ data-attached={merged.isAttached}
282
+ data-slot="button"
283
+ data-vertical={merged.isVertical}
284
+ isDisabled={mergedDisabled}
285
+ isPending={isLoading}
286
+ {...props}
287
+ >
288
+ {(renderProps) => (
289
+ <ButtonContext.Provider value={{ styles, classNames }}>
290
+ <ButtonContent
291
+ endIcon={endIcon}
292
+ isIconOnly={isIconOnly}
293
+ isLoading={isLoading}
294
+ loadingIndicator={loadingIndicator}
295
+ loadingPosition={loadingPosition}
296
+ renderProps={renderProps}
297
+ shortcut={shortcut}
298
+ startIcon={startIcon}
299
+ >
300
+ {children}
301
+ </ButtonContent>
302
+ </ButtonContext.Provider>
303
+ )}
304
+ </ButtonPrimitive>
305
+ );
306
+ });
307
+
308
+ ButtonBase.displayName = 'IdeasUI.Button';
309
+
310
+ /* -----------------------------------------------------------------------------------------------
311
+ * Final Export
312
+ * ---------------------------------------------------------------------------------------------*/
313
+
314
+ export const Button = ButtonBase as ButtonComponent;
315
+
316
+ Button.Icon = ButtonIcon;
317
+ Button.Shortcut = ButtonShortcut;
318
+ Button.Spinner = ButtonSpinner;
319
+ Button.Label = ButtonLabel;
320
+ Button.Group = ButtonGroup;
@@ -0,0 +1,162 @@
1
+ import type { ButtonProps as ButtonPrimitiveProps, ButtonRenderProps } from 'react-aria-components';
2
+ import type { ForwardRefExoticComponent, HTMLAttributes, ReactNode, RefAttributes } from 'react';
3
+ import type { ButtonVariantProps } from '@ideasui/theme/recipes';
4
+ import type { ButtonGroupProps } from './button-group';
5
+
6
+ /**
7
+ * Granular class names for the Button component slots.
8
+ */
9
+ export interface ButtonClassNames {
10
+ /** Class name for the button container. */
11
+ base?: string;
12
+ /** Class name for the button label. */
13
+ label?: string;
14
+ /** Class name for the start icon container. */
15
+ startIcon?: string;
16
+ /** Class name for the end icon container. */
17
+ endIcon?: string;
18
+ /** Class name for the loading spinner. */
19
+ spinner?: string;
20
+ /** Class name for the shortcut container. */
21
+ shortcut?: string;
22
+ }
23
+
24
+ /**
25
+ * Props for the Button component.
26
+ */
27
+ export interface ButtonProps
28
+ extends
29
+ Omit<ButtonPrimitiveProps, 'className'>,
30
+ Omit<ButtonVariantProps, 'isLoading' | 'isDisabled'> {
31
+ /**
32
+ * Whether the button is in a loading state.
33
+ * @default false
34
+ */
35
+ isLoading?: boolean;
36
+ /**
37
+ * The element to display as a loading indicator.
38
+ * If not provided, a default spinner will be used.
39
+ */
40
+ loadingIndicator?: ReactNode;
41
+ /**
42
+ * The position of the loading indicator relative to the button content.
43
+ * @default 'start'
44
+ */
45
+ loadingPosition?: 'start' | 'end' | 'center';
46
+ /**
47
+ * Whether the button should be square and optimized for icons.
48
+ *
49
+ * Note: When true, you MUST provide an `aria-label` or `aria-labelledby` for accessibility.
50
+ * @default false
51
+ */
52
+ isIconOnly?: boolean;
53
+ /**
54
+ * Icon to display before the button content.
55
+ */
56
+ startIcon?: ReactNode;
57
+ /**
58
+ * Icon to display after the button content.
59
+ */
60
+ endIcon?: ReactNode;
61
+ /**
62
+ * The shortcut keys to display.
63
+ */
64
+ shortcut?: ReactNode;
65
+ /**
66
+ * Whether to show a divider between the buttons in a group.
67
+ * @default true (when isAttached is true)
68
+ */
69
+ showDivider?: boolean;
70
+ /**
71
+ * Custom class names for individual button slots.
72
+ */
73
+ classNames?: ButtonClassNames;
74
+ /**
75
+ * The CSS class name for the button.
76
+ * Can be a string or a function that receives the button render props.
77
+ */
78
+ className?: string | ((props: ButtonRenderProps) => string);
79
+ /**
80
+ * The content to display inside the button.
81
+ * Can be a ReactNode or a function that receives the button render props.
82
+ */
83
+ children?: ReactNode | ((props: ButtonRenderProps) => ReactNode);
84
+ /**
85
+ * Accessibility label for the button.
86
+ * Required if the button has no visible label (e.g., `isIconOnly`).
87
+ */
88
+ 'aria-label'?: string;
89
+ /**
90
+ * ID of an element that serves as the accessible label for the button.
91
+ */
92
+ 'aria-labelledby'?: string;
93
+ }
94
+
95
+ /**
96
+ * Props for the Button.Label component.
97
+ */
98
+ export interface ButtonLabelProps extends HTMLAttributes<HTMLSpanElement> {}
99
+
100
+ /**
101
+ * Props for the Button.Icon component.
102
+ */
103
+ export interface ButtonIconProps extends HTMLAttributes<HTMLElement> {
104
+ /**
105
+ * The icon content.
106
+ */
107
+ children: ReactNode;
108
+ /**
109
+ * The placement of the icon relative to the label.
110
+ * @default 'start'
111
+ */
112
+ placement?: 'start' | 'end';
113
+ }
114
+
115
+ /**
116
+ * Props for the Button.Spinner component.
117
+ */
118
+ export interface ButtonSpinnerProps extends HTMLAttributes<HTMLSpanElement> {
119
+ /**
120
+ * Accessibility label for the spinner.
121
+ * @default 'Loading'
122
+ */
123
+ label?: string;
124
+ }
125
+
126
+ /**
127
+ * Props for the Button.Shortcut component.
128
+ */
129
+ export interface ButtonShortcutProps extends HTMLAttributes<HTMLSpanElement> {
130
+ /**
131
+ * The shortcut keys to display.
132
+ */
133
+ children: ReactNode;
134
+ }
135
+
136
+ /**
137
+ * Type for the Button component with its static compound components.
138
+ */
139
+ export interface ButtonComponent extends ForwardRefExoticComponent<
140
+ ButtonProps & RefAttributes<HTMLButtonElement>
141
+ > {
142
+ /**
143
+ * Component to display the button label.
144
+ */
145
+ Label: ForwardRefExoticComponent<ButtonLabelProps & RefAttributes<HTMLSpanElement>>;
146
+ /**
147
+ * Component to display an icon inside the button.
148
+ */
149
+ Icon: ForwardRefExoticComponent<ButtonIconProps & RefAttributes<HTMLElement>>;
150
+ /**
151
+ * Component to display a shortcut indicator inside the button.
152
+ */
153
+ Shortcut: ForwardRefExoticComponent<ButtonShortcutProps & RefAttributes<HTMLSpanElement>>;
154
+ /**
155
+ * Component to display a loading spinner inside the button.
156
+ */
157
+ Spinner: ForwardRefExoticComponent<ButtonSpinnerProps & RefAttributes<HTMLSpanElement>>;
158
+ /**
159
+ * Component to group multiple buttons together.
160
+ */
161
+ Group: ForwardRefExoticComponent<ButtonGroupProps & RefAttributes<HTMLDivElement>>;
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './button';
2
+ export * from './button-group';
3
+ export * from './button-group-context';
4
+ export type * from './button.types';