Skip to content

Commit 0352c3d

Browse files
authored
Merge pull request #162 from script-development/war-room/wr0460-ui-inputs-0.3.0-atoms
ui-inputs 0.3.0 — Number/Date/Textarea atoms + control-bg state vars (WR-0460)
2 parents 20a6ca2 + 88a33ac commit 0352c3d

13 files changed

Lines changed: 342 additions & 4 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. 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 & 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; owns the `NaN``null` empty-value guard |
27+
| `DateInput` | Native `date` input |
28+
| `Textarea` | Native `textarea` with `rows` |
2629
| `SingleSelect` | Accessible listbox/combobox over `@floating-ui/vue`, generic over your option type |
2730

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

3639
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.
3740

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+
3845
## Errors are a prop, never a service
3946

4047
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/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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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="model = ($event.target as HTMLInputElement).value"
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+
// 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.
36+
const model = defineModel<string | null>({required: true});
37+
</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>

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>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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="model = ($event.target as HTMLTextAreaElement).value"
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+
// 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.
34+
const model = defineModel<string | null>({required: true});
35+
</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 '' when the date is cleared (raw native value; middleware maps to null)", 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(['']);
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+
});

0 commit comments

Comments
 (0)