@sublimee/auth-ui 0.1.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) 2026 Juan Figueroa
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,170 @@
1
+ # @sublimee/auth-ui
2
+
3
+ Headless authentication UI components for Sublime, built on base-ui primitives.
4
+
5
+ ## Design Philosophy
6
+
7
+ **"Headless but sensible"**
8
+
9
+ Our components are built on top of [base-ui](https://base-ui.com/) primitives. This means:
10
+
11
+ - ✅ **We inherit base-ui's sensible defaults** — cursor-pointer, focus states, keyboard navigation, accessibility
12
+ - ✅ **We DON'T impose visual opinions** — you control 100% of styling via Tailwind `className`
13
+ - ✅ **You get a solid, accessible foundation** that looks exactly how you want
14
+
15
+ This approach solves recurring UI issues (like missing `cursor: pointer`) once and for all, while preserving complete flexibility over visual design.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pnpm add @sublimee/auth-ui
21
+ ```
22
+
23
+ ## Dependencies
24
+
25
+ - `@base-ui/react` — Button primitives with sensible defaults
26
+ - `react` ^19.0.0
27
+ - `react-dom` ^19.0.0
28
+
29
+ ## Components
30
+
31
+ ### OAuthButton
32
+
33
+ A headless OAuth authentication button with Google and Discord provider icons.
34
+
35
+ ```tsx
36
+ import { OAuthButton } from '@sublimee/auth-ui';
37
+
38
+ // Basic usage — inherits cursor-pointer and focus states from base-ui
39
+ <OAuthButton
40
+ provider="google"
41
+ onClick={signInWithGoogle}
42
+ className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
43
+ />
44
+
45
+ // Outline style
46
+ <OAuthButton
47
+ provider="discord"
48
+ onClick={signInWithDiscord}
49
+ className="flex items-center gap-3 px-6 py-3 border border-white/20 text-white rounded-lg hover:bg-white/5"
50
+ />
51
+
52
+ // Icon only — pass {null} as children
53
+ <OAuthButton
54
+ provider="google"
55
+ onClick={signInWithGoogle}
56
+ className="p-3 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
57
+ >
58
+ {null}
59
+ </OAuthButton>
60
+
61
+ // Loading state
62
+ <OAuthButton
63
+ provider="google"
64
+ loading
65
+ className="opacity-50"
66
+ />
67
+
68
+ // Custom text
69
+ <OAuthButton
70
+ provider="google"
71
+ onClick={signInWithGoogle}
72
+ className="px-6 py-3 bg-blue-600 text-white rounded-lg"
73
+ >
74
+ Sign in with Google
75
+ </OAuthButton>
76
+ ```
77
+
78
+ #### Props
79
+
80
+ | Prop | Type | Default | Description |
81
+ |------|------|---------|-------------|
82
+ | `provider` | `'google' \| 'discord'` | required | OAuth provider to authenticate with |
83
+ | `onClick` | `() => void` | - | Click handler |
84
+ | `loading` | `boolean` | `false` | Shows spinner when true |
85
+ | `disabled` | `boolean` | `false` | Disables the button |
86
+ | `className` | `string` | - | Tailwind classes for styling |
87
+ | `children` | `ReactNode` | provider name | Button text. Pass `{null}` for icon-only |
88
+
89
+ #### Sensible defaults we provide
90
+
91
+ `OAuthButton` includes these UX fundamentals automatically:
92
+
93
+ | Behavior | Automatic? |
94
+ |----------|------------|
95
+ | `cursor: pointer` on hover | ✅ |
96
+ | `cursor: not-allowed` when disabled/loading | ✅ |
97
+ | **Focus visible states** via base-ui | ✅ |
98
+ | **Keyboard navigation** via base-ui | ✅ |
99
+ | **ARIA attributes** via base-ui | ✅ |
100
+ | **Disabled state handling** via base-ui | ✅ |
101
+
102
+ You don't need to add `cursor-pointer` to your className — it's automatic. Your `className` is merged with our sensible defaults, with your styles taking precedence.
103
+
104
+ ### Icons
105
+
106
+ Individual icons are also exported if you need them separately:
107
+
108
+ ```tsx
109
+ import { GoogleIcon, DiscordIcon, SpinnerIcon } from '@sublimee/auth-ui';
110
+
111
+ <GoogleIcon size={24} className="text-red-500" />
112
+ <DiscordIcon size={20} />
113
+ <SpinnerIcon size={16} className="animate-spin" />
114
+ ```
115
+
116
+ ## Styling Guidelines
117
+
118
+ Since components are headless, you provide all styles via `className`. Here are recommended patterns:
119
+
120
+ ### Common Patterns
121
+
122
+ ```tsx
123
+ // Full-width button
124
+ className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg"
125
+
126
+ // Minimal icon button
127
+ className="p-2 rounded-full hover:bg-white/10 transition-colors"
128
+
129
+ // Outline variant
130
+ className="flex items-center gap-2 px-4 py-2 border border-current rounded-md hover:bg-white/5"
131
+
132
+ // Loading state
133
+ className="opacity-50 cursor-wait"
134
+ ```
135
+
136
+ ### State Handling
137
+
138
+ The component accepts a `disabled` prop and renders in a loading state when `loading={true}`. These states automatically get `cursor: not-allowed` from base-ui, but you may want to add visual indicators:
139
+
140
+ ```tsx
141
+ <OAuthButton
142
+ provider="google"
143
+ loading={isLoading}
144
+ disabled={isLoading}
145
+ className={`flex items-center gap-3 px-6 py-3 rounded-lg ${
146
+ isLoading ? 'opacity-50' : 'hover:bg-gray-50'
147
+ }`}
148
+ />
149
+ ```
150
+
151
+ ## TypeScript
152
+
153
+ Full TypeScript support is included:
154
+
155
+ ```tsx
156
+ import type { OAuthButtonProps, OAuthProvider, OAuthIconsProps } from '@sublimee/auth-ui';
157
+ ```
158
+
159
+ ## Why base-ui?
160
+
161
+ We chose base-ui as our foundation because:
162
+
163
+ 1. **Sensible defaults out of the box** — No more forgetting `cursor: pointer`
164
+ 2. **Accessibility built-in** — Proper ARIA, focus management, keyboard support
165
+ 3. **Headless by design** — Complete control over styling
166
+ 4. **Production-tested** — Built by the MUI team, battle-tested at scale
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,96 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import * as react_jsx_runtime from 'react/jsx-runtime';
4
+
5
+ type OAuthProvider = 'google' | 'discord';
6
+ interface OAuthButtonProps {
7
+ /**
8
+ * The OAuth provider to authenticate with
9
+ */
10
+ provider: OAuthProvider;
11
+ /**
12
+ * Click handler - typically calls supabase.auth.signInWithOAuth
13
+ */
14
+ onClick?: () => void;
15
+ /**
16
+ * Loading state - shows spinner when true
17
+ */
18
+ loading?: boolean;
19
+ /**
20
+ * Custom className - use Tailwind for styling
21
+ * @example "flex items-center gap-2 px-4 py-2 bg-white text-gray-900 rounded-lg"
22
+ */
23
+ className?: string;
24
+ /**
25
+ * Button children - defaults to provider name
26
+ * Pass `null` for icon-only mode
27
+ */
28
+ children?: ReactNode;
29
+ /**
30
+ * Disable the button
31
+ */
32
+ disabled?: boolean;
33
+ }
34
+ interface OAuthIconsProps {
35
+ /**
36
+ * Icon size in pixels (min: 1)
37
+ * @default 24
38
+ */
39
+ size?: number;
40
+ /**
41
+ * Custom className
42
+ */
43
+ className?: string;
44
+ }
45
+
46
+ /**
47
+ * OAuthButton component
48
+ *
49
+ * A headless OAuth button built on top of base-ui's Button primitive.
50
+ * Includes sensible defaults (cursor-pointer) while remaining fully
51
+ * customizable via className.
52
+ *
53
+ * Design Philosophy:
54
+ * - We add sensible UX defaults (cursor-pointer, proper disabled states)
55
+ * - We don't impose visual opinions - you control all styling via className
56
+ * - The result is a solid, accessible foundation that looks exactly how you want
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * // Basic usage - cursor-pointer is automatic
61
+ * <OAuthButton
62
+ * provider="google"
63
+ * onClick={signInWithGoogle}
64
+ * className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
65
+ * />
66
+ *
67
+ * // Icon only - pass {null} as children
68
+ * <OAuthButton
69
+ * provider="google"
70
+ * className="p-3 rounded-full hover:bg-white/10"
71
+ * >
72
+ * {null}
73
+ * </OAuthButton>
74
+ *
75
+ * // Loading state - cursor-not-allowed is automatic
76
+ * <OAuthButton provider="google" loading className="opacity-50" />
77
+ * ```
78
+ */
79
+ declare const OAuthButton: react.ForwardRefExoticComponent<OAuthButtonProps & react.RefAttributes<HTMLButtonElement>>;
80
+
81
+ /**
82
+ * Google Logo Icon
83
+ * Official Google "G" logo for OAuth authentication
84
+ */
85
+ declare function GoogleIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
86
+ /**
87
+ * Discord Logo Icon
88
+ * Official Discord logo for OAuth authentication
89
+ */
90
+ declare function DiscordIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
91
+ /**
92
+ * Spinner Icon for loading state
93
+ */
94
+ declare function SpinnerIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
95
+
96
+ export { DiscordIcon, GoogleIcon, OAuthButton, type OAuthButtonProps, type OAuthIconsProps, type OAuthProvider, SpinnerIcon, OAuthButton as default };
@@ -0,0 +1,96 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import * as react_jsx_runtime from 'react/jsx-runtime';
4
+
5
+ type OAuthProvider = 'google' | 'discord';
6
+ interface OAuthButtonProps {
7
+ /**
8
+ * The OAuth provider to authenticate with
9
+ */
10
+ provider: OAuthProvider;
11
+ /**
12
+ * Click handler - typically calls supabase.auth.signInWithOAuth
13
+ */
14
+ onClick?: () => void;
15
+ /**
16
+ * Loading state - shows spinner when true
17
+ */
18
+ loading?: boolean;
19
+ /**
20
+ * Custom className - use Tailwind for styling
21
+ * @example "flex items-center gap-2 px-4 py-2 bg-white text-gray-900 rounded-lg"
22
+ */
23
+ className?: string;
24
+ /**
25
+ * Button children - defaults to provider name
26
+ * Pass `null` for icon-only mode
27
+ */
28
+ children?: ReactNode;
29
+ /**
30
+ * Disable the button
31
+ */
32
+ disabled?: boolean;
33
+ }
34
+ interface OAuthIconsProps {
35
+ /**
36
+ * Icon size in pixels (min: 1)
37
+ * @default 24
38
+ */
39
+ size?: number;
40
+ /**
41
+ * Custom className
42
+ */
43
+ className?: string;
44
+ }
45
+
46
+ /**
47
+ * OAuthButton component
48
+ *
49
+ * A headless OAuth button built on top of base-ui's Button primitive.
50
+ * Includes sensible defaults (cursor-pointer) while remaining fully
51
+ * customizable via className.
52
+ *
53
+ * Design Philosophy:
54
+ * - We add sensible UX defaults (cursor-pointer, proper disabled states)
55
+ * - We don't impose visual opinions - you control all styling via className
56
+ * - The result is a solid, accessible foundation that looks exactly how you want
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * // Basic usage - cursor-pointer is automatic
61
+ * <OAuthButton
62
+ * provider="google"
63
+ * onClick={signInWithGoogle}
64
+ * className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
65
+ * />
66
+ *
67
+ * // Icon only - pass {null} as children
68
+ * <OAuthButton
69
+ * provider="google"
70
+ * className="p-3 rounded-full hover:bg-white/10"
71
+ * >
72
+ * {null}
73
+ * </OAuthButton>
74
+ *
75
+ * // Loading state - cursor-not-allowed is automatic
76
+ * <OAuthButton provider="google" loading className="opacity-50" />
77
+ * ```
78
+ */
79
+ declare const OAuthButton: react.ForwardRefExoticComponent<OAuthButtonProps & react.RefAttributes<HTMLButtonElement>>;
80
+
81
+ /**
82
+ * Google Logo Icon
83
+ * Official Google "G" logo for OAuth authentication
84
+ */
85
+ declare function GoogleIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
86
+ /**
87
+ * Discord Logo Icon
88
+ * Official Discord logo for OAuth authentication
89
+ */
90
+ declare function DiscordIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
91
+ /**
92
+ * Spinner Icon for loading state
93
+ */
94
+ declare function SpinnerIcon({ size, className }: OAuthIconsProps): react_jsx_runtime.JSX.Element;
95
+
96
+ export { DiscordIcon, GoogleIcon, OAuthButton, type OAuthButtonProps, type OAuthIconsProps, type OAuthProvider, SpinnerIcon, OAuthButton as default };
package/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DiscordIcon: () => DiscordIcon,
24
+ GoogleIcon: () => GoogleIcon,
25
+ OAuthButton: () => OAuthButton,
26
+ SpinnerIcon: () => SpinnerIcon,
27
+ default: () => oauth_button_default
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/oauth-button.tsx
32
+ var import_react = require("react");
33
+ var import_button = require("@base-ui/react/button");
34
+
35
+ // src/icons.tsx
36
+ var import_jsx_runtime = require("react/jsx-runtime");
37
+ function getSafeSize(size) {
38
+ const safeSize = size ?? 24;
39
+ return Math.max(1, safeSize);
40
+ }
41
+ function GoogleIcon({ size, className }) {
42
+ const safeSize = getSafeSize(size);
43
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
44
+ "svg",
45
+ {
46
+ width: safeSize,
47
+ height: safeSize,
48
+ viewBox: "0 0 24 24",
49
+ fill: "none",
50
+ xmlns: "http://www.w3.org/2000/svg",
51
+ className,
52
+ "aria-hidden": "true",
53
+ children: [
54
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
55
+ "path",
56
+ {
57
+ d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z",
58
+ fill: "#4285F4"
59
+ }
60
+ ),
61
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
62
+ "path",
63
+ {
64
+ d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z",
65
+ fill: "#34A853"
66
+ }
67
+ ),
68
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
69
+ "path",
70
+ {
71
+ d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z",
72
+ fill: "#FBBC05"
73
+ }
74
+ ),
75
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
76
+ "path",
77
+ {
78
+ d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z",
79
+ fill: "#EA4335"
80
+ }
81
+ )
82
+ ]
83
+ }
84
+ );
85
+ }
86
+ function DiscordIcon({ size, className }) {
87
+ const safeSize = getSafeSize(size);
88
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
89
+ "svg",
90
+ {
91
+ width: safeSize,
92
+ height: safeSize,
93
+ viewBox: "0 0 24 24",
94
+ fill: "none",
95
+ xmlns: "http://www.w3.org/2000/svg",
96
+ className,
97
+ "aria-hidden": "true",
98
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
99
+ "path",
100
+ {
101
+ d: "M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z",
102
+ fill: "currentColor"
103
+ }
104
+ )
105
+ }
106
+ );
107
+ }
108
+ function SpinnerIcon({ size, className }) {
109
+ const safeSize = size ?? 20;
110
+ const finalSize = Math.max(1, safeSize);
111
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
112
+ "svg",
113
+ {
114
+ width: finalSize,
115
+ height: finalSize,
116
+ viewBox: "0 0 24 24",
117
+ fill: "none",
118
+ xmlns: "http://www.w3.org/2000/svg",
119
+ className,
120
+ "aria-hidden": "true",
121
+ children: [
122
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
123
+ "circle",
124
+ {
125
+ cx: "12",
126
+ cy: "12",
127
+ r: "10",
128
+ stroke: "currentColor",
129
+ strokeWidth: "4",
130
+ strokeLinecap: "round",
131
+ strokeDasharray: "60",
132
+ strokeDashoffset: "20",
133
+ opacity: "0.25"
134
+ }
135
+ ),
136
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
137
+ "path",
138
+ {
139
+ d: "M12 2C6.477 2 2 6.477 2 12",
140
+ stroke: "currentColor",
141
+ strokeWidth: "4",
142
+ strokeLinecap: "round",
143
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
144
+ "animateTransform",
145
+ {
146
+ attributeName: "transform",
147
+ type: "rotate",
148
+ from: "0 12 12",
149
+ to: "360 12 12",
150
+ dur: "1s",
151
+ repeatCount: "indefinite"
152
+ }
153
+ )
154
+ }
155
+ )
156
+ ]
157
+ }
158
+ );
159
+ }
160
+
161
+ // src/oauth-button.tsx
162
+ var import_jsx_runtime2 = require("react/jsx-runtime");
163
+ var PROVIDER_NAMES = {
164
+ google: "Google",
165
+ discord: "Discord"
166
+ };
167
+ function getProviderIcon(provider) {
168
+ switch (provider) {
169
+ case "google":
170
+ return GoogleIcon;
171
+ case "discord":
172
+ return DiscordIcon;
173
+ default:
174
+ return () => null;
175
+ }
176
+ }
177
+ var SENSIBLE_DEFAULTS = ["cursor-pointer"];
178
+ function mergeButtonClasses(userClassName, isDisabled) {
179
+ const userClasses = userClassName?.trim() ?? "";
180
+ const neededDefaults = SENSIBLE_DEFAULTS.filter(
181
+ (defaultClass) => !userClasses.includes(defaultClass)
182
+ );
183
+ if (isDisabled && !userClasses.includes("cursor-not-allowed")) {
184
+ neededDefaults.push("cursor-not-allowed");
185
+ }
186
+ if (neededDefaults.length === 0) {
187
+ return userClasses;
188
+ }
189
+ return `${neededDefaults.join(" ")} ${userClasses}`.trim();
190
+ }
191
+ var OAuthButton = (0, import_react.forwardRef)(
192
+ function OAuthButton2(props, forwardedRef) {
193
+ const {
194
+ provider,
195
+ onClick,
196
+ loading = false,
197
+ className,
198
+ children,
199
+ disabled = false,
200
+ ...otherProps
201
+ } = props;
202
+ const isDisabled = disabled || loading;
203
+ const Icon = getProviderIcon(provider);
204
+ const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
205
+ const mergedClassName = (0, import_react.useMemo)(
206
+ () => mergeButtonClasses(className, isDisabled),
207
+ [className, isDisabled]
208
+ );
209
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
210
+ import_button.Button,
211
+ {
212
+ ref: forwardedRef,
213
+ onClick,
214
+ disabled: isDisabled,
215
+ "aria-busy": loading,
216
+ className: mergedClassName,
217
+ ...otherProps,
218
+ children: loading ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
219
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SpinnerIcon, {}),
220
+ children !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: children ?? "Conectando..." })
221
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
222
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Icon, {}),
223
+ children !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: children ?? defaultText })
224
+ ] })
225
+ }
226
+ );
227
+ }
228
+ );
229
+ var oauth_button_default = OAuthButton;
230
+ // Annotate the CommonJS export names for ESM import in node:
231
+ 0 && (module.exports = {
232
+ DiscordIcon,
233
+ GoogleIcon,
234
+ OAuthButton,
235
+ SpinnerIcon
236
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,206 @@
1
+ // src/oauth-button.tsx
2
+ import { forwardRef, useMemo } from "react";
3
+ import { Button } from "@base-ui/react/button";
4
+
5
+ // src/icons.tsx
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ function getSafeSize(size) {
8
+ const safeSize = size ?? 24;
9
+ return Math.max(1, safeSize);
10
+ }
11
+ function GoogleIcon({ size, className }) {
12
+ const safeSize = getSafeSize(size);
13
+ return /* @__PURE__ */ jsxs(
14
+ "svg",
15
+ {
16
+ width: safeSize,
17
+ height: safeSize,
18
+ viewBox: "0 0 24 24",
19
+ fill: "none",
20
+ xmlns: "http://www.w3.org/2000/svg",
21
+ className,
22
+ "aria-hidden": "true",
23
+ children: [
24
+ /* @__PURE__ */ jsx(
25
+ "path",
26
+ {
27
+ d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z",
28
+ fill: "#4285F4"
29
+ }
30
+ ),
31
+ /* @__PURE__ */ jsx(
32
+ "path",
33
+ {
34
+ d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z",
35
+ fill: "#34A853"
36
+ }
37
+ ),
38
+ /* @__PURE__ */ jsx(
39
+ "path",
40
+ {
41
+ d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z",
42
+ fill: "#FBBC05"
43
+ }
44
+ ),
45
+ /* @__PURE__ */ jsx(
46
+ "path",
47
+ {
48
+ d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z",
49
+ fill: "#EA4335"
50
+ }
51
+ )
52
+ ]
53
+ }
54
+ );
55
+ }
56
+ function DiscordIcon({ size, className }) {
57
+ const safeSize = getSafeSize(size);
58
+ return /* @__PURE__ */ jsx(
59
+ "svg",
60
+ {
61
+ width: safeSize,
62
+ height: safeSize,
63
+ viewBox: "0 0 24 24",
64
+ fill: "none",
65
+ xmlns: "http://www.w3.org/2000/svg",
66
+ className,
67
+ "aria-hidden": "true",
68
+ children: /* @__PURE__ */ jsx(
69
+ "path",
70
+ {
71
+ d: "M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z",
72
+ fill: "currentColor"
73
+ }
74
+ )
75
+ }
76
+ );
77
+ }
78
+ function SpinnerIcon({ size, className }) {
79
+ const safeSize = size ?? 20;
80
+ const finalSize = Math.max(1, safeSize);
81
+ return /* @__PURE__ */ jsxs(
82
+ "svg",
83
+ {
84
+ width: finalSize,
85
+ height: finalSize,
86
+ viewBox: "0 0 24 24",
87
+ fill: "none",
88
+ xmlns: "http://www.w3.org/2000/svg",
89
+ className,
90
+ "aria-hidden": "true",
91
+ children: [
92
+ /* @__PURE__ */ jsx(
93
+ "circle",
94
+ {
95
+ cx: "12",
96
+ cy: "12",
97
+ r: "10",
98
+ stroke: "currentColor",
99
+ strokeWidth: "4",
100
+ strokeLinecap: "round",
101
+ strokeDasharray: "60",
102
+ strokeDashoffset: "20",
103
+ opacity: "0.25"
104
+ }
105
+ ),
106
+ /* @__PURE__ */ jsx(
107
+ "path",
108
+ {
109
+ d: "M12 2C6.477 2 2 6.477 2 12",
110
+ stroke: "currentColor",
111
+ strokeWidth: "4",
112
+ strokeLinecap: "round",
113
+ children: /* @__PURE__ */ jsx(
114
+ "animateTransform",
115
+ {
116
+ attributeName: "transform",
117
+ type: "rotate",
118
+ from: "0 12 12",
119
+ to: "360 12 12",
120
+ dur: "1s",
121
+ repeatCount: "indefinite"
122
+ }
123
+ )
124
+ }
125
+ )
126
+ ]
127
+ }
128
+ );
129
+ }
130
+
131
+ // src/oauth-button.tsx
132
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
133
+ var PROVIDER_NAMES = {
134
+ google: "Google",
135
+ discord: "Discord"
136
+ };
137
+ function getProviderIcon(provider) {
138
+ switch (provider) {
139
+ case "google":
140
+ return GoogleIcon;
141
+ case "discord":
142
+ return DiscordIcon;
143
+ default:
144
+ return () => null;
145
+ }
146
+ }
147
+ var SENSIBLE_DEFAULTS = ["cursor-pointer"];
148
+ function mergeButtonClasses(userClassName, isDisabled) {
149
+ const userClasses = userClassName?.trim() ?? "";
150
+ const neededDefaults = SENSIBLE_DEFAULTS.filter(
151
+ (defaultClass) => !userClasses.includes(defaultClass)
152
+ );
153
+ if (isDisabled && !userClasses.includes("cursor-not-allowed")) {
154
+ neededDefaults.push("cursor-not-allowed");
155
+ }
156
+ if (neededDefaults.length === 0) {
157
+ return userClasses;
158
+ }
159
+ return `${neededDefaults.join(" ")} ${userClasses}`.trim();
160
+ }
161
+ var OAuthButton = forwardRef(
162
+ function OAuthButton2(props, forwardedRef) {
163
+ const {
164
+ provider,
165
+ onClick,
166
+ loading = false,
167
+ className,
168
+ children,
169
+ disabled = false,
170
+ ...otherProps
171
+ } = props;
172
+ const isDisabled = disabled || loading;
173
+ const Icon = getProviderIcon(provider);
174
+ const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
175
+ const mergedClassName = useMemo(
176
+ () => mergeButtonClasses(className, isDisabled),
177
+ [className, isDisabled]
178
+ );
179
+ return /* @__PURE__ */ jsx2(
180
+ Button,
181
+ {
182
+ ref: forwardedRef,
183
+ onClick,
184
+ disabled: isDisabled,
185
+ "aria-busy": loading,
186
+ className: mergedClassName,
187
+ ...otherProps,
188
+ children: loading ? /* @__PURE__ */ jsxs2(Fragment, { children: [
189
+ /* @__PURE__ */ jsx2(SpinnerIcon, {}),
190
+ children !== null && /* @__PURE__ */ jsx2("span", { children: children ?? "Conectando..." })
191
+ ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
192
+ /* @__PURE__ */ jsx2(Icon, {}),
193
+ children !== null && /* @__PURE__ */ jsx2("span", { children: children ?? defaultText })
194
+ ] })
195
+ }
196
+ );
197
+ }
198
+ );
199
+ var oauth_button_default = OAuthButton;
200
+ export {
201
+ DiscordIcon,
202
+ GoogleIcon,
203
+ OAuthButton,
204
+ SpinnerIcon,
205
+ oauth_button_default as default
206
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@sublimee/auth-ui",
3
+ "version": "0.1.0",
4
+ "description": "Headless authentication UI components for Sublime",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "src"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs",
16
+ "require": "./dist/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/juancrfig/sublime.git",
26
+ "directory": "packages/auth-ui"
27
+ },
28
+ "keywords": [
29
+ "react",
30
+ "authentication",
31
+ "oauth",
32
+ "ui",
33
+ "components",
34
+ "headless",
35
+ "base-ui"
36
+ ],
37
+ "author": "Sublime Team",
38
+ "license": "MIT",
39
+ "peerDependencies": {
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0"
42
+ },
43
+ "dependencies": {
44
+ "@base-ui/react": "^1.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/react": "^19",
48
+ "@types/react-dom": "^19",
49
+ "tsup": "^8",
50
+ "typescript": "^5"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
54
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
55
+ "typecheck": "tsc --noEmit"
56
+ }
57
+ }
package/src/icons.tsx ADDED
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import type { OAuthIconsProps } from './types';
4
+
5
+ /**
6
+ * Validates and sanitizes icon size (minimum 1px)
7
+ */
8
+ function getSafeSize(size: number | undefined): number {
9
+ const safeSize = size ?? 24;
10
+ return Math.max(1, safeSize);
11
+ }
12
+
13
+ /**
14
+ * Google Logo Icon
15
+ * Official Google "G" logo for OAuth authentication
16
+ */
17
+ export function GoogleIcon({ size, className }: OAuthIconsProps) {
18
+ const safeSize = getSafeSize(size);
19
+ return (
20
+ <svg
21
+ width={safeSize}
22
+ height={safeSize}
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ xmlns="http://www.w3.org/2000/svg"
26
+ className={className}
27
+ aria-hidden="true"
28
+ >
29
+ <path
30
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
31
+ fill="#4285F4"
32
+ />
33
+ <path
34
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
35
+ fill="#34A853"
36
+ />
37
+ <path
38
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
39
+ fill="#FBBC05"
40
+ />
41
+ <path
42
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
43
+ fill="#EA4335"
44
+ />
45
+ </svg>
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Discord Logo Icon
51
+ * Official Discord logo for OAuth authentication
52
+ */
53
+ export function DiscordIcon({ size, className }: OAuthIconsProps) {
54
+ const safeSize = getSafeSize(size);
55
+ return (
56
+ <svg
57
+ width={safeSize}
58
+ height={safeSize}
59
+ viewBox="0 0 24 24"
60
+ fill="none"
61
+ xmlns="http://www.w3.org/2000/svg"
62
+ className={className}
63
+ aria-hidden="true"
64
+ >
65
+ <path
66
+ d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
67
+ fill="currentColor"
68
+ />
69
+ </svg>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Spinner Icon for loading state
75
+ */
76
+ export function SpinnerIcon({ size, className }: OAuthIconsProps) {
77
+ // Spinner defaults to 20px instead of 24px
78
+ const safeSize = size ?? 20;
79
+ const finalSize = Math.max(1, safeSize);
80
+ return (
81
+ <svg
82
+ width={finalSize}
83
+ height={finalSize}
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ xmlns="http://www.w3.org/2000/svg"
87
+ className={className}
88
+ aria-hidden="true"
89
+ >
90
+ <circle
91
+ cx="12"
92
+ cy="12"
93
+ r="10"
94
+ stroke="currentColor"
95
+ strokeWidth="4"
96
+ strokeLinecap="round"
97
+ strokeDasharray="60"
98
+ strokeDashoffset="20"
99
+ opacity="0.25"
100
+ />
101
+ <path
102
+ d="M12 2C6.477 2 2 6.477 2 12"
103
+ stroke="currentColor"
104
+ strokeWidth="4"
105
+ strokeLinecap="round"
106
+ >
107
+ <animateTransform
108
+ attributeName="transform"
109
+ type="rotate"
110
+ from="0 12 12"
111
+ to="360 12 12"
112
+ dur="1s"
113
+ repeatCount="indefinite"
114
+ />
115
+ </path>
116
+ </svg>
117
+ );
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @sublimee/auth-ui
3
+ * Headless authentication UI components for Sublime
4
+ *
5
+ * Design Philosophy:
6
+ * -----------------
7
+ * "Headless but sensible"
8
+ *
9
+ * Our components are built on top of base-ui primitives, which means:
10
+ * - We inherit base-ui's accessibility defaults (focus states, keyboard nav, ARIA)
11
+ * - We ADD sensible UX defaults (cursor-pointer, disabled states)
12
+ * - We DON'T impose visual opinions - you control 100% of styling via className
13
+ * - You get a solid, accessible foundation that looks exactly how you want
14
+ *
15
+ * This approach solves recurring UI issues (like cursor:pointer) once and for all,
16
+ * while preserving the flexibility to style however you need.
17
+ *
18
+ * Built with base-ui for maximum flexibility and composability.
19
+ */
20
+
21
+ // Components
22
+ export { OAuthButton } from './oauth-button';
23
+ export { default } from './oauth-button';
24
+
25
+ // Icons
26
+ export { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
27
+
28
+ // Types
29
+ export type {
30
+ OAuthProvider,
31
+ OAuthButtonProps,
32
+ OAuthIconsProps,
33
+ } from './types';
@@ -0,0 +1,139 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useMemo } from 'react';
4
+ import { Button } from '@base-ui/react/button';
5
+ import type { OAuthButtonProps, OAuthProvider } from './types';
6
+ import { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
7
+
8
+ /**
9
+ * Maps OAuth providers to their display names
10
+ */
11
+ const PROVIDER_NAMES: Record<OAuthProvider, string> = {
12
+ google: 'Google',
13
+ discord: 'Discord',
14
+ };
15
+
16
+ /**
17
+ * Returns the appropriate icon component for the provider
18
+ */
19
+ function getProviderIcon(provider: OAuthProvider) {
20
+ switch (provider) {
21
+ case 'google':
22
+ return GoogleIcon;
23
+ case 'discord':
24
+ return DiscordIcon;
25
+ default:
26
+ return () => null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Sensible defaults that every button should have.
32
+ * These are UX fundamentals that don't affect visual design.
33
+ */
34
+ const SENSIBLE_DEFAULTS: readonly string[] = ['cursor-pointer'];
35
+
36
+ /**
37
+ * Merges sensible default classes with user-provided classes.
38
+ * Prevents duplicate cursor classes if user already specified them.
39
+ */
40
+ function mergeButtonClasses(userClassName: string | undefined, isDisabled: boolean): string {
41
+ const userClasses = userClassName?.trim() ?? '';
42
+
43
+ // Filter out defaults that user already specified to avoid duplicates
44
+ const neededDefaults = SENSIBLE_DEFAULTS.filter(
45
+ defaultClass => !userClasses.includes(defaultClass)
46
+ );
47
+
48
+ // Add disabled cursor class if needed and not already present
49
+ if (isDisabled && !userClasses.includes('cursor-not-allowed')) {
50
+ neededDefaults.push('cursor-not-allowed');
51
+ }
52
+
53
+ if (neededDefaults.length === 0) {
54
+ return userClasses;
55
+ }
56
+
57
+ return `${neededDefaults.join(' ')} ${userClasses}`.trim();
58
+ }
59
+
60
+ /**
61
+ * OAuthButton component
62
+ *
63
+ * A headless OAuth button built on top of base-ui's Button primitive.
64
+ * Includes sensible defaults (cursor-pointer) while remaining fully
65
+ * customizable via className.
66
+ *
67
+ * Design Philosophy:
68
+ * - We add sensible UX defaults (cursor-pointer, proper disabled states)
69
+ * - We don't impose visual opinions - you control all styling via className
70
+ * - The result is a solid, accessible foundation that looks exactly how you want
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // Basic usage - cursor-pointer is automatic
75
+ * <OAuthButton
76
+ * provider="google"
77
+ * onClick={signInWithGoogle}
78
+ * className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
79
+ * />
80
+ *
81
+ * // Icon only - pass {null} as children
82
+ * <OAuthButton
83
+ * provider="google"
84
+ * className="p-3 rounded-full hover:bg-white/10"
85
+ * >
86
+ * {null}
87
+ * </OAuthButton>
88
+ *
89
+ * // Loading state - cursor-not-allowed is automatic
90
+ * <OAuthButton provider="google" loading className="opacity-50" />
91
+ * ```
92
+ */
93
+ export const OAuthButton = forwardRef<HTMLButtonElement, OAuthButtonProps>(
94
+ function OAuthButton(props, forwardedRef) {
95
+ const {
96
+ provider,
97
+ onClick,
98
+ loading = false,
99
+ className,
100
+ children,
101
+ disabled = false,
102
+ ...otherProps
103
+ } = props;
104
+
105
+ const isDisabled = disabled || loading;
106
+ const Icon = getProviderIcon(provider);
107
+ const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
108
+
109
+ const mergedClassName = useMemo(
110
+ () => mergeButtonClasses(className, isDisabled),
111
+ [className, isDisabled]
112
+ );
113
+
114
+ return (
115
+ <Button
116
+ ref={forwardedRef}
117
+ onClick={onClick}
118
+ disabled={isDisabled}
119
+ aria-busy={loading}
120
+ className={mergedClassName}
121
+ {...otherProps}
122
+ >
123
+ {loading ? (
124
+ <>
125
+ <SpinnerIcon />
126
+ {children !== null && <span>{children ?? 'Conectando...'}</span>}
127
+ </>
128
+ ) : (
129
+ <>
130
+ <Icon />
131
+ {children !== null && <span>{children ?? defaultText}</span>}
132
+ </>
133
+ )}
134
+ </Button>
135
+ );
136
+ }
137
+ );
138
+
139
+ export default OAuthButton;
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type OAuthProvider = 'google' | 'discord';
4
+
5
+ export interface OAuthButtonProps {
6
+ /**
7
+ * The OAuth provider to authenticate with
8
+ */
9
+ provider: OAuthProvider;
10
+
11
+ /**
12
+ * Click handler - typically calls supabase.auth.signInWithOAuth
13
+ */
14
+ onClick?: () => void;
15
+
16
+ /**
17
+ * Loading state - shows spinner when true
18
+ */
19
+ loading?: boolean;
20
+
21
+ /**
22
+ * Custom className - use Tailwind for styling
23
+ * @example "flex items-center gap-2 px-4 py-2 bg-white text-gray-900 rounded-lg"
24
+ */
25
+ className?: string;
26
+
27
+ /**
28
+ * Button children - defaults to provider name
29
+ * Pass `null` for icon-only mode
30
+ */
31
+ children?: ReactNode;
32
+
33
+ /**
34
+ * Disable the button
35
+ */
36
+ disabled?: boolean;
37
+ }
38
+
39
+ export interface OAuthIconsProps {
40
+ /**
41
+ * Icon size in pixels (min: 1)
42
+ * @default 24
43
+ */
44
+ size?: number;
45
+
46
+ /**
47
+ * Custom className
48
+ */
49
+ className?: string;
50
+ }