@sjolystinnovation/app-kit 0.20.0 → 0.22.0

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 (78) hide show
  1. package/README.md +315 -253
  2. package/package.json +13 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +123 -0
  6. package/src/admin/LegalPagesEditor.tsx +132 -0
  7. package/src/admin/LocaleTabs.tsx +36 -0
  8. package/src/admin/StatHistoryChart.tsx +100 -0
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/companySettingsFormStrings.ts +50 -0
  11. package/src/admin/index.ts +14 -0
  12. package/src/admin/legalPagesEditorStrings.ts +32 -0
  13. package/src/admin/statHistoryChartStrings.ts +17 -0
  14. package/src/api/apiFieldErrors.ts +4 -4
  15. package/src/api/company.ts +47 -0
  16. package/src/api/createApiClient.ts +3 -2
  17. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  18. package/src/api/index.ts +6 -1
  19. package/src/api/request.ts +2 -2
  20. package/src/api/revalidateTargets.ts +42 -0
  21. package/src/api/services.ts +14 -13
  22. package/src/auth/createAuthOptions.ts +7 -5
  23. package/src/branding/BrandingManager.tsx +10 -5
  24. package/src/branding/BrandingProvider.tsx +5 -3
  25. package/src/branding/index.ts +1 -2
  26. package/src/branding/server.ts +10 -9
  27. package/src/branding/types.ts +3 -3
  28. package/src/config.ts +10 -10
  29. package/src/forms/useFormDraft.ts +9 -7
  30. package/src/index.ts +1 -3
  31. package/src/legal/LegalPageView.tsx +62 -0
  32. package/src/legal/index.ts +1 -0
  33. package/src/markdown/Markdown.tsx +12 -10
  34. package/src/markdown/index.ts +1 -1
  35. package/src/next-auth.d.ts +11 -9
  36. package/src/next-config/headers.ts +96 -0
  37. package/src/next-config/index.ts +18 -107
  38. package/src/payments/PaymentReturn.tsx +9 -7
  39. package/src/payments/formatNok.ts +1 -1
  40. package/src/payments/usePollUntilFinal.ts +7 -5
  41. package/src/seo/JsonLd.tsx +1 -1
  42. package/src/seo/environment.ts +3 -3
  43. package/src/seo/index.ts +2 -1
  44. package/src/seo/og/index.tsx +5 -4
  45. package/src/seo/schema.ts +6 -2
  46. package/src/seo/site.ts +90 -30
  47. package/src/seo/sitemap.ts +2 -2
  48. package/src/server/publicApi.ts +13 -9
  49. package/src/server/revalidateRoute.ts +10 -8
  50. package/src/session/expiry.ts +8 -5
  51. package/src/session/react/AppSessionProvider.tsx +3 -2
  52. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  53. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  54. package/src/session/react/ProtectedGate.tsx +3 -2
  55. package/src/session/react/RoleGate.tsx +4 -3
  56. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  57. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  58. package/src/session/react/index.ts +8 -2
  59. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  60. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  61. package/src/session/react/useSessionGate.ts +3 -3
  62. package/src/styles.css +2 -0
  63. package/src/testing/index.ts +113 -0
  64. package/src/ui/Alert.tsx +6 -4
  65. package/src/ui/ContentImage.tsx +12 -11
  66. package/src/ui/ErrorState.tsx +5 -3
  67. package/src/ui/NotFoundState.tsx +9 -7
  68. package/src/ui/PasswordInput.tsx +6 -5
  69. package/src/ui/ResetPasswordFlow.tsx +10 -8
  70. package/src/ui/TextArea.tsx +6 -4
  71. package/src/ui/TextInput.tsx +4 -2
  72. package/src/ui/Toggle.tsx +3 -3
  73. package/src/ui/field.tsx +5 -3
  74. package/src/ui/index.ts +2 -10
  75. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  76. package/src/validation/fieldErrors.ts +4 -3
  77. package/src/validation/password.ts +5 -5
  78. package/src/validation/passwordRules.ts +2 -2
@@ -4,9 +4,15 @@ export { SessionExpiryGuard } from "./SessionExpiryGuard";
4
4
  export type { SessionExpiryGuardProps } from "./SessionExpiryGuard";
