Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9,658 changes: 5,394 additions & 4,264 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@changesets/cli": "^2",
"@stryker-mutator/core": "^9",
"@stryker-mutator/vitest-runner": "^9",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/coverage-v8": "^4",
"happy-dom": "^20.10.3",
"oxfmt": "^0.57.0",
Expand Down
40 changes: 40 additions & 0 deletions packages/ui-inputs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# @script-development/ui-inputs

Headless, themeable Vue 3 form input components, styled entirely through `--ui-*` CSS custom properties.

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.

## Install

```sh
npm install @script-development/ui-inputs
```

Peer dependency: `vue@^3.5`. Import the stylesheet once (e.g. in your entry):

```ts
import '@script-development/ui-inputs/style.css';
```

## Components

| Component | Purpose |
| ------------------------- | ---------------------------------------------------------------------------------- |
| `FormField` | Label + error + required-marker composition wrapper (error-as-prop) |
| `FormLabel` / `FormError` | The atoms `FormField` composes |
| `TextInput` | Native `text` / `email` / `password` / `search` / `tel` / `url` input |
| `SingleSelect` | Accessible listbox/combobox over `@floating-ui/vue`, generic over your option type |

```vue
<FormField id="fruit" label="Fruit" :error="errors.fruit" #default="{controlId, describedby, invalid}">
<SingleSelect :id="controlId" v-model="fruit" :options="fruits" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>
```

## Theming

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.

## Errors are a prop, never a service

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.
63 changes: 63 additions & 0 deletions packages/ui-inputs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "@script-development/ui-inputs",
"version": "0.2.0",
"description": "Headless, themeable Vue 3 form input components (field, label, error, text input, select) styled entirely through --fs-* CSS custom properties.",
"homepage": "https://packages.script.nl/packages/ui-inputs",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/script-development/fs-packages.git",
"directory": "packages/ui-inputs"
},
"files": [
"dist",
"styles.css"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./style.css": "./styles.css"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"scripts": {
"build": "tsdown",
"lint:pkg": "publint && attw --pack",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"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.'",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@floating-ui/vue": "^1.1.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.10.3",
"unplugin-vue": "^6.0.1",
"vue": "^3.5.39",
"vue-tsc": "^3.1.1"
},
"peerDependencies": {
"vue": "^3.5.39"
},
"engines": {
"node": ">=24.0.0"
}
}
16 changes: 16 additions & 0 deletions packages/ui-inputs/src/components/FormError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<p :id="id" class="ui-error" role="alert">{{ error }}</p>
</template>

<script setup lang="ts">
defineProps<{
/**
* the resolved error string. Supplied by the consumer — never read from a service.
* Guard rendering with `v-if` in the parent (as `FormField` does); do not pass an
* empty string, or an empty `role="alert"` element is announced.
*/
error: string;
/** id used to pair with a control's `aria-describedby`. */
id: string;
}>();
</script>
36 changes: 36 additions & 0 deletions packages/ui-inputs/src/components/FormField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<template>
<div class="ui-field">
<FormLabel v-if="label" :html-for="id" :required="required">{{ label }}</FormLabel>
<!-- the control slot receives the wiring it needs to stay accessible -->
<slot
:control-id="id"
:error-id="errorId"
:required="required"
:invalid="Boolean(error)"
:describedby="error ? errorId : undefined"
/>
<FormError v-if="error" :error="error" :id="errorId" />
</div>
</template>

<script setup lang="ts">
import FormError from './FormError.vue';
import FormLabel from './FormLabel.vue';

const {
label,
required = false,
error,
id,
} = defineProps<{
/** label text; omit for an unlabelled field. */
label?: string;
required?: boolean;
/** resolved error string, supplied by the consumer (error-as-prop). */
error?: string;
/** stable control id — pass `useId()` at the call site if you have no natural one. */
id: string;
}>();

const errorId = `${id}-error`;
</script>
15 changes: 15 additions & 0 deletions packages/ui-inputs/src/components/FormLabel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<label class="ui-label" :for="htmlFor">
<slot />
<span v-if="required" class="ui-label__req" aria-hidden="true">*</span>
</label>
</template>

<script setup lang="ts">
defineProps<{
/** id of the control this label describes (`for` attribute). */
htmlFor: string;
/** render the required marker. */
required?: boolean;
}>();
</script>
166 changes: 166 additions & 0 deletions packages/ui-inputs/src/components/SingleSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<template>
<div ref="root" class="ui-select" @keydown="onKey">
<button
:id="id"
ref="reference"
type="button"
class="ui-control ui-select__trigger"
:class="{'is-open': open, 'has-value': selected !== undefined, 'is-invalid': invalid}"
:disabled="disabled"
role="combobox"
aria-haspopup="listbox"
:aria-expanded="open"
:aria-required="required || undefined"
:aria-invalid="invalid || undefined"
:aria-describedby="describedby"
@click="toggle"
>
<span v-if="selected === undefined" class="ui-select__placeholder">{{ placeholder }}</span>
<span v-else class="ui-select__value">{{ labelOf(selected) }}</span>
<svg class="ui-select__chevron" viewBox="0 0 20 20" aria-hidden="true">
<path d="M5 8l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" />
</svg>
</button>

