@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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @sublimee/auth-ui runtime animations
3
+ *
4
+ * Optional legacy export.
5
+ * Most consumers should not need to import this file because auth-ui now
6
+ * injects its runtime button styles automatically.
7
+ */
8
+
9
+ @keyframes sublime-button-press-release {
10
+ 0% {
11
+ transform: translateY(1px) scale(0.985);
12
+ box-shadow: var(--sublime-button-shadow-active);
13
+ }
14
+ 100% {
15
+ transform: translateY(0) scale(1);
16
+ box-shadow: var(--sublime-button-shadow);
17
+ }
18
+ }
19
+
20
+ @keyframes sublime-button-spinner {
21
+ from {
22
+ transform: rotate(0deg);
23
+ }
24
+ to {
25
+ transform: rotate(360deg);
26
+ }
27
+ }
28
+
29
+ .sublime-button-pressed {
30
+ transform: translateY(1px) scale(0.985);
31
+ box-shadow: var(--sublime-button-shadow-active);
32
+ }
33
+
34
+ .animate-sublime-button-press-release {
35
+ animation: sublime-button-press-release 180ms var(--sublime-ease-out, cubic-bezier(0, 0, 0.2, 1));
36
+ }
37
+
38
+ .sublime-button__spinner {
39
+ animation: sublime-button-spinner 720ms linear infinite;
40
+ transform-origin: center;
41
+ }
42
+
43
+ @media (prefers-reduced-motion: reduce) {
44
+ .animate-sublime-button-press-release {
45
+ animation-duration: 0.01ms !important;
46
+ animation-iteration-count: 1 !important;
47
+ }
48
+
49
+ .sublime-button__spinner {
50
+ animation-duration: 1.6s !important;
51
+ }
52
+ }
@@ -0,0 +1,96 @@
1
+ import type { Story } from '@ladle/react';
2
+
3
+ import { BaseButton } from './base-button';
4
+ import type { ButtonVariant } from './types';
5
+
6
+ export default {
7
+ title: 'A Base Button',
8
+ };
9
+
10
+ interface BaseButtonStoryProps {
11
+ children: string;
12
+ disabled: boolean;
13
+ isIconOnly: boolean;
14
+ loading: boolean;
15
+ variant: ButtonVariant;
16
+ }
17
+
18
+ export const Default: Story<BaseButtonStoryProps> = ({
19
+ children,
20
+ disabled,
21
+ isIconOnly,
22
+ loading,
23
+ variant,
24
+ }) => (
25
+ <div className="flex flex-wrap items-center gap-4">
26
+ <BaseButton
27
+ disabled={disabled}
28
+ isIconOnly={isIconOnly}
29
+ loading={loading}
30
+ variant={variant}
31
+ aria-label={isIconOnly ? children : undefined}
32
+ >
33
+ {isIconOnly ? <span aria-hidden="true">G</span> : children}
34
+ </BaseButton>
35
+
36
+ <BaseButton
37
+ disabled={disabled}
38
+ loading={loading}
39
+ isIconOnly
40
+ variant="secondary"
41
+ aria-label="Quick action"
42
+ >
43
+ <span aria-hidden="true">+</span>
44
+ </BaseButton>
45
+ </div>
46
+ );
47
+
48
+ Default.args = {
49
+ children: 'Continue',
50
+ disabled: false,
51
+ isIconOnly: false,
52
+ loading: false,
53
+ variant: 'secondary',
54
+ };
55
+
56
+ Default.argTypes = {
57
+ variant: {
58
+ control: { type: 'select' },
59
+ options: ['primary', 'secondary'],
60
+ },
61
+ children: {
62
+ control: { type: 'text' },
63
+ },
64
+ };
65
+
66
+ export const AllVariants: Story = () => (
67
+ <div className="flex flex-col gap-4 items-start">
68
+ <div className="space-y-2">
69
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Primary</p>
70
+ <BaseButton variant="primary">Continue with email</BaseButton>
71
+ </div>
72
+
73
+ <div className="space-y-2">
74
+ <p className="text-sm font-medium text-[var(--sublime-color-text-muted)]">Secondary</p>
75
+ <BaseButton variant="secondary">Continue with email</BaseButton>
76
+ </div>
77
+ </div>
78
+ );
79
+
80
+ export const States: Story = () => (
81
+ <div className="flex flex-col gap-4 items-start">
82
+ <BaseButton>Default</BaseButton>
83
+ <BaseButton disabled>Disabled</BaseButton>
84
+ <BaseButton loading>Saving changes</BaseButton>
85
+ </div>
86
+ );
87
+
88
+ export const DarkMode: Story = () => (
89
+ <div className="dark bg-[var(--sublime-color-surface-0)] p-8 rounded-lg">
90
+ <div className="flex flex-col gap-4 items-start">
91
+ <BaseButton variant="primary">Primary</BaseButton>
92
+ <BaseButton variant="secondary">Secondary</BaseButton>
93
+ <BaseButton loading variant="secondary">Loading</BaseButton>
94
+ </div>
95
+ </div>
96
+ );
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ import { forwardRef } from 'react';
4
+ import { Button } from '@base-ui/react/button';
5
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
6
+
7
+ import { ButtonLoadingIndicator } from './button-loading-indicator';
8
+ import { useButtonAnimation } from './use-button-animation';
9
+ import { useAuthUiRuntimeStyles } from './runtime-styles';
10
+ import type {
11
+ ButtonAnimation,
12
+ ButtonLoadingAnimation,
13
+ ButtonVariant,
14
+ } from './types';
15
+
16
+ interface BaseButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
17
+ animation?: ButtonAnimation;
18
+ isIconOnly?: boolean;
19
+ leadingVisual?: ReactNode;
20
+ loading?: boolean;
21
+ loadingAnimation?: ButtonLoadingAnimation;
22
+ onClick?: ButtonHTMLAttributes<HTMLButtonElement>['onClick'];
23
+ variant?: ButtonVariant;
24
+ }
25
+
26
+ type EventHandler<Event> = ((event: Event) => void) | undefined;
27
+
28
+ function mergeClassNames(...classNames: Array<string | undefined>): string {
29
+ return classNames.filter(Boolean).join(' ');
30
+ }
31
+
32
+ function composeEventHandlers<Event>(
33
+ internalHandler: EventHandler<Event>,
34
+ externalHandler: EventHandler<Event>
35
+ ): EventHandler<Event> {
36
+ if (!internalHandler) {
37
+ return externalHandler;
38
+ }
39
+
40
+ if (!externalHandler) {
41
+ return internalHandler;
42
+ }
43
+
44
+ return (event: Event) => {
45
+ internalHandler(event);
46
+ externalHandler(event);
47
+ };
48
+ }
49
+
50
+ export const BaseButton = forwardRef<HTMLButtonElement, BaseButtonProps>(
51
+ function BaseButton(props, forwardedRef) {
52
+ const {
53
+ animation = 'press',
54
+ 'aria-busy': ariaBusy,
55
+ children,
56
+ className,
57
+ disabled = false,
58
+ isIconOnly = false,
59
+ leadingVisual,
60
+ loading = false,
61
+ loadingAnimation = 'spinner',
62
+ type = 'button',
63
+ variant = 'secondary',
64
+ onBlur,
65
+ onKeyDown,
66
+ onKeyUp,
67
+ onMouseDown,
68
+ onMouseLeave,
69
+ onMouseUp,
70
+ onTouchEnd,
71
+ onTouchStart,
72
+ ...otherProps
73
+ } = props;
74
+
75
+ useAuthUiRuntimeStyles();
76
+
77
+ const { animationClassName, eventHandlers } = useButtonAnimation(
78
+ disabled || loading ? null : animation
79
+ );
80
+
81
+ const mergedClassName = mergeClassNames(
82
+ 'sublime-button',
83
+ `sublime-button--${variant}`,
84
+ animationClassName,
85
+ className
86
+ );
87
+
88
+ const visualContent = loading
89
+ ? <ButtonLoadingIndicator animation={loadingAnimation} size={18} />
90
+ : leadingVisual ?? (isIconOnly ? children : undefined);
91
+
92
+ const labelContent = isIconOnly ? null : children;
93
+
94
+ return (
95
+ <Button
96
+ ref={forwardedRef}
97
+ type={type}
98
+ disabled={disabled || loading}
99
+ className={mergedClassName}
100
+ data-icon-only={isIconOnly ? 'true' : undefined}
101
+ data-loading={loading ? 'true' : undefined}
102
+ aria-busy={loading ? true : ariaBusy}
103
+ {...otherProps}
104
+ onBlur={composeEventHandlers(eventHandlers.onBlur, onBlur)}
105
+ onKeyDown={composeEventHandlers(eventHandlers.onKeyDown, onKeyDown)}
106
+ onKeyUp={composeEventHandlers(eventHandlers.onKeyUp, onKeyUp)}
107
+ onMouseDown={composeEventHandlers(eventHandlers.onMouseDown, onMouseDown)}
108
+ onMouseLeave={composeEventHandlers(eventHandlers.onMouseLeave, onMouseLeave)}
109
+ onMouseUp={composeEventHandlers(eventHandlers.onMouseUp, onMouseUp)}
110
+ onTouchEnd={composeEventHandlers(eventHandlers.onTouchEnd, onTouchEnd)}
111
+ onTouchStart={composeEventHandlers(eventHandlers.onTouchStart, onTouchStart)}
112
+ >
113
+ <span className="sublime-button__content">
114
+ {visualContent ? (
115
+ <span className="sublime-button__visual">{visualContent}</span>
116
+ ) : null}
117
+ {labelContent !== null && labelContent !== undefined ? (
118
+ <span className="sublime-button__label">{labelContent}</span>
119
+ ) : null}
120
+ </span>
121
+ </Button>
122
+ );
123
+ }
124
+ );
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { SpinnerIcon } from './icons';
4
+ import type { ButtonLoadingAnimation } from './types';
5
+
6
+ interface ButtonLoadingIndicatorProps {
7
+ animation?: ButtonLoadingAnimation;
8
+ size?: number;
9
+ }
10
+
11
+ interface LoadingIndicatorVisualProps {
12
+ size: number;
13
+ }
14
+
15
+ function SpinnerLoadingIndicator({ size }: LoadingIndicatorVisualProps) {
16
+ return <SpinnerIcon size={size} className="sublime-button__spinner" />;
17
+ }
18
+
19
+ const LOADING_INDICATORS: Record<
20
+ ButtonLoadingAnimation,
21
+ (props: LoadingIndicatorVisualProps) => ReactNode
22
+ > = {
23
+ spinner: SpinnerLoadingIndicator,
24
+ };
25
+
26
+ export function ButtonLoadingIndicator({
27
+ animation = 'spinner',
28
+ size = 18,
29
+ }: ButtonLoadingIndicatorProps) {
30
+ const LoadingIndicator = LOADING_INDICATORS[animation];
31
+
32
+ return <LoadingIndicator size={size} />;
33
+ }
package/src/icons.tsx CHANGED
@@ -1,13 +1,10 @@
1
- 'use client';
2
-
3
1
  import type { OAuthIconsProps } from './types';
