@sjolystinnovation/app-kit 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -566,6 +566,46 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
566
566
  <TextInput label="Email" name="email" type="email" required error={errors.email} />
567
567
  ```
568
568
 
569
+ ### `TextArea`
570
+
571
+ From `@sjolystinnovation/app-kit/ui`. A labelled multi-line field with the same look, label and
572
+ error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
573
+ or uncontrolled.
574
+
575
+ ```tsx
576
+ <TextArea label="Description" name="description" rows={4} maxRows={10} error={errors.description} />
577
+ ```
578
+
579
+ | Prop | Type | Default | Description |
580
+ | --- | --- | --- | --- |
581
+ | `label` | `string` | | Visible label, tied to the text area. |
582
+ | `error` | `string` | | Message shown under the field, read out with it by screen readers. |
583
+ | `autoGrow` | `boolean` | `true` | Grows with its text from `rows` lines up to `maxRows`, and has no resize handle. With `false` it keeps its size and shows the handle. |
584
+ | `rows` | `number` | `3` | Lines shown when empty. |
585
+ | `maxRows` | `number` | `12` | The most lines an auto-growing box shows before it scrolls. |
586
+ | `className` | `string` | | Classes for placing the field, on the outer element. |
587
+
588
+ ### `Toggle`
589
+
590
+ From `@sjolystinnovation/app-kit/ui`. An on and off switch for a setting that takes effect at once.
591
+ It takes the other `<button>` attributes too.
592
+
593
+ ```tsx
594
+ <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
595
+ <Toggle aria-label="VAT" checked={vat} onChange={setVat} disabled={saving} />
596
+ ```
597
+
598
+ | Prop | Type | Description |
599
+ | --- | --- | --- |
600
+ | `checked` | `boolean` | Whether the switch is on. |
601
+ | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
602
+ | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
603
+ | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
604
+ | `disabled` | `boolean` | Greys the switch out and ignores clicks. |
605
+ | `className` | `string` | Classes for placing it, on the outer element. |
606
+
607
+ An `onClick` that calls `event.preventDefault()` stops the change.
608
+
569
609
  ### `Alert`
570
610
 
571
611
  ```tsx
@@ -613,6 +653,24 @@ toast passes through, and so do the error states of `toast.promise`.
613
653
  import { toast } from "@sjolystinnovation/app-kit/toast";
614
654
  ```
615
655
 
656
+ ### `passwordSchema(strings?)`
657
+
658
+ From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
659
+ enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase letter
660
+ and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and the digits
661
+ 0 to 9, and "Å" does not count as uppercase. Identity sets no upper limit. An app whose API does adds
662
+ it with `.max()`. A failing password gets one issue per rule it breaks, each with its message from
663
+ `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
664
+
665
+ ```ts
666
+ import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
667
+
668
+ const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z." }).safeParse(newPassword);
669
+ const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
670
+ ```
671
+
672
+ `PASSWORD_MIN_LENGTH` holds the minimum, for hint text.
673
+
616
674
  ## Theming
617
675
 
618
676
  The components draw every colour, corner radius and label size from theme variables with a light
@@ -656,6 +714,9 @@ selector such as `.dark` for a scoped theme:
656
714
  | `--input-focus-ring-width` | `2px` |
657
715
  | `--color-input-toggle` | gray 400, the show password icon |
658
716
  | `--color-input-toggle-hover` | gray 600 |
717
+ | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
718
+ | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
719
+ | `--color-toggle-thumb` | white |
659
720
  | `--radius-button` | `var(--radius-lg)` |
660
721
  | `--color-button-primary-bg` | `var(--color-primary)` |
661
722
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -680,6 +741,7 @@ with it and has to set the ones it uses itself.
680
741
 
681
742
  - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
682
743
  app installs itself.
744
+ - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
683
745
  - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
684
746
  copy, because two copies keep two separate toast stores.
685
747
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -24,6 +24,7 @@
24
24
  "./server": "./src/server/index.ts",
25
25
  "./ui": "./src/ui/index.ts",
26
26
  "./toast": "./src/toast/index.ts",
27
+ "./validation": "./src/validation/index.ts",
27
28
  "./styles.css": "./src/styles.css"
28
29
  },
29
30
  "files": [
@@ -44,11 +45,15 @@
44
45
  "next-auth": "^4.24",
45
46
  "react": "^19",
46
47
  "react-dom": "^19",
47
- "sonner": "^2"
48
+ "sonner": "^2",
49
+ "zod": "^4"
48
50
  },
