Skip to content

Commit 1a46d9e

Browse files
Goosterhofclaude
andcommitted
ui-inputs: 0.3.0 — Number/Date/Textarea atoms + control-bg state vars (WR-0460)
Add three nullable-model atoms mirroring TextInput's headless/error-as-prop shape, each owning its empty-value coercion once so consumers stop reinventing per-field handlers (the WR-0431 BIO pilot finding): - NumberInput (number | null): owns the valueAsNumber NaN -> null guard; min/max/step - DateInput (string | null): <input type="date">; clears to null; min/max - Textarea (string | null): rows; clears to null Add --ui-control-bg-focus / --ui-control-bg-invalid theme vars (default to --ui-control-bg, so no-op until a territory opts in) — the --ui-* contract had no background-on-state hook, which brick's focus/invalid fills rode as class overrides in the BIO pilot. 100% component-test coverage (branches included) per the SFC recipe; barrel + README + CLAUDE.md packages row updated; package description --fs-* -> --ui-*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUvgVSQr81z4BqP5KcrJE6
1 parent 20a6ca2 commit 1a46d9e

11 files changed

Lines changed: 337 additions & 3 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`/`SingleSelect`) styled purely through `--ui-*` CSS vars; error-as-prop, `aria-*` a11y wiring. 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. 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) |
6666

6767
## Conventions
6868

packages/ui-inputs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +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` |
2629
| `SingleSelect` | Accessible listbox/combobox over `@floating-ui/vue`, generic over your option type |
2730

