@sjolystinnovation/app-kit 0.8.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
@@ -674,6 +714,9 @@ selector such as `.dark` for a scoped theme:
674
714
  | `--input-focus-ring-width` | `2px` |
675
715
  | `--color-input-toggle` | gray 400, the show password icon |
676
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 |
677
720
  | `--radius-button` | `var(--radius-lg)` |
678
721
  | `--color-button-primary-bg` | `var(--color-primary)` |
679
722
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.8.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",
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";