shelving 1.289.1 → 1.290.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.289.1",
3
+ "version": "1.290.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,11 +18,18 @@ export type ChoiceOptions<K extends string> = {
18
18
  * @see https://shelving.cc/schema/PossibleChoiceOptions
19
19
  */
20
20
  export type PossibleChoiceOptions<K extends string> = ImmutableArray<K> | ChoiceOptions<K>;
21
+ /**
22
+ * Input control hint for a `ChoiceSchema`: `"radio"` for radio inputs, or `"select"` for a `<select>`.
23
+ *
24
+ * @see https://shelving.cc/schema/ChoiceInputType
25
+ */
26
+ export type ChoiceInputType = "radio" | "select";
21
27
  /**
22
28
  * Options for `ChoiceSchema`.
23
29
  *
24
30
  * - `options` — the allowed choices, as a `{ key: title }` dictionary or an array of keys.
25
31
  * - `value` — default option used when the input is `undefined`.
32
+ * - `input` — control hint for downstream UIs (radios or a select).
26
33
  *
27
34
  * @see https://shelving.cc/schema/ChoiceSchemaOptions
28
35
  */
@@ -31,6 +38,11 @@ export interface ChoiceSchemaOptions<O extends string, I = never> extends Schema
31
38
  readonly options: PossibleChoiceOptions<O>;
32
39
  /** Default option for the value. */
33
40
  readonly value?: O | I;
41
+ /**
42
+ * Input control hint for downstream UIs.
43
+ * - When not set, a UI picks the control from the number of options.
44
+ */
45
+ readonly input?: ChoiceInputType | undefined;
34
46
  }
35
47
  /**
36
48
  * Schema that validates a value against a fixed set of allowed string choices.
@@ -43,7 +55,8 @@ export interface ChoiceSchemaOptions<O extends string, I = never> extends Schema
43
55
  export declare class ChoiceSchema<O extends string, I = never> extends Schema<O> {
44
56
  readonly value: O | I | undefined;
45
57
  readonly options: ChoiceOptions<O>;
46
- constructor({ one, title, placeholder, options, value, ...rest }: ChoiceSchemaOptions<O, I>);
58
+ readonly input: ChoiceInputType | undefined;
59
+ constructor({ one, title, placeholder, options, value, input, ...rest }: ChoiceSchemaOptions<O, I>);
47
60
  /**
48
61
  * Get an unknown value as one of the allowed choices, or `undefined` if it isn't one.
49
62
  *
@@ -18,9 +18,11 @@ function _getChoiceOption(k) {
18
18
  */
19
19
  export class ChoiceSchema extends Schema {
20
20
  options;
21
- constructor({ one = "choice", title = "Choice", placeholder = `No ${one}`, options, value, ...rest }) {
21
+ input;
22
+ constructor({ one = "choice", title = "Choice", placeholder = `No ${one}`, options, value, input, ...rest }) {
22
23
  super({ one, title, value, placeholder, ...rest });
23
24
  this.options = _getChoiceOptions(options);
25
+ this.input = input;
24
26
  }
25
27
  /**
26
28
  * Get an unknown value as one of the allowed choices, or `undefined` if it isn't one.
@@ -167,24 +167,25 @@
167
167
  pointer-events: none;
168
168
  }
169
169
 
170
- /* First [data-slot] child sits one indent from the start, where the text of a plain input starts. */
170
+ /* First [data-slot] child sits one indent from the left, where the text of a plain input starts. Physical, not
171
+ logical: `inset-inline-start` makes bundlers emit a `:lang()` selector for every right-to-left language. */
171
172
  & > [data-slot]:first-child {
172
- inset-inline-start: var(--input-indent, var(--space-small));
173
+ left: var(--input-indent, var(--space-small));
173
174
  }
174
175
 
175
- /* Last [data-slot] child sits one indent from the end. */
176
+ /* Last [data-slot] child sits one indent from the right. */
176
177
  & > [data-slot]:last-child {
177
- inset-inline-end: var(--input-indent, var(--space-small));
178
+ right: var(--input-indent, var(--space-small));
178
179
  }
179
180
 
180
- /* Push the inner .input text past the start slot, with one indent gap. */
181
+ /* Push the inner .input text past the left slot, with one indent gap. */
181
182
  &:has(> [data-slot]:first-child) > .input {
182
- padding-inline-start: calc(var(--input-icon-size, var(--size-icon)) + var(--input-indent, var(--space-small)));
183
+ padding-left: calc(var(--input-icon-size, var(--size-icon)) + var(--input-indent, var(--space-small)));
183
184
  }
184
185
 
185
- /* Push the inner .input text clear of the end slot, with one indent gap. */
186
+ /* Push the inner .input text clear of the right slot, with one indent gap. */
186
187
  &:has(> [data-slot]:last-child) > .input {
187
- padding-inline-end: calc(var(--input-icon-size, var(--size-icon)) + var(--input-indent, var(--space-small)));
188
+ padding-right: calc(var(--input-icon-size, var(--size-icon)) + var(--input-indent, var(--space-small)));
188
189
  }
189
190
  }
190
191
  }