2831
```vue

packages/ui-inputs/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@script-development/ui-inputs",
3-
"version": "0.2.0",
4-
"description": "Headless, themeable Vue 3 form input components (field, label, error, text input, select) styled entirely through --fs-* CSS custom properties.",
3+
"version": "0.3.0",
4+
"description": "Headless, themeable Vue 3 form input components (field, label, error, text/number/date input, textarea, select) styled entirely through --ui-* CSS custom properties.",
55
"homepage": "https://packages.script.nl/packages/ui-inputs",
66
"license": "MIT",
77
"repository": {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<template>
2+
<input
3+
:id="id"
4+
type="date"
5+
class="ui-control ui-input"
6+
:class="{'is-invalid': invalid}"
7+
:value="model"
8+
:disabled="disabled"
9+
:min="min"
10+
:max="max"
11+
:aria-required="required || undefined"
12+
:aria-invalid="invalid || undefined"
13+
:aria-describedby="describedby"
14+
@input="onInput"
15+
/>
16+
</template>
17+
18+
<script setup lang="ts">
19+
defineProps<{
20+
id: string;
21+
disabled?: boolean;
22+
/** ISO date bound (`YYYY-MM-DD`) for the native picker. */
23+
min?: string;
24+
max?: string;
25+
/** conveys the required state to assistive tech via `aria-required`. */
26+
required?: boolean;
27+
/** invalid styling + aria; drive it from the field's error. */
28+
invalid?: boolean;
29+
/** id of the paired error element for `aria-describedby`. */
30+
describedby?: string;
31+
}>();
32+
33+
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+
}
41+
</script>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<template>
2+
<input
3+
:id="id"
4+
type="number"
5+
class="ui-control ui-input"
6+
:class="{'is-invalid': invalid}"
7+
:value="model"
8+
:placeholder="placeholder"
9+
:disabled="disabled"
10+
:min="min"
11+
:max="max"
12+
:step="step"
13+
:aria-required="required || undefined"
14+
:aria-invalid="invalid || undefined"
15+
:aria-describedby="describedby"
16+
@input="onInput"
17+
/>
18+
</template>
19+
20+
<script setup lang="ts">
21+
defineProps<{
22+
id: string;
23+
placeholder?: string;
24+
disabled?: boolean;
25+
min?: number;
26+
max?: number;
27+
step?: number;
28+
/** conveys the required state to assistive tech via `aria-required`. */
29+
required?: boolean;
30+
/** invalid styling + aria; drive it from the field's error. */
31+
invalid?: boolean;
32+
/** id of the paired error element for `aria-describedby`. */
33+
describedby?: string;
34+
}>();
35+
36+
const model = defineModel<number | null>({required: true});
37+
38+
// Own the empty-input coercion ONCE, so no consumer reinvents it: a native number
39+
// input yields NaN for an empty or unparseable value — map that to null so the
40+
// model is always a real number or an explicit "no value", never NaN.
41+
function onInput(event: Event) {
42+
const {valueAsNumber} = event.target as HTMLInputElement;
43+
model.value = Number.isNaN(valueAsNumber) ? null : valueAsNumber;
44+
}
45+
</script>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<template>
2+
<textarea
3+
:id="id"
4+
class="ui-control ui-textarea"
5+
:class="{'is-invalid': invalid}"
6+
:value="model ?? ''"
7+
:placeholder="placeholder"
8+
:disabled="disabled"
9+
:rows="rows"
10+
:aria-required="required || undefined"
11+
:aria-invalid="invalid || undefined"
12+
:aria-describedby="describedby"
13+
@input="onInput"
14+
/>
15+
</template>
16+
17+
<script setup lang="ts">
18+
defineProps<{
19+
id: string;
20+
placeholder?: string;
21+
disabled?: boolean;
22+
rows?: number;
23+
/** conveys the required state to assistive tech via `aria-required`. */
24+
required?: boolean;
25+
/** invalid styling + aria; drive it from the field's error. */
26+
invalid?: boolean;
27+
/** id of the paired error element for `aria-describedby`. */
28+
describedby?: string;
29+
}>();
30+
31+
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+
}
39+
</script>

packages/ui-inputs/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ export {default as FormField} from './components/FormField.vue';
22
export {default as FormLabel} from './components/FormLabel.vue';
33
export {default as FormError} from './components/FormError.vue';
44
export {default as TextInput} from './components/TextInput.vue';
5+
export {default as NumberInput} from './components/NumberInput.vue';
6+
export {default as DateInput} from './components/DateInput.vue';
7+
export {default as Textarea} from './components/Textarea.vue';
58
export {default as SingleSelect} from './components/SingleSelect.vue';
69

710
export type {SelectItem, LabelKey} from './types';

packages/ui-inputs/styles.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222

2323
/* control (input + select trigger) */
2424
--ui-control-bg: #ffffff;
25+
/* background-on-state hooks — default to the resting background so they are a
26+
no-op until a territory opts in (brick fills the control on focus/invalid). */
27+
--ui-control-bg-focus: var(--ui-control-bg);
28+
--ui-control-bg-invalid: var(--ui-control-bg);
2529
--ui-control-bg-disabled: #f3f4f6;
2630
--ui-control-text: #111827;
2731
--ui-control-text-muted: #6b7280;
@@ -97,16 +101,22 @@
97101
}
98102
.ui-control:focus-visible {
99103
outline: none;
104+
background: var(--ui-control-bg-focus);
100105
box-shadow: var(--ui-focus-ring);
101106
}
102107
.ui-control.is-open {
103108
border-color: var(--ui-control-border-open);
104109
box-shadow: var(--ui-focus-ring);
105110
}
106111
.ui-control.is-invalid {
112+
background: var(--ui-control-bg-invalid);
107113
border-color: var(--ui-danger-border);
108114
box-shadow: var(--ui-danger-shadow);
109115
}
116+
117+
.ui-textarea {
118+
resize: vertical;
119+
}
110120
.ui-control:disabled {
111121
background: var(--ui-control-bg-disabled);
112122
color: var(--ui-control-text-muted);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment happy-dom
2+
import {mount} from '@vue/test-utils';
3+
import {describe, expect, it} from 'vitest';
4+
5+
import DateInput from '../src/components/DateInput.vue';
6+
7+
describe('DateInput', () => {
8+
it('renders a date input reflecting the model and valid state', () => {
9+
const wrapper = mount(DateInput, {props: {id: 'dob', modelValue: '2026-07-17'}});
10+
const input = wrapper.find('input');
11+
12+
expect(input.attributes('type')).toBe('date');
13+
expect((input.element as HTMLInputElement).value).toBe('2026-07-17');
14+
expect(input.classes()).toContain('ui-control');
15+
expect(input.classes()).not.toContain('is-invalid');
16+
expect(input.attributes('aria-invalid')).toBeUndefined();
17+
expect(input.attributes('aria-required')).toBeUndefined();
18+
});
19+
20+
it('renders empty for a null model', () => {
21+
const wrapper = mount(DateInput, {props: {id: 'dob', modelValue: null}});
22+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
23+
});
24+
25+
it('honours min/max/required/invalid/describedby', () => {
26+
const wrapper = mount(DateInput, {
27+
props: {
28+
id: 'dob',
29+
modelValue: '2026-07-17',
30+
min: '2026-01-01',
31+
max: '2026-12-31',
32+
required: true,
33+
invalid: true,
34+
describedby: 'dob-error',
35+
},
36+
});
37+
const input = wrapper.find('input');
38+
39+
expect(input.attributes('min')).toBe('2026-01-01');
40+
expect(input.attributes('max')).toBe('2026-12-31');
41+
expect(input.classes()).toContain('is-invalid');
42+
expect(input.attributes('aria-invalid')).toBe('true');
43+
expect(input.attributes('aria-required')).toBe('true');
44+
expect(input.attributes('aria-describedby')).toBe('dob-error');
45+
});
46+
47+
it('emits the date string on input', async () => {
48+
const wrapper = mount(DateInput, {props: {id: 'dob', modelValue: null}});
49+
await wrapper.find('input').setValue('2026-03-01');
50+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2026-03-01']);
51+
});
52+
53+
it('emits null when the date is cleared', async () => {
54+
const wrapper = mount(DateInput, {props: {id: 'dob', modelValue: '2026-07-17'}});
55+
await wrapper.find('input').setValue('');
56+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]);
57+
});
58+
59+
it('renders the disabled attribute when disabled', () => {
60+
const wrapper = mount(DateInput, {props: {id: 'dob', disabled: true, modelValue: null}});
61+
expect(wrapper.find('input').attributes('disabled')).toBeDefined();
62+
});
63+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @vitest-environment happy-dom
2+
import {mount} from '@vue/test-utils';
3+
import {describe, expect, it} from 'vitest';
4+
5+
import NumberInput from '../src/components/NumberInput.vue';
6+
7+
describe('NumberInput', () => {
8+
it('renders a number input reflecting the model and valid state', () => {
9+
const wrapper = mount(NumberInput, {props: {id: 'qty', modelValue: 5}});
10+
const input = wrapper.find('input');
11+
12+
expect(input.attributes('type')).toBe('number');
13+
expect((input.element as HTMLInputElement).value).toBe('5');
14+
expect(input.classes()).toContain('ui-control');
15+
expect(input.classes()).not.toContain('is-invalid');
16+
expect(input.attributes('aria-invalid')).toBeUndefined();
17+
expect(input.attributes('aria-required')).toBeUndefined();
18+
});
19+
20+
it('renders empty for a null model', () => {
21+
const wrapper = mount(NumberInput, {props: {id: 'qty', modelValue: null}});
22+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
23+
});
24+
25+
it('honours placeholder/min/max/step/required/invalid/describedby', () => {
26+
const wrapper = mount(NumberInput, {
27+
props: {
28+
id: 'qty',
29+
modelValue: 3,
30+
placeholder: 'How many',
31+
min: 0,
32+
max: 10,
33+
step: 2,
34+
required: true,
35+
invalid: true,
36+
describedby: 'qty-error',
37+
},
38+
});
39+
const input = wrapper.find('input');
40+
41+
expect(input.attributes('placeholder')).toBe('How many');
42+
expect(input.attributes('min')).toBe('0');
43+
expect(input.attributes('max')).toBe('10');
44+
expect(input.attributes('step')).toBe('2');
45+
expect(input.classes()).toContain('is-invalid');
46+
expect(input.attributes('aria-invalid')).toBe('true');
47+
expect(input.attributes('aria-required')).toBe('true');
48+
expect(input.attributes('aria-describedby')).toBe('qty-error');
49+
});
50+
51+
it('emits the parsed number on input', async () => {
52+
const wrapper = mount(NumberInput, {props: {id: 'qty', modelValue: null}});
53+
await wrapper.find('input').setValue('42');
54+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([42]);
55+
});
56+
57+
it('emits null when the input is cleared (NaN guard, owned once)', async () => {
58+
const wrapper = mount(NumberInput, {props: {id: 'qty', modelValue: 7}});
59+
await wrapper.find('input').setValue('');
60+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null]);
61+
});
62+
63+
it('renders the disabled attribute when disabled', () => {
64+
const wrapper = mount(NumberInput, {props: {id: 'qty', disabled: true, modelValue: null}});
65+
expect(wrapper.find('input').attributes('disabled')).toBeDefined();
66+
});
67+
});

0 commit comments

Comments
 (0)