@slyxup/ui 0.2.0 → 0.2.2

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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +16 -0
  3. package/LICENSE +21 -0
  4. package/dist/components/BillingPortal/BillingPortal.d.ts +22 -0
  5. package/dist/components/BillingPortal/BillingPortal.d.ts.map +1 -0
  6. package/dist/components/BillingPortal/BillingPortal.js +61 -0
  7. package/dist/components/BillingPortal/BillingPortal.js.map +1 -0
  8. package/dist/components/EmailVerification/EmailVerification.d.ts.map +1 -1
  9. package/dist/components/EmailVerification/EmailVerification.js +2 -0
  10. package/dist/components/EmailVerification/EmailVerification.js.map +1 -1
  11. package/dist/components/ForgotPassword/ForgotPassword.d.ts.map +1 -1
  12. package/dist/components/ForgotPassword/ForgotPassword.js +2 -0
  13. package/dist/components/ForgotPassword/ForgotPassword.js.map +1 -1
  14. package/dist/components/PricingTable/PricingTable.d.ts +19 -0
  15. package/dist/components/PricingTable/PricingTable.d.ts.map +1 -0
  16. package/dist/components/PricingTable/PricingTable.js +54 -0
  17. package/dist/components/PricingTable/PricingTable.js.map +1 -0
  18. package/dist/components/ResetPassword/ResetPassword.d.ts.map +1 -1
  19. package/dist/components/ResetPassword/ResetPassword.js +2 -0
  20. package/dist/components/ResetPassword/ResetPassword.js.map +1 -1
  21. package/dist/components/SignIn/SignIn.d.ts.map +1 -1
  22. package/dist/components/SignIn/SignIn.js +16 -3
  23. package/dist/components/SignIn/SignIn.js.map +1 -1
  24. package/dist/components/SignUp/SignUp.d.ts.map +1 -1
  25. package/dist/components/SignUp/SignUp.js +16 -3
  26. package/dist/components/SignUp/SignUp.js.map +1 -1
  27. package/dist/components/SocialButtons/SocialButtons.d.ts +1 -1
  28. package/dist/components/SocialButtons/SocialButtons.d.ts.map +1 -1
  29. package/dist/components/SocialButtons/SocialButtons.js +7 -2
  30. package/dist/components/SocialButtons/SocialButtons.js.map +1 -1
  31. package/dist/components/UserButton/UserButton.d.ts.map +1 -1
  32. package/dist/components/UserButton/UserButton.js +2 -0
  33. package/dist/components/UserButton/UserButton.js.map +1 -1
  34. package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
  35. package/dist/components/UserProfile/UserProfile.js +2 -0
  36. package/dist/components/UserProfile/UserProfile.js.map +1 -1
  37. package/dist/styles.d.ts +3 -2
  38. package/dist/styles.d.ts.map +1 -1
  39. package/dist/styles.js +37 -16
  40. package/dist/styles.js.map +1 -1
  41. package/package.json +13 -9
  42. package/src/components/BillingPortal/BillingPortal.tsx +184 -0
  43. package/src/components/EmailVerification/EmailVerification.tsx +2 -0
  44. package/src/components/ForgotPassword/ForgotPassword.tsx +2 -0
  45. package/src/components/PricingTable/PricingTable.tsx +157 -0
  46. package/src/components/ResetPassword/ResetPassword.tsx +2 -0
  47. package/src/components/SignIn/SignIn.tsx +29 -2
  48. package/src/components/SignUp/SignUp.tsx +29 -2
  49. package/src/components/SocialButtons/SocialButtons.tsx +10 -3
  50. package/src/components/UserButton/UserButton.tsx +2 -0
  51. package/src/components/UserProfile/UserProfile.tsx +2 -0
  52. package/src/styles.ts +38 -16
  53. package/test/components.test.tsx +135 -0
  54. package/test/icons.test.tsx +32 -0
  55. package/test/styles.test.ts +47 -0
  56. package/vitest.config.ts +2 -0
@@ -2,6 +2,7 @@ import { SlyxupError } from '@slyxup/core';
2
2
  import { useAuth } from '@slyxup/react';
3
3
  import { type FormEvent, useEffect, useRef, useState } from 'react';
4
4
  import { GitHubIcon, GoogleIcon, KeyholeMark } from '../../icons';
5
+ import { injectStyles } from '../../styles';
5
6
 
