Skip to content

Commit 88a33ac

Browse files
Goosterhofclaude
andcommitted
ui-inputs: align nullable contract with emmie — widen TextInput, drop Date/Textarea coercion
Recon of emmie (our highest-compliance Laravel territory, which runs the same TextInput/NumberInput component pair) settled the null-vs-empty-string contract: nullable columns serialize to literal null, FE types string|null, the component binds null directly (no ?? '' inside), and a cleared input emits '' — the ConvertEmptyStringsToNull middleware (Kernel.php:58) maps it back to null on submit. Adopt that convention wholesale: - TextInput: widen defineModel<string> -> <string|null> so a null backend field binds directly, killing the call-site ?? '' friction (breaking type-widen, absorbed here; only BIO consumes it, fixed at its 0.3.0 adoption bump). - DateInput / Textarea: drop the empty->null coercion I'd added; emit the raw native '' like TextInput. Thinner, matches the fleet. - NumberInput: keep the NaN->null guard — a number model cannot hold '' honestly, so null is the type-honest empty and round-trips without relying on middleware. 100% component coverage retained; README gains a Nullable values section; CLAUDE.md row + package description updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUvgVSQr81z4BqP5KcrJE6
1 parent 1a46d9e commit 88a33ac

8 files changed

Lines changed: 30 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th
6262
| fs-form | Yes | One-call `useForm`: double-submit guard + `submitting` loading flag + 422 validation-error binding (guarded fs-http middleware); `keyMapper` seam for raw/camel field keys. `useValidationErrors`/`useFormSubmit` primitives still exported |
6363
| fs-translation | Yes | Type-safe reactive i18n with dot-notation keys |
6464
| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router |
65-
| ui-inputs | Yes | Headless, themeable form inputs (`FormField`/`FormLabel`/`FormError`/`TextInput`/`NumberInput`/`DateInput`/`Textarea`/`SingleSelect`) styled purely through `--ui-*` CSS vars; error-as-prop, `aria-*` a11y wiring. The nullable atoms (Number/Date/Textarea) own their empty-value→`null` coercion once. First `ui-*`-family package (ADR-0043); SFC + coverage-only gate (Stryker mutates `.ts`, so the mutation gate is a no-op for this package) |
65+
| ui-inputs | Yes | Headless, themeable form inputs (`FormField`/`FormLabel`/`FormError`/`TextInput`/`NumberInput`/`DateInput`/`Textarea`/`SingleSelect`) styled purely through `--ui-*` CSS vars; error-as-prop, `aria-*` a11y wiring. All inputs model a nullable value (`string \| null` / `number \| null`) so a null backend column binds directly; string inputs emit `''` on clear (fleet `ConvertEmptyStringsToNull` maps it back), `NumberInput` emits `null` (the honest-number exception). Matches emmie's live convention. First `ui-*`-family package (ADR-0043); SFC + coverage-only gate (Stryker mutates `.ts`, so the mutation gate is a no-op for this package) |
6666

6767
## Conventions
6868

packages/ui-inputs/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import '@script-development/ui-inputs/style.css';
2323
| `FormField` | Label + error + required-marker composition wrapper (error-as-prop) |
2424
| `FormLabel` / `FormError` | The atoms `FormField` composes |
2525
| `TextInput` | Native `text` / `email` / `password` / `search` / `tel` / `url` input |
26-
| `NumberInput` | Native `number` input; nullable model, owns the `NaN``null` empty-value coercion |
27-
| `DateInput` | Native `date` input; nullable model, coerces a cleared date to `null` |
28-
| `Textarea` | Native `textarea`; nullable model, coerces a cleared value to `null` |
26+
| `NumberInput` | Native `number` input; owns the `NaN``null` empty-value guard |
27+
| `DateInput` | Native `date` input |
28+
| `Textarea` | Native `textarea` with `rows` |
2929
| `SingleSelect` | Accessible listbox/combobox over `@floating-ui/vue`, generic over your option type |
3030

