Skip to content

Commit 65343e3

Browse files
authored
Merge pull request #160 from script-development/feat/ui-inputs-package
feat(ui-inputs): headless themeable Vue input package (ADR-0043)
2 parents 82b91a5 + ae33143 commit 65343e3

22 files changed

Lines changed: 6318 additions & 4265 deletions

package-lock.json

Lines changed: 5394 additions & 4264 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@changesets/cli": "^2",
2929
"@stryker-mutator/core": "^9",
3030
"@stryker-mutator/vitest-runner": "^9",
31+
"@vitejs/plugin-vue": "^6.0.8",
3132
"@vitest/coverage-v8": "^4",
3233
"happy-dom": "^20.10.3",
3334
"oxfmt": "^0.57.0",

packages/ui-inputs/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# @script-development/ui-inputs
2+
3+
Headless, themeable Vue 3 form input components, styled entirely through `--ui-*` CSS custom properties.
4+
5+
Part of the Armory `ui-*` family. The components ship **no token vocabulary and no colour literal** — you map your design tokens onto the `--ui-*` contract once, and every component follows. Kendo-soft or brutalist, light or dark, from one component set.
6+
7+
## Install
8+
9+
```sh
10+
npm install @script-development/ui-inputs
11+
```
12+
13+
Peer dependency: `vue@^3.5`. Import the stylesheet once (e.g. in your entry):
14+
15+
```ts
16+
import '@script-development/ui-inputs/style.css';
17+
```
18+
19+
## Components
20+
21+
| Component | Purpose |
22+
| ------------------------- | ---------------------------------------------------------------------------------- |
23+
| `FormField` | Label + error + required-marker composition wrapper (error-as-prop) |
24+
| `FormLabel` / `FormError` | The atoms `FormField` composes |
25+
| `TextInput` | Native `text` / `email` / `password` / `search` / `tel` / `url` input |
26+
| `SingleSelect` | Accessible listbox/combobox over `@floating-ui/vue`, generic over your option type |
27+
28+
```vue
29+
<FormField id="fruit" label="Fruit" :error="errors.fruit" #default="{controlId, describedby, invalid}">
30+
<SingleSelect :id="controlId" v-model="fruit" :options="fruits" label="name" :invalid="invalid" :describedby="describedby" />
31+
</FormField>
32+
```
33+
34+
## Theming
35+
36+
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.
37+
38+
## Errors are a prop, never a service
39+
40+
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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"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.",
5+
"homepage": "https://packages.script.nl/packages/ui-inputs",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "git+https://github.com/script-development/fs-packages.git",
10+
"directory": "packages/ui-inputs"
11+
},
12+
"files": [
13+
"dist",
14+
"styles.css"
15+
],
16+
"type": "module",
17+
"sideEffects": false,
18+
"main": "./dist/index.cjs",
19+
"module": "./dist/index.mjs",
20+
"types": "./dist/index.d.mts",
21+
"exports": {
22+
".": {
23+
"import": {
24+
"types": "./dist/index.d.mts",
25+
"default": "./dist/index.mjs"
26+
},
27+
"require": {
28+
"types": "./dist/index.d.cts",
29+
"default": "./dist/index.cjs"
30+
}
31+
},
32+
"./style.css": "./styles.css"
33+
},
34+
"publishConfig": {
35+
"access": "public",
36+
"registry": "https://registry.npmjs.org"
37+
},
38+
"scripts": {
39+
"build": "tsdown",
40+
"lint:pkg": "publint && attw --pack",
41+
"test": "vitest run",
42+
"test:coverage": "vitest run --coverage",
43+
"test:mutation": "echo 'ui-inputs: component logic lives in SFCs — Stryker mutates .ts only, so there is nothing to mutate; behaviour is guarded by the 100% component-test coverage gate.'",
44+
"typecheck": "vue-tsc --noEmit"
45+
},
46+
"dependencies": {
47+
"@floating-ui/vue": "^1.1.6"
48+
},
49+
"devDependencies": {
50+
"@vitejs/plugin-vue": "^6.0.8",
51+
"@vue/test-utils": "^2.4.6",
52+
"happy-dom": "^20.10.3",
53+
"unplugin-vue": "^6.0.1",
54+
"vue": "^3.5.39",
55+
"vue-tsc": "^3.1.1"
56+
},
57+
"peerDependencies": {
58+
"vue": "^3.5.39"
59+
},
60+
"engines": {
61+
"node": ">=24.0.0"
62+
}
63+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<p :id="id" class="ui-error" role="alert">{{ error }}</p>
3+
</template>
4+
5+
<script setup lang="ts">
6+
defineProps<{
7+
/**
8+
* the resolved error string. Supplied by the consumer — never read from a service.
9+
* Guard rendering with `v-if` in the parent (as `FormField` does); do not pass an
10+
* empty string, or an empty `role="alert"` element is announced.
11+
*/
12+
error: string;
13+
/** id used to pair with a control's `aria-describedby`. */
14+
id: string;
15+
}>();
16+
</script>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<template>
2+
<div class="ui-field">
3+
<FormLabel v-if="label" :html-for="id" :required="required">{{ label }}</FormLabel>
4+
<!-- the control slot receives the wiring it needs to stay accessible -->
5+
<slot
6+
:control-id="id"
7+
:error-id="errorId"
8+
:required="required"
9+
:invalid="Boolean(error)"
10+
:describedby="error ? errorId : undefined"
11+
/>
12+
<FormError v-if="error" :error="error" :id="errorId" />
13+
</div>
14+
</template>
15+
16+
<script setup lang="ts">
17+
import FormError from './FormError.vue';
18+
import FormLabel from './FormLabel.vue';
19+
20+
const {
21+
label,
22+
required = false,
23+
error,
24+
id,
25+
} = defineProps<{
26+
/** label text; omit for an unlabelled field. */
27+
label?: string;
28+
required?: boolean;
29+
/** resolved error string, supplied by the consumer (error-as-prop). */
30+
error?: string;
31+
/** stable control id — pass `useId()` at the call site if you have no natural one. */
32+
id: string;
33+
}>();
34+
35+
const errorId = `${id}-error`;
36+
</script>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<template>
2+
<label class="ui-label" :for="htmlFor">
3+
<slot />
4+
<span v-if="required" class="ui-label__req" aria-hidden="true">*</span>
5+
</label>
6+
</template>
7+
8+
<script setup lang="ts">
9+
defineProps<{
10+
/** id of the control this label describes (`for` attribute). */
11+
htmlFor: string;
12+
/** render the required marker. */
13+
required?: boolean;
14+
}>();
15+
</script>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<template>
2+
<div ref="root" class="ui-select" @keydown="onKey">
3+
<button
4+
:id="id"
5+
ref="reference"
6+
type="button"
7+
class="ui-control ui-select__trigger"
8+
:class="{'is-open': open, 'has-value': selected !== undefined, 'is-invalid': invalid}"
9+
:disabled="disabled"
10+
role="combobox"
11+
aria-haspopup="listbox"
12+
:aria-expanded="open"
13+
:aria-required="required || undefined"
14+
:aria-invalid="invalid || undefined"
15+
:aria-describedby="describedby"
16+
@click="toggle"
17+
>
18+
<span v-if="selected === undefined" class="ui-select__placeholder">{{ placeholder }}</span>
19+
<span v-else class="ui-select__value">{{ labelOf(selected) }}</span>
20+
<svg class="ui-select__chevron" viewBox="0 0 20 20" aria-hidden="true">
21+
<path d="M5 8l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" />
22+
</svg>
23+
</button>
24+
25+
<ul
26+
v-if="open"
27+
ref="floating"
28+
class="ui-select__menu"
29+
role="listbox"
30+
:aria-label="optionsLabel"
31+
:style="floatingStyles"
32+
>
33+
<li v-if="!options.length" class="ui-select__empty">{{ emptyText }}</li>
34+
<li
35+
v-for="(option, index) in sorted"
36+
:key="String(option.id)"
37+
class="ui-select__option"
38+
:class="{'is-active': pointer === index}"
39+
role="option"
40+
:aria-selected="pointer === index"
41+
@mouseover="pointer = index"
42+
@click="choose(option)"
43+
>
44+
{{ labelOf(option) }}
45+
</li>
46+
</ul>
47+
</div>
48+
</template>
49+
50+
<script setup lang="ts" generic="T extends SelectItem">
51+
import {autoUpdate, flip, hide, offset, shift, useFloating} from '@floating-ui/vue';
52+
import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue';
53+
54+
import type {LabelKey, SelectItem} from '../types';
55+
56+
const {
57+
options,
58+
label,
59+
placeholder = 'Select…',
60+
disabled = false,
61+
alphabeticalSort = true,
62+
required = false,
63+
invalid = false,
64+
describedby,
65+
emptyText = 'No options',
66+
optionsLabel = 'Options',
67+
} = defineProps<{
68+
options: T[];
69+
/** property name or getter for an option's display string. */
70+
label: LabelKey<T>;
71+
/** stable id — required so the trigger can pair with a label/error. */
72+
id: string;
73+
placeholder?: string;
74+
disabled?: boolean;
75+
alphabeticalSort?: boolean;
76+
/** conveys the required state to assistive tech via `aria-required`. */
77+
required?: boolean;
78+
invalid?: boolean;
79+
describedby?: string;
80+
emptyText?: string;
81+
/** accessible name for the listbox popup (`aria-label`). */
82+
optionsLabel?: string;
83+
}>();
84+
85+
const model = defineModel<T['id'] | null>({required: true});
86+
87+
/** Resolve an option's display string from the `label` prop (property name or getter). */
88+
const labelOf = (option: T): string =>
89+
typeof label === 'function'
90+
? label(option)
91+
: String((option as Record<PropertyKey, unknown>)[label as PropertyKey]);
92+
93+
const selected = computed(() => options.find((option) => option.id === model.value));
94+
const sorted = computed(() =>
95+
alphabeticalSort ? [...options].sort((a, b) => labelOf(a).localeCompare(labelOf(b))) : options,
96+
);
97+
98+
const open = ref(false);
99+
const pointer = ref(-1);
100+
101+
const toggle = () => {
102+
open.value = !open.value;
103+
};
104+
const close = () => {
105+
open.value = false;
106+
pointer.value = -1;
107+
};
108+
const choose = (option: T) => {
109+
model.value = option.id;
110+
close();
111+
};
112+
113+
// Listbox keyboard navigation. The trigger's native :disabled blocks clicks; this
114+
// guards the keyboard path too.
115+
const onKey = (event: KeyboardEvent) => {
116+
if (disabled) return;
117+
if (event.key === 'Tab') {
118+
close();
119+
return;
120+
}
121+
if (!open.value) {
122+
if (['Enter', 'ArrowDown', ' '].includes(event.key)) {
123+
event.preventDefault();
124+
open.value = true;
125+
}
126+
return;
127+
}
128+
switch (event.key) {
129+
case 'ArrowDown':
130+
pointer.value = Math.min(pointer.value + 1, sorted.value.length - 1);
131+
event.preventDefault();
132+
break;
133+
case 'ArrowUp':
134+
pointer.value = Math.max(pointer.value - 1, -1);
135+
event.preventDefault();
136+
break;
137+
case 'Enter':
138+
if (pointer.value >= 0) {
139+
choose(sorted.value[pointer.value]);
140+
event.preventDefault();
141+
}
142+
break;
143+
case 'Escape':
144+
close();
145+
event.preventDefault();
146+
break;
147+
}
148+
};
149+
150+
// click-outside — closes the menu without a shared directive dependency.
151+
const root = useTemplateRef<HTMLElement>('root');
152+
const onDocumentPointer = (event: MouseEvent) => {
153+
// The listener is attached only between mount and unmount, so the ref is non-null here.
154+
if (!root.value!.contains(event.target as Node)) close();
155+
};
156+
onMounted(() => document.addEventListener('click', onDocumentPointer));
157+
onBeforeUnmount(() => document.removeEventListener('click', onDocumentPointer));
158+
159+
const reference = useTemplateRef<HTMLElement>('reference');
160+
const floating = useTemplateRef<HTMLElement>('floating');
161+
const {floatingStyles} = useFloating(reference, floating, {
162+
placement: 'bottom-start',
163+
middleware: [offset(4), flip({fallbackPlacements: ['top-start']}), shift({padding: 8}), hide()],
164+
whileElementsMounted: autoUpdate,
165+
});
166+
</script>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<template>
2+
<input
3+
:id="id"
4+
:type="type"
5+
class="ui-control ui-input"
6+
:class="{'is-invalid': invalid}"
7+
:value="model"
8+
:placeholder="placeholder"
9+
:disabled="disabled"
10+
:aria-required="required || undefined"
11+
:aria-invalid="invalid || undefined"
12+
:aria-describedby="describedby"
13+
@input="model = ($event.target as HTMLInputElement).value"
14+
/>
15+
</template>
16+
17+
<script setup lang="ts">
18+
const {type = 'text'} = defineProps<{
19+
id: string;
20+
type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
21+
placeholder?: string;
22+
disabled?: boolean;
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>({required: true});
32+
</script>

packages/ui-inputs/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {default as FormField} from './components/FormField.vue';
2+
export {default as FormLabel} from './components/FormLabel.vue';
3+
export {default as FormError} from './components/FormError.vue';
4+
export {default as TextInput} from './components/TextInput.vue';
5+
export {default as SingleSelect} from './components/SingleSelect.vue';
6+
7+
export type {SelectItem, LabelKey} from './types';

0 commit comments

Comments
 (0)