@sublimee/auth-ui 0.1.0 → 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.
- package/AI_INDEX.md +64 -0
- package/README.md +80 -120
- package/dist/animations.css +52 -0
- package/dist/index.d.mts +55 -46
- package/dist/index.d.ts +55 -46
- package/dist/index.js +449 -78
- package/dist/index.mjs +449 -78
- package/package.json +9 -6
- package/src/animations.css +52 -0
- package/src/base-button.stories.tsx +96 -0
- package/src/base-button.tsx +124 -0
- package/src/button-loading-indicator.tsx +33 -0
- package/src/icons.tsx +20 -33
- package/src/index.ts +25 -20
- package/src/oauth-button.stories.tsx +124 -0
- package/src/oauth-button.tsx +40 -99
- package/src/runtime-styles.ts +225 -0
- package/src/types.ts +62 -16
- package/src/use-button-animation.ts +114 -0
package/src/oauth-button.tsx
CHANGED
|
@@ -1,139 +1,80 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { forwardRef
|
|
4
|
-
|
|
3
|
+
import { forwardRef } from 'react';
|
|
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',
|
|
14
12
|
};
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
}
|
|
14
|
+
const PROVIDER_ICONS: Record<OAuthProvider, typeof GoogleIcon> = {
|
|
15
|
+
google: GoogleIcon,
|
|
16
|
+
discord: DiscordIcon,
|
|
17
|
+
};
|
|
56
18
|
|
|
57
|
-
|
|
19
|
+
function mergeClassNames(...classNames: Array<string | undefined>): string {
|
|
20
|
+
return classNames.filter(Boolean).join(' ');
|
|
58
21
|
}
|
|
59
22
|
|
|
60
23
|
/**
|
|
61
24
|
* OAuthButton component
|
|
62
25
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
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.
|
|
71
29
|
*
|
|
72
30
|
* @example
|
|
73
31
|
* ```tsx
|
|
74
|
-
*
|
|
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" />
|
|
32
|
+
* <OAuthButton provider="google" />
|
|
33
|
+
* <OAuthButton provider="discord" variant="primary" loading />
|
|
91
34
|
* ```
|
|
92
35
|
*/
|
|
93
36
|
export const OAuthButton = forwardRef<HTMLButtonElement, OAuthButtonProps>(
|
|
94
37
|
function OAuthButton(props, forwardedRef) {
|
|
95
38
|
const {
|
|
39
|
+
'aria-label': ariaLabel,
|
|
96
40
|
provider,
|
|
97
41
|
onClick,
|
|
98
42
|
loading = false,
|
|
43
|
+
loadingAnimation = 'spinner',
|
|
44
|
+
disabled = false,
|
|
45
|
+
variant = 'secondary',
|
|
46
|
+
animation = 'press',
|
|
99
47
|
className,
|
|
100
48
|
children,
|
|
101
|
-
disabled = false,
|
|
102
49
|
...otherProps
|
|
103
50
|
} = props;
|
|
104
51
|
|
|
105
|
-
const
|
|
106
|
-
const Icon =
|
|
52
|
+
const isIconOnly = children === null;
|
|
53
|
+
const Icon = PROVIDER_ICONS[provider];
|
|
107
54
|
const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
55
|
+
const buttonLabel = loading
|
|
56
|
+
? children ?? 'Conectando...'
|
|
57
|
+
: children ?? defaultText;
|
|
58
|
+
const resolvedAriaLabel = ariaLabel ?? (isIconOnly ? defaultText : undefined);
|
|
113
59
|
|
|
114
60
|
return (
|
|
115
|
-
<
|
|
61
|
+
<BaseButton
|
|
116
62
|
ref={forwardedRef}
|
|
117
63
|
onClick={onClick}
|
|
118
|
-
disabled={
|
|
119
|
-
|
|
120
|
-
|
|
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}
|
|
121
74
|
{...otherProps}
|
|
122
75
|
>
|
|
123
|
-
{
|
|
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>
|
|
76
|
+
{buttonLabel}
|
|
77
|
+
</BaseButton>
|
|
135
78
|
);
|
|
136
79
|
}
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
export default OAuthButton;
|
|
80
|
+
);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useInsertionEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
const AUTH_UI_RUNTIME_STYLE_ID = 'sublime-auth-ui-runtime-styles';
|
|
6
|
+
|
|
7
|
+
const AUTH_UI_RUNTIME_STYLES = `
|
|
8
|
+
.sublime-button {
|
|
9
|
+
--sublime-button-gap: var(--sublime-space-3, 0.75rem);
|
|
10
|
+
--sublime-button-padding-x: var(--sublime-space-button-x, 1rem);
|
|
11
|
+
--sublime-button-padding-y: var(--sublime-space-button-y, 0.75rem);
|
|
12
|
+
--sublime-button-height: var(--sublime-size-button-height-md, 2.75rem);
|
|
13
|
+
--sublime-button-radius: var(--sublime-radius-button, 0.875rem);
|
|
14
|
+
--sublime-button-font-family: var(--sublime-font-family-sans, ui-sans-serif, system-ui, sans-serif);
|
|
15
|
+
--sublime-button-font-size: var(--sublime-font-size-sm, 0.875rem);
|
|
16
|
+
--sublime-button-font-weight: var(--sublime-font-weight-medium, 500);
|
|
17
|
+
--sublime-button-line-height: var(--sublime-line-height-tight, 1.15);
|
|
18
|
+
--sublime-button-visual-size: 1.25rem;
|
|
19
|
+
--sublime-button-transition: var(--sublime-transition-button, background-color 160ms cubic-bezier(0.4, 0, 0.2, 1), border-color 160ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 160ms cubic-bezier(0.4, 0, 0.2, 1), color 160ms cubic-bezier(0.4, 0, 0.2, 1), transform 160ms cubic-bezier(0.4, 0, 0.2, 1));
|
|
20
|
+
--sublime-button-bg: var(--sublime-color-surface-1, #ffffff);
|
|
21
|
+
--sublime-button-bg-hover: var(--sublime-color-surface-1-hover, #f8fafc);
|
|
22
|
+
--sublime-button-bg-active: var(--sublime-color-surface-1-active, #eef2f7);
|
|
23
|
+
--sublime-button-color: var(--sublime-color-text-primary, #111827);
|
|
24
|
+
--sublime-button-color-hover: var(--sublime-color-text-primary, #111827);
|
|
25
|
+
--sublime-button-border: var(--sublime-color-border-primary, #d1d5db);
|
|
26
|
+
--sublime-button-border-hover: var(--sublime-color-border-primary-hover, #9ca3af);
|
|
27
|
+
--sublime-button-border-active: var(--sublime-color-border-primary-active, #6b7280);
|
|
28
|
+
--sublime-button-shadow: var(--sublime-shadow-button, 0 1px 2px rgb(15 23 42 / 0.08));
|
|
29
|
+
--sublime-button-shadow-hover: var(--sublime-shadow-button-hover, 0 8px 20px rgb(15 23 42 / 0.12));
|
|
30
|
+
--sublime-button-shadow-active: var(--sublime-shadow-button-active, inset 0 1px 2px rgb(15 23 42 / 0.14));
|
|
31
|
+
--sublime-button-focus-ring: var(--sublime-color-focus-ring, var(--sublime-color-interactive-accent, #2563eb));
|
|
32
|
+
--sublime-button-focus-ring-offset: var(--sublime-color-focus-ring-offset, var(--sublime-color-surface-0, #ffffff));
|
|
33
|
+
position: relative;
|
|
34
|
+
display: inline-flex;
|
|
35
|
+
min-height: var(--sublime-button-height);
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
gap: var(--sublime-button-gap);
|
|
39
|
+
padding: var(--sublime-button-padding-y) var(--sublime-button-padding-x);
|
|
40
|
+
border: 1px solid var(--sublime-button-border);
|
|
41
|
+
border-radius: var(--sublime-button-radius);
|
|
42
|
+
background: var(--sublime-button-bg);
|
|
43
|
+
color: var(--sublime-button-color);
|
|
44
|
+
box-shadow: var(--sublime-button-shadow);
|
|
45
|
+
font-family: var(--sublime-button-font-family);
|
|
46
|
+
font-size: var(--sublime-button-font-size);
|
|
47
|
+
font-weight: var(--sublime-button-font-weight);
|
|
48
|
+
line-height: var(--sublime-button-line-height);
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
user-select: none;
|
|
51
|
+
text-decoration: none;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
vertical-align: middle;
|
|
54
|
+
isolation: isolate;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
appearance: none;
|
|
57
|
+
-webkit-tap-highlight-color: transparent;
|
|
58
|
+
transition: var(--sublime-button-transition);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.sublime-button:hover:not(:disabled) {
|
|
62
|
+
background: var(--sublime-button-bg-hover);
|
|
63
|
+
color: var(--sublime-button-color-hover);
|
|
64
|
+
border-color: var(--sublime-button-border-hover);
|
|
65
|
+
box-shadow: var(--sublime-button-shadow-hover);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.sublime-button:active:not(:disabled) {
|
|
69
|
+
background: var(--sublime-button-bg-active);
|
|
70
|
+
border-color: var(--sublime-button-border-active);
|
|
71
|
+
box-shadow: var(--sublime-button-shadow-active);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.sublime-button:focus {
|
|
75
|
+
outline: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sublime-button:focus-visible {
|
|
79
|
+
box-shadow:
|
|
80
|
+
0 0 0 2px var(--sublime-button-focus-ring-offset),
|
|
81
|
+
0 0 0 4px var(--sublime-button-focus-ring),
|
|
82
|
+
var(--sublime-button-shadow-hover);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.sublime-button:disabled {
|
|
86
|
+
cursor: not-allowed;
|
|
87
|
+
opacity: 0.6;
|
|
88
|
+
box-shadow: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.sublime-button[data-loading='true'] {
|
|
92
|
+
cursor: progress;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.sublime-button[data-loading='true']:disabled {
|
|
96
|
+
opacity: 0.78;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.sublime-button[data-icon-only='true'] {
|
|
100
|
+
min-width: var(--sublime-button-height);
|
|
101
|
+
padding-inline: calc(var(--sublime-button-padding-y) + 0.125rem);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.sublime-button__content {
|
|
105
|
+
display: inline-flex;
|
|
106
|
+
width: 100%;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
gap: inherit;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sublime-button__visual,
|
|
113
|
+
.sublime-button__label {
|
|
114
|
+
display: inline-flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.sublime-button__visual {
|
|
119
|
+
flex-shrink: 0;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
min-width: var(--sublime-button-visual-size);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.sublime-button__visual svg {
|
|
125
|
+
display: block;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.sublime-button__label {
|
|
129
|
+
min-width: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.sublime-button--primary {
|
|
133
|
+
--sublime-button-bg: var(--sublime-color-interactive-primary, #111827);
|
|
134
|
+
--sublime-button-bg-hover: var(--sublime-color-interactive-primary-hover, #1f2937);
|
|
135
|
+
--sublime-button-bg-active: var(--sublime-color-interactive-primary-active, #0f172a);
|
|
136
|
+
--sublime-button-color: var(--sublime-color-interactive-primary-text, #f9fafb);
|
|
137
|
+
--sublime-button-color-hover: var(--sublime-color-interactive-primary-text, #f9fafb);
|
|
138
|
+
--sublime-button-border: var(--sublime-color-interactive-primary, #111827);
|
|
139
|
+
--sublime-button-border-hover: var(--sublime-color-interactive-primary-hover, #1f2937);
|
|
140
|
+
--sublime-button-border-active: var(--sublime-color-interactive-primary-active, #0f172a);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.sublime-button--secondary {
|
|
144
|
+
--sublime-button-bg: var(--sublime-color-surface-1, #ffffff);
|
|
145
|
+
--sublime-button-bg-hover: var(--sublime-color-surface-1-hover, #f8fafc);
|
|
146
|
+
--sublime-button-bg-active: var(--sublime-color-surface-1-active, #eef2f7);
|
|
147
|
+
--sublime-button-color: var(--sublime-color-text-primary, #111827);
|
|
148
|
+
--sublime-button-color-hover: var(--sublime-color-text-primary, #111827);
|
|
149
|
+
--sublime-button-border: var(--sublime-color-border-primary, #d1d5db);
|
|
150
|
+
--sublime-button-border-hover: var(--sublime-color-border-primary-hover, #9ca3af);
|
|
151
|
+
--sublime-button-border-active: var(--sublime-color-border-primary-active, #6b7280);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.sublime-auth-button[data-provider='discord'] .sublime-button__visual {
|
|
155
|
+
color: #5865f2;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.sublime-auth-button[data-loading='true'] .sublime-button__visual {
|
|
159
|
+
color: currentColor;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@keyframes sublime-button-press-release {
|
|
163
|
+
0% {
|
|
164
|
+
transform: translateY(1px) scale(0.985);
|
|
165
|
+
box-shadow: var(--sublime-button-shadow-active);
|
|
166
|
+
}
|
|
167
|
+
100% {
|
|
168
|
+
transform: translateY(0) scale(1);
|
|
169
|
+
box-shadow: var(--sublime-button-shadow);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@keyframes sublime-button-spinner {
|
|
174
|
+
from {
|
|
175
|
+
transform: rotate(0deg);
|
|
176
|
+
}
|
|
177
|
+
to {
|
|
178
|
+
transform: rotate(360deg);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.sublime-button-pressed {
|
|
183
|
+
transform: translateY(1px) scale(0.985);
|
|
184
|
+
box-shadow: var(--sublime-button-shadow-active);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.animate-sublime-button-press-release {
|
|
188
|
+
animation: sublime-button-press-release 180ms var(--sublime-ease-out, cubic-bezier(0, 0, 0.2, 1));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.sublime-button__spinner {
|
|
192
|
+
animation: sublime-button-spinner 720ms linear infinite;
|
|
193
|
+
transform-origin: center;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@media (prefers-reduced-motion: reduce) {
|
|
197
|
+
.sublime-button,
|
|
198
|
+
.animate-sublime-button-press-release {
|
|
199
|
+
animation-duration: 0.01ms !important;
|
|
200
|
+
animation-iteration-count: 1 !important;
|
|
201
|
+
transition-duration: 0.01ms !important;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.sublime-button__spinner {
|
|
205
|
+
animation-duration: 1.6s !important;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
export function useAuthUiRuntimeStyles() {
|
|
211
|
+
useInsertionEffect(() => {
|
|
212
|
+
if (typeof document === 'undefined') {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (document.getElementById(AUTH_UI_RUNTIME_STYLE_ID)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const styleElement = document.createElement('style');
|
|
221
|
+
styleElement.id = AUTH_UI_RUNTIME_STYLE_ID;
|
|
222
|
+
styleElement.textContent = AUTH_UI_RUNTIME_STYLES;
|
|
223
|
+
document.head.prepend(styleElement);
|
|
224
|
+
}, []);
|
|
225
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,50 +1,96 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ButtonHTMLAttributes,
|
|
3
|
+
MouseEventHandler,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from 'react';
|
|
2
6
|
|
|
3
7
|
export type OAuthProvider = 'google' | 'discord';
|
|
4
8
|
|
|
5
|
-
export
|
|
9
|
+
export type ButtonVariant = 'primary' | 'secondary';
|
|
10
|
+
|
|
11
|
+
export type ButtonAnimation = 'press';
|
|
12
|
+
|
|
13
|
+
export type ButtonLoadingAnimation = 'spinner';
|
|
14
|
+
|
|
15
|
+
export interface OAuthButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
|
6
16
|
/**
|
|
7
|
-
* The OAuth provider to authenticate with
|
|
17
|
+
* The OAuth provider to authenticate with.
|
|
8
18
|
*/
|
|
9
19
|
provider: OAuthProvider;
|
|
10
20
|
|
|
11
21
|
/**
|
|
12
|
-
* Click handler - typically calls supabase.auth.signInWithOAuth
|
|
22
|
+
* Click handler - typically calls supabase.auth.signInWithOAuth.
|
|
13
23
|
*/
|
|
14
|
-
onClick?:
|
|
24
|
+
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
|
-
* Loading state - shows
|
|
27
|
+
* Loading state - shows the configured loading animation and disables interaction.
|
|
18
28
|
*/
|
|
19
29
|
loading?: boolean;
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
|
-
*
|
|
23
|
-
* @
|
|
32
|
+
* Loading animation shown while `loading` is true.
|
|
33
|
+
* @default 'spinner'
|
|
24
34
|
*/
|
|
25
|
-
|
|
35
|
+
loadingAnimation?: ButtonLoadingAnimation;
|
|
26
36
|
|
|
27
37
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
38
|
+
* Visual variant of the button.
|
|
39
|
+
* - primary: High emphasis, filled background
|
|
40
|
+
* - secondary: Medium emphasis, bordered (default)
|
|
41
|
+
* @default 'secondary'
|
|
30
42
|
*/
|
|
31
|
-
|
|
43
|
+
variant?: ButtonVariant;
|
|
32
44
|
|
|
33
45
|
/**
|
|
34
|
-
*
|
|
46
|
+
* Interaction motion applied on press.
|
|
47
|
+
* @default 'press'
|
|
35
48
|
*/
|
|
36
|
-
|
|
49
|
+
animation?: ButtonAnimation;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Custom className appended after the built-in runtime classes.
|
|
53
|
+
*/
|
|
54
|
+
className?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Button children - defaults to provider name in Spanish.
|
|
58
|
+
* Pass `null` for icon-only mode.
|
|
59
|
+
*/
|
|
60
|
+
children?: ReactNode;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
export interface OAuthIconsProps {
|
|
40
64
|
/**
|
|
41
|
-
* Icon size in pixels (min: 1)
|
|
65
|
+
* Icon size in pixels (min: 1).
|
|
42
66
|
* @default 24
|
|
43
67
|
*/
|
|
44
68
|
size?: number;
|
|
45
69
|
|
|
46
70
|
/**
|
|
47
|
-
*
|
|
71
|
+
* Optional className for icon-specific styling.
|
|
48
72
|
*/
|
|
49
73
|
className?: string;
|
|
50
74
|
}
|
|
75
|
+
|
|
76
|
+
export interface UseButtonAnimationResult {
|
|
77
|
+
/**
|
|
78
|
+
* CSS classes to apply for the current animation state.
|
|
79
|
+
*/
|
|
80
|
+
animationClassName: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Event handlers to attach to the button.
|
|
84
|
+
*/
|
|
85
|
+
eventHandlers: Pick<
|
|
86
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
87
|
+
| 'onBlur'
|
|
88
|
+
| 'onKeyDown'
|
|
89
|
+
| 'onKeyUp'
|
|
90
|
+
| 'onMouseDown'
|
|
91
|
+
| 'onMouseLeave'
|
|
92
|
+
| 'onMouseUp'
|
|
93
|
+
| 'onTouchEnd'
|
|
94
|
+
| 'onTouchStart'
|
|
95
|
+
>;
|
|
96
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { KeyboardEvent } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ButtonAnimation, UseButtonAnimationResult } from './types';
|
|
7
|
+
|
|
8
|
+
type AnimationPhase = 'idle' | 'pressed' | 'releasing';
|
|
9
|
+
|
|
10
|
+
const PRESS_RELEASE_DURATION = 180;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook to provide the curated button press animation.
|
|
14
|
+
*
|
|
15
|
+
* Handles mouse, touch, and keyboard interaction so motion feedback stays
|
|
16
|
+
* consistent across the different ways users can activate a button.
|
|
17
|
+
*/
|
|
18
|
+
export function useButtonAnimation(
|
|
19
|
+
animation: ButtonAnimation | null = 'press'
|
|
20
|
+
): UseButtonAnimationResult {
|
|
21
|
+
const [phase, setPhase] = useState<AnimationPhase>('idle');
|
|
22
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
|
|
24
|
+
const clearExistingTimeout = useCallback(() => {
|
|
25
|
+
if (timeoutRef.current) {
|
|
26
|
+
clearTimeout(timeoutRef.current);
|
|
27
|
+
timeoutRef.current = null;
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return () => {
|
|
33
|
+
clearExistingTimeout();
|
|
34
|
+
};
|
|
35
|
+
}, [clearExistingTimeout]);
|
|
36
|
+
|
|
37
|
+
const handlePressStart = useCallback(() => {
|
|
38
|
+
if (!animation) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clearExistingTimeout();
|
|
43
|
+
setPhase('pressed');
|
|
44
|
+
}, [animation, clearExistingTimeout]);
|
|
45
|
+
|
|
46
|
+
const handlePressEnd = useCallback(() => {
|
|
47
|
+
if (!animation || phase !== 'pressed') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setPhase('releasing');
|
|
52
|
+
|
|
53
|
+
timeoutRef.current = setTimeout(() => {
|
|
54
|
+
setPhase('idle');
|
|
55
|
+
}, PRESS_RELEASE_DURATION);
|
|
56
|
+
}, [animation, phase]);
|
|
57
|
+
|
|
58
|
+
const handleBlur = useCallback(() => {
|
|
59
|
+
clearExistingTimeout();
|
|
60
|
+
setPhase('idle');
|
|
61
|
+
}, [clearExistingTimeout]);
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = useCallback(
|
|
64
|
+
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
65
|
+
if (event.repeat) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
70
|
+
handlePressStart();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[handlePressStart]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const handleKeyUp = useCallback(
|
|
77
|
+
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
78
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
79
|
+
handlePressEnd();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[handlePressEnd]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const getAnimationClass = (): string => {
|
|
86
|
+
if (!animation) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (phase === 'pressed') {
|
|
91
|
+
return 'sublime-button-pressed';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (phase === 'releasing') {
|
|
95
|
+
return 'animate-sublime-button-press-release';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return '';
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
animationClassName: getAnimationClass(),
|
|
103
|
+
eventHandlers: {
|
|
104
|
+
onBlur: handleBlur,
|
|
105
|
+
onKeyDown: handleKeyDown,
|
|
106
|
+
onKeyUp: handleKeyUp,
|
|
107
|
+
onMouseDown: handlePressStart,
|
|
108
|
+
onMouseLeave: handlePressEnd,
|
|
109
|
+
onMouseUp: handlePressEnd,
|
|
110
|
+
onTouchEnd: handlePressEnd,
|
|
111
|
+
onTouchStart: handlePressStart,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|