@@ -80,8 +80,10 @@ export declare function NumberSchemaInput({ schema, value, ...props }: NumberSch
80
80
  export interface ChoiceSchemaInputProps extends SchemaInputProps<ChoiceSchema<string>, unknown> {
81
81
  }
82
82
  /**
83
- * Show a choice input for a `ChoiceSchema` — radio inputs for up to 8 options, otherwise a select.
83
+ * Show a choice input for a `ChoiceSchema` — radio inputs or a select.
84
84
  *
85
+ * - The schema's `input` picks the control: `"radio"` for radio inputs, `"select"` for a select.
86
+ * - When `input` is not set, it shows radio inputs for up to 8 options, otherwise a select.
85
87
  * - The value is normalised through the schema's `get()`, so invalid or sentinel values (e.g. a `CountrySchema`'s `"detect"`) resolve to a real option or fall back to the placeholder rather than mis-selecting the first option.
86
88
  *
87
89
  * @returns A `ChoiceRadioInputs` or `SelectInput` element bound to the schema.
@@ -94,8 +94,10 @@ export function NumberSchemaInput({ schema, value, ...props }) {
94
94
  return _jsx(NumberInput, { ...schema, value: getNumber(value), formatter: num => schema.format(num), ...props });
95
95
  }
96
96
  /**
97
- * Show a choice input for a `ChoiceSchema` — radio inputs for up to 8 options, otherwise a select.
97
+ * Show a choice input for a `ChoiceSchema` — radio inputs or a select.
98
98
  *
99
+ * - The schema's `input` picks the control: `"radio"` for radio inputs, `"select"` for a select.
100
+ * - When `input` is not set, it shows radio inputs for up to 8 options, otherwise a select.
99
101
  * - The value is normalised through the schema's `get()`, so invalid or sentinel values (e.g. a `CountrySchema`'s `"detect"`) resolve to a real option or fall back to the placeholder rather than mis-selecting the first option.
100
102
  *
101
103
  * @returns A `ChoiceRadioInputs` or `SelectInput` element bound to the schema.
@@ -106,7 +108,8 @@ export function NumberSchemaInput({ schema, value, ...props }) {
106
108
  export function ChoiceSchemaInput({ schema, value, ...props }) {
107
109
  const choice = requireSource(ChoiceSchema, schema);
108
110
  const string = choice.get(value);
109
- if (getKeys(choice.options).length <= 8)
111
+ const input = choice.input ?? (getKeys(choice.options).length <= 8 ? "radio" : "select");
112
+ if (input === "radio")
110
113
  return _jsx(ChoiceRadioInputs, { ...schema, value: string, ...props });
111
114
  return _jsx(SelectInput, { ...schema, value: string, ...props });
112
115
  }
@@ -17,8 +17,8 @@ The schema-to-input mapping:
17
17
  | `NumberSchema` | `<NumberInput>` (formatted on blur) |
18
18
  | `DateSchema` | `<DateInput>` |
19
19
  | `BooleanSchema` | `<CheckboxInput>` |
20
- | `ChoiceSchema` (≤ 8 options) | `<ChoiceRadioInputs>` |
21
- | `ChoiceSchema` (> 8 options) | `<SelectInput>` |
20
+ | `ChoiceSchema` (`input: "radio"`, or ≤ 8 options) | `<ChoiceRadioInputs>` |
21
+ | `ChoiceSchema` (`input: "select"`, or > 8 options) | `<SelectInput>` |
22
22
  | `ArraySchema` | `<ArrayInput>` |
23
23
  | `DictionarySchema` | `<DictionaryInput>` |
24
24
  | `DataSchema` | `<DataInput>` |
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
- import { PASSWORD, StringSchema, URL_SCHEMA } from "shelving/schema";
4
- import { StringSchemaInput, TextInput, type TextInputProps } from "shelving/ui";
3
+ import { ChoiceSchema, PASSWORD, StringSchema, URL_SCHEMA } from "shelving/schema";
4
+ import { ChoiceSchemaInput, StringSchemaInput, TextInput, type TextInputProps } from "shelving/ui";
5
5
  import { PASSTHROUGH } from "shelving/util/function";
6
6
 
7
7
  /** A `StringSchema` with a non-identity `format()` (wraps in brackets) so display and published values differ. */
@@ -100,3 +100,31 @@ describe("StringSchemaInput", () => {
100
100
  expect(currentTarget.value).toBe("example.com/path");
101
101
  });
102
102
  });
