@sublimee/auth-ui 0.1.1 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,385 +1,124 @@
1
- import type { Story } from "@ladle/react";
2
- import { useState, useCallback, useRef } from "react";
3
- import { OAuthButton } from "./oauth-button";
4
- import type { OAuthProvider } from "./types";
1
+ import type { Story } from '@ladle/react';
5
2
 
6
- /**
7
- * Hook to mimic real button behavior:
8
- * - Presses immediately on mousedown (scale down + shadow-inner)
9
- * - Stays pressed while holding
10
- * - Plays release animation on mouseup (always completes fully)
11
- */
12
- function useRealPress() {
13
- const [phase, setPhase] = useState<"idle" | "pressed" | "releasing">("idle");
14
- const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
-
16
- const onMouseDown = useCallback(() => {
17
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
18
- setPhase("pressed");
19
- }, []);
20
-
21
- const onMouseUp = useCallback(() => {
22
- if (phase !== "pressed") return;
23
- setPhase("releasing");
24
- timeoutRef.current = setTimeout(() => setPhase("idle"), 200);
25
- }, [phase]);
3
+ import { OAuthButton } from './oauth-button';
4
+ import type { OAuthProvider, ButtonVariant } from './types';
26
5
 
27
- const onMouseLeave = useCallback(() => {
28
- if (phase === "pressed") onMouseUp();
29
- }, [phase, onMouseUp]);
30
-
31
- const className =
32
- phase === "pressed" ? "scale-[0.96] shadow-inner" :
33
- phase === "releasing" ? "animate-button-release" : "";
34
-
35
- return { className, onMouseDown, onMouseUp, onMouseLeave };
36
- }
6
+ export default {
7
+ title: 'B Auth Button',
8
+ };
37
9
 
38
10
  /**
39
11
  * OAuthButton stories
40
12
  *
41
- * A headless OAuth button with sensible defaults built on base-ui.
13
+ * Semantic OAuth buttons with curated motion and built-in loading behavior.
14
+ * These components adapt to your theme via CSS custom properties.
42
15
  */
43
16
 
44
17
  interface OAuthButtonStoryProps {
45
18
  provider: OAuthProvider;
19
+ variant: ButtonVariant;
46
20
  disabled: boolean;
47
21
  loading: boolean;
48
22
  label: string;
49
23
  }
50
24
 
51
- const baseStyles =
52
- "flex items-center justify-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer";
53
-
54
- const darkStyles =
55
- "dark:bg-gray-900 dark:text-white dark:border-gray-700 dark:hover:bg-gray-800";
56
-
57
25
  export const Default: Story<OAuthButtonStoryProps> = ({
58
26
  provider,
27
+ variant,
59
28
  disabled,
60
29
  loading,
61
30
  label,
62
- }) => {
63
- const press = useRealPress();
64
-
65
- return (
31
+ }) => (
32
+ <div className="flex flex-wrap items-center gap-4">
66
33
  <OAuthButton
67
34
  provider={provider}
35
+ variant={variant}
68
36
  disabled={disabled}
69
37
  loading={loading}
70
- onMouseDown={press.onMouseDown}
71
- onMouseUp={press.onMouseUp}
72
- onMouseLeave={press.onMouseLeave}
73
- className={`${baseStyles} ${darkStyles} ${press.className}`}
74
38
  >
75
39
  {label || undefined}
76
40
  </OAuthButton>
77
- );
78
- };
41
+
42
+ <OAuthButton
43
+ provider={provider}
44
+ variant="secondary"
45
+ disabled={disabled}
46
+ loading={loading}
47
+ >
48
+ {null}
49
+ </OAuthButton>
50
+ </div>
51
+ );
79
52
 
80
53
  Default.args = {
81
- provider: "google",
54
+ provider: 'google',
55
+ variant: 'secondary',
82
56
  disabled: false,
83
57
  loading: false,
84
- label: "",
58
+ label: '',
85
59
  };
86
60
 
87
61
  Default.argTypes = {
88
62
  provider: {
89
- control: { type: "select" },
90
- options: ["google", "discord"],
63
+ control: { type: 'select' },
64
+ options: ['google', 'discord'],
65
+ },
66
+ variant: {
67
+ control: { type: 'select' },
68
+ options: ['primary', 'secondary'],
91
69
  },
92
70
  label: {
93
- control: { type: "text" },
71
+ control: { type: 'text' },
94
72
  },
95
73
  };
96
74
 