<ul
v-if="open"
ref="floating"
class="ui-select__menu"
role="listbox"
:aria-label="optionsLabel"
:style="floatingStyles"
>
<li v-if="!options.length" class="ui-select__empty">{{ emptyText }}</li>
<li
v-for="(option, index) in sorted"
:key="String(option.id)"
class="ui-select__option"
:class="{'is-active': pointer === index}"
role="option"
:aria-selected="pointer === index"
@mouseover="pointer = index"
@click="choose(option)"
>
{{ labelOf(option) }}
</li>
</ul>
</div>
</template>

<script setup lang="ts" generic="T extends SelectItem">
import {autoUpdate, flip, hide, offset, shift, useFloating} from '@floating-ui/vue';
import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue';

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

const {
options,
label,
placeholder = 'Select…',
disabled = false,
alphabeticalSort = true,
required = false,
invalid = false,
describedby,
emptyText = 'No options',
optionsLabel = 'Options',
} = defineProps<{
options: T[];
/** property name or getter for an option's display string. */
label: LabelKey<T>;
/** stable id — required so the trigger can pair with a label/error. */
id: string;
placeholder?: string;
disabled?: boolean;
alphabeticalSort?: boolean;
/** conveys the required state to assistive tech via `aria-required`. */
required?: boolean;
invalid?: boolean;
describedby?: string;
emptyText?: string;
/** accessible name for the listbox popup (`aria-label`). */
optionsLabel?: string;
}>();

const model = defineModel<T['id'] | null>({required: true});

/** Resolve an option's display string from the `label` prop (property name or getter). */
const labelOf = (option: T): string =>
typeof label === 'function'
? label(option)
: String((option as Record<PropertyKey, unknown>)[label as PropertyKey]);

const selected = computed(() => options.find((option) => option.id === model.value));
const sorted = computed(() =>
alphabeticalSort ? [...options].sort((a, b) => labelOf(a).localeCompare(labelOf(b))) : options,
);

const open = ref(false);
const pointer = ref(-1);

const toggle = () => {
open.value = !open.value;
};
const close = () => {
open.value = false;
pointer.value = -1;
};
const choose = (option: T) => {
model.value = option.id;
close();
};

// Listbox keyboard navigation. The trigger's native :disabled blocks clicks; this
// guards the keyboard path too.
const onKey = (event: KeyboardEvent) => {
if (disabled) return;
if (event.key === 'Tab') {
close();
return;
}
if (!open.value) {
if (['Enter', 'ArrowDown', ' '].includes(event.key)) {
event.preventDefault();
open.value = true;
}
return;
}
switch (event.key) {
case 'ArrowDown':
pointer.value = Math.min(pointer.value + 1, sorted.value.length - 1);
event.preventDefault();
break;
case 'ArrowUp':
pointer.value = Math.max(pointer.value - 1, -1);
event.preventDefault();
break;
case 'Enter':
if (pointer.value >= 0) {
choose(sorted.value[pointer.value]);
event.preventDefault();
}
break;
case 'Escape':
close();
event.preventDefault();
break;
}
};

// click-outside — closes the menu without a shared directive dependency.
const root = useTemplateRef<HTMLElement>('root');
const onDocumentPointer = (event: MouseEvent) => {
// The listener is attached only between mount and unmount, so the ref is non-null here.
if (!root.value!.contains(event.target as Node)) close();
};
onMounted(() => document.addEventListener('click', onDocumentPointer));
onBeforeUnmount(() => document.removeEventListener('click', onDocumentPointer));

const reference = useTemplateRef<HTMLElement>('reference');
const floating = useTemplateRef<HTMLElement>('floating');
const {floatingStyles} = useFloating(reference, floating, {
placement: 'bottom-start',
middleware: [offset(4), flip({fallbackPlacements: ['top-start']}), shift({padding: 8}), hide()],
whileElementsMounted: autoUpdate,
});
</script>
32 changes: 32 additions & 0 deletions packages/ui-inputs/src/components/TextInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<input
:id="id"
:type="type"
class="ui-control ui-input"
:class="{'is-invalid': invalid}"
:value="model"
:placeholder="placeholder"
:disabled="disabled"
:aria-required="required || undefined"
:aria-invalid="invalid || undefined"
:aria-describedby="describedby"
@input="model = ($event.target as HTMLInputElement).value"
/>
</template>

<script setup lang="ts">
const {type = 'text'} = defineProps<{
id: string;
type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
placeholder?: string;
disabled?: boolean;
/** conveys the required state to assistive tech via `aria-required`. */
required?: boolean;
/** invalid styling + aria; drive it from the field's error. */
invalid?: boolean;
/** id of the paired error element for `aria-describedby`. */
describedby?: string;
}>();

const model = defineModel<string>({required: true});
</script>
7 changes: 7 additions & 0 deletions packages/ui-inputs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {default as FormField} from './components/FormField.vue';
export {default as FormLabel} from './components/FormLabel.vue';
export {default as FormError} from './components/FormError.vue';
export {default as TextInput} from './components/TextInput.vue';
export {default as SingleSelect} from './components/SingleSelect.vue';

export type {SelectItem, LabelKey} from './types';
8 changes: 8 additions & 0 deletions packages/ui-inputs/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** The minimal shape every option in a select must satisfy: a stable identity. */
export type SelectItem = {id: string | number};

/**
* How to derive a display string from an option: either the name of a string
* property on the option, or a getter function.
*/
export type LabelKey<T> = keyof T | ((option: T) => string);
Loading