103
+
104
+ describe("ChoiceSchemaInput", () => {
105
+ const FEW = ["a", "b", "c"];
106
+ const MANY = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
107
+
108
+ test("shows radios for 8 or fewer options when input is not set", () => {
109
+ const html = renderToStaticMarkup(<ChoiceSchemaInput name="x" schema={new ChoiceSchema({ options: FEW })} onValue={PASSTHROUGH} />);
110
+ expect(html).toContain('type="radio"');
111
+ expect(html).not.toContain("<select");
112
+ });
113
+ test("shows a select for more than 8 options when input is not set", () => {
114
+ const html = renderToStaticMarkup(<ChoiceSchemaInput name="x" schema={new ChoiceSchema({ options: MANY })} onValue={PASSTHROUGH} />);
115
+ expect(html).toContain("<select");
116
+ expect(html).not.toContain('type="radio"');
117
+ });
118
+ test("shows a select for few options when input is select", () => {
119
+ const schema = new ChoiceSchema({ options: FEW, input: "select" });
120
+ const html = renderToStaticMarkup(<ChoiceSchemaInput name="x" schema={schema} onValue={PASSTHROUGH} />);
121
+ expect(html).toContain("<select");
122
+ expect(html).not.toContain('type="radio"');
123
+ });
124
+ test("shows radios for many options when input is radio", () => {
125
+ const schema = new ChoiceSchema({ options: MANY, input: "radio" });
126
+ const html = renderToStaticMarkup(<ChoiceSchemaInput name="x" schema={schema} onValue={PASSTHROUGH} />);
127
+ expect(html).toContain('type="radio"');
128
+ expect(html).not.toContain("<select");
129
+ });
130
+ });
@@ -148,8 +148,10 @@ export function NumberSchemaInput({ schema, value, ...props }: NumberSchemaInput
148
148
  export interface ChoiceSchemaInputProps extends SchemaInputProps<ChoiceSchema<string>, unknown> {}
149
149
 
150
150
  /**
151
- * Show a choice input for a `ChoiceSchema` — radio inputs for up to 8 options, otherwise a select.
151
+ * Show a choice input for a `ChoiceSchema` — radio inputs or a select.
152
152
  *
153
+ * - The schema's `input` picks the control: `"radio"` for radio inputs, `"select"` for a select.
154
+ * - When `input` is not set, it shows radio inputs for up to 8 options, otherwise a select.
153
155
  * - The value is normalised through the schema's `get()`, so invalid or sentinel values (e.g. a `CountrySchema`'s `"detect"`) resolve to a real option or fall back to the placeholder rather than mis-selecting the first option.
154
156
  *
155
157
  * @returns A `ChoiceRadioInputs` or `SelectInput` element bound to the schema.
@@ -160,7 +162,8 @@ export interface ChoiceSchemaInputProps extends SchemaInputProps<ChoiceSchema<st
160
162
  export function ChoiceSchemaInput({ schema, value, ...props }: ChoiceSchemaInputProps): ReactElement {
161
163
  const choice = requireSource(ChoiceSchema, schema);
162
164
  const string = choice.get(value);
163
- if (getKeys(choice.options).length <= 8) return <ChoiceRadioInputs {...schema} value={string} {...props} />;
165
+ const input = choice.input ?? (getKeys(choice.options).length <= 8 ? "radio" : "select");
166
+ if (input === "radio") return <ChoiceRadioInputs {...schema} value={string} {...props} />;
164
167
  return <SelectInput {...schema} value={string} {...props} />;
165
168
  }
166
169