@sublimee/auth-ui 0.1.1 → 0.1.13

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,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
@@ -11,7 +11,7 @@ function getSafeSize(size: number | undefined, defaultSize = 24): number {
11
11
  * Google Logo Icon
12
12
  * Official Google "G" logo for OAuth authentication
13
13
  */
14
- export function GoogleIcon({ size }: OAuthIconsProps) {
14
+ export function GoogleIcon({ size, className }: OAuthIconsProps) {
15
15
  const safeSize = getSafeSize(size);
16
16
 
17
17
  return (
@@ -22,8 +22,8 @@ export function GoogleIcon({ size }: OAuthIconsProps) {
22
22
  fill="none"
23
23
  xmlns="http://www.w3.org/2000/svg"
24
24
  aria-hidden="true"
25
+ className={className}
25
26
  >
26
- {/* Colored G paths */}
27
27
  <path
28
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"
29
29
  fill="#4285F4"
@@ -48,8 +48,9 @@ export function GoogleIcon({ size }: OAuthIconsProps) {
48
48
  * Discord Logo Icon
49
49
  * Official Discord logo for OAuth authentication
50
50
  */
51
- export function DiscordIcon({ size }: OAuthIconsProps) {
51
+ export function DiscordIcon({ size, className }: OAuthIconsProps) {
52
52
  const safeSize = getSafeSize(size);
53
+
53
54
  return (
54
55
  <svg
55
56
  width={safeSize}
@@ -58,6 +59,7 @@ export function DiscordIcon({ size }: OAuthIconsProps) {
58
59
  fill="none"
59
60
  xmlns="http://www.w3.org/2000/svg"
60
61
  aria-hidden="true"
62
+ className={className}
61
63
  >
62
64
  <path
63
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"
@@ -68,10 +70,12 @@ export function DiscordIcon({ size }: OAuthIconsProps) {
68
70
  }
69
71
 
70
72
  /**
71
- * Spinner Icon for loading state
73
+ * Spinner Icon for loading state.
74
+ * Motion is applied through package runtime CSS so the icon stays responsive.
72
75
  */
73
- export function SpinnerIcon({ size }: OAuthIconsProps) {
76
+ export function SpinnerIcon({ size, className }: OAuthIconsProps) {
74
77
  const safeSize = getSafeSize(size, 20);
78
+
75
79
  return (
76
80
  <svg
77
81
  width={safeSize}
@@ -80,33 +84,22 @@ export function SpinnerIcon({ size }: OAuthIconsProps) {
80
84
  fill="none"
81
85
  xmlns="http://www.w3.org/2000/svg"
82
86
  aria-hidden="true"
87
+ className={className}
83
88
  >
84
89
  <circle
85
90
  cx="12"
86
91
  cy="12"
87
- r="10"
92
+ r="9"
88
93
  stroke="currentColor"
89
- strokeWidth="4"
90
- strokeLinecap="round"
91
- strokeDasharray="60"
92
- strokeDashoffset="20"
93
- opacity="0.25"
94
+ strokeWidth="3"
95
+ opacity="0.22"
94
96
  />
95
97
  <path
96
- d="M12 2C6.477 2 2 6.477 2 12"
98
+ d="M12 3a9 9 0 0 1 9 9"
97
99
  stroke="currentColor"
98
- strokeWidth="4"
100
+ strokeWidth="3"
99
101
  strokeLinecap="round"
100
- >
101
- <animateTransform
102
- attributeName="transform"
103
- type="rotate"
104
- from="0 12 12"
105
- to="360 12 12"
106
- dur="1s"
107
- repeatCount="indefinite"
108
- />
109
- </path>
102
+ />
110
103
  </svg>
111
104
  );
112
- }
105
+ }
package/src/index.ts CHANGED
@@ -1,32 +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
-
24
- // Icons
25
- export { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
26
-
27
- // Types
28
+ export { useButtonAnimation } from './use-button-animation';
29
+ export { GoogleIcon, DiscordIcon } from './icons';
28
30
  export type {
29
31
  OAuthProvider,
30
32
  OAuthButtonProps,
31
33
  OAuthIconsProps,
32
- } from './types';
34
+ ButtonVariant,
35
+ ButtonAnimation,
36
+ ButtonLoadingAnimation,
37
+ UseButtonAnimationResult,
38
+ } from './types';