3131
```vue
@@ -38,6 +38,10 @@ import '@script-development/ui-inputs/style.css';
3838

3939
Every visual rule keys on a `--ui-*` custom property — colours **and** structure (`--ui-control-border-width`, `--ui-control-radius`, `--ui-control-shadow`, `--ui-label-transform`, …). Remap them under any selector to theme the whole set; the shipped defaults render out of the box. Dark/light is orthogonal — pair with `@script-development/fs-theme`'s `data-theme` switching.
4040

41+
## Nullable values
42+
43+
Every text-like input (`TextInput`, `DateInput`, `Textarea`) models `string | null`, and `NumberInput` models `number | null`. A `null` from a nullable backend column binds directly — the control renders empty, no `?? ''` at the call site. When the user clears the field, the string inputs emit `''` (the raw native value); a Laravel backend's `ConvertEmptyStringsToNull` middleware maps that back to `null` on submit. `NumberInput` is the one exception: an empty number input emits `null` (not `NaN`, not `''`), since a `number` model can never hold `''` honestly — so it round-trips to `null` without relying on the middleware.
44+
4145
## Errors are a prop, never a service
4246

4347
The components never import an error service. Resolve the message in your app and pass `error` (to `FormField`) or `invalid` + `describedby` (to the inputs). That keeps the package agnostic to how your territory produces validation errors.

packages/ui-inputs/src/components/DateInput.vue

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
:aria-required="required || undefined"
1212
:aria-invalid="invalid || undefined"
1313
:aria-describedby="describedby"
14-
@input="onInput"
14+
@input="model = ($event.target as HTMLInputElement).value"
1515
/>
1616
</template>
1717

@@ -30,12 +30,8 @@ defineProps<{
3030
describedby?: string;
3131
}>();
3232
33+
// Accepts null so it binds a nullable date column directly (Vue renders null as an
34+
// empty control); a cleared date emits '', which the fleet's
35+
// ConvertEmptyStringsToNull middleware converts back to null on submit.
3336
const model = defineModel<string | null>({required: true});
34-
35-
// Coerce a cleared date to null (not the empty string), so a nullable date column
36-
// receives null rather than "" — the latter fails backend date validation.
37-
function onInput(event: Event) {
38-
const {value} = event.target as HTMLInputElement;
39-
model.value = value === '' ? null : value;
40-
}
4137
</script>

packages/ui-inputs/src/components/TextInput.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,8 @@ const {type = 'text'} = defineProps<{
2828
describedby?: string;
2929
}>();
3030
31-
const model = defineModel<string>({required: true});
31+
// Accepts null so it binds a nullable backend field directly (Vue renders null as
32+
// an empty control); a cleared input emits '', which the fleet's
33+
// ConvertEmptyStringsToNull middleware converts back to null on submit.
34+
const model = defineModel<string | null>({required: true});
3235
</script>

packages/ui-inputs/src/components/Textarea.vue

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
:id="id"
44
class="ui-control ui-textarea"
55
:class="{'is-invalid': invalid}"
6-
:value="model ?? ''"
6+
:value="model"
77
:placeholder="placeholder"
88
:disabled="disabled"
99
:rows="rows"
1010
:aria-required="required || undefined"
1111
:aria-invalid="invalid || undefined"
1212
:aria-describedby="describedby"
13-
@input="onInput"
13+
@input="model = ($event.target as HTMLTextAreaElement).value"
1414
/>
1515
</template>
1616

@@ -28,12 +28,8 @@ defineProps<{
2828
describedby?: string;
2929
}>();
3030
31+
// Accepts null so it binds a nullable text column directly (Vue renders null as an
32+
// empty control); a cleared textarea emits '', which the fleet's
33+
// ConvertEmptyStringsToNull middleware converts back to null on submit.
3134
const model = defineModel<string | null>({required: true});
32-
33-
// Coerce a cleared textarea to null (not the empty string), mirroring DateInput,
34-
// so a nullable text column receives null rather than "".
35-
function onInput(event: Event) {
36-
const {value} = event.target as HTMLTextAreaElement;
37-
model.value = value === '' ? null : value;
38-
}
3935
</script>

packages/ui-inputs/tests/DateInput.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ describe('DateInput', () => {
5050
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2026-03-01']);
5151
});
5252

53-
it('emits null when the date is cleared', async () => {
53+
it("emits '' when the date is cleared (raw native value; middleware maps to null)", async () => {
5454
const wrapper = mount(DateInput, {props: {id: 'dob', modelValue: '2026-07-17'}});
5555
await wrapper.find('input').setValue('');
56-
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]);
56+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']);
5757
});
5858

5959
it('renders the disabled attribute when disabled', () => {

packages/ui-inputs/tests/TextInput.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@ describe('TextInput', () => {
4646
const wrapper = mount(TextInput, {props: {id: 'd', disabled: true, modelValue: ''}});
4747
expect(wrapper.find('input').attributes('disabled')).toBeDefined();
4848
});
49+
50+
it('renders empty for a null model (binds a nullable backend field directly)', () => {
51+
const wrapper = mount(TextInput, {props: {id: 'name', modelValue: null}});
52+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
53+
});
4954
});

packages/ui-inputs/tests/Textarea.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ describe('Textarea', () => {
5050
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['a note']);
5151
});
5252

53-
it('emits null when the textarea is cleared', async () => {
53+
it("emits '' when the textarea is cleared (raw native value; middleware maps to null)", async () => {
5454
const wrapper = mount(Textarea, {props: {id: 'notes', modelValue: 'hi'}});
5555
await wrapper.find('textarea').setValue('');
56-
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]);
56+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']);
5757
});
5858

5959
it('renders the disabled attribute when disabled', () => {

0 commit comments

Comments
 (0)