5
5
  export { defaultSessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
6
6
  export type { SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
7
- export { useSessionGate } from "./useSessionGate";
8
- export type { SessionGate } from "./useSessionGate";
9
7
  export { AppSessionProvider } from "./AppSessionProvider";
10
8
  export type { AppSessionProviderProps } from "./AppSessionProvider";
11
9
  export { RoleGate } from "./RoleGate";
12
10
  export type { RoleGateProps } from "./RoleGate";
11
+ export { CredentialsForm } from "./CredentialsForm";
12
+ export type { CredentialsFormProps } from "./CredentialsForm";
13
+ export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
14
+ export type { CredentialsFormStrings } from "./credentialsFormStrings";
15
+ export { LoginPage } from "./LoginPage";
16
+ export type { LoginPageProps } from "./LoginPage";
17
+ export { defaultLoginPageStrings } from "./loginPageStrings";
18
+ export type { LoginPageStrings } from "./loginPageStrings";
@@ -4,7 +4,7 @@
4
4
  export interface LoginPageStrings {
5
5
  title: string;
6
6
  forgotPassword: string;
7
- /** Shown before the register link, such as "No account?". Null leaves it out. */
7
+ /** The text shown before the register link, such as "No account?". Null leaves it out. */
8
8
  registerPrompt: string | null;
9
9
  register: string;
10
10
  }
@@ -7,7 +7,7 @@
7
7
  export interface SessionExpiryGuardStrings {
8
8
  expiredTitle: string;
9
9
  expiredBody: string;
10
- /** Every `{minutes}` is replaced with the minutes left. */
10
+ /** The warning before expiry. Every `{minutes}` is replaced with the minutes left. */
11
11
  expiringSoon: string;
12
12
  reSignIn: string;
13
13
  notRestored: string;
@@ -6,7 +6,7 @@ import { useState, useSyncExternalStore } from "react";
6
6
  import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt } from "../expiry";
7
7
 
8
8
  /**
9
- * Whether a protected page may render, and whether it must stay rendered.
9
+ * Checks whether a protected page may render, and whether it must stay rendered.
10
10
  *
11
11
  * Every gate on the way down should use this, so they all agree. A nested layout that decides on
12
12
  * its own can blank the page. That undoes the in-place recovery the re-sign-in prompt exists for.
@@ -44,8 +44,8 @@ export function useSessionGate() {
44
44
  wasUsable,
45
45
  recovering,
46
46
  /**
47
- * The one answer every gate on the way down uses. A layout that decides this again can blank
48
- * the page that the gate above it chose to keep.
47
+ * Whether the page may render. Every gate on the way down uses this one answer. A layout that
48
+ * decides it again can blank the page that the gate above it chose to keep.
49
49
  */
50
50
  mayRender:
51
51
  recovering ||
package/src/styles.css CHANGED
@@ -88,6 +88,8 @@
88
88
  --color-admin-tab-hover: var(--color-gray-900);
89
89
  --color-admin-tab-active: var(--color-gray-900);
90
90
  --color-admin-tab-indicator: var(--color-primary);
91
+ --color-chart-grid: var(--color-gray-200);
92
+ --color-chart-label: var(--color-gray-400);
91
93
 
92
94
  --radius-button: var(--radius-lg);
93
95
  --color-button-primary-bg: var(--color-primary);
@@ -0,0 +1,113 @@
1
+ // Test doubles for the modules every app's tests mock. Import them inside the vi.mock factory, which
2
+ // vitest hoists above the file's own imports:
3
+ //
4
+ // vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
5
+ //
6
+ // The test then imports the same module instance to set the state and read the spies, and calls
7
+ // resetTestMocks before each test, since the state lives for the whole file.
8
+ import type { ReactNode } from "react";
9
+ import type { Session } from "next-auth";
10
+ import { vi } from "vitest";
11
+
12
+ type SessionStatus = "loading" | "authenticated" | "unauthenticated";
13
+
14
+ /** What `useSession` returns, and the next-auth calls as spies. `update` is the one `useSession` returns. */
15
+ export const auth = {
16
+ state: { data: null, status: "unauthenticated" } as { data: Session | null; status: SessionStatus },
17
+ signIn: vi.fn(),
18
+ signOut: vi.fn(),
19
+ getSession: vi.fn(),
20
+ update: vi.fn(),
21
+ };
22
+
23
+ /** Builds a signed-in session with `roles`. Defaults to an admin. */
24
+ export function makeSession({ roles = ["Admin"], ...overrides }: Partial<Session> & { roles?: string[] } = {}): Session {
25
+ return {
26
+ user: { id: "1", name: "admin", email: "a@b.no", roles },
27
+ accessToken: "token",
28
+ expires: "",
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ /** Signs in with `session`, signs out with null, or leaves the session loading. */
34
+ export function setSession(session: Session | null | "loading"): void {
35
+ auth.state =
36
+ session === "loading"
37
+ ? { data: null, status: "loading" }
38
+ : session === null
39
+ ? { data: null, status: "unauthenticated" }
40
+ : { data: session, status: "authenticated" };
41
+ }
42
+
43
+ /** Builds the `next-auth/react` module, reading `auth`. */
44
+ export function nextAuthMock() {
45
+ return {
46
+ useSession: () => ({ ...auth.state, update: (...args: unknown[]) => auth.update(...args) }),
47
+ signIn: (...args: unknown[]) => auth.signIn(...args),
48
+ signOut: (...args: unknown[]) => auth.signOut(...args),
49
+ getSession: (...args: unknown[]) => auth.getSession(...args),
50
+ SessionProvider: ({ children }: { children?: ReactNode }) => children,
51
+ };
52
+ }
53
+
54
+ /** The current URL and the router's calls as spies. */
55
+ export const navigation = {
56
+ pathname: "/",
57
+ searchParams: new URLSearchParams(),
58
+ router: { push: vi.fn(), replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() },
59
+ };
60
+
61
+ /** Builds the `next/navigation` hooks, reading `navigation`. */
62
+ export function nextNavigationMock() {
63
+ return {
64
+ useRouter: () => navigation.router,
65
+ usePathname: () => navigation.pathname,
66
+ useSearchParams: () => navigation.searchParams,
67
+ };
68
+ }
69
+
70
+ /** The toast calls as spies. */
71
+ export const toastSpies = {
72
+ success: vi.fn(),
73
+ error: vi.fn(),
74
+ info: vi.fn(),
75
+ warning: vi.fn(),
76
+ message: vi.fn(),
77
+ loading: vi.fn(),
78
+ promise: vi.fn(),
79
+ dismiss: vi.fn(),
80
+ };
81
+
82
+ /** Builds the `sonner` module, or `@sjolystinnovation/app-kit/toast`. A bare `toast(text)` goes to `toastSpies.message`. */
83
+ export function toastMock() {
84
+ const toast = Object.assign((...args: unknown[]) => toastSpies.message(...args), {
85
+ success: (...args: unknown[]) => toastSpies.success(...args),
86
+ error: (...args: unknown[]) => toastSpies.error(...args),
87
+ info: (...args: unknown[]) => toastSpies.info(...args),
88
+ warning: (...args: unknown[]) => toastSpies.warning(...args),
89
+ message: (...args: unknown[]) => toastSpies.message(...args),
90
+ loading: (...args: unknown[]) => toastSpies.loading(...args),
91
+ promise: (...args: unknown[]) => toastSpies.promise(...args),
92
+ dismiss: (...args: unknown[]) => toastSpies.dismiss(...args),
93
+ });
94
+ return { toast, Toaster: () => null };
95
+ }
96
+
97
+ /** The HTTP calls of an app's axios instance as spies. */
98
+ export const axiosSpies = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() };
99
+
100
+ /** Builds a module whose default export is an axios instance, such as the app's `services/axiosInstance`. */
101
+ export function axiosInstanceMock() {
102
+ return { default: axiosSpies };
103
+ }
104
+
105
+ /** Signs out, goes back to `/`, and clears every spy's calls and answers. */
106
+ export function resetTestMocks(): void {
107
+ setSession(null);
108
+ navigation.pathname = "/";
109
+ navigation.searchParams = new URLSearchParams();
110
+ for (const spy of [auth.signIn, auth.signOut, auth.getSession, auth.update, ...Object.values(navigation.router), ...Object.values(toastSpies), ...Object.values(axiosSpies)]) {
111
+ spy.mockReset();
112
+ }
113
+ }
package/src/ui/Alert.tsx CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { cx } from "./cx";
3
3
 
4
+ /** Props for `Alert`. */
4
5
  export interface AlertProps {
5
6
  variant?: "error" | "success" | "info" | "warning";
6
7
  /**
7
- * Defaults to alert, which a screen reader interrupts for. A screen reader announces status
8
- * without interrupting, on every change of its text. Use none when a separate live region
9
- * announces the text.
8
+ * The ARIA role. A screen reader interrupts for `alert`. It announces `status` without
9
+ * interrupting, on every change of its text. Use `none` when a separate live region announces the
10
+ * text. Defaults to `alert`.
10
11
  */
11
12
  role?: "alert" | "status" | "none";
12
13
  /**
13
- * Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
14
+ * The classes for placing the alert, such as a margin. Restyle it through the theme variables. A
14
15
  * class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
15
16
  */
16
17
  className?: string;
@@ -24,6 +25,7 @@ const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
24
25
  warning: "bg-alert-warning-bg border-alert-warning-border text-alert-warning-text",
25
26
  };
26
27
 
28
+ /** Renders a message in an error, success, info, or warning box. */
27
29
  export function Alert({ variant = "info", role = "alert", className, children }: AlertProps) {
28
30
  return (
29
31
  <div className={cx("rounded-alert border px-3 py-2 text-sm", styles[variant], className)} role={role}>
@@ -1,42 +1,43 @@
1
1
  /** The widths the API serves an uploaded image at, as webp. */
2
2
  export const CONTENT_IMAGE_WIDTHS: readonly number[] = [384, 640, 768, 1024, 1366, 1600];
3
3
 
4
- /** Where the browser fetches an uploaded image. Site-relative, through the app's content images rewrite. */
4
+ /** Builds the site-relative path where the browser fetches an uploaded image, through the app's content images rewrite. */
5
5
  export function contentImagePath(id: string, base = "/content-images"): string {
6
6
  return `${base}/${encodeURIComponent(id)}`;
7
7
  }
8
8
 
9
9
  /**
10
- * The `srcset` for an uploaded image. The API serves the nearest webp at or above the requested
11
- * width, or the original when the browser does not accept webp.
10
+ * Builds the `srcset` for an uploaded image. The API serves the nearest webp at or above the
11
+ * requested width, or the original when the browser does not accept webp.
12
12
  */
13
13
  export function contentImageSrcSet(id: string, widths: readonly number[] = CONTENT_IMAGE_WIDTHS, base?: string): string {
14
14
  return widths.map(w => `${contentImagePath(id, base)}?w=${w} ${w}w`).join(", ");
15
15
  }
16
16
 
17
+ /** Props for `ContentImage`. */
17
18
  export interface ContentImageProps {
18
- /** Uploaded image id. Null falls back to `fallbackSrc`, which is served as it is. */
19
+ /** The uploaded image's id. Null falls back to `fallbackSrc`, which is served as it is. */
19
20
  imageId: string | null;
20
- /** Empty for an image that carries no meaning of its own, such as a decorative backdrop. */
21
+ /** The alt text. Leave it empty for an image that carries no meaning of its own, such as a decorative backdrop. */
21
22
  alt: string;
22
- /** Widths the image is shown at. Required, since a browser assumes 100vw without it. */
23
+ /** The widths the image is shown at. It is required, since a browser assumes 100vw without it. */
23
24
  sizes: string;
24
25
  /** A static path for when there is no uploaded image. */
25
26
  fallbackSrc?: string | undefined;
26
27
  className?: string | undefined;
27
- /** Intrinsic size, to reserve space. Only needed where the layout fixes no ratio. */
28
+ /** The intrinsic size, to reserve space. You only need it where the layout fixes no ratio. */
28
29
  width?: number | undefined;
29
30
  height?: number | undefined;
30
- /** Set on the one image visible without scrolling. It loads eagerly, at high priority. */
31
+ /** Whether this is the one image visible without scrolling. That image loads eagerly, at high priority. */
31
32
  priority?: boolean | undefined;
32
33
  "aria-hidden"?: boolean | undefined;
33
- /** Path the content images rewrite serves, `/content-images` by default. */
34
+ /** The path the content images rewrite serves. Defaults to `/content-images`. */
34
35
  base?: string | undefined;
35
- /** Widths in the `srcset`. Defaults to the widths the API serves. */
36
+ /** The widths in the `srcset`. Defaults to the widths the API serves. */
36
37
  widths?: readonly number[] | undefined;
37
38
  }
38
39
 
39
- /** An image from the content API, served responsively from the `sizes` a call site declares. */
40
+ /** Renders an image from the content API, served responsively from the `sizes` a call site declares. */
40
41
  export function ContentImage({
41
42
  imageId,
42
43
  alt,
@@ -4,6 +4,7 @@ import Link from "next/link";
4
4
  import { cx } from "./cx";
5
5
  import { withDefaults, type TextOverrides } from "./withDefaults";
6
6
 
7
+ /** The error page's text. Pass the app's own language. The defaults are English. */
7
8
  export interface ErrorStateStrings {
8
9
  title: string;
9
10
  body: string;
@@ -18,17 +19,18 @@ export const defaultErrorStateStrings: ErrorStateStrings = {
18
19
  home: "Go to the front page",
19
20
  };
20
21
 
22
+ /** Props for `ErrorState`. */
21
23
  export interface ErrorStateProps {
22
24
  /** The `reset` Next passes to an `error.tsx`. It renders the page again. */
23
25
  reset: () => void;
24
- /** Where the front page link goes, such as `/` or `/en`. */
26
+ /** The front page link's target, such as `/` or `/en`. */
25
27
  homeHref: string;
26
28
  strings?: TextOverrides<ErrorStateStrings> | undefined;
27
- /** Classes for placing it, on the outer element. */
29
+ /** The classes for placing the component, on its outer element. */
28
30
  className?: string | undefined;
29
31
  }
30
32
 
31
- /** What an `error.tsx` shows when a page cannot be rendered. The response is still a 500. */
33
+ /** Renders the content of an `error.tsx` when a page cannot be rendered. The response is still a 500. */
32
34
  export function ErrorState({ reset, homeHref, strings, className }: ErrorStateProps) {
33
35
  const text = withDefaults(defaultErrorStateStrings, strings);
34
36
  return (
@@ -2,29 +2,31 @@ import Link from "next/link";
2
2
  import { cx } from "./cx";
3
3
  import { withDefaults, type TextOverrides } from "./withDefaults";
4
4
 
5
- export interface NotFoundStrings {
5
+ /** The not-found page's text. Pass the app's own language. The defaults are English. */
6
+ export interface NotFoundStateStrings {
6
7
  code: string;
7
8
  body: string;
8
9
  home: string;
9
10
  }
10
11
 
11
- export const defaultNotFoundStrings: NotFoundStrings = {
12
+ export const defaultNotFoundStateStrings: NotFoundStateStrings = {
12
13
  code: "404",
13
14
  body: "This page does not exist.",
14
15
  home: "Go home",
15
16
  };
16
17
 
18
+ /** Props for `NotFoundState`. */
17
19
  export interface NotFoundStateProps {
18
- /** Where the home link goes, such as `/` or `/en`. */
20
+ /** The home link's target, such as `/` or `/en`. */
19
21
  homeHref: string;
20
- strings?: TextOverrides<NotFoundStrings> | undefined;
21
- /** Classes for placing it, on the outer element. */
22
+ strings?: TextOverrides<NotFoundStateStrings> | undefined;
23
+ /** The classes for placing the component, on its outer element. */
22
24
  className?: string | undefined;
23
25
  }
24
26
 
25
- /** What a `not-found.tsx` shows. The message is the page's heading. */
27
+ /** Renders the content of a `not-found.tsx`. The message is the page's heading. */
26
28
  export function NotFoundState({ homeHref, strings, className }: NotFoundStateProps) {
27
- const text = withDefaults(defaultNotFoundStrings, strings);
29
+ const text = withDefaults(defaultNotFoundStateStrings, strings);
28
30
  return (
29
31
  <div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
30
32
  <span className="text-6xl font-black font-state-code text-state-code">{text.code}</span>
@@ -4,21 +4,22 @@ import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
4
  import { useState, type InputHTMLAttributes } from "react";
5
5
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
6
6
 
7
- /** The input's type is left out: the component switches it to show and hide the value. */
7
+ /** Props for `PasswordInput`. They leave out the input's `type`, since the component switches it to show and hide the value. */
8
8
  export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
9
9
  label: string;
10
10
  error?: string | undefined;
11
- /** Accessible name of the toggle while the password is hidden. */
11
+ /** The accessible name of the toggle while the password is hidden. */
12
12
  showPasswordLabel?: string | undefined;
13
- /** Accessible name of the toggle while the password is visible. */
13
+ /** The accessible name of the toggle while the password is visible. */
14
14
  hidePasswordLabel?: string | undefined;
15
15
  /**
16
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
17
- * label, the input and the error. Restyle the field through the theme variables.
16
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
17
+ * the label, the input, and the error. Restyle the field through the theme variables.
18
18
  */
19
19
  className?: string;
20
20
  }
21
21
 
22
+ /** Renders a labeled password field with a button that shows and hides the value. */
22
23
  export function PasswordInput({
23
24
  label,
24
25
  error,
@@ -10,18 +10,20 @@ import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "
10
10
  import { TextInput } from "./TextInput";
11
11
  import { withDefaults, type TextOverrides } from "./withDefaults";
12
12
 
13
+ /** Props for `ResetPasswordFlow`. */
13
14
  export interface ResetPasswordFlowProps {
14
- /** Asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
15
+ /** A function that asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
15
16
  requestReset: (data: { email: string }) => Promise<unknown>;
16
- /** Sets the new password with the code, such as `createUserService`'s `resetPassword`. */
17
+ /** A function that sets the new password with the code, such as `createUserService`'s `resetPassword`. */
17
18
  reset: (data: { email: string; code: string; newPassword: string }) => Promise<{ message?: string } | unknown>;
18
- /** Where the sign-in links go. */
19
+ /** The path the sign-in links go to. */
19
20
  loginHref: string;
20
- /** From the emailed link. A code opens the flow on its last step. */
21
+ /** The email from the emailed link. */
21
22
  initialEmail?: string | undefined;
23
+ /** The code from the emailed link. A code opens the flow on its last step. */
22
24
  initialCode?: string | undefined;
23
25
  strings?: TextOverrides<ResetPasswordFlowStrings> | undefined;
24
- /** Replaces the page's outer spacing, such as when the flow sits inside the app's own card. */
26
+ /** The classes that replace the page's outer spacing, such as when the flow sits inside the app's own card. */
25
27
  className?: string | undefined;
26
28
  /** The password rule messages, for the app's own language. */
27
29
  passwordStrings?: TextOverrides<PasswordStrings> | undefined;
@@ -31,9 +33,9 @@ const button =
31
33
  "w-full rounded-button bg-button-primary-bg text-button-primary-text font-semibold py-2.5 hover:brightness-95 disabled:opacity-60 transition";
32
34
 
33
35
  /**
34
- * Password reset in two steps: the email asks for a code, then the code and a new password set it. The
35
- * new password is checked against the API's rules before it is sent. A link with the email and code,
36
- * such as an invite, opens on the second step.
36
+ * Renders password reset in two steps: the email asks for a code, then the code and a new password
37
+ * set it. The flow checks the new password against the API's rules before it sends it. A link with
38
+ * the email and code, such as an invite, opens on the second step.
37
39
  */
38
40
  export function ResetPasswordFlow({
39
41
  requestReset,
@@ -3,20 +3,22 @@
3
3
  import { useCallback, useEffect, useRef, type TextareaHTMLAttributes } from "react";
4
4
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
5
 
6
+ /** Props for `TextArea`. */
6
7
  export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
7
8
  label: string;
8
9
  error?: string | undefined;
9
10
  /**
10
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
- * label, the text area and the error. Restyle the field through the theme variables.
11
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
12
+ * the label, the text area, and the error. Restyle the field through the theme variables.
12
13
  */
13
14
  className?: string | undefined;
14
- /** Grows with its text, from `rows` lines up to `maxRows` lines. On by default. */
15
+ /** Whether the box grows with its text, from `rows` lines up to `maxRows` lines. Defaults to `true`. */
15
16
  autoGrow?: boolean | undefined;
16
- /** The most lines an auto-growing box shows before it scrolls. Defaults to 12. */
17
+ /** The most lines an auto-growing box shows before it scrolls. Defaults to `12`. */
17
18
  maxRows?: number | undefined;
18
19
  }
19
20
 
21
+ /** Renders a labeled text area that can grow with its text. */
20
22
  export function TextArea({ label, error, className, autoGrow = true, maxRows = 12, rows = 3, onInput, ...props }: TextAreaProps) {
21
23
  const field = useField(props, error);
22
24
  const ref = useRef<HTMLTextAreaElement>(null);
@@ -3,16 +3,18 @@
3
3
  import type { InputHTMLAttributes } from "react";
4
4
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
5
 
6
+ /** Props for `TextInput`. */
6
7
  export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
7
8
  label: string;
8
9
  error?: string | undefined;
9
10
  /**
10
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
- * label, the input and the error. Restyle the field through the theme variables.
11
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
12
+ * the label, the input, and the error. Restyle the field through the theme variables.
12
13
  */
13
14
  className?: string | undefined;
14
15
  }
15
16
 
17
+ /** Renders a labeled text input with an optional error message. */
16
18
  export function TextInput({ label, error, className, ...props }: TextInputProps) {
17
19
  const field = useField(props, error);
18
20
 
package/src/ui/Toggle.tsx CHANGED
@@ -5,14 +5,14 @@ import { cx } from "./cx";
5
5
 
6
6
  type ToggleBase = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "role" | "type" | "aria-checked"> & {
7
7
  checked: boolean;
8
- /** Called with the new state when the switch is flipped. */
8
+ /** A function called with the new state when the switch is flipped. */
9
9
  onChange: (checked: boolean) => void;
10
10
  };
11
11
 
12
- /** A switch needs a name. Either a visible `label`, or an `aria-label` when there is none. */
12
+ /** Props for `Toggle`. A switch needs a name: either a visible `label`, or an `aria-label` when there is none. */
13
13
  export type ToggleProps = ToggleBase & ({ label: string } | { label?: undefined; "aria-label": string });
14
14
 
15
- /** An on and off switch, for a setting that takes effect at once. */
15
+ /** Renders an on and off switch, for a setting that takes effect at once. */
16
16
  export function Toggle({ checked, onChange, label, className, disabled, onClick, ...props }: ToggleProps) {
17
17
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
18
18
  onClick?.(event);
package/src/ui/field.tsx CHANGED
@@ -3,13 +3,13 @@
3
3
  import { useId, type AriaAttributes, type ReactNode } from "react";
4
4
  import { cx } from "./cx";
5
5
 
6
- /** The props of a field control that useField reads. An `<input>` and a `<textarea>` both have them. */
6
+ /** The props of a field control that `useField` reads. An `<input>` and a `<textarea>` both have them. */
7
7
  export type FieldControlProps = Pick<AriaAttributes, "aria-required" | "aria-invalid" | "aria-describedby"> & {
8
8
  id?: string | undefined;
9
9
  required?: boolean | undefined;
10
10
  };
11
11
 
12
- /** The ids and aria attributes a labelled input needs, from the caller's own props. */
12
+ /** Builds the ids and aria attributes a labeled input needs, from the caller's own props. */
13
13
  export function useField(props: FieldControlProps, error: string | undefined) {
14
14
  // A generated id ties the label to its own control, even when two fields on a page share a name.
15
15
  const generated = useId();
@@ -29,7 +29,7 @@ export function useField(props: FieldControlProps, error: string | undefined) {
29
29
  };
30
30
  }
31
31
 
32
- /** The input's look, with the error border when there is an error. */
32
+ /** Builds the input's classes, with the error border when there is an error. */
33
33
  export function inputClass(error: string | undefined, extra?: string): string {
34
34
  return cx(
35
35
  "w-full rounded-input border px-3 py-input-y text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
@@ -38,6 +38,7 @@ export function inputClass(error: string | undefined, extra?: string): string {
38
38
  );
39
39
  }
40
40
 
41
+ /** Renders a field's label, with a star when the field is required. */
41
42
  export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; required: boolean | undefined; children: ReactNode }) {
42
43
  return (
43
44
  <label htmlFor={htmlFor} className="block text-field-label font-medium text-field-label-text mb-field-label">
@@ -47,6 +48,7 @@ export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; r
47
48
  );
48
49
  }
49
50
 
51
+ /** Renders a field's error message, or nothing when there is none. */
50
52
  export function FieldError({ id, error }: { id: string; error: string | undefined }) {
51
53
  if (!error) return null;
52
54
  return (
package/src/ui/index.ts CHANGED
@@ -2,18 +2,10 @@ export { Alert } from "./Alert";
2
2
  export type { AlertProps } from "./Alert";
3
3
  export { ContentImage, CONTENT_IMAGE_WIDTHS, contentImagePath, contentImageSrcSet } from "./ContentImage";
4
4
  export type { ContentImageProps } from "./ContentImage";
5
- export { CredentialsForm } from "./CredentialsForm";
6
- export type { CredentialsFormProps } from "./CredentialsForm";
7
- export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
8
- export type { CredentialsFormStrings } from "./credentialsFormStrings";
9
5
  export { ErrorState, defaultErrorStateStrings } from "./ErrorState";
10
6
  export type { ErrorStateProps, ErrorStateStrings } from "./ErrorState";
11
- export { LoginPage } from "./LoginPage";
12
- export type { LoginPageProps } from "./LoginPage";
13
- export { defaultLoginPageStrings } from "./loginPageStrings";
14
- export type { LoginPageStrings } from "./loginPageStrings";
15
- export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
16
- export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
7
+ export { NotFoundState, defaultNotFoundStateStrings } from "./NotFoundState";
8
+ export type { NotFoundStateProps, NotFoundStateStrings } from "./NotFoundState";
17
9
  export { PasswordInput } from "./PasswordInput";
18
10
  export type { PasswordInputProps } from "./PasswordInput";
19
11
  export { ResetPasswordFlow } from "./ResetPasswordFlow";
@@ -6,27 +6,27 @@
6
6
  */
7
7
  export interface ResetPasswordFlowStrings {
8
8
  title: string;
9
- /** Shown before the sign-in link under the title, such as "Remembered it?". */
9
+ /** The text shown before the sign-in link under the title, such as "Remembered it?". */
10
10
  signInPrompt: string | null;
11
11
  signIn: string;
12
- /** The link back to sign-in once the password is set. */
12
+ /** The text of the link back to sign-in once the password is set. */
13
13
  backToSignIn: string;
14
- /** Shown above the email field on the first step. */
14
+ /** The text shown above the email field on the first step. */
15
15
  intro: string | null;
16
- /** Shown at the top of the second step, once a code is on its way. */
16
+ /** The text shown at the top of the second step, once a code is on its way. */
17
17
  codeSent: string | null;
18
18
  email: string;
19
19
  code: string;
20
20
  newPassword: string;
21
- /** Shown under the new password field. */
21
+ /** The text shown under the new password field. */
22
22
  passwordHint: string | null;
23
23
  requestCode: string;
24
24
  requestingCode: string;
25
25
  setPassword: string;
26
26
  settingPassword: string;
27
- /** Shown when the password is set. Null shows the API's own message, or only the link when it sends none. */
27
+ /** The text shown when the password is set. Null shows the API's own message, or only the link when the API sends none. */
28
28
  done: string | null;
29
- /** Shown for a password the rules refuse. Null lists each rule it breaks. */
29
+ /** The text shown for a password the rules refuse. Null lists each rule it breaks. */
30
30
  passwordInvalid: string | null;
31
31
  somethingWentWrong: string;
32
32
  }
@@ -1,11 +1,12 @@
1
1
  import type { z } from "zod";
2
2
 
3
- /** Picks the text shown for an issue, such as a translated message by the issue's code. */
3
+ /** A function that picks the text shown for an issue, such as a translated message by the issue's code. */
4
4
  export type IssueMessage = (issue: z.core.$ZodIssue) => string;
5
5
 
6
6
  /**
7
- * A failed parse's issues by the top-level field they belong to, for showing beside each input. By
8
- * default each field gets its first issue's message. With `all: true` it gets every message, in order.
7
+ * Groups a failed parse's issues by the top-level field they belong to, for showing beside each
8
+ * input. By default each field gets its first issue's message. With `all: true` it gets every
9
+ * message, in order.
9
10
  * `message` replaces zod's text, such as with the app's own translations. Issues without a field,
10
11
  * from a refinement of the whole object, are left out.
11
12
  */
@@ -6,11 +6,11 @@ export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordProblems } from ".
6
6
  export type { PasswordStrings } from "./passwordRules";
7
7
 
8
8
  /**
9
- * A zod schema for a new password, with the rules the APIs enforce through ASP.NET Identity. Identity
10
- * counts only ASCII letters and digits, so "Å" is neither an uppercase nor a lowercase letter here.
11
- * Length counts UTF-16 code units, as Identity does. There is no upper limit, because Identity has
12
- * none. An app whose API sets one adds it with `.max()`. A failing password gets one issue per rule
13
- * it breaks.
9
+ * Builds a zod schema for a new password, with the rules the APIs enforce through ASP.NET Identity.
10
+ * Identity counts only ASCII letters and digits, so "Å" is neither an uppercase nor a lowercase
11
+ * letter here. Length counts UTF-16 code units, as Identity does. There is no upper limit, because
12
+ * Identity has none. An app whose API sets one adds it with `.max()`. A failing password gets one
13
+ * issue per rule it breaks.
14
14
  */
15
15
  export function passwordSchema(strings?: TextOverrides<PasswordStrings>) {
16
16
  const text = withDefaults(defaultPasswordStrings, strings);
@@ -21,8 +21,8 @@ export const defaultPasswordStrings: PasswordStrings = {
21
21
  };
22
22
 
23
23
  /**
24
- * The message of every password rule the password breaks, in the order `passwordSchema` checks them.
25
- * An empty list means the APIs accept it.
24
+ * Lists the message of every password rule the password breaks, in the order `passwordSchema`
25
+ * checks them. An empty list means the APIs accept it.
26
26
  */
27
27
  export function passwordProblems(password: string, strings?: TextOverrides<PasswordStrings>): string[] {
28
28
  const text = withDefaults(defaultPasswordStrings, strings);