@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 +21 -0
- package/README.md +123 -0
- package/dist/index.mjs +2 -0
- package/package.json +54 -0
- package/src/button-group-context.tsx +28 -0
- package/src/button-group.tsx +97 -0
- package/src/button.tsx +320 -0
- package/src/button.types.ts +162 -0
- package/src/index.ts +4 -0
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