shelving 1.285.0 → 1.285.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,7 +89,10 @@ export class CacheDBProvider extends ThroughDBProvider {
89
89
  */
90
90
  async transact(callback) {
91
91
  let transaction;
92
- const result = await this.source.transact(provider => callback((transaction = new RecordingDBProvider(this.cloneWith(provider)))));
92
+ const result = await this.source.transact(provider => {
93
+ transaction = new RecordingDBProvider(this.cloneWith(provider));
94
+ return callback(transaction);
95
+ });
93
96
  if (transaction)
94
97
  await transaction.replayWrites(this.memory); // Commit the recorded writes into the cache.
95
98
  return result;
@@ -89,7 +89,7 @@ function _getServer() {
89
89
  return _server;
90
90
  }
91
91
  /** Close the shared compiler server after a short idle, so the process can exit without callers managing the lifecycle. */
92
- function _scheduleClose() {
92
+ async function _scheduleClose() {
93
93
  if (_timer)
94
94
  clearTimeout(_timer);
95
95
  _timer = setTimeout(() => {
@@ -98,7 +98,7 @@ function _scheduleClose() {
98
98
  _previous = undefined;
99
99
  _timer = undefined;
100
100
  try {
101
- server?.api.close();
101
+ void server?.api.close();
102
102
  }
103
103
  catch {
104
104
  // Closing can reject in-flight bookkeeping requests — harmless on shutdown.
@@ -135,8 +135,8 @@ function _parseSourceFile(text) {
135
135
  return source;
136
136
  }
137
137
  finally {
138
- snapshot.dispose();
139
- _scheduleClose();
138
+ await snapshot.dispose();
139
+ await _scheduleClose();
140
140
  }
141
141
  };
142
142
  const result = _queue.then(task, task);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.285.0",
3
+ "version": "1.285.2",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,21 +9,17 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.5.8",
12
+ "@biomejs/biome": "^2.5.13",
13
13
  "@heroicons/react": "^2.2.0",
14
- "@types/bun": "^1.3.14",
15
- "@types/react": "^19.2.18",
16
- "@types/react-dom": "^19.2.4",
17
- "react": "^19.3.0-canary-fef12a01-20260413",
18
- "react-dom": "^19.3.0-canary-fef12a01-20260413",
19
- "stylelint": "^17.14.1",
14
+ "@types/bun": "^1.4.2",
15
+ "@types/react": "^19.3.0",
16
+ "@types/react-dom": "^19.3.0",
17
+ "stylelint": "^17.15.0",
20
18
  "stylelint-config-standard": "^40.0.0",
21
19
  "typescript": "^7.0.2"
22
20
  },
23
21
  "peerDependencies": {
24
- "@heroicons/react": ">=2.0.0",
25
- "react": ">=19.0.0",
26
- "react-dom": ">=19.0.0"
22
+ "@heroicons/react": ">=2.0.0"
27
23
  },
28
24
  "exports": {
29
25
  ".": "./index.js",
@@ -83,5 +79,9 @@
83
79
  "*.css"
84
80
  ],
85
81
  "type": "module",
86
- "types": "./index.d.ts"
82
+ "types": "./index.d.ts",
83
+ "dependencies": {
84
+ "react": "^19.3.0",
85
+ "react-dom": "^19.3.0"
86
+ }
87
87
  }
@@ -3,15 +3,13 @@ import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
3
3
  * Schema that defines a valid password string.
4
4
  *
5
5
  * - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
6
- * - Never formats the value for display (`format()` always returns `""`).
6
+ * - Formats the value unchanged (like `StringSchema`) so a password `<input />` can hold and mask it — hiding the value is the input's job, not the schema's.
7
7
  *
8
8
  * @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
9
9
  * @see https://shelving.cc/schema/PasswordSchema
10
10
  */
11
11
  export declare class PasswordSchema extends StringSchema {
12
12
  constructor({ one, title, min, input, ...options }?: StringSchemaOptions);
13
- /** Always returns `""` — passwords are never shown. */
14
- format(): string;
15
13
  }
16
14
  /**
17
15
  * Sugar instance of `PasswordSchema` for a password string. Equivalent to `new PasswordSchema({})`.
@@ -3,7 +3,7 @@ import { StringSchema } from "./StringSchema.js";
3
3
  * Schema that defines a valid password string.
4
4
  *
5
5
  * - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
6
- * - Never formats the value for display (`format()` always returns `""`).
6
+ * - Formats the value unchanged (like `StringSchema`) so a password `<input />` can hold and mask it — hiding the value is the input's job, not the schema's.
7
7
  *
8
8
  * @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
9
9
  * @see https://shelving.cc/schema/PasswordSchema
@@ -12,10 +12,6 @@ export class PasswordSchema extends StringSchema {
12
12
  constructor({ one = "password", title = "Password", min = 6, input = "password", ...options } = {}) {
13
13
  super({ one, title, min, input, ...options });
14
14
  }
15
- /** Always returns `""` — passwords are never shown. */
16
- format() {
17
- return ""; // Never format a password for display.
18
- }
19
15
  }
20
16
  /**
21
17
  * Sugar instance of `PasswordSchema` for a password string. Equivalent to `new PasswordSchema({})`.
@@ -115,13 +115,14 @@ export interface StringSchemaInputProps extends SchemaInputProps<StringSchema, u
115
115
  }
116
116
  /**
117
117
  * Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
118
+ * - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
118
119
  *
119
120
  * @returns A `TextInput` element bound to the schema.
120
121
  * @kind component
121
122
  * @example <StringSchemaInput name="email" schema={EMAIL} />
122
123
  * @see https://shelving.cc/ui/StringSchemaInput
123
124
  */
124
- export declare function StringSchemaInput({ schema, value, ...props }: StringSchemaInputProps): ReactElement;
125
+ export declare function StringSchemaInput({ schema, value, onValue, ...props }: StringSchemaInputProps): ReactElement;
125
126
  /**
126
127
  * Props for `ArraySchemaInput`, the `ArraySchema` input variant.
127
128
  *
@@ -123,14 +123,15 @@ export function BooleanSchemaInput({ schema, value, ...props }) {
123
123
  }
124
124
  /**
125
125
  * Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
126
+ * - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
126
127
  *
127
128
  * @returns A `TextInput` element bound to the schema.
128
129
  * @kind component
129
130
  * @example <StringSchemaInput name="email" schema={EMAIL} />
130
131
  * @see https://shelving.cc/ui/StringSchemaInput
131
132
  */
132
- export function StringSchemaInput({ schema, value, ...props }) {
133
- return _jsx(TextInput, { ...schema, value: getString(value), formatter: str => schema.format(schema.sanitize(str)), ...props });
133
+ export function StringSchemaInput({ schema, value, onValue, ...props }) {
134
+ return (_jsx(TextInput, { ...schema, value: getString(value), onValue: str => onValue(str === undefined ? undefined : schema.sanitize(str)), formatter: str => schema.format(schema.sanitize(str)), ...props }));
134
135
  }
135
136
  /**
136
137
  * Show an `ArrayInput` for an `ArraySchema`.
@@ -1,9 +1,34 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
- import { StringSchema } from "shelving/schema";
4
- import { StringSchemaInput } from "shelving/ui";
3
+ import { PASSWORD, StringSchema, URL_SCHEMA } from "shelving/schema";
4
+ import { StringSchemaInput, TextInput, type TextInputProps } from "shelving/ui";
5
5
  import { PASSTHROUGH } from "shelving/util/function";
6
6
 
7
+ /** A `StringSchema` with a non-identity `format()` (wraps in brackets) so display and published values differ. */
8
+ class BracketSchema extends StringSchema {
9
+ override format(str: string): string {
10
+ return str ? `[${str}]` : str;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Render a `StringSchemaInput` down to its `<input>` element's props, so the event handlers can be called directly.
16
+ * - Neither component uses hooks, so calling them as plain functions is safe without a DOM.
17
+ */
18
+ function _getInputProps(schema: StringSchema, onValue: (value: string | undefined) => void, value?: string): _InputProps {
19
+ const props: { schema: StringSchema; name: string; onValue: typeof onValue; value?: string } = { schema, name: "field", onValue };
20
+ if (value !== undefined) props.value = value;
21
+ return TextInput(StringSchemaInput(props).props as TextInputProps).props as _InputProps;
22
+ }
23
+
24
+ /** The `<input>` props the tests read and call. */
25
+ interface _InputProps {
26
+ readonly type?: string;
27
+ readonly defaultValue?: string;
28
+ onInput(e: { currentTarget: { value: string } }): void;
29
+ onBlur(e: { currentTarget: { value: string } }): void;
30
+ }
31
+
7
32
  describe("StringSchemaInput", () => {
8
33
  test("formats the initial value to its clean sanitized value", () => {
9
34
  // The `formatter` runs `schema.sanitize()` then `schema.format()`, so runs of whitespace collapse and the value trims.
@@ -23,14 +48,55 @@ describe("StringSchemaInput", () => {
23
48
 
24
49
  test("applies a subclass `format()` after sanitizing", () => {
25
50
  // A subclass `format()` is non-identity (here wrapping in brackets), so the clean value is sanitized then formatted.
26
- class BracketSchema extends StringSchema {
27
- override format(str: string): string {
28
- return str ? `[${str}]` : str;
29
- }
30
- }
31
51
  const schema = new BracketSchema({});
32
52
  const html = renderToStaticMarkup(<StringSchemaInput name="tag" schema={schema} value=" abc " onValue={PASSTHROUGH} />);
33
53
 
34
54
  expect(html).toContain('value="[abc]"');
35
55
  });
56
+
57
+ test("publishes the sanitized value, not the formatted one", () => {
58
+ // `format()` is for display only — the store must receive a value that re-validates.
59
+ const values: (string | undefined)[] = [];
60
+ const input = _getInputProps(new BracketSchema({}), v => void values.push(v));
61
+ input.onInput({ currentTarget: { value: " abc " } });
62
+
63
+ expect(values).toEqual(["abc"]);
64
+ });
65
+
66
+ test("reformats the displayed value on blur without publishing it", () => {
67
+ const values: (string | undefined)[] = [];
68
+ const input = _getInputProps(new BracketSchema({}), v => void values.push(v));
69
+ const currentTarget = { value: " abc " };
70
+ input.onBlur({ currentTarget });
71
+
72
+ expect(currentTarget.value).toBe("[abc]");
73
+ expect(values).toEqual([]);
74
+ });
75
+
76
+ test("publishes a password unchanged and keeps it displayed on blur", () => {
77
+ // A `PasswordSchema` field must submit the typed password; masking is the `type="password"` input's job.
78
+ const values: (string | undefined)[] = [];
79
+ const input = _getInputProps(PASSWORD, v => void values.push(v), "hunter2");
80
+ input.onInput({ currentTarget: { value: "hunter22" } });
81
+ const currentTarget = { value: "hunter22" };
82
+ input.onBlur({ currentTarget });
83
+
84
+ expect(input.type).toBe("password");
85
+ expect(input.defaultValue).toBe("hunter2");
86
+ expect(values).toEqual(["hunter22"]);
87
+ expect(currentTarget.value).toBe("hunter22");
88
+ });
89
+
90
+ test("publishes a URL that still validates while displaying its friendly form", () => {
91
+ // `URLSchema.format()` strips the scheme for display; publishing that form would fail re-validation.
92
+ const values: (string | undefined)[] = [];
93
+ const input = _getInputProps(URL_SCHEMA, v => void values.push(v));
94
+ input.onInput({ currentTarget: { value: " https://example.com/path " } });
95
+ const currentTarget = { value: "https://example.com/path" };
96
+ input.onBlur({ currentTarget });
97
+
98
+ expect(values).toEqual(["https://example.com/path"]);
99
+ expect(URL_SCHEMA.validate(values[0])).toBe("https://example.com/path");
100
+ expect(currentTarget.value).toBe("example.com/path");
101
+ });
36
102
  });
@@ -192,14 +192,23 @@ export interface StringSchemaInputProps extends SchemaInputProps<StringSchema, u
192
192
 
193
193
  /**
194
194
  * Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
195
+ * - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
195
196
  *
196
197
  * @returns A `TextInput` element bound to the schema.
197
198
  * @kind component
198
199
  * @example <StringSchemaInput name="email" schema={EMAIL} />
199
200
  * @see https://shelving.cc/ui/StringSchemaInput
200
201
  */
201
- export function StringSchemaInput({ schema, value, ...props }: StringSchemaInputProps): ReactElement {
202
- return <TextInput {...schema} value={getString(value)} formatter={str => schema.format(schema.sanitize(str))} {...props} />;
202
+ export function StringSchemaInput({ schema, value, onValue, ...props }: StringSchemaInputProps): ReactElement {
203
+ return (
204
+ <TextInput
205
+ {...schema}
206
+ value={getString(value)}
207
+ onValue={str => onValue(str === undefined ? undefined : schema.sanitize(str))}
208
+ formatter={str => schema.format(schema.sanitize(str))}
209
+ {...props}
210
+ />
211
+ );
203
212
  }
204
213
 
205
214
  /**
@@ -13,12 +13,12 @@ export interface TextInputProps extends ValueInputProps<string>, InputVariants {
13
13
  input?: StringInputType;
14
14
  min?: number | undefined;
15
15
  max?: number | undefined;
16
- /** Optional formatter — when provided the value is reformatted on blur and when initially displayed. */
16
+ /** Optional formatter — when provided the value is reformatted on blur and when initially displayed (the value published through `onValue` is never formatted). */
17
17
  formatter?: TextFormatter | undefined;
18
18
  }
19
19
  /**
20
20
  * Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
21
- * - Applies an optional `formatter` on initial display and on blur.
21
+ * - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
22
22
  * - Multiline mode auto-grows the textarea to fit its content.
23
23
  *
24
24
  * @returns A text `<input>` or `<textarea>` element.
@@ -5,7 +5,7 @@ import { getClass, getModuleClass } from "../util/css.js";
5
5
  import INPUT_CSS from "./Input.module.css";
6
6
  /**
7
7
  * Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
8
- * - Applies an optional `formatter` on initial display and on blur.
8
+ * - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
9
9
  * - Multiline mode auto-grows the textarea to fit its content.
10
10
  *
11
11
  * @returns A text `<input>` or `<textarea>` element.
@@ -19,13 +19,13 @@ required = false, disabled = false, message = "", value, onValue, input = "text"
19
19
  };
20
20
  if (rows > 1) {
21
21
  const onChange = (e) => {
22
- onValue?.(formatter(e.currentTarget.value));
22
+ onValue?.(e.currentTarget.value);
23
23
  _resize(e.currentTarget, rows);
24
24
  };
25
25
  return (_jsx("textarea", { ref: el => void (el && _resize(el, rows)), name: name, defaultValue: value !== undefined ? formatter(value) : "", minLength: Number.isFinite(min) ? min : 0, maxLength: Number.isFinite(max) ? max : undefined, rows: rows, required: required && min > 0, disabled: disabled, placeholder: placeholder || " ", className: getClass(getInputClass(variants), getModuleClass(INPUT_CSS, "text"), getModuleClass(INPUT_CSS, "multiline"), className), onInput: onChange, onChange: onChange, onBlur: onBlur, title: message, "aria-invalid": !!message }));
26
26
  }
27
27
  const onChange = (e) => {
28
- onValue?.(formatter(e.currentTarget.value));
28
+ onValue?.(e.currentTarget.value);
29
29
  };
30
30
  return (_jsx("input", { name: name, type: input, defaultValue: value !== undefined ? formatter(value) : "", minLength: Number.isFinite(min) ? min : 0, maxLength: Number.isFinite(max) ? max : undefined, required: required && min > 0, disabled: disabled, placeholder: placeholder || " ", className: getClass(getInputClass(variants), getModuleClass(INPUT_CSS, "text"), className), onInput: onChange, onChange: onChange, onBlur: onBlur, title: message, "aria-invalid": !!message }));
31
31
  }
@@ -18,13 +18,13 @@ export interface TextInputProps extends ValueInputProps<string>, InputVariants {
18
18
  input?: StringInputType;
19
19
  min?: number | undefined;
20
20
  max?: number | undefined;
21
- /** Optional formatter — when provided the value is reformatted on blur and when initially displayed. */
21
+ /** Optional formatter — when provided the value is reformatted on blur and when initially displayed (the value published through `onValue` is never formatted). */
22
22
  formatter?: TextFormatter | undefined;
23
23
  }
24
24
 
25
25
  /**
26
26
  * Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
27
- * - Applies an optional `formatter` on initial display and on blur.
27
+ * - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
28
28
  * - Multiline mode auto-grows the textarea to fit its content.
29
29
  *
30
30
  * @returns A text `<input>` or `<textarea>` element.
@@ -54,7 +54,7 @@ export function TextInput({
54
54
 
55
55
  if (rows > 1) {
56
56
  const onChange = (e: SyntheticEvent<HTMLTextAreaElement>) => {
57
- onValue?.(formatter(e.currentTarget.value));
57
+ onValue?.(e.currentTarget.value);
58
58
  _resize(e.currentTarget, rows);
59
59
  };
60
60
 
@@ -80,7 +80,7 @@ export function TextInput({
80
80
  }
81
81
 
82
82
  const onChange = (e: SyntheticEvent<HTMLInputElement>) => {
83
- onValue?.(formatter(e.currentTarget.value));
83
+ onValue?.(e.currentTarget.value);
84
84
  };
85
85
 
86
86
  return (