6
7
  export interface SignInProps {
7
8
  /** Show social buttons (default true) */
@@ -18,7 +19,11 @@ export function SignIn({
18
19
  onSuccess,
19
20
  onSignUpClick,
20
21
  }: SignInProps) {
21
- const { signIn } = useAuth();
22
+ injectStyles();
23
+ const { signIn, client } = useAuth() as unknown as {
24
+ signIn: ReturnType<typeof useAuth>['signIn'];
25
+ client: { publishableKey?: string; apiUrl: string };
26
+ };
22
27
  const [email, setEmail] = useState('');
23
28
  const [password, setPassword] = useState('');
24
29
  const [busy, setBusy] = useState(false);
@@ -51,11 +56,33 @@ export function SignIn({
51
56
  }
52
57
 
53
58
  function oauth(provider: 'google' | 'github') {
54
- window.location.href = `/v1/oauth/${provider}`;
59
+ window.location.href = `${client.apiUrl}/v1/oauth/${provider}`;
55
60
  }
56
61
 
62
+ const missingKey =
63
+ !client.publishableKey ||
64
+ client.publishableKey === 'pk_test_missing' ||
65
+ client.publishableKey.includes('REPLACE');
66
+
57
67
  return (
58
68
  <div ref={cardRef} className={`slx-card${error ? ' slx-card-error' : ''}`}>
69
+ {missingKey && (
70
+ <p
71
+ style={{
72
+ fontSize: 12,
73
+ background: '#fff3cd',
74
+ border: '1px solid #ffe69c',
75
+ borderRadius: 8,
76
+ padding: '8px 10px',
77
+ marginBottom: 14,
78
+ lineHeight: 1.4,
79
+ }}
80
+ >
81
+ <strong>Setup:</strong> Add{' '}
82
+ <code>NEXT_PUBLIC_SLYXUP_PUBLISHABLE_KEY</code> to{' '}
83
+ <code>.env.local</code> — run <code>npx @slyxup/cli keys create</code>
84
+ </p>
85
+ )}
59
86
  <div className="slx-mark">
60
87
  <KeyholeMark />
61
88
  </div>
@@ -2,6 +2,7 @@ import { SlyxupError } from '@slyxup/core';
2
2
  import { useAuth } from '@slyxup/react';
3
3
  import { type FormEvent, useEffect, useRef, useState } from 'react';
4
4
  import { GitHubIcon, GoogleIcon, KeyholeMark } from '../../icons';
5
+ import { injectStyles } from '../../styles';
5
6
 
6
7
  export interface SignUpProps {
7
8
  social?: boolean;
@@ -15,7 +16,11 @@ export function SignUp({
15
16
  onSuccess,
16
17
  onSignInClick,
17
18
  }: SignUpProps) {
18
- const { signUp } = useAuth();
19
+ injectStyles();
20
+ const { signUp, client } = useAuth() as unknown as {
21
+ signUp: ReturnType<typeof useAuth>['signUp'];
22
+ client: { publishableKey?: string; apiUrl: string };
23
+ };
19
24
  const [firstName, setFirstName] = useState('');
20
25
  const [email, setEmail] = useState('');
21
26
  const [password, setPassword] = useState('');
@@ -49,11 +54,33 @@ export function SignUp({
49
54
  }
50
55
 
51
56
  function oauth(provider: 'google' | 'github') {
52
- window.location.href = `/v1/oauth/${provider}`;
57
+ window.location.href = `${client.apiUrl}/v1/oauth/${provider}`;
53
58
  }
54
59
 
60
+ const missingKey =
61
+ !client.publishableKey ||
62
+ client.publishableKey === 'pk_test_missing' ||
63
+ client.publishableKey.includes('REPLACE');
64
+
55
65
  return (
56
66
  <div ref={cardRef} className={`slx-card${error ? ' slx-card-error' : ''}`}>
67
+ {missingKey && (
68
+ <p
69
+ style={{
70
+ fontSize: 12,
71
+ background: '#fff3cd',
72
+ border: '1px solid #ffe69c',
73
+ borderRadius: 8,
74
+ padding: '8px 10px',
75
+ marginBottom: 14,
76
+ lineHeight: 1.4,
77
+ }}
78
+ >
79
+ <strong>Setup:</strong> Add{' '}
80
+ <code>NEXT_PUBLIC_SLYXUP_PUBLISHABLE_KEY</code> — run{' '}
81
+ <code>npx @slyxup/cli keys create</code>
82
+ </p>
83
+ )}
57
84
  <div className="slx-mark">
58
85
  <KeyholeMark />
59
86
  </div>
@@ -1,9 +1,11 @@
1
+ import { useAuth } from '@slyxup/react';
1
2
  import { GitHubIcon, GoogleIcon } from '../../icons';
3
+ import { injectStyles } from '../../styles';
2
4
 
3
5
  export interface SocialButtonsProps {
4
6
  /** Show only these providers. Default: both */
5
7
  providers?: Array<'google' | 'github'>;
6
- /** OAuth start path base (default /v1/oauth) */
8
+ /** OAuth start path base. Default: `<apiUrl>/v1/oauth` from the provider client */
7
9
  basePath?: string;
8
10
  }
9
11
 
@@ -15,8 +17,13 @@ const META = {
15
17
  /** Provider buttons that redirect to hosted OAuth start. */
16
18
  export function SocialButtons({
17
19
  providers = ['google', 'github'],
18
- basePath = '/v1/oauth',
20
+ basePath,
19
21
  }: SocialButtonsProps) {
22
+ injectStyles();
23
+ const { client } = useAuth() as unknown as {
24
+ client?: { apiUrl?: string };
25
+ };
26
+ const base = basePath ?? `${client?.apiUrl ?? ''}/v1/oauth`;
20
27
  return (
21
28
  <div className="slx-social">
22
29
  {providers.map((p) => {
@@ -27,7 +34,7 @@ export function SocialButtons({
27
34
  type="button"
28
35
  className="slx-social-btn"
29
36
  onClick={() => {
30
- window.location.href = `${basePath}/${p}`;
37
+ window.location.href = `${base}/${p}`;
31
38
  }}
32
39
  >
33
40
  <Icon /> {label}
@@ -1,5 +1,6 @@
1
1
  import { useAuth, useUser } from '@slyxup/react';
2
2
  import { useEffect, useRef, useState } from 'react';
3
+ import { injectStyles } from '../../styles';
3
4
 
4
5
  function initials(name: string | null | undefined, email: string): string {
5
6
  if (name?.trim()) return name.trim().slice(0, 1).toUpperCase();
@@ -8,6 +9,7 @@ function initials(name: string | null | undefined, email: string): string {
8
9
 
9
10
  /** Avatar + dropdown with profile actions and sign out. */
10
11
  export function UserButton() {
12
+ injectStyles();
11
13
  const { isLoaded, user } = useUser();
12
14
  const { signOut } = useAuth();
13
15
  const [open, setOpen] = useState(false);
@@ -1,8 +1,10 @@
1
1
  import { useAuth, useUser } from '@slyxup/react';
2
2
  import { type FormEvent, useEffect, useState } from 'react';
3
+ import { injectStyles } from '../../styles';
3
4
 
4
5
  /** Edit first/last name + avatar URL. */
5
6
  export function UserProfile() {
7
+ injectStyles();
6
8
  const { isLoaded, user, reload } = useUser();
7
9
  const { client } = useAuth();
8
10
  const [firstName, setFirstName] = useState('');
package/src/styles.ts CHANGED
@@ -3,19 +3,24 @@
3
3
  * Injected once via <SlyxUpStyles />. Theme with CSS variables on :root or .slyxup-scope.
4
4
  */
5
5
 
6
+ export const FONT_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">`;
7
+
6
8
  export const CSS = `
9
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap');
10
+
7
11
  .slyxup-root {
8
12
  --slx-accent: #5b5bd6;
9
13
  --slx-accent-hover: #4c4cc4;
10
- --slx-accent-soft: rgba(91, 91, 214, 0.1);
14
+ --slx-accent-soft: rgba(91, 91, 214, 0.14);
11
15
  --slx-bg: #ffffff;
12
- --slx-bg-page: #f7f7fa;
16
+ --slx-bg-page: #e9eaf6;
13
17
  --slx-ink: #16161d;
14
18
  --slx-muted: #6f6f7b;
15
- --slx-border: #e6e6ec;
19
+ --slx-border: #d8d8e8;
16
20
  --slx-danger: #d64550;
17
21
  --slx-radius: 12px;
18
- --slx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
22
+ --slx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
23
+ --slx-display: "Space Grotesk", "DM Sans", sans-serif;
19
24
  --slx-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
20
25
 
21
26
  font-family: var(--slx-font);
@@ -55,9 +60,10 @@ export const CSS = `
55
60
  border: 1px solid var(--slx-border);
56
61
  border-radius: calc(var(--slx-radius) + 4px);
57
62
  padding: 32px;
58
- box-shadow: 0 1px 2px rgba(10,10,20,.04), 0 8px 24px rgba(10,10,20,.06);
63
+ box-shadow: 0 4px 12px rgba(16,16,29,.08), 0 16px 40px rgba(16,16,29,.12), 0 0 0 1px rgba(16,16,29,.04);
59
64
  box-sizing: border-box;
60
65
  }
66
+ .slyxup-root .slx-card { background: #ffffff; }
61
67
  .slx-card-error { animation: slx-shake .45s cubic-bezier(.36,.07,.19,.97) both; }
62
68
 
63
69
  /* ── Header / keyhole mark ── */
@@ -68,7 +74,7 @@ export const CSS = `
68
74
  margin-bottom: 18px;
69
75
  }
70
76
  .slx-mark svg { display: block; }
71
- .slx-title { font-size: 19px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 6px; }
77
+ .slx-title { font-family: var(--slx-display); font-size: 20px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 6px; }
72
78
  .slx-subtitle { font-size: 13.5px; color: var(--slx-muted); margin: 0 0 22px; line-height: 1.5; }
73
79
 
74
80
  /* ── Fields ── */
@@ -99,17 +105,21 @@ export const CSS = `
99
105
  /* ── Button ── */
100
106
  .slx-btn {
101
107
  width: 100%; box-sizing: border-box;
102
- font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.01em;
103
- color: #fff; background: var(--slx-accent);
104
- border: none; border-radius: var(--slx-radius);
108
+ font-family: var(--slx-font); font-size: 14px; font-weight: 600; letter-spacing: 0.01em;
109
+ color: #fff; background: #0a0a0f;
110
+ border: 1px solid #0a0a0f; border-radius: var(--slx-radius);
105
111
  padding: 11px 14px; cursor: pointer;
106
112
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
107
- transition: background .15s, transform .06s;
113
+ transition: background .15s, transform .06s, border-color .15s;
108
114
  }
109
- .slx-btn:hover { background: var(--slx-accent-hover); }
110
- .slx-btn:active { transform: scale(.985); }
111
- .slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft), 0 0 0 1px var(--slx-accent); }
115
+ .slx-btn:hover { background: #1a1a23; border-color: #1a1a23; }
116
+ .slx-btn:active { transform: scale(.985); background: #000; }
117
+ .slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(10,10,15,.14), 0 0 0 1px #0a0a0f; }
112
118
  .slx-btn[disabled] { opacity: .6; cursor: not-allowed; }
119
+ @media (prefers-color-scheme: dark) {
120
+ .slyxup-root:not(.slyxup-light) .slx-btn { background: #f2f2f5; color: #0a0a0f; border-color: #f2f2f5; }
121
+ .slyxup-root:not(.slyxup-light) .slx-btn:hover { background: #e6e6eb; border-color: #e6e6eb; }
122
+ }
113
123
  .slx-spinner {
114
124
  width: 15px; height: 15px; flex: none;
115
125
  border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
@@ -140,8 +150,12 @@ export const CSS = `
140
150
  content: ""; height: 1px; flex: 1; background: var(--slx-border);
141
151
  }
142
152
  .slx-footer { font-size: 13px; color: var(--slx-muted); margin-top: 20px; text-align: center; }
143
- .slx-link { color: var(--slx-accent); font-weight: 550; text-decoration: none; cursor: pointer; }
144
- .slx-link:hover { text-decoration: underline; }
153
+ .slx-link {
154
+ color: var(--slx-accent); font-weight: 600; text-decoration: none; cursor: pointer;
155
+ background: none; border: none; font: inherit; font-size: inherit;
156
+ padding: 0; margin: 0;
157
+ }
158
+ .slx-link:hover { text-decoration: underline; color: var(--slx-accent-hover); }
145
159
  .slx-link:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft); border-radius: 4px; }
146
160
 
147
161
  /* ── Success state ── */
@@ -188,9 +202,17 @@ export const CSS = `
188
202
 
189
203
  let injected = false;
190
204
 
191
- /** Inject the SlyxUp stylesheet once per document. */
205
+ /** Inject the SlyxUp stylesheet + DM Sans font once per document. */
192
206
  export function injectStyles(): void {
193
207
  if (injected || typeof document === 'undefined') return;
208
+ // DM Sans font link
209
+ if (!document.querySelector('link[href*="DM+Sans"]')) {
210
+ const fontLink = document.createElement('link');
211
+ fontLink.rel = 'stylesheet';
212
+ fontLink.href =
213
+ 'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap';
214
+ document.head.appendChild(fontLink);
215
+ }
194
216
  const style = document.createElement('style');
195
217
  style.setAttribute('data-slyxup', 'styles');
196
218
  style.textContent = CSS;
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import React from 'react';
4
+
5
+ const mockSignIn = vi.fn().mockResolvedValue({ ok: true });
6
+ const mockSignUp = vi.fn().mockResolvedValue({ ok: true });
7
+ const mockSignOut = vi.fn().mockResolvedValue({ ok: true });
8
+
9
+ vi.mock('@slyxup/react', () => ({
10
+ useAuth: () => ({
11
+ isLoaded: true,
12
+ isSignedIn: false,
13
+ userId: null,
14
+ client: {},
15
+ signIn: mockSignIn,
16
+ signUp: mockSignUp,
17
+ signOut: mockSignOut,
18
+ }),
19
+ useUser: () => ({
20
+ isLoaded: true,
21
+ isSignedIn: false,
22
+ user: null,
23
+ isSignedOut: true,
24
+ reload: vi.fn(),
25
+ }),
26
+ useSession: () => ({
27
+ isLoaded: true,
28
+ isSignedIn: false,
29
+ session: null,
30
+ reload: vi.fn(),
31
+ }),
32
+ }));
33
+
34
+ import { SignIn } from '../src/components/SignIn/SignIn';
35
+ import { SignUp } from '../src/components/SignUp/SignUp';
36
+ import { SocialButtons } from '../src/components/SocialButtons/SocialButtons';
37
+ import { UserButton } from '../src/components/UserButton/UserButton';
38
+
39
+ describe('SignIn', () => {
40
+ beforeEach(() => vi.clearAllMocks());
41
+
42
+ it('renders sign in heading', () => {
43
+ render(<SignIn />);
44
+ expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy();
45
+ });
46
+
47
+ it('renders email and password inputs', () => {
48
+ render(<SignIn />);
49
+ expect(screen.getByLabelText(/email/i)).toBeTruthy();
50
+ expect(screen.getByLabelText(/password/i)).toBeTruthy();
51
+ });
52
+
53
+ it('renders submit button', () => {
54
+ render(<SignIn />);
55
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeTruthy();
56
+ });
57
+
58
+ it('shows social buttons by default', () => {
59
+ render(<SignIn />);
60
+ expect(screen.getByText(/continue with google/i)).toBeTruthy();
61
+ expect(screen.getByText(/continue with github/i)).toBeTruthy();
62
+ });
63
+
64
+ it('hides social buttons when social=false', () => {
65
+ render(<SignIn social={false} />);
66
+ expect(screen.queryByText(/continue with google/i)).toBeNull();
67
+ expect(screen.queryByText(/continue with github/i)).toBeNull();
68
+ });
69
+
70
+ it('calls signIn on form submit', async () => {
71
+ render(<SignIn />);
72
+ fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.com' } });
73
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: '12345678' } });
74
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
75
+ await waitFor(() => expect(mockSignIn).toHaveBeenCalledWith({ email: 'a@b.com', password: '12345678' }));
76
+ });
77
+
78
+ it('shows sign up link when onSignUpClick provided', () => {
79
+ const onSignUp = vi.fn();
80
+ render(<SignIn onSignUpClick={onSignUp} />);
81
+ expect(screen.getByText(/sign up/i)).toBeTruthy();
82
+ });
83
+ });
84
+
85
+ describe('SignUp', () => {
86
+ beforeEach(() => vi.clearAllMocks());
87
+
88
+ it('renders create your account heading', () => {
89
+ render(<SignUp />);
90
+ expect(screen.getByRole('heading', { name: /create your account/i })).toBeTruthy();
91
+ });
92
+
93
+ it('renders first name, email, password inputs', () => {
94
+ render(<SignUp />);
95
+ expect(screen.getByLabelText(/first name/i)).toBeTruthy();
96
+ expect(screen.getByLabelText(/email/i)).toBeTruthy();
97
+ expect(screen.getByLabelText(/password/i)).toBeTruthy();
98
+ });
99
+
100
+ it('shows social buttons by default', () => {
101
+ render(<SignUp />);
102
+ expect(screen.getByText(/continue with google/i)).toBeTruthy();
103
+ });
104
+ });
105
+
106
+ describe('SocialButtons', () => {
107
+ beforeEach(() => vi.clearAllMocks());
108
+
109
+ it('renders both providers by default', () => {
110
+ render(<SocialButtons />);
111
+ expect(screen.getByText(/continue with google/i)).toBeTruthy();
112
+ expect(screen.getByText(/continue with github/i)).toBeTruthy();
113
+ });
114
+
115
+ it('renders only specified providers', () => {
116
+ render(<SocialButtons providers={['google']} />);
117
+ expect(screen.getByText(/continue with google/i)).toBeTruthy();
118
+ expect(screen.queryByText(/continue with github/i)).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe('UserButton', () => {
123
+ beforeEach(() => vi.clearAllMocks());
124
+
125
+ it('renders avatar', () => {
126
+ render(<UserButton />);
127
+ expect(screen.getByRole('button', { name: /account menu/i })).toBeTruthy();
128
+ });
129
+
130
+ it('opens dropdown on click', async () => {
131
+ render(<UserButton />);
132
+ fireEvent.click(screen.getByRole('button', { name: /account menu/i }));
133
+ await waitFor(() => expect(screen.getByText(/sign out/i)).toBeTruthy());
134
+ });
135
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { KeyholeMark, GoogleIcon, GitHubIcon, CheckIcon } from '../src/icons';
5
+
6
+ describe('KeyholeMark', () => {
7
+ it('renders an SVG', () => {
8
+ const { container } = render(<KeyholeMark />);
9
+ expect(container.querySelector('svg')).toBeTruthy();
10
+ });
11
+ });
12
+
13
+ describe('GoogleIcon', () => {
14
+ it('renders an SVG', () => {
15
+ const { container } = render(<GoogleIcon />);
16
+ expect(container.querySelector('svg')).toBeTruthy();
17
+ });
18
+ });
19
+
20
+ describe('GitHubIcon', () => {
21
+ it('renders an SVG', () => {
22
+ const { container } = render(<GitHubIcon />);
23
+ expect(container.querySelector('svg')).toBeTruthy();
24
+ });
25
+ });
26
+
27
+ describe('CheckIcon', () => {
28
+ it('renders an SVG', () => {
29
+ const { container } = render(<CheckIcon />);
30
+ expect(container.querySelector('svg')).toBeTruthy();
31
+ });
32
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CSS, injectStyles } from '../src/styles';
3
+
4
+ describe('CSS', () => {
5
+ it('is a non-empty string', () => {
6
+ expect(typeof CSS).toBe('string');
7
+ expect(CSS.length).toBeGreaterThan(0);
8
+ });
9
+
10
+ it('contains .slyxup-root', () => {
11
+ expect(CSS).toContain('.slyxup-root');
12
+ });
13
+
14
+ it('contains --slx-accent', () => {
15
+ expect(CSS).toContain('--slx-accent');
16
+ });
17
+
18
+ it('contains dark mode media query', () => {
19
+ expect(CSS).toContain('prefers-color-scheme: dark');
20
+ });
21
+
22
+ it('contains @keyframes slx-shake', () => {
23
+ expect(CSS).toContain('@keyframes slx-shake');
24
+ });
25
+
26
+ it('contains @keyframes slx-spin', () => {
27
+ expect(CSS).toContain('@keyframes slx-spin');
28
+ });
29
+
30
+ it('contains prefers-reduced-motion', () => {
31
+ expect(CSS).toContain('prefers-reduced-motion');
32
+ });
33
+
34
+ it('contains card styles', () => {
35
+ expect(CSS).toContain('.slx-card');
36
+ });
37
+
38
+ it('contains button styles', () => {
39
+ expect(CSS).toContain('.slx-btn');
40
+ });
41
+ });
42
+
43
+ describe('injectStyles', () => {
44
+ it('is a function', () => {
45
+ expect(typeof injectStyles).toBe('function');
46
+ });
47
+ });
@@ -0,0 +1,2 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({ test: { globals: true, environment: 'happy-dom' } });