shelving 1.244.0 → 1.245.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.244.0",
3
+ "version": "1.245.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,7 +4,7 @@ import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
4
4
  *
5
5
  * @see https://dhoulb.github.io/shelving/schema/ColorSchema/ColorSchemaOptions
6
6
  */
7
- export interface ColorSchemaOptions extends Omit<StringSchemaOptions, "type" | "min" | "max" | "match" | "rows"> {
7
+ export interface ColorSchemaOptions extends Omit<StringSchemaOptions, "min" | "match" | "rows"> {
8
8
  }
9
9
  /**
10
10
  * Schema that defines a valid color hex string, e.g. `#00CCFF`
@@ -19,12 +19,14 @@ export declare class ColorSchema extends StringSchema {
19
19
  /**
20
20
  * Create a new `ColorSchema`.
21
21
  *
22
- * @param options Options for the schema (inherits `StringSchema` options except `type`, `min`, `max`, `match`, and `rows`, which are fixed for hex colors).
22
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for hex colors).
23
23
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"color"`).
24
24
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Color"`).
25
25
  * @param options.value Default hex value used when the input is `undefined` (defaults to `"#000000"`).
26
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"color"`).
27
+ * @param options.max Maximum allowed character length (defaults to `7`).
26
28
  */
27
- constructor({ one, title, value, ...options }: ColorSchemaOptions);
29
+ constructor({ one, title, value, input, max, ...options }: ColorSchemaOptions);
28
30
  /**
29
31
  * Sanitize the string into a `#RRGGBB` hex color.
30
32
  *
@@ -15,20 +15,22 @@ export class ColorSchema extends StringSchema {
15
15
  /**
16
16
  * Create a new `ColorSchema`.
17
17
  *
18
- * @param options Options for the schema (inherits `StringSchema` options except `type`, `min`, `max`, `match`, and `rows`, which are fixed for hex colors).
18
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for hex colors).
19
19
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"color"`).
20
20
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Color"`).
21
21
  * @param options.value Default hex value used when the input is `undefined` (defaults to `"#000000"`).
22
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"color"`).
23
+ * @param options.max Maximum allowed character length (defaults to `7`).
22
24
  */
23
- constructor({ one = "color", title = "Color", value = "#000000", ...options }) {
25
+ constructor({ one = "color", title = "Color", value = "#000000", input = "color", max = 7, ...options }) {
24
26
  super({
25
27
  one,
26
28
  title,
27
29
  value,
28
30
  ...options,
29
- input: "color",
31
+ input,
32
+ max,
30
33
  min: 1,
31
- max: 7,
32
34
  rows: 1,
33
35
  match: COLOR_REGEXP,
34
36
  });
@@ -9,7 +9,7 @@ import { StringSchema } from "./StringSchema.js";
9
9
  *
10
10
  * @see https://dhoulb.github.io/shelving/schema/CurrencyCodeSchema/CurrencyCodeSchemaOptions
11
11
  */
12
- export interface CurrencyCodeSchemaOptions extends Omit<StringSchemaOptions, "input" | "min" | "max" | "match" | "rows"> {
12
+ export interface CurrencyCodeSchemaOptions extends Omit<StringSchemaOptions, "min" | "match" | "rows"> {
13
13
  currencies?: ImmutableArray<CurrencyCode>;
14
14
  }
15
15
  /**
@@ -32,11 +32,11 @@ export declare class CurrencyCodeSchema extends StringSchema {
32
32
  /**
33
33
  * Create a new `CurrencyCodeSchema`.
34
34
  *
35
- * @param options Options for the schema (`currencies`, plus base `StringSchemaOptions` except `input`/`min`/`max`/`match`/`rows`).
35
+ * @param options Options for the schema (`currencies`, plus base `StringSchemaOptions` except `min`/`match`/`rows`).
36
36
  * @example new CurrencyCodeSchema({ currencies: ["GBP", "USD"] })
37
37
  * @see https://dhoulb.github.io/shelving/schema/CurrencyCodeSchema/CurrencyCodeSchema
38
38
  */
39
- constructor({ one, title, currencies, ...options }: CurrencyCodeSchemaOptions);
39
+ constructor({ one, title, currencies, max, ...options }: CurrencyCodeSchemaOptions);
40
40
  /**
41
41
  * Sanitize an input string down to uppercase `A-Z` letters.
42
42
  *
@@ -21,17 +21,17 @@ export class CurrencyCodeSchema extends StringSchema {
21
21
  /**
22
22
  * Create a new `CurrencyCodeSchema`.
23
23
  *
24
- * @param options Options for the schema (`currencies`, plus base `StringSchemaOptions` except `input`/`min`/`max`/`match`/`rows`).
24
+ * @param options Options for the schema (`currencies`, plus base `StringSchemaOptions` except `min`/`match`/`rows`).
25
25
  * @example new CurrencyCodeSchema({ currencies: ["GBP", "USD"] })
26
26
  * @see https://dhoulb.github.io/shelving/schema/CurrencyCodeSchema/CurrencyCodeSchema
27
27
  */
28
- constructor({ one = "currency", title = "Currency", currencies = CURRENCY_CODES, ...options }) {
28
+ constructor({ one = "currency", title = "Currency", currencies = CURRENCY_CODES, max = 3, ...options }) {
29
29
  super({
30
30
  one,
31
31
  title,
32
32
  ...options,
33
+ max, // Valid currency code is 3 uppercase letters.
33
34
  min: 3,
34
- max: 3, // Valid currency code is 3 uppercase letters.
35
35
  rows: 1,
36
36
  case: "upper",
37
37
  match: /^[A-Z]{3}$/, // Valid currency code is 3 uppercase letters.
@@ -1,5 +1,12 @@
1
1
  import type { StringSchemaOptions } from "./StringSchema.js";
2
2
  import { StringSchema } from "./StringSchema.js";
3
+ /**
4
+ * Options for an `EmailSchema`.
5
+ *
6
+ * @see https://dhoulb.github.io/shelving/schema/EmailSchema/EmailSchemaOptions
7
+ */
8
+ export interface EmailSchemaOptions extends Omit<StringSchemaOptions, "min" | "match" | "rows"> {
9
+ }
3
10
  /**
4
11
  * Schema that defines a valid email address.
5
12
  *
@@ -24,11 +31,13 @@ export declare class EmailSchema extends StringSchema {
24
31
  /**
25
32
  * Create a new `EmailSchema`.
26
33
  *
27
- * @param options Options for the schema (inherits `StringSchema` options except `type`, `min`, `max`, `match`, and `rows`, which are fixed for emails).
34
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for emails).
28
35
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"email address"`).
29
36
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Email"`).
37
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"email"`).
38
+ * @param options.max Maximum allowed character length (defaults to `254`).
30
39
  */
31
- constructor({ one, title, ...options }: Omit<StringSchemaOptions, "type" | "min" | "max" | "match" | "rows">);
40
+ constructor({ one, title, input, max, ...options }: EmailSchemaOptions);
32
41
  /**
33
42
  * Sanitize the string into a valid email address.
34
43
  *
@@ -26,18 +26,20 @@ export class EmailSchema extends StringSchema {
26
26
  /**
27
27
  * Create a new `EmailSchema`.
28
28
  *
29
- * @param options Options for the schema (inherits `StringSchema` options except `type`, `min`, `max`, `match`, and `rows`, which are fixed for emails).
29
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for emails).
30
30
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"email address"`).
31
31
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Email"`).
32
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"email"`).
33
+ * @param options.max Maximum allowed character length (defaults to `254`).
32
34
  */
33
- constructor({ one = "email address", title = "Email", ...options }) {
35
+ constructor({ one = "email address", title = "Email", input = "email", max = 254, ...options }) {
34
36
  super({
35
37
  one,
36
38
  title,
37
39
  ...options,
38
- input: "email",
40
+ input,
41
+ max,
39
42
  min: 1,
40
- max: 254,
41
43
  rows: 1,
42
44
  match: R_MATCH,
43
45
  });
@@ -1,15 +1,8 @@
1
1
  import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
2
- /**
3
- * Options for a `PasswordSchema`.
4
- *
5
- * @see https://dhoulb.github.io/shelving/schema/PasswordSchema/PasswordSchemaOptions
6
- */
7
- export interface PasswordSchemaOptions extends Omit<StringSchemaOptions, "input"> {
8
- }
9
2
  /**
10
3
  * Schema that defines a valid password string.
11
4
  *
12
- * - Forces the `<input />` hint to `"password"` for downstream UIs.
5
+ * - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
13
6
  * - Never formats the value for display (`format()` always returns `""`).
14
7
  *
15
8
  * @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
@@ -19,12 +12,13 @@ export declare class PasswordSchema extends StringSchema {
19
12
  /**
20
13
  * Create a new `PasswordSchema`.
21
14
  *
22
- * @param options Options for the schema (inherits `StringSchema` options except `input`, which is fixed to `"password"`).
15
+ * @param options Options for the schema (inherits all `StringSchema` options).
23
16
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"password"`).
24
17
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Password"`).
25
18
  * @param options.min Minimum allowed character length (defaults to `6`).
19
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"password"`).
26
20
  */
27
- constructor({ one, title, min, ...options }?: PasswordSchemaOptions);
21
+ constructor({ one, title, min, input, ...options }?: StringSchemaOptions);
28
22
  /**
29
23
  * Format a password value for display (always returns `""`).
30
24
  *
@@ -37,9 +31,9 @@ export declare class PasswordSchema extends StringSchema {
37
31
  format(): string;
38
32
  }
39
33
  /**
40
- * Sugar instance of [`StringSchema`](/schema/StringSchema) for a password string. Equivalent to `new StringSchema({})`.
34
+ * Sugar instance of [`PasswordSchema`](/schema/PasswordSchema) for a password string. Equivalent to `new PasswordSchema({})`.
41
35
  *
42
36
  * @example PASSWORD.validate("hunter2"); // Returns "hunter2"
43
37
  * @see https://dhoulb.github.io/shelving/schema/PasswordSchema/PASSWORD
44
38
  */
45
- export declare const PASSWORD: StringSchema;
39
+ export declare const PASSWORD: PasswordSchema;
@@ -2,7 +2,7 @@ import { StringSchema } from "./StringSchema.js";
2
2
  /**
3
3
  * Schema that defines a valid password string.
4
4
  *
5
- * - Forces the `<input />` hint to `"password"` for downstream UIs.
5
+ * - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
6
6
  * - Never formats the value for display (`format()` always returns `""`).
7
7
  *
8
8
  * @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
@@ -12,13 +12,14 @@ export class PasswordSchema extends StringSchema {
12
12
  /**
13
13
  * Create a new `PasswordSchema`.
14
14
  *
15
- * @param options Options for the schema (inherits `StringSchema` options except `input`, which is fixed to `"password"`).
15
+ * @param options Options for the schema (inherits all `StringSchema` options).
16
16
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"password"`).
17
17
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Password"`).
18
18
  * @param options.min Minimum allowed character length (defaults to `6`).
19
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"password"`).
19
20
  */
20
- constructor({ one = "password", title = "Password", min = 6, ...options } = {}) {
21
- super({ one, title, min, ...options, input: "password" });
21
+ constructor({ one = "password", title = "Password", min = 6, input = "password", ...options } = {}) {
22
+ super({ one, title, min, input, ...options });
22
23
  }
23
24
  /**
24
25
  * Format a password value for display (always returns `""`).
@@ -34,9 +35,9 @@ export class PasswordSchema extends StringSchema {
34
35
  }
35
36
  }
36
37
  /**
37
- * Sugar instance of [`StringSchema`](/schema/StringSchema) for a password string. Equivalent to `new StringSchema({})`.
38
+ * Sugar instance of [`PasswordSchema`](/schema/PasswordSchema) for a password string. Equivalent to `new PasswordSchema({})`.
38
39
  *
39
40
  * @example PASSWORD.validate("hunter2"); // Returns "hunter2"
40
41
  * @see https://dhoulb.github.io/shelving/schema/PasswordSchema/PASSWORD
41
42
  */
42
- export const PASSWORD = new StringSchema({});
43
+ export const PASSWORD = new PasswordSchema({});
@@ -5,7 +5,7 @@ import { StringSchema } from "./StringSchema.js";
5
5
  *
6
6
  * @see https://dhoulb.github.io/shelving/schema/PhoneSchema/PhoneSchemaOptions
7
7
  */
8
- export interface PhoneSchemaOptions extends Omit<StringSchemaOptions, "input" | "min" | "max" | "match" | "rows"> {
8
+ export interface PhoneSchemaOptions extends Omit<StringSchemaOptions, "min" | "match" | "rows"> {
9
9
  }
10
10
  /**
11
11
  * Schema that defines a valid phone number.
@@ -20,11 +20,13 @@ export declare class PhoneSchema extends StringSchema {
20
20
  /**
21
21
  * Create a new `PhoneSchema`.
22
22
  *
23
- * @param options Options for the schema (inherits `StringSchema` options except `input`, `min`, `max`, `match`, and `rows`, which are fixed for phone numbers).
23
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for phone numbers).
24
24
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"phone number"`).
25
25
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Phone"`).
26
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"tel"`).
27
+ * @param options.max Maximum allowed character length (defaults to `16`).
26
28
  */
27
- constructor({ one, title, ...options }: PhoneSchemaOptions);
29
+ constructor({ one, title, input, max, ...options }: PhoneSchemaOptions);
28
30
  /**
29
31
  * Sanitize the string into a valid E.164 phone number.
30
32
  *
@@ -13,19 +13,21 @@ export class PhoneSchema extends StringSchema {
13
13
  /**
14
14
  * Create a new `PhoneSchema`.
15
15
  *
16
- * @param options Options for the schema (inherits `StringSchema` options except `input`, `min`, `max`, `match`, and `rows`, which are fixed for phone numbers).
16
+ * @param options Options for the schema (inherits `StringSchema` options except `min`, `match`, and `rows`, which are fixed for phone numbers).
17
17
  * @param options.one Singular noun describing one value, used in error messages (defaults to `"phone number"`).
18
18
  * @param options.title Title of the schema, e.g. for a corresponding field (defaults to `"Phone"`).
19
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"tel"`).
20
+ * @param options.max Maximum allowed character length (defaults to `16`).
19
21
  */
20
- constructor({ one = "phone number", title = "Phone", ...options }) {
22
+ constructor({ one = "phone number", title = "Phone", input = "tel", max = 16, ...options }) {
21
23
  super({
22
24
  one,
23
25
  title,
24
26
  ...options,
25
- input: "tel",
26
- min: 1,
27
+ input,
27
28
  // Valid phone number is 16 digits or fewer (15 numerals with a leading `+` plus).
28
- max: 16,
29
+ max,
30
+ min: 1,
29
31
  rows: 1,
30
32
  // Valid phone number is max 16 digits made up of:
31
33
  // - Country code (`+` plus character and 1-3 digits, e.g. `+44` or `+1`).
@@ -1,4 +1,11 @@
1
1
  import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
2
+ /**
3
+ * Options for a `SlugSchema`.
4
+ *
5
+ * @see https://dhoulb.github.io/shelving/schema/SlugSchema/SlugSchemaOptions
6
+ */
7
+ export interface SlugSchemaOptions extends Omit<StringSchemaOptions, "min" | "rows"> {
8
+ }
2
9
  /**
3
10
  * Schema that defines a valid slug, e.g. `this-is-a-slug`.
4
11
  *
@@ -17,7 +24,7 @@ export declare class SlugSchema extends StringSchema {
17
24
  *
18
25
  * @param options Options for the schema (inherited string options like `one`, `title`, `value`).
19
26
  */
20
- constructor(options: Omit<StringSchemaOptions, "min" | "max" | "rows">);
27
+ constructor({ max, ...options }: SlugSchemaOptions);
21
28
  /**
22
29
  * Sanitize a string before validation by converting it into a slug.
23
30
  *
@@ -19,11 +19,11 @@ export class SlugSchema extends StringSchema {
19
19
  *
20
20
  * @param options Options for the schema (inherited string options like `one`, `title`, `value`).
21
21
  */
22
- constructor(options) {
22
+ constructor({ max = 32, ...options }) {
23
23
  super({
24
24
  ...options,
25
+ max,
25
26
  min: 1,
26
- max: 32,
27
27
  rows: 1,
28
28
  });
29
29
  }
@@ -8,7 +8,7 @@ import { StringSchema } from "./StringSchema.js";
8
8
  *
9
9
  * @see https://dhoulb.github.io/shelving/schema/URISchema/URISchemaOptions
10
10
  */
11
- export interface URISchemaOptions extends Omit<StringSchemaOptions, "input" | "min" | "max" | "rows"> {
11
+ export interface URISchemaOptions extends Omit<StringSchemaOptions, "min" | "rows"> {
12
12
  readonly schemes?: URISchemes | undefined;
13
13
  }
14
14
  /**
@@ -28,9 +28,11 @@ export declare class URISchema extends StringSchema {
28
28
  /**
29
29
  * Create a new `URISchema`.
30
30
  *
31
- * @param options Options for the schema (`schemes`, plus inherited string options like `one`, `title`, `value`).
31
+ * @param options Options for the schema (`schemes`, plus inherited string options like `one`, `title`, `value`, `input`, `max`).
32
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"url"`).
33
+ * @param options.max Maximum allowed character length (defaults to `512`).
32
34
  */
33
- constructor({ one, title, schemes, ...options }: URISchemaOptions);
35
+ constructor({ one, title, schemes, input, max, ...options }: URISchemaOptions);
34
36
  /**
35
37
  * Validate an unknown input value and return a normalised absolute URI string.
36
38
  *
@@ -20,16 +20,18 @@ export class URISchema extends StringSchema {
20
20
  /**
21
21
  * Create a new `URISchema`.
22
22
  *
23
- * @param options Options for the schema (`schemes`, plus inherited string options like `one`, `title`, `value`).
23
+ * @param options Options for the schema (`schemes`, plus inherited string options like `one`, `title`, `value`, `input`, `max`).
24
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"url"`).
25
+ * @param options.max Maximum allowed character length (defaults to `512`).
24
26
  */
25
- constructor({ one = "URI", title = "URI", schemes = HTTP_SCHEMES, ...options }) {
27
+ constructor({ one = "URI", title = "URI", schemes = HTTP_SCHEMES, input = "url", max = 512, ...options }) {
26
28
  super({
27
29
  one,
28
30
  title,
29
31
  ...options,
30
- input: "url",
32
+ input,
33
+ max,
31
34
  min: 1,
32
- max: 512,
33
35
  rows: 1,
34
36
  });
35
37
  this.schemes = schemes;
@@ -10,7 +10,7 @@ import { StringSchema } from "./StringSchema.js";
10
10
  *
11
11
  * @see https://dhoulb.github.io/shelving/schema/URLSchema/URLSchemaOptions
12
12
  */
13
- export interface URLSchemaOptions extends Omit<StringSchemaOptions, "input" | "min" | "max" | "rows"> {
13
+ export interface URLSchemaOptions extends Omit<StringSchemaOptions, "min" | "rows"> {
14
14
  readonly base?: URL | URLString | undefined;
15
15
  readonly schemes?: URISchemes | undefined;
16
16
  }
@@ -33,9 +33,11 @@ export declare class URLSchema extends StringSchema {
33
33
  /**
34
34
  * Create a new `URLSchema`.
35
35
  *
36
- * @param options Options for the schema (`base`, `schemes`, plus inherited string options like `one`, `title`, `value`).
36
+ * @param options Options for the schema (`base`, `schemes`, plus inherited string options like `one`, `title`, `value`, `input`, `max`).
37
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"url"`).
38
+ * @param options.max Maximum allowed character length (defaults to `512`).
37
39
  */
38
- constructor({ one, title, base, schemes, ...options }: URLSchemaOptions);
40
+ constructor({ one, title, base, schemes, input, max, ...options }: URLSchemaOptions);
39
41
  /**
40
42
  * Validate an unknown input value and return a normalised absolute URL string.
41
43
  *
@@ -23,16 +23,18 @@ export class URLSchema extends StringSchema {
23
23
  /**
24
24
  * Create a new `URLSchema`.
25
25
  *
26
- * @param options Options for the schema (`base`, `schemes`, plus inherited string options like `one`, `title`, `value`).
26
+ * @param options Options for the schema (`base`, `schemes`, plus inherited string options like `one`, `title`, `value`, `input`, `max`).
27
+ * @param options.input HTML `<input />` `type=""` hint (defaults to `"url"`).
28
+ * @param options.max Maximum allowed character length (defaults to `512`).
27
29
  */
28
- constructor({ one = "URL", title = "URL", base, schemes = HTTP_SCHEMES, ...options }) {
30
+ constructor({ one = "URL", title = "URL", base, schemes = HTTP_SCHEMES, input = "url", max = 512, ...options }) {
29
31
  super({
30
32
  one,
31
33
  title,
32
34
  ...options,
33
- input: "url",
35
+ input,
36
+ max,
34
37
  min: 1,
35
- max: 512,
36
38
  rows: 1,
37
39
  });
38
40
  this.base = getURL(base)?.href;
@@ -1,4 +1,11 @@
1
1
  import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
2
+ /**
3
+ * Options for a `UUIDSchema`.
4
+ *
5
+ * @see https://dhoulb.github.io/shelving/schema/UUIDSchema/UUIDSchemaOptions
6
+ */
7
+ export interface UUIDSchemaOptions extends Omit<StringSchemaOptions, "min" | "match" | "rows"> {
8
+ }
2
9
  /**
3
10
  * Schema that defines a valid UUID string (versions 1-5). Defaults to any-version validation.
4
11
  *
@@ -14,9 +21,10 @@ export declare class UUIDSchema extends StringSchema {
14
21
  /**
15
22
  * Create a new `UUIDSchema`.
16
23
  *
17
- * @param options Options for the schema (inherited string options like `one`, `title`, `value`).
24
+ * @param options Options for the schema (inherited string options like `one`, `title`, `value`, `input`, `max`).
25
+ * @param options.max Maximum allowed character length (defaults to `36`).
18
26
  */
19
- constructor({ one, title, ...rest }?: Omit<StringSchemaOptions, "input" | "min" | "max" | "match" | "rows">);
27
+ constructor({ one, title, max, ...rest }?: UUIDSchemaOptions);
20
28
  /**
21
29
  * Sanitize a string before validation by normalising it into a canonical UUID.
22
30
  *
@@ -16,15 +16,16 @@ export class UUIDSchema extends StringSchema {
16
16
  /**
17
17
  * Create a new `UUIDSchema`.
18
18
  *
19
- * @param options Options for the schema (inherited string options like `one`, `title`, `value`).
19
+ * @param options Options for the schema (inherited string options like `one`, `title`, `value`, `input`, `max`).
20
+ * @param options.max Maximum allowed character length (defaults to `36`).
20
21
  */
21
- constructor({ one = "UUID", title = "UUID", ...rest } = {}) {
22
+ constructor({ one = "UUID", title = "UUID", max = 36, ...rest } = {}) {
22
23
  super({
23
24
  one,
24
25
  title,
25
26
  ...rest,
27
+ max, // 36 chars including hyphens (which get stripped by sanitize for appearances).
26
28
  min: 32,
27
- max: 36, // 36 chars including hyphens (which get stripped by sanitize for appearances).
28
29
  rows: 1,
29
30
  });
30
31
  }
package/ui/README.md CHANGED
@@ -11,7 +11,7 @@ The `ui` module exists so an app never hand-rolls the same form field, card, or
11
11
  A few conventions run through every component:
12
12
 
13
13
  - **Styling props are for one-off overrides.** Visual options are props on the component — enumerated props for the scales (`color="red"`, `size="large"`, `space="none"`, `width="narrow"`) and boolean props for on/off variants (`<Button strong>`, `<Title center>`, `<Flex wrap>`). Each maps to a class in a CSS Module. Reach for them when a component needs to look different in *one place* — the way the docs site tints its accents purple — not as the way to dress a whole app. You never pass `style` or raw `className`.
14
- - **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [`Card`](/ui/Card), [`Section`](/ui/Section), [`Button`](/ui/Button), and [`Tag`](/ui/Tag) rather than shipping their own styling.
14
+ - **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), [`<Button>`](/ui/Button), and [`<Tag>`](/ui/Tag) rather than shipping their own styling.
15
15
  - **Sentence case.** Titles, headings, and button labels capitalise only the first word.
16
16
  - **Theme with CSS.** An app-wide custom look is a CSS file, not a wall of props. Write a `theme.css` that overrides the base design-token variables (and, where needed, per-component hooks) at `:root`, and import it after the library styles. The recommended workflow is to spend time tuning those variables to match your design — see [Theming](#theming) below.
17
17
 
@@ -19,15 +19,15 @@ A few conventions run through every component:
19
19
 
20
20
  The styling system lives in `style/` and has four moving parts: design tokens, the tint scale, cascade layers, and the styling props. Components compose them in a predictable shape; consumers theme by overriding CSS custom properties at `:root`.
21
21
 
22
- **Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass`](/ui/getColorClass)), font sizes ([`getSizeClass`](/ui/getSizeClass)), font weights ([`getWeightClass`](/ui/getWeightClass)), font faces ([`getFontClass`](/ui/getFontClass)), spacing ([`getSpaceClass`](/ui/getSpaceClass)), widths ([`getWidthClass`](/ui/getWidthClass)), radii ([`getRadiusClass`](/ui/getRadiusClass)), strokes ([`getStrokeClass`](/ui/getStrokeClass)), shadows ([`getShadowClass`](/ui/getShadowClass)), and durations ([`getDurationClass`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
22
+ **Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass()`](/ui/getColorClass)), font sizes ([`getSizeClass()`](/ui/getSizeClass)), font weights ([`getWeightClass()`](/ui/getWeightClass)), font faces ([`getFontClass()`](/ui/getFontClass)), spacing ([`getSpaceClass()`](/ui/getSpaceClass)), widths ([`getWidthClass()`](/ui/getWidthClass)), radii ([`getRadiusClass()`](/ui/getRadiusClass)), strokes ([`getStrokeClass()`](/ui/getStrokeClass)), shadows ([`getShadowClass()`](/ui/getShadowClass)), and durations ([`getDurationClass()`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
23
23
 
24
- **The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the [`TINT_CLASS`](/ui/TINT_CLASS) page.
24
+ **The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the `TINT_CLASS` page.
25
25
 
26
26
  **Cascade layers.** Styles are ordered by `@layer`, lowest to highest priority: `defaults` (`:root` tokens, the tint ladder, body baseline) → `components` (the bulk of the CSS: `.card`, `.button`, …) → `variants` (cross-cutting opt-in modifiers, which always beat components) → `overrides` (top-priority structural fixes like `:first-child` / `:last-child` margin collapses). Unlayered rules beat all layered rules, so a theme should set tokens at `:root` or wrap its rules in `@layer`.
27
27
 
28
- **Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor — [`getColorClass`](/ui/getColorClass) and [`getStatusClass`](/ui/getStatusClass); font size, weight, and family come from [`getSizeClass`](/ui/getSizeClass), [`getWeightClass`](/ui/getWeightClass), and [`getFontClass`](/ui/getFontClass), which [`getTypographyClass`](/ui/getTypographyClass) combines with text alignment and tint; spacing, padding, and gap from [`getSpaceClass`](/ui/getSpaceClass), [`getPaddingClass`](/ui/getPaddingClass), and [`getGapClass`](/ui/getGapClass); width constraints from [`getWidthClass`](/ui/getWidthClass); flex layout from [`getFlexClass`](/ui/getFlexClass); and opt-in scrolling from [`getScrollClass`](/ui/getScrollClass). Each helper's page lists its exact prop values and what they set. A component opts into the props it wants by extending the matching `*Props` interfaces and composing the `getXxxClass(props)` calls.
28
+ **Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor — `getColorClass()` and [`getStatusClass()`](/ui/getStatusClass); font size, weight, and family come from `getSizeClass()`, `getWeightClass()`, and `getFontClass()`, which [`getTypographyClass()`](/ui/getTypographyClass) combines with text alignment and tint; spacing, padding, and gap from `getSpaceClass()`, [`getPaddingClass()`](/ui/getPaddingClass), and [`getGapClass()`](/ui/getGapClass); width constraints from `getWidthClass()`; flex layout from [`getFlexClass()`](/ui/getFlexClass); and opt-in scrolling from [`getScrollClass()`](/ui/getScrollClass). Each helper's page lists its exact prop values and what they set. A component opts into the props it wants by extending the matching `*Props` interfaces and composing the `getXxxClass(props)` calls.
29
29
 
30
- Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [`Card`](/ui/Card) for the precedent).
30
+ Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [`<Card>`](/ui/Card) for the precedent).
31
31
 
32
32
  ## Theming
33
33
 
@@ -45,10 +45,10 @@ The recommended way to give an app its own look is a **theme stylesheet**, not s
45
45
 
46
46
  Each base token lives in a themed module that documents the variables it defines and which ones a theme usually overrides. Work from broadest (a palette colour or scale root) to narrowest (a single semantic alias):
47
47
 
48
- - [weight](/ui/getWeightClass) · [size](/ui/getSizeClass) · [font](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
49
- - [color](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
50
- - [space](/ui/getSpaceClass) · [width](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
51
- - [radius](/ui/getRadiusClass) · [stroke](/ui/getStrokeClass) · [shadow](/ui/getShadowClass) · [duration](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
48
+ - [`weight`](/ui/getWeightClass) · [`size`](/ui/getSizeClass) · [`font`](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
49
+ - [`color`](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
50
+ - [`space`](/ui/getSpaceClass) · [`width`](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
51
+ - [`radius`](/ui/getRadiusClass) · [`stroke`](/ui/getStrokeClass) · [`shadow`](/ui/getShadowClass) · [`duration`](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
52
52
 
53
53
  The **tint ladder** is the one exception that doesn't follow the override-a-variable pattern: its 21 steps are *recomputed* from a single anchor inside every tinted scope, so you move the anchor rather than overriding individual steps. See [`TINT_CLASS`](/ui/TINT_CLASS) for the full theming guide, and each component's **Styling** section for its per-component hooks.
54
54
 
@@ -56,19 +56,19 @@ The **tint ladder** is the one exception that doesn't follow the override-a-vari
56
56
 
57
57
  The components below are listed in the index following this page; this is the short version of where to start reading.
58
58
 
59
- **Content.** Block-level structure starts with [`Card`](/ui/Card), [`Section`](/ui/Section), and the [`Heading`](/ui/Heading) / [`Title`](/ui/Title) family, with [`Table`](/ui/Table), [`List`](/ui/List), and [`Figure`](/ui/Figure) for specific shapes; wrap longform copy in [`Prose`](/ui/Prose). Inline pieces — [`Link`](/ui/Link), [`Code`](/ui/Code), [`Strong`](/ui/Strong), [`Mark`](/ui/Mark) — live inside that block content. To render a Markdown string as components, use [`Markup`](/ui/Markup).
59
+ **Content.** Block-level structure starts with [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), and the [`<Heading>`](/ui/Heading) / [`<Title>`](/ui/Title) family, with [`<Table>`](/ui/Table), [`<List>`](/ui/List), and [`<Figure>`](/ui/Figure) for specific shapes; wrap longform copy in [`<Prose>`](/ui/Prose). Inline pieces — [`<Link>`](/ui/Link), [`<Code>`](/ui/Code), [`<Strong>`](/ui/Strong), [`<Mark>`](/ui/Mark) — live inside that block content. To render a Markdown string as components, use [`<Markup>`](/ui/Markup).
60
60
 
61
- **Structure.** Mount a client app with [`App`](/ui/App), or render a full server document with [`HTML`](/ui/HTML) and [`Page`](/ui/Page). Arrange the screen with [`CenteredLayout`](/ui/CenteredLayout) or [`SidebarLayout`](/ui/SidebarLayout), and drive URLs with [`Navigation`](/ui/Navigation) and [`Router`](/ui/Router).
61
+ **Structure.** Mount a client app with [`<App>`](/ui/App), or render a full server document with [`<HTML>`](/ui/HTML) and [`<Page>`](/ui/Page). Arrange the screen with [`<CenteredLayout>`](/ui/CenteredLayout) or [`<SidebarLayout>`](/ui/SidebarLayout), and drive URLs with [`<Navigation>`](/ui/Navigation) and [`<Router>`](/ui/Router).
62
62
 
63
- **Interaction.** Forms start at [`Form`](/ui/Form), which wires [`Field`](/ui/Field) and the typed inputs to a [`FormStore`](/ui/FormStore); [`Button`](/ui/Button) is the standalone action. Overlays are [`Dialog`](/ui/Dialog) and [`Modal`](/ui/Modal); navigation menus are [`Menu`](/ui/Menu) and [`MenuItem`](/ui/MenuItem); transient feedback is [`Notice`](/ui/Notice) (and the global [`Notices`](/ui/Notices) list); animate enter/leave with [`Transition`](/ui/Transition).
63
+ **Interaction.** Forms start at [`<Form>`](/ui/Form), which wires [`<Field>`](/ui/Field) and the typed inputs to a [`FormStore`](/ui/FormStore); [`<Button>`](/ui/Button) is the standalone action. Overlays are [`<Dialog>`](/ui/Dialog) and [`<Modal>`](/ui/Modal); navigation menus are [`<Menu>`](/ui/Menu) and [`<MenuItem>`](/ui/MenuItem); transient feedback is [`<Notice>`](/ui/Notice) (and the global [`<Notices>`](/ui/Notices) list); animate enter/leave with [`<Transition>`](/ui/Transition).
64
64
 
65
- **Documentation site.** Hand an extracted tree to [`TreeApp`](/ui/TreeApp) and you get a complete site — sidebar, routing, and a rendered page per element — using the renderers in `docs/`.
65
+ **Documentation site.** Hand an extracted tree to [`<TreeApp>`](/ui/TreeApp) and you get a complete site — sidebar, routing, and a rendered page per element — using the renderers in `docs/`.
66
66
 
67
67
  ## See also
68
68
 
69
- - [extract](/extract) — builds the tree that the documentation components render
70
- - [markup](/markup) — Markdown rendering used by [`Markup`](/ui/Markup) and [`Prose`](/ui/Prose)
71
- - [store](/store) — reactive state behind [`FormStore`](/ui/FormStore), `NavigationStore`, and notices
72
- - [react](/react) — store and provider hooks used alongside these components
69
+ - [`extract`](/extract) — builds the tree that the documentation components render
70
+ - [`markup`](/markup) — Markdown rendering used by [`<Markup>`](/ui/Markup) and [`<Prose>`](/ui/Prose)
71
+ - [`store`](/store) — reactive state behind [`FormStore`](/ui/FormStore), [`NavigationStore`](/ui/NavigationStore), and notices
72
+ - [`react`](/react) — store and provider hooks used alongside these components
73
73
 
74
74
  > Building or extending a component? The contributor walkthrough (file layout, the tint-anchor + per-property-hook pattern, `:first-child` / `:last-child` overrides, and the checklist) lives in the **React Components** section of `AGENTS.md`.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { requireMetaURL } from "../misc/MetaContext.js";
2
+ import { RouteCache } from "../router/RouteCache.js";
3
3
  import { getClass, getModuleClass } from "../util/css.js";
4
4
  import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
5
5
  import { LAYOUT_CLASS } from "./Layout.js";
@@ -15,6 +15,7 @@ import { LAYOUT_CLASS } from "./Layout.js";
15
15
  * @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
16
16
  */
17
17
  export function CenteredLayout({ children, fullWidth = false }) {
18
- const { path } = requireMetaURL();
19
- return (_jsx("main", { className: getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: _jsx("div", { className: getModuleClass(CENTERED_LAYOUT_CSS, "mainInner"), style: fullWidth ? { maxWidth: "none" } : undefined, children: children }) }, path));
18
+ // Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
19
+ // keeping their scroll position and state intact across back/forward navigation.
20
+ return (_jsx(RouteCache, { children: _jsx("main", { className: getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: _jsx("div", { className: getModuleClass(CENTERED_LAYOUT_CSS, "mainInner"), style: fullWidth ? { maxWidth: "none" } : undefined, children: children }) }) }));
20
21
  }
@@ -1,5 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
- import { requireMetaURL } from "../misc/MetaContext.js";
2
+ import { RouteCache } from "../router/RouteCache.js";
3
3
  import { getClass, getModuleClass } from "../util/css.js";
4
4
  import type { OptionalChildProps } from "../util/props.js";
5
5
  import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
@@ -26,12 +26,15 @@ export interface CenteredLayoutProps extends OptionalChildProps {
26
26
  * @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
27
27
  */
28
28
  export function CenteredLayout({ children, fullWidth = false }: CenteredLayoutProps): ReactElement {
29
- const { path } = requireMetaURL();
29
+ // Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
30
+ // keeping their scroll position and state intact across back/forward navigation.
30
31
  return (
31
- <main key={path} className={getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS)}>
32
- <div className={getModuleClass(CENTERED_LAYOUT_CSS, "mainInner")} style={fullWidth ? { maxWidth: "none" } : undefined}>
33
- {children}
34
- </div>
35
- </main>
32
+ <RouteCache>
33
+ <main className={getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS)}>
34
+ <div className={getModuleClass(CENTERED_LAYOUT_CSS, "mainInner")} style={fullWidth ? { maxWidth: "none" } : undefined}>
35
+ {children}
36
+ </div>
37
+ </main>
38
+ </RouteCache>
36
39
  );
37
40
  }
@@ -17,6 +17,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
17
17
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
18
18
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
19
19
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
20
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
20
21
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
21
22
  *
22
23
  * @kind component
@@ -3,6 +3,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
3
3
  import { useEffect, useState } from "react";
4
4
  import { Button } from "../form/Button.js";
5
5
  import { requireMetaURL } from "../misc/MetaContext.js";
6
+ import { RouteCache } from "../router/RouteCache.js";
6
7
  import { getClass, getModuleClass } from "../util/css.js";
7
8
  import { LAYOUT_CLASS } from "./Layout.js";
8
9
  import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
@@ -12,6 +13,7 @@ import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
12
13
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
13
14
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
14
15
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
16
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
15
17
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
16
18
  *
17
19
  * @kind component
@@ -31,7 +33,11 @@ export function SidebarLayout({ sidebar, children, right = false }) {
31
33
  setOpen(false);
32
34
  }, [path]);
33
35
  const sidebarEl = (_jsx("nav", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "sidebar"), open && getModuleClass(SIDEBAR_LAYOUT_CSS, "open")), children: sidebar }, "sidebar"));
34
- const contentEl = (_jsxs("div", { className: getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content")), children: [_jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle"), children: _jsx(Button, { title: open ? "Close menu" : "Show menu", onClick: () => setOpen(o => !o), children: open ? _jsx(XMarkIcon, {}) : _jsx(Bars3Icon, {}) }) }), _jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner"), children: children })] }, path));
36
+ // Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
37
+ // — keeping the scroll position of this `.content` container (and all page state) intact across
38
+ // back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
39
+ // duplicated nor remounted as the URL changes.
40
+ const contentEl = (_jsx(RouteCache, { children: _jsxs("div", { className: getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content")), children: [_jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle"), children: _jsx(Button, { title: open ? "Close menu" : "Show menu", onClick: () => setOpen(o => !o), children: open ? _jsx(XMarkIcon, {}) : _jsx(Bars3Icon, {}) }) }), _jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner"), children: children })] }) }, "content"));
35
41
  const overlayEl = open && (_jsx("button", { type: "button", className: getModuleClass(SIDEBAR_LAYOUT_CSS, "overlay"), "aria-label": "Close menu", onClick: () => setOpen(false) }, "overlay"));
36
42
  return (_jsx("main", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: right ? [contentEl, sidebarEl, overlayEl] : [sidebarEl, contentEl, overlayEl] }));
37
43
  }
@@ -2,6 +2,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
2
2
  import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3
3
  import { Button } from "../form/Button.js";
4
4
  import { requireMetaURL } from "../misc/MetaContext.js";
5
+ import { RouteCache } from "../router/RouteCache.js";
5
6
  import { getClass, getModuleClass } from "../util/css.js";
6
7
  import type { OptionalChildProps } from "../util/props.js";
7
8
  import { LAYOUT_CLASS } from "./Layout.js";
@@ -25,6 +26,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
25
26
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
26
27
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
27
28
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
29
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
28
30
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
29
31
  *
30
32
  * @kind component
@@ -52,15 +54,21 @@ export function SidebarLayout({ sidebar, children, right = false }: SidebarLayou
52
54
  {sidebar}
53
55
  </nav>
54
56
  );
57
+ // Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
58
+ // — keeping the scroll position of this `.content` container (and all page state) intact across
59
+ // back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
60
+ // duplicated nor remounted as the URL changes.
55
61
  const contentEl = (
56
- <div key={path} className={getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content"))}>
57
- <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle")}>
58
- <Button title={open ? "Close menu" : "Show menu"} onClick={() => setOpen(o => !o)}>
59
- {open ? <XMarkIcon /> : <Bars3Icon />}
60
- </Button>
62
+ <RouteCache key="content">
63
+ <div className={getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content"))}>
64
+ <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle")}>
65
+ <Button title={open ? "Close menu" : "Show menu"} onClick={() => setOpen(o => !o)}>
66
+ {open ? <XMarkIcon /> : <Bars3Icon />}
67
+ </Button>
68
+ </div>
69
+ <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner")}>{children}</div>
61
70
  </div>
62
- <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner")}>{children}</div>
63
- </div>
71
+ </RouteCache>
64
72
  );
65
73
  const overlayEl = open && (
66
74
  <button
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from "react";
2
+ /**
3
+ * Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
4
+ *
5
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
6
+ */
7
+ export interface RouteCacheProps {
8
+ /**
9
+ * Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
10
+ * scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
11
+ * focus — is restored intact when navigating back or forward to them.
12
+ * - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
13
+ * - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
14
+ */
15
+ readonly maxCached?: number | undefined;
16
+ /** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
17
+ readonly children: ReactNode;
18
+ }
19
+ /**
20
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
21
+ *
22
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
23
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
24
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
25
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
26
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
27
+ * different page per path and a hidden page never re-renders for someone else's URL.
28
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
29
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
30
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
31
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
32
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
33
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
34
+ *
35
+ * @kind component
36
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
37
+ * @param children The content region to render and cache (typically a layout's scrollable column).
38
+ * @returns The visible current page plus any cached pages kept alive but hidden.
39
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
40
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
41
+ */
42
+ export declare function RouteCache({ maxCached, children }: RouteCacheProps): ReactNode;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Activity, useRef } from "react";
3
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
4
+ /**
5
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
6
+ *
7
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
8
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
9
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
10
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
11
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
12
+ * different page per path and a hidden page never re-renders for someone else's URL.
13
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
14
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
15
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
16
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
17
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
18
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
19
+ *
20
+ * @kind component
21
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
22
+ * @param children The content region to render and cache (typically a layout's scrollable column).
23
+ * @returns The visible current page plus any cached pages kept alive but hidden.
24
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
25
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
26
+ */
27
+ export function RouteCache({ maxCached = 10, children }) {
28
+ // Read the live URL from context; each navigation re-renders this with the new path.
29
+ const meta = requireMetaURL();
30
+ const mapRef = useRef(undefined);
31
+ const usedRef = useRef(0);
32
+ // Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
33
+ // it was captured at — the same `children` element resolves a different page per path.
34
+ const node = _jsx(MetaContext, { value: meta, children: children });
35
+ // Caching disabled — render the page directly so it unmounts as soon as you leave it.
36
+ if (maxCached <= 0)
37
+ return node;
38
+ // Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
39
+ const { path } = meta;
40
+ const map = (mapRef.current ??= new Map());
41
+ const used = ++usedRef.current;
42
+ const entry = map.get(path);
43
+ if (entry) {
44
+ entry.node = node;
45
+ entry.used = used;
46
+ }
47
+ else {
48
+ map.set(path, { node, used });
49
+ }
50
+ while (map.size > maxCached)
51
+ map.delete(_findLeastRecentlyUsed(map));
52
+ // Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
53
+ return Array.from(map, ([key, cached]) => (_jsx(Activity, { mode: key === path ? "visible" : "hidden", children: cached.node }, key)));
54
+ }
55
+ /** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
56
+ function _findLeastRecentlyUsed(map) {
57
+ let lruKey;
58
+ let lruUsed = Number.POSITIVE_INFINITY;
59
+ for (const [key, entry] of map) {
60
+ if (entry.used < lruUsed) {
61
+ lruUsed = entry.used;
62
+ lruKey = key;
63
+ }
64
+ }
65
+ return lruKey;
66
+ }
@@ -0,0 +1,31 @@
1
+ # RouteCache
2
+
3
+ A keep-alive page cache. Drop it into a layout around its scrolling content region: it reads the current URL from the surrounding [`<Meta>`](/ui/MetaContext) context and keeps a handful of recently-visited pages mounted but *hidden* (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state — scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs, focus — instead of remounting it fresh at the top.
4
+
5
+ Shelving's [`SidebarLayout`](/ui/SidebarLayout) and [`CenteredLayout`](/ui/CenteredLayout) already wrap their scrollable content column in one for you, so pages rendered inside them keep their state across navigation automatically. Reach for it by hand only when building a custom layout.
6
+
7
+ **Things to know:**
8
+
9
+ - Pages are kept in a least-recently-used map keyed by `path`. Once `maxCached` pages are retained the least-recently-visited one is unmounted (and loses its state). A never-seen or evicted page mounts fresh at the top.
10
+ - Pass `maxCached={0}` (or less) to disable caching entirely — the page renders directly and unmounts as soon as you leave it.
11
+ - `<Activity mode="hidden">` preserves a hidden page's *state* while unmounting its *effects*, so subscriptions, observers (e.g. infinite-scroll), and timers pause and resume cleanly as the page is hidden and shown.
12
+ - Each snapshot is frozen under its own `<Meta>` context, so the same single `children` element resolves a different page per path and a hidden page never re-renders for someone else's URL.
13
+ - For per-page **scroll** to be preserved the cache has to wrap the scroll container itself — which is why it lives in the layout (around the scrollable column) rather than below it inside the router. Surrounding chrome (sidebar, drawer state) stays outside the cache, so it is neither duplicated nor remounted on navigation.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ import { RouteCache } from "shelving/ui";
19
+
20
+ <SidebarLayout sidebar={<Menu/>}>
21
+ <RouteCache>
22
+ <Router routes={routes}/>
23
+ </RouteCache>
24
+ </SidebarLayout>
25
+ ```
26
+
27
+ ## See also
28
+
29
+ - [`SidebarLayout`](/ui/SidebarLayout) / [`CenteredLayout`](/ui/CenteredLayout) — wrap their scrollable content column in a `<RouteCache>` for you
30
+ - [`Router`](/ui/Router) — matches a URL to a route; render it inside a `<RouteCache>` to keep pages alive
31
+ - [`Navigation`](/ui/Navigation) — drives the URL changes that move between cached pages
@@ -0,0 +1,97 @@
1
+ import { Activity, type ReactNode, useRef } from "react";
2
+ import type { AbsolutePath } from "../../util/path.js";
3
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
4
+
5
+ /** A cached page: the rendered node plus a monotonic `used` tick for least-recently-used eviction. */
6
+ interface CacheEntry {
7
+ node: ReactNode;
8
+ used: number;
9
+ }
10
+
11
+ /**
12
+ * Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
13
+ *
14
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
15
+ */
16
+ export interface RouteCacheProps {
17
+ /**
18
+ * Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
19
+ * scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
20
+ * focus — is restored intact when navigating back or forward to them.
21
+ * - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
22
+ * - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
23
+ */
24
+ readonly maxCached?: number | undefined;
25
+ /** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
26
+ readonly children: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
31
+ *
32
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
33
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
34
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
35
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
36
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
37
+ * different page per path and a hidden page never re-renders for someone else's URL.
38
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
39
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
40
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
41
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
42
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
43
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
44
+ *
45
+ * @kind component
46
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
47
+ * @param children The content region to render and cache (typically a layout's scrollable column).
48
+ * @returns The visible current page plus any cached pages kept alive but hidden.
49
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
50
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
51
+ */
52
+ export function RouteCache({ maxCached = 10, children }: RouteCacheProps): ReactNode {
53
+ // Read the live URL from context; each navigation re-renders this with the new path.
54
+ const meta = requireMetaURL();
55
+ const mapRef = useRef<Map<AbsolutePath, CacheEntry>>(undefined);
56
+ const usedRef = useRef(0);
57
+
58
+ // Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
59
+ // it was captured at — the same `children` element resolves a different page per path.
60
+ const node = <MetaContext value={meta}>{children}</MetaContext>;
61
+
62
+ // Caching disabled — render the page directly so it unmounts as soon as you leave it.
63
+ if (maxCached <= 0) return node;
64
+
65
+ // Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
66
+ const { path } = meta;
67
+ const map = (mapRef.current ??= new Map());
68
+ const used = ++usedRef.current;
69
+ const entry = map.get(path);
70
+ if (entry) {
71
+ entry.node = node;
72
+ entry.used = used;
73
+ } else {
74
+ map.set(path, { node, used });
75
+ }
76
+ while (map.size > maxCached) map.delete(_findLeastRecentlyUsed(map));
77
+
78
+ // Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
79
+ return Array.from(map, ([key, cached]) => (
80
+ <Activity key={key} mode={key === path ? "visible" : "hidden"}>
81
+ {cached.node}
82
+ </Activity>
83
+ ));
84
+ }
85
+
86
+ /** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
87
+ function _findLeastRecentlyUsed(map: Map<AbsolutePath, CacheEntry>): AbsolutePath {
88
+ let lruKey!: AbsolutePath;
89
+ let lruUsed = Number.POSITIVE_INFINITY;
90
+ for (const [key, entry] of map) {
91
+ if (entry.used < lruUsed) {
92
+ lruUsed = entry.used;
93
+ lruKey = key;
94
+ }
95
+ }
96
+ return lruKey;
97
+ }
@@ -8,6 +8,7 @@ A pure URL matcher: it reads the current URL from the surrounding `<Meta>` conte
8
8
  - `<Router>` accepts `PossibleMeta` props (`url`, `base`, etc.) to override the surrounding context — this is how nested routers scope themselves.
9
9
  - With a `base` set, the path used for matching is the URL after `matchURLPrefix` strips the base prefix; URLs outside the base render as `null`.
10
10
  - Pass `fallback` to control no-match behaviour. An explicit `null` renders nothing; leaving it `undefined` throws a `NotFoundError`.
11
+ - `cache` (default `10`) keeps recently-visited pages mounted but hidden so back/forward navigation restores their state — see [Keeping page state](#keeping-page-state).
11
12
 
12
13
  ## Usage
13
14
 
@@ -119,6 +120,30 @@ const SIDEBARRED_ROUTES = {
119
120
  }}/>
120
121
  ```
121
122
 
123
+ ### Keeping page state
124
+
125
+ By default `<Router>` unmounts a page when you navigate away and mounts a fresh one when you return — so scroll position, open/closed toggles, in-progress searches, form inputs, and focus are all lost, and you land back at the top.
126
+
127
+ The `cache` prop keeps recently-visited pages mounted but hidden (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state untouched — no per-feature scroll-capturing or state serialisation required.
128
+
129
+ ```tsx
130
+ // Keep the last 10 visited pages alive (the default).
131
+ <Router routes={ROUTES}/>
132
+
133
+ // Keep more pages, at the cost of more retained DOM/memory.
134
+ <Router routes={ROUTES} cache={25}/>
135
+
136
+ // Opt out — unmount each page as you leave it (original behaviour).
137
+ <Router routes={ROUTES} cache={0}/>
138
+ ```
139
+
140
+ **Things to know:**
141
+
142
+ - Pages are keyed by their matched `path`; once `cache` pages are retained the least-recently-visited one is unmounted (and so loses its state). Visiting a never-seen or evicted page mounts it fresh at the top.
143
+ - Each cached page is wrapped in its own frozen `<Meta>` context, so hidden pages never re-render for the current URL.
144
+ - `<Activity mode="hidden">` unmounts a hidden page's _effects_ while preserving its state, so subscriptions, observers (e.g. infinite-scroll), and timers pause politely and resume when the page is shown again.
145
+ - For per-page scroll to be preserved, each page must own its scroll container (the scrollable element must live _inside_ the route, not in a shared layout wrapper that all routes render into).
146
+
122
147
  ### SSR / static rendering
123
148
 
124
149
  `<Router>` re-renders when context changes and needs no client. For static rendering set `url` and `root` on the outer wrapper and skip `<Navigation>`:
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { MetaContext } from "../misc/MetaContext.js";
4
+ import { createMeta } from "../util/meta.js";
5
+ import { Router } from "./Router.js";
6
+
7
+ const ROUTES = {
8
+ "/": () => <main>Home</main>,
9
+ "/about": () => <main>About</main>,
10
+ } as const;
11
+
12
+ function render(url: string) {
13
+ return renderToStaticMarkup(
14
+ <MetaContext value={createMeta({ root: "http://x.com/", url })}>
15
+ <Router routes={ROUTES} />
16
+ </MetaContext>,
17
+ );
18
+ }
19
+
20
+ describe("Router", () => {
21
+ test("renders the matched route", () => {
22
+ expect(render("./about")).toContain("About");
23
+ });
24
+
25
+ test("throws when no route matches and no fallback is given", () => {
26
+ expect(() => render("./missing")).toThrow();
27
+ });
28
+ });
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";