97
- export const Disabled: Story = () => (
98
- <div className="flex flex-col gap-4">
99
- <OAuthButton
100
- provider="google"
101
- disabled
102
- className={`${baseStyles} opacity-60 cursor-not-allowed`}
103
- >
104
- Google (Disabled)
105
- </OAuthButton>
106
- <OAuthButton
107
- provider="discord"
108
- disabled
109
- className={`${baseStyles} opacity-60 cursor-not-allowed`}
110
- >
111
- Discord (Disabled)
112
- </OAuthButton>
113
- </div>
114
- );
115
-
116
- export const Loading: Story = () => (
117
- <div className="flex flex-col gap-4">
118
- <OAuthButton provider="google" loading className={baseStyles}>
119
- Google (Loading)
120
- </OAuthButton>
121
- <OAuthButton provider="discord" loading className={baseStyles}>
122
- Discord (Loading)
123
- </OAuthButton>
124
- </div>
125
- );
75
+ export const AllVariants: Story = () => (
76
+ <div className="flex flex-col gap-4 items-start">
77
+ <div className="space-y-2">
78
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Primary</p>
79
+ <div className="flex flex-wrap items-center gap-4">
80
+ <OAuthButton provider="google" variant="primary" />
81
+ <OAuthButton provider="discord" variant="primary" />
82
+ </div>
83
+ </div>
126
84
 
127
- export const LongLabel: Story = () => (
128
- <div className="flex flex-col gap-4 max-w-md">
129
- <OAuthButton provider="google" className={baseStyles}>
130
- Sign in with your Google account to continue
131
- </OAuthButton>
132
- <OAuthButton provider="discord" className={baseStyles}>
133
- Connect with Discord for community access and notifications
134
- </OAuthButton>
85
+ <div className="space-y-2">
86
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Secondary (Default)</p>
87
+ <div className="flex flex-wrap items-center gap-4">
88
+ <OAuthButton provider="google" variant="secondary" />
89
+ <OAuthButton provider="discord" variant="secondary" />
90
+ </div>
91
+ </div>
135
92
  </div>
136
93
  );
137
94
 
138
- export const IconOnly: Story = () => {
139
- const p1 = useRealPress();
140
- const p2 = useRealPress();
141
-
142
- return (
143
- <div className="flex gap-4">
144
- <OAuthButton
145
- provider="google"
146
- onMouseDown={p1.onMouseDown}
147
- onMouseUp={p1.onMouseUp}
148
- onMouseLeave={p1.onMouseLeave}
149
- className={`p-3 rounded-full bg-white border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer ${p1.className}`}
150
- aria-label="Sign in with Google"
151
- >
152
- {null}
153
- </OAuthButton>
154
- <OAuthButton
155
- provider="discord"
156
- onMouseDown={p2.onMouseDown}
157
- onMouseUp={p2.onMouseUp}
158
- onMouseLeave={p2.onMouseLeave}
159
- className={`p-3 rounded-full bg-white border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer ${p2.className}`}
160
- aria-label="Sign in with Discord"
161
- >
162
- {null}
163
- </OAuthButton>
95
+ export const States: Story = () => (
96
+ <div className="flex flex-col gap-4 items-start">
97
+ <div className="space-y-2">
98
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Default</p>
99
+ <OAuthButton provider="google" />
164
100
  </div>
165
- );
166
- };
167
101
 
168
- export const CircularIconOnly: Story = () => {
169
- const g1 = useRealPress();
170
- const d1 = useRealPress();
171
- const g2 = useRealPress();
172
- const d2 = useRealPress();
173
-
174
- return (
175
- <div className="flex flex-col gap-6">
176
- <div className="flex items-center gap-4">
177
- <OAuthButton
178
- provider="google"
179
- onMouseDown={g1.onMouseDown}
180
- onMouseUp={g1.onMouseUp}
181
- onMouseLeave={g1.onMouseLeave}
182
- className={`size-12 rounded-full bg-white border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer flex items-center justify-center ${g1.className}`}
183
- aria-label="Sign in with Google"
184
- >
185
- {null}
186
- </OAuthButton>
187
- <OAuthButton
188
- provider="discord"
189
- onMouseDown={d1.onMouseDown}
190
- onMouseUp={d1.onMouseUp}
191
- onMouseLeave={d1.onMouseLeave}
192
- className={`size-12 rounded-full bg-white border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer flex items-center justify-center ${d1.className}`}
193
- aria-label="Sign in with Discord"
194
- >
195
- {null}
196
- </OAuthButton>
197
- </div>
198
-
199
- <div className="flex items-center gap-4">
200
- <OAuthButton
201
- provider="google"
202
- onMouseDown={g2.onMouseDown}
203
- onMouseUp={g2.onMouseUp}
204
- onMouseLeave={g2.onMouseLeave}
205
- className={`size-14 rounded-full bg-gray-900 text-white border border-gray-800 shadow-md hover:bg-gray-800 hover:shadow-lg transition-all duration-75 cursor-pointer flex items-center justify-center ${g2.className}`}
206
- aria-label="Sign in with Google"
207
- >
208
- {null}
209
- </OAuthButton>
210
- <OAuthButton
211
- provider="discord"
212
- onMouseDown={d2.onMouseDown}
213
- onMouseUp={d2.onMouseUp}
214
- onMouseLeave={d2.onMouseLeave}
215
- className={`size-14 rounded-full bg-indigo-600 text-white border border-indigo-500 shadow-md hover:bg-indigo-500 hover:shadow-lg transition-all duration-75 cursor-pointer flex items-center justify-center ${d2.className}`}
216
- aria-label="Sign in with Discord"
217
- >
218
- {null}
219
- </OAuthButton>
220
- </div>
221
-
222
- <div className="flex items-center gap-4">
223
- <OAuthButton
224
- provider="google"
225
- loading
226
- className="size-12 rounded-full bg-white border border-gray-300 shadow-sm cursor-wait flex items-center justify-center"
227
- aria-label="Connecting with Google"
228
- >
229
- {null}
230
- </OAuthButton>
231
- <OAuthButton
232
- provider="discord"
233
- disabled
234
- className="size-12 rounded-full bg-gray-100 border border-gray-300 opacity-60 cursor-not-allowed flex items-center justify-center"
235
- aria-label="Sign in with Discord"
236
- >
237
- {null}
238
- </OAuthButton>
239
- </div>
102
+ <div className="space-y-2">
103
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Disabled</p>
104
+ <OAuthButton provider="google" disabled />
240
105
  </div>
241
- );
242
- };
243
-
244
- export const CustomStyling: Story = () => {
245
- const g = useRealPress();
246
- const d = useRealPress();
247
106
 
248
- return (
249
- <div className="flex flex-col gap-4">
250
- <OAuthButton
251
- provider="google"
252
- onMouseDown={g.onMouseDown}
253
- onMouseUp={g.onMouseUp}
254
- onMouseLeave={g.onMouseLeave}
255
- className={`flex items-center gap-3 px-8 py-4 bg-red-600 text-white rounded-full font-semibold shadow-lg hover:bg-red-700 hover:shadow-xl transition-all duration-75 cursor-pointer ${g.className}`}
256
- >
257
- Continue with Google
258
- </OAuthButton>
259
- <OAuthButton
260
- provider="discord"
261
- onMouseDown={d.onMouseDown}
262
- onMouseUp={d.onMouseUp}
263
- onMouseLeave={d.onMouseLeave}
264
- className={`flex items-center gap-3 px-8 py-4 bg-indigo-600 text-white rounded-lg font-semibold shadow-lg hover:bg-indigo-700 hover:shadow-xl transition-all duration-75 cursor-pointer ${d.className}`}
265
- >
266
- Join via Discord
267
- </OAuthButton>
107
+ <div className="space-y-2">
108
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Loading</p>
109
+ <OAuthButton provider="google" loading />
268
110
  </div>
269
- );
270
- };
271
-
272
- const circularBaseStyles =
273
- "size-12 rounded-full bg-white border border-gray-300 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:shadow transition-all duration-75 cursor-pointer flex items-center justify-center";
274
-
275
- const circularDarkStyles =
276
- "dark:bg-gray-900 dark:border-gray-700 dark:hover:bg-gray-800";
111
+ </div>
112
+ );
277
113
 
278
- export const AllStates: Story = () => {
279
- const g = useRealPress();
280
- const d = useRealPress();
281
- const gCirc = useRealPress();
282
- const dCirc = useRealPress();
114
+ export const DarkMode: Story = () => (
115
+ <div className="dark bg-[var(--sublime-color-surface-0)] p-8 rounded-lg">
116
+ <div className="flex flex-col gap-4 items-start">
117
+ <p className="text-[var(--sublime-color-text-primary)] mb-4">Dark Mode Preview</p>
283
118
 
284
- return (
285
- <div className="grid grid-cols-3 gap-6 max-w-3xl">
286
- <div className="space-y-2">
287
- <p className="text-sm font-medium text-gray-500">Google</p>
288
- <OAuthButton
289
- provider="google"
290
- onMouseDown={g.onMouseDown}
291
- onMouseUp={g.onMouseUp}
292
- onMouseLeave={g.onMouseLeave}
293
- className={`${baseStyles} ${darkStyles} ${g.className}`}
294
- >
295
- Default
296
- </OAuthButton>
297
- <OAuthButton provider="google" disabled className={baseStyles}>
298
- Disabled
299
- </OAuthButton>
300
- <OAuthButton provider="google" loading className={baseStyles}>
301
- Loading
302
- </OAuthButton>
303
- </div>
304
- <div className="space-y-2">
305
- <p className="text-sm font-medium text-gray-500">Discord</p>
306
- <OAuthButton
307
- provider="discord"
308
- onMouseDown={d.onMouseDown}
309
- onMouseUp={d.onMouseUp}
310
- onMouseLeave={d.onMouseLeave}
311
- className={`${baseStyles} ${darkStyles} ${d.className}`}
312
- >
313
- Default
314
- </OAuthButton>
315
- <OAuthButton provider="discord" disabled className={baseStyles}>
316
- Disabled
317
- </OAuthButton>
318
- <OAuthButton provider="discord" loading className={baseStyles}>
319
- Loading
320
- </OAuthButton>
321
- </div>
322
- <div className="space-y-2">
323
- <p className="text-sm font-medium text-gray-500">Circular Icons</p>
324
- <div className="flex items-center gap-2">
325
- <OAuthButton
326
- provider="google"
327
- onMouseDown={gCirc.onMouseDown}
328
- onMouseUp={gCirc.onMouseUp}
329
- onMouseLeave={gCirc.onMouseLeave}
330
- className={`${circularBaseStyles} ${circularDarkStyles} ${gCirc.className}`}
331
- aria-label="Sign in with Google"
332
- >
333
- {null}
334
- </OAuthButton>
335
- <OAuthButton
336
- provider="discord"
337
- onMouseDown={dCirc.onMouseDown}
338
- onMouseUp={dCirc.onMouseUp}
339
- onMouseLeave={dCirc.onMouseLeave}
340
- className={`${circularBaseStyles} ${circularDarkStyles} ${dCirc.className}`}
341
- aria-label="Sign in with Discord"
342
- >
343
- {null}
344
- </OAuthButton>
345
- </div>
346
- <div className="flex items-center gap-2">
347
- <OAuthButton
348
- provider="google"
349
- disabled
350
- className={`${circularBaseStyles} opacity-60 cursor-not-allowed`}
351
- aria-label="Sign in with Google"
352
- >
353
- {null}
354
- </OAuthButton>
355
- <OAuthButton
356
- provider="discord"
357
- disabled
358
- className={`${circularBaseStyles} opacity-60 cursor-not-allowed`}
359
- aria-label="Sign in with Discord"
360
- >
361
- {null}
362
- </OAuthButton>
363
- </div>
364
- <div className="flex items-center gap-2">
365
- <OAuthButton
366
- provider="google"
367
- loading
368
- className={`${circularBaseStyles} cursor-wait`}
369
- aria-label="Connecting with Google"
370
- >
371
- {null}
372
- </OAuthButton>
373
- <OAuthButton
374
- provider="discord"
375
- loading
376
- className={`${circularBaseStyles} cursor-wait`}
377
- aria-label="Connecting with Discord"
378
- >
379
- {null}
380
- </OAuthButton>
381
- </div>
382
- </div>
119
+ <OAuthButton provider="google" variant="primary" />
120
+ <OAuthButton provider="google" variant="secondary" />
121
+ <OAuthButton provider="discord" variant="secondary" loading />
383
122
  </div>
384
- );
385
- };
123
+ </div>
124
+ );
@@ -1,13 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { forwardRef } from 'react';
4
- import { Button } from '@base-ui/react/button';
4
+
5
+ import { BaseButton } from './base-button';
6
+ import { DiscordIcon, GoogleIcon } from './icons';
5
7
  import type { OAuthButtonProps, OAuthProvider } from './types';
6
- import { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
7
8
 
8
- /**
9
- * Maps OAuth providers to their display names
10
- */
11
9
  const PROVIDER_NAMES: Record<OAuthProvider, string> = {
12
10
  google: 'Google',
13
11
  discord: 'Discord',
@@ -18,108 +16,65 @@ const PROVIDER_ICONS: Record<OAuthProvider, typeof GoogleIcon> = {
18
16
  discord: DiscordIcon,
19
17
  };
20
18
 
21
- /**
22
- * Sensible defaults that every button should have.
23
- * These are UX fundamentals that don't affect visual design.
24
- */
25
- const SENSIBLE_DEFAULTS: readonly string[] = ['cursor-pointer'];
26
-
27
- /**
28
- * Merges sensible default classes with user-provided classes.
29
- * Prevents duplicate cursor classes if user already specified them.
30
- */
31
- function mergeButtonClasses(userClassName: string | undefined, isDisabled: boolean): string {
32
- const userClasses = userClassName?.trim() ?? '';
33
-
34
- // Filter out defaults that user already specified to avoid duplicates
35
- const neededDefaults = SENSIBLE_DEFAULTS.filter(
36
- defaultClass => !userClasses.includes(defaultClass)
37
- );
38
-
39
- // Add disabled cursor class if needed and not already present
40
- if (isDisabled && !userClasses.includes('cursor-not-allowed')) {
41
- neededDefaults.push('cursor-not-allowed');
42
- }
43
-
44
- if (neededDefaults.length === 0) {
45
- return userClasses;
46
- }
47
-
48
- return `${neededDefaults.join(' ')} ${userClasses}`.trim();
19
+ function mergeClassNames(...classNames: Array<string | undefined>): string {
20
+ return classNames.filter(Boolean).join(' ');
49
21
  }
50
22
 
51
23
  /**
52
24
  * OAuthButton component
53
25
  *
54
- * A headless OAuth button built on top of base-ui's Button primitive.
55
- * Includes sensible defaults (cursor-pointer) while remaining fully
56
- * customizable via className.
57
- *
58
- * Design Philosophy:
59
- * - We add sensible UX defaults (cursor-pointer, proper disabled states)
60
- * - We don't impose visual opinions - you control all styling via className
61
- * - The result is a solid, accessible foundation that looks exactly how you want
26
+ * Built on top of the shared Sublime button foundation so it inherits
27
+ * runtime styling, semantic variants, curated press motion, and loading
28
+ * behavior by default.
62
29
  *
63
30
  * @example
64
31
  * ```tsx
65
- * // Basic usage - cursor-pointer is automatic
66
- * <OAuthButton
67
- * provider="google"
68
- * onClick={signInWithGoogle}
69
- * className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
70
- * />
71
- *
72
- * // Icon only - pass {null} as children
73
- * <OAuthButton
74
- * provider="google"
75
- * className="p-3 rounded-full hover:bg-white/10"
76
- * >
77
- * {null}
78
- * </OAuthButton>
79
- *
80
- * // Loading state - cursor-not-allowed is automatic
81
- * <OAuthButton provider="google" loading className="opacity-50" />
32
+ * <OAuthButton provider="google" />
33
+ * <OAuthButton provider="discord" variant="primary" loading />
82
34
  * ```
83
35
  */
84
36
  export const OAuthButton = forwardRef<HTMLButtonElement, OAuthButtonProps>(
85
37
  function OAuthButton(props, forwardedRef) {
86
38
  const {
39
+ 'aria-label': ariaLabel,
87
40
  provider,
88
41
  onClick,
89
42
  loading = false,
43
+ loadingAnimation = 'spinner',
44
+ disabled = false,
45
+ variant = 'secondary',
46
+ animation = 'press',
90
47
  className,
91
48
  children,
92
- disabled = false,
93
49
  ...otherProps
94
50
  } = props;
95
51
 
96
- const isDisabled = disabled || loading;
52
+ const isIconOnly = children === null;
97
53
  const Icon = PROVIDER_ICONS[provider];
98
54
  const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
99
- const mergedClassName = mergeButtonClasses(className, isDisabled);
55
+ const buttonLabel = loading
56
+ ? children ?? 'Conectando...'
57
+ : children ?? defaultText;
58
+ const resolvedAriaLabel = ariaLabel ?? (isIconOnly ? defaultText : undefined);
100
59
 
101
60
  return (
102
- <Button
61
+ <BaseButton
103
62
  ref={forwardedRef}
104
63
  onClick={onClick}
105
- disabled={isDisabled}
106
- aria-busy={loading}
107
- className={mergedClassName}
64
+ disabled={disabled}
65
+ animation={animation}
66
+ variant={variant}
67
+ loading={loading}
68
+ loadingAnimation={loadingAnimation}
69
+ isIconOnly={isIconOnly}
70
+ leadingVisual={<Icon size={20} />}
71
+ className={mergeClassNames('sublime-auth-button', className)}
72
+ data-provider={provider}
73
+ aria-label={resolvedAriaLabel}
108
74
  {...otherProps}
109
75
  >
110
- {loading ? (
111
- <>
112
- <SpinnerIcon />
113
- {children !== null && <span>{children ?? 'Conectando...'}</span>}
114
- </>
115
- ) : (
116
- <>
117
- <Icon />
118
- {children !== null && <span>{children ?? defaultText}</span>}
119
- </>
120
- )}
121
- </Button>
76
+ {buttonLabel}
77
+ </BaseButton>
122
78
  );
123
79
  }
124
- );
125
-
80
+ );