4
2
 
5
3
  /**
6
4
  * Validates and sanitizes icon size (minimum 1px)
7
5
  */
8
- function getSafeSize(size: number | undefined): number {
9
- const safeSize = size ?? 24;
10
- return Math.max(1, safeSize);
6
+ function getSafeSize(size: number | undefined, defaultSize = 24): number {
7
+ return Math.max(1, size ?? defaultSize);
11
8
  }
12
9
 
13
10
  /**
@@ -16,6 +13,7 @@ function getSafeSize(size: number | undefined): number {
16
13
  */
17
14
  export function GoogleIcon({ size, className }: OAuthIconsProps) {
18
15
  const safeSize = getSafeSize(size);
16
+
19
17
  return (
20
18
  <svg
21
19
  width={safeSize}
@@ -23,8 +21,8 @@ export function GoogleIcon({ size, className }: OAuthIconsProps) {
23
21
  viewBox="0 0 24 24"
24
22
  fill="none"
25
23
  xmlns="http://www.w3.org/2000/svg"
26
- className={className}
27
24
  aria-hidden="true"
25
+ className={className}
28
26
  >
29
27
  <path
30
28
  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"
@@ -52,6 +50,7 @@ export function GoogleIcon({ size, className }: OAuthIconsProps) {
52
50
  */
53
51
  export function DiscordIcon({ size, className }: OAuthIconsProps) {
54
52
  const safeSize = getSafeSize(size);
53
+
55
54
  return (
56
55
  <svg
57
56
  width={safeSize}
@@ -59,8 +58,8 @@ export function DiscordIcon({ size, className }: OAuthIconsProps) {
59
58
  viewBox="0 0 24 24"
60
59
  fill="none"
61
60
  xmlns="http://www.w3.org/2000/svg"
62
- className={className}
63
61
  aria-hidden="true"
62
+ className={className}
64
63
  >
65
64
  <path
66
65
  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"
@@ -71,48 +70,36 @@ export function DiscordIcon({ size, className }: OAuthIconsProps) {
71
70
  }
72
71
 
73
72
  /**
74
- * Spinner Icon for loading state
73
+ * Spinner Icon for loading state.
74
+ * Motion is applied through package runtime CSS so the icon stays responsive.
75
75
  */
76
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);
77
+ const safeSize = getSafeSize(size, 20);
78
+
80
79
  return (
81
80
  <svg
82
- width={finalSize}
83
- height={finalSize}
81
+ width={safeSize}
82
+ height={safeSize}
84
83
  viewBox="0 0 24 24"
85
84
  fill="none"
86
85
  xmlns="http://www.w3.org/2000/svg"
87
- className={className}
88
86
  aria-hidden="true"
87
+ className={className}
89
88
  >
90
89
  <circle
91
90
  cx="12"
92
91
  cy="12"
93
- r="10"
92
+ r="9"
94
93
  stroke="currentColor"
95
- strokeWidth="4"
96
- strokeLinecap="round"
97
- strokeDasharray="60"
98
- strokeDashoffset="20"
99
- opacity="0.25"
94
+ strokeWidth="3"
95
+ opacity="0.22"
100
96
  />
101
97
  <path
102
- d="M12 2C6.477 2 2 6.477 2 12"
98
+ d="M12 3a9 9 0 0 1 9 9"
103
99
  stroke="currentColor"
104
- strokeWidth="4"
100
+ strokeWidth="3"
105
101
  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>
102
+ />
116
103
  </svg>
117
104
  );
118
- }
105
+ }
package/src/index.ts CHANGED
@@ -1,33 +1,38 @@
1
1
  /**
2
2
  * @sublimee/auth-ui
3
- * Headless authentication UI components for Sublime
3
+ * Ready-to-use authentication UI components for Sublime.
4
4
  *
5
- * Design Philosophy:
6
- * -----------------
7
- * "Headless but sensible"
5
+ * Philosophy:
6
+ * -----------
7
+ * Auth components in this package are meant to be visible, polished,
8
+ * and useful on first render. They are semantic and adaptable, not
9
+ * intentionally bare or headless.
8
10
  *
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
11
+ * Quick Start:
12
+ * -----------
13
+ * ```tsx
14
+ * import '@sublimee/tokens/tokens.css';
15
+ * import { OAuthButton } from '@sublimee/auth-ui';
14
16
  *
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
+ * <OAuthButton provider="google" />
18
+ * <OAuthButton provider="discord" variant="primary" loading />
19
+ * ```
17
20
  *
18
- * Built with base-ui for maximum flexibility and composability.
21
+ * The package injects its runtime button styles automatically so the
22
+ * default experience stays zero-setup for consumers.
23
+ *
24
+ * @packageDocumentation
19
25
  */
20
26
 
21
- // Components
22
27
  export { OAuthButton } from './oauth-button';
23
- export { default } from './oauth-button';
24
-
25
- // Icons
26
- export { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
27
-
28
- // Types
28
+ export { useButtonAnimation } from './use-button-animation';
29
+ export { GoogleIcon, DiscordIcon } from './icons';
29
30
  export type {
30
31
  OAuthProvider,
31
32
  OAuthButtonProps,
32
33
  OAuthIconsProps,
33
- } from './types';
34
+ ButtonVariant,
35
+ ButtonAnimation,
36
+ ButtonLoadingAnimation,
37
+ UseButtonAnimationResult,
38
+ } from './types';
@@ -0,0 +1,124 @@
1
+ import type { Story } from '@ladle/react';
2
+
3
+ import { OAuthButton } from './oauth-button';
4
+ import type { OAuthProvider, ButtonVariant } from './types';
5
+
6
+ export default {
7
+ title: 'B Auth Button',
8
+ };
9
+
10
+ /**
11
+ * OAuthButton stories
12
+ *
13
+ * Semantic OAuth buttons with curated motion and built-in loading behavior.
14
+ * These components adapt to your theme via CSS custom properties.
15
+ */
16
+
17
+ interface OAuthButtonStoryProps {
18
+ provider: OAuthProvider;
19
+ variant: ButtonVariant;
20
+ disabled: boolean;
21
+ loading: boolean;
22
+ label: string;
23
+ }
24
+
25
+ export const Default: Story<OAuthButtonStoryProps> = ({
26
+ provider,
27
+ variant,
28
+ disabled,
29
+ loading,
30
+ label,
31
+ }) => (
32
+ <div className="flex flex-wrap items-center gap-4">
33
+ <OAuthButton
34
+ provider={provider}
35
+ variant={variant}
36
+ disabled={disabled}
37
+ loading={loading}
38
+ >
39
+ {label || undefined}
40
+ </OAuthButton>
41
+
42
+ <OAuthButton
43
+ provider={provider}
44
+ variant="secondary"
45
+ disabled={disabled}
46
+ loading={loading}
47
+ >
48
+ {null}
49
+ </OAuthButton>
50
+ </div>
51
+ );
52
+
53
+ Default.args = {
54
+ provider: 'google',
55
+ variant: 'secondary',
56
+ disabled: false,
57
+ loading: false,
58
+ label: '',
59
+ };
60
+
61
+ Default.argTypes = {
62
+ provider: {
63
+ control: { type: 'select' },
64
+ options: ['google', 'discord'],
65
+ },
66
+ variant: {
67
+ control: { type: 'select' },
68
+ options: ['primary', 'secondary'],
69
+ },
70
+ label: {
71
+ control: { type: 'text' },
72
+ },
73
+ };
74
+
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>
84
+
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>
92
+ </div>
93
+ );
94
+
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" />
100
+ </div>
101
+
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 />
105
+ </div>
106
+
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 />
110
+ </div>
111
+ </div>
112
+ );
113
+
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>
118
+
119
+ <OAuthButton provider="google" variant="primary" />
120
+ <OAuthButton provider="google" variant="secondary" />
121
+ <OAuthButton provider="discord" variant="secondary" loading />
122
+ </div>
123
+ </div>
124
+ );