49
51
  "peerDependenciesMeta": {
50
52
  "sonner": {
51
53
  "optional": true
54
+ },
55
+ "zod": {
56
+ "optional": true
52
57
  }
53
58
  },
54
59
  "devDependencies": {
@@ -67,7 +72,8 @@
67
72
  "react-dom": "^19.3.0",
68
73
  "sonner": "^2.0.8",
69
74
  "typescript": "^6.0.3",
70
- "vitest": "^5.0.3"
75
+ "vitest": "^5.0.3",
76
+ "zod": "^4.6.5"
71
77
  },
72
78
  "keywords": [
73
79
  "next.js",
package/src/styles.css CHANGED
@@ -41,6 +41,10 @@
41
41
  --color-input-toggle: var(--color-gray-400);
42
42
  --color-input-toggle-hover: var(--color-gray-600);
43
43
 
44
+ --color-toggle-on: var(--color-primary);
45
+ --color-toggle-off: var(--color-gray-200);
46
+ --color-toggle-thumb: var(--color-white);
47
+
44
48
  --radius-button: var(--radius-lg);
45
49
  --color-button-primary-bg: var(--color-primary);
46
50
  --color-button-primary-text: var(--color-primary-foreground);
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, type TextareaHTMLAttributes } from "react";
4
+ import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
+
6
+ export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
7
+ label: string;
8
+ error?: string | undefined;
9
+ /**
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.
12
+ */
13
+ className?: string | undefined;
14
+ /** Grows with its text, from `rows` lines up to `maxRows` lines. On by default. */
15
+ autoGrow?: boolean | undefined;
16
+ /** The most lines an auto-growing box shows before it scrolls. Defaults to 12. */
17
+ maxRows?: number | undefined;
18
+ }
19
+
20
+ export function TextArea({ label, error, className, autoGrow = true, maxRows = 12, rows = 3, onInput, ...props }: TextAreaProps) {
21
+ const field = useField(props, error);
22
+ const ref = useRef<HTMLTextAreaElement>(null);
23
+
24
+ const resize = useCallback(() => {
25
+ const el = ref.current;
26
+ if (!el || !autoGrow) return;
27
+ const style = getComputedStyle(el);
28
+ const lineHeight = parseFloat(style.lineHeight) || 20;
29
+ const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
30
+ // scrollHeight covers the content and padding. A border-box height also needs the border.
31
+ const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
32
+ const boxFor = (lines: number) => lineHeight * lines + padding + border;
33
+ el.style.height = "auto";
34
+ el.style.height = `${Math.min(Math.max(el.scrollHeight + border, boxFor(Number(rows))), boxFor(maxRows))}px`;
35
+ }, [autoGrow, rows, maxRows]);
36
+
37
+ // A controlled box grows when its value changes. An uncontrolled one grows as it is typed in.
38
+ useEffect(resize, [resize, props.value]);
39
+
40
+ const handleInput = (event: Parameters<NonNullable<TextAreaProps["onInput"]>>[0]) => {
41
+ onInput?.(event);
42
+ resize();
43
+ };
44
+
45
+ return (
46
+ <div className={className}>
47
+ <FieldLabel htmlFor={field.inputId} required={props.required}>
48
+ {label}
49
+ </FieldLabel>
50
+ <textarea
51
+ {...props}
52
+ {...field.inputProps}
53
+ ref={ref}
54
+ rows={rows}
55
+ onInput={handleInput}
56
+ // An auto-growing box has no resize handle. The next keystroke would undo the drag.
57
+ className={inputClass(error, autoGrow ? "leading-relaxed resize-none" : "leading-relaxed resize-y")}
58
+ />
59
+ <FieldError id={field.errorId} error={error} />
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import type { ButtonHTMLAttributes, MouseEvent } from "react";
4
+ import { cx } from "./cx";
5
+
6
+ type ToggleBase = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "role" | "type" | "aria-checked"> & {
7
+ checked: boolean;
8
+ /** Called with the new state when the switch is flipped. */
9
+ onChange: (checked: boolean) => void;
10
+ };
11
+
12
+ /** A switch needs a name. Either a visible `label`, or an `aria-label` when there is none. */
13
+ export type ToggleProps = ToggleBase & ({ label: string } | { label?: undefined; "aria-label": string });
14
+
15
+ /** An on and off switch, for a setting that takes effect at once. */
16
+ export function Toggle({ checked, onChange, label, className, disabled, onClick, ...props }: ToggleProps) {
17
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
18
+ onClick?.(event);
19
+ if (!event.defaultPrevented) onChange(!checked);
20
+ };
21
+
22
+ const button = (
23
+ <button
24
+ {...props}
25
+ type="button"
26
+ role="switch"
27
+ aria-checked={checked}
28
+ disabled={disabled}
29
+ onClick={handleClick}
30
+ className={cx(
31
+ "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-hidden focus-visible:ring-(length:--input-focus-ring-width) focus-visible:ring-input-focus-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
32
+ checked ? "bg-toggle-on" : "bg-toggle-off",
33
+ label === undefined && className,
34
+ )}
35
+ >
36
+ <span
37
+ aria-hidden="true"
38
+ className={cx(
39
+ "inline-block h-5 w-5 rounded-full bg-toggle-thumb shadow-sm transition-transform motion-reduce:transition-none",
40
+ checked ? "translate-x-5" : "translate-x-0.5",
41
+ )}
42
+ />
43
+ </button>
44
+ );
45
+
46
+ if (label === undefined) return button;
47
+
48
+ // The label wraps the switch, so the text names it and a click on the text flips it.
49
+ return (
50
+ <label className={cx("inline-flex items-center gap-2.5 text-field-label font-medium text-field-label-text select-none", disabled ? "cursor-not-allowed" : "cursor-pointer", className)}>
51
+ {button}
52
+ {label}
53
+ </label>
54
+ );
55
+ }
package/src/ui/field.tsx CHANGED
@@ -1,10 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { useId, type InputHTMLAttributes, type ReactNode } from "react";
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. */
7
+ export type FieldControlProps = Pick<AriaAttributes, "aria-required" | "aria-invalid" | "aria-describedby"> & {
8
+ id?: string | undefined;
9
+ required?: boolean | undefined;
10
+ };
11
+
6
12
  /** The ids and aria attributes a labelled input needs, from the caller's own props. */
7
- export function useField(props: Pick<InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "aria-required" | "aria-invalid" | "aria-describedby">, error: string | undefined) {
13
+ export function useField(props: FieldControlProps, error: string | undefined) {
8
14
  // A generated id ties the label to its own control, even when two fields on a page share a name.
9
15
  const generated = useId();
10
16
  const inputId = props.id ?? generated;
package/src/ui/index.ts CHANGED
@@ -6,6 +6,10 @@ export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsForm
6
6
  export type { CredentialsFormStrings } from "./credentialsFormStrings";
7
7
  export { PasswordInput } from "./PasswordInput";
8
8
  export type { PasswordInputProps } from "./PasswordInput";
9
+ export { TextArea } from "./TextArea";
10
+ export type { TextAreaProps } from "./TextArea";
9
11
  export { TextInput } from "./TextInput";
10
12
  export type { TextInputProps } from "./TextInput";
13
+ export { Toggle } from "./Toggle";
14
+ export type { ToggleProps } from "./Toggle";
11
15
  export type { TextOverrides } from "./withDefaults";
@@ -0,0 +1,2 @@
1
+ export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordSchema } from "./password";
2
+ export type { PasswordStrings } from "./password";
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
3
+
4
+ /** The fewest characters a password may have. */
5
+ export const PASSWORD_MIN_LENGTH = 8;
6
+
7
+ /** The message for each password rule that fails. */
8
+ export interface PasswordStrings {
9
+ tooShort: string;
10
+ lowercase: string;
11
+ uppercase: string;
12
+ digit: string;
13
+ }
14
+
15
+ export const defaultPasswordStrings: PasswordStrings = {
16
+ tooShort: `Use at least ${PASSWORD_MIN_LENGTH} characters.`,
17
+ lowercase: "Include a lowercase letter from a to z.",
18
+ uppercase: "Include an uppercase letter from A to Z.",
19
+ digit: "Include a digit from 0 to 9.",
20
+ };
21
+
22
+ /**
23
+ * A zod schema for a new password, with the rules the APIs enforce through ASP.NET Identity. Identity
24
+ * counts only ASCII letters and digits, so "Å" is neither an uppercase nor a lowercase letter here.
25
+ * Length counts UTF-16 code units, as Identity does. There is no upper limit, because Identity has
26
+ * none. An app whose API sets one adds it with `.max()`. A failing password gets one issue per rule
27
+ * it breaks.
28
+ */
29
+ export function passwordSchema(strings?: TextOverrides<PasswordStrings>) {
30
+ const text = withDefaults(defaultPasswordStrings, strings);
31
+ return z
32
+ .string()
33
+ .min(PASSWORD_MIN_LENGTH, text.tooShort)
34
+ .regex(/[a-z]/, text.lowercase)
35
+ .regex(/[A-Z]/, text.uppercase)
36
+ .regex(/[0-9]/, text.digit);
37
+ }