Rental
Meter Input
A utility-meter reading field for rentals. Shows the previous reading beside the box, refuses a reading that runs backwards, and explains a consumption figure that does not look like the usual one for the meter.
Reading a water or electricity meter is three jobs at once: remember what the dial said last time, refuse a number that cannot be right, and say something useful when the number is possible but surprising. The meter input does all three in one field.
Preview
Section titled “Preview”<nana-meter-input
label="Water reading"
unit="m³"
previous="1240"
previous-date="2026-08-03"
average="18"
value="1258"
style="max-width: 22rem"
></nana-meter-input> Installation
Section titled “Installation”The meter input ships for all three targets. Pick the tab for your project — the CLI detects which you are on and serves the matching implementation.
npx @nana-tec/ui add meter-inputLands as src/components/ui/meter-input/, with reading.ts beside the
component — the arithmetic and the wording live there, so you can reuse them in
a server-side check without importing the field.
npx @nana-tec/ui add meter-inputLands in src/lib/components/ui/meter-input/ as MeterInput.svelte, with
reading.ts beside it holding the arithmetic and the wording.
npm install @nana-tec/ui-componentsOr copy the source in and own it:
npx @nana-tec/ui add meter-inputimport { MeterInput } from "@/components/ui/meter-input";
export function WaterReading() { return ( <MeterInput label="Water reading" unit="m³" previous={1240} previousDate="2026-08-03" average={18} required onReadingChange={(reading) => console.log(reading.consumption, reading.anomaly)} /> );}The field is uncontrolled until you pass value. onReadingChange hands you the
whole verdict — reading, consumption, ratio, anomaly, error and
explanation — so a form can store the consumption it just worked out rather
than recomputing it.
<script lang="ts"> import { MeterInput } from '$lib/components/ui/meter-input';
let reading = $state('');</script>
<MeterInput label="Water reading" unit="m³" previous={1240} previousDate="2026-08-03" average={18} required bind:value={reading}/>value is bindable, and onReadingChange reports the whole verdict whenever it
moves.
import "@nana-tec/ui-components/meter-input";<nana-meter-input label="Water reading" unit="m³" previous="1240" previous-date="2026-08-03" average="18" name="water" required></nana-meter-input>The element is form-associated, so it submits under its name and takes part in
constraint validation like a native input. element.reading returns the same
verdict object the framework builds pass to onReadingChange.
What it checks
Section titled “What it checks”A reading is refused — the field goes invalid and the form will not submit — only for the three things that cannot be true:
| Case | Message |
|---|---|
| Required and blank | Enter the current reading. |
| Not a number | Enter the reading as a number. |
| Below the previous reading | A reading cannot be lower than the previous one (1,240 m³). |
Readings are typed on site, so spaces inside the number are ignored: 1 240 is
1240. Commas are not — they mean a decimal point in some places and a thousands
separator in others, and guessing would turn 1,5 into fifteen.
The error waits until the field is left — half a reading is not a mistake yet.
A form that validates on submit sets touched to reveal the message early,
which is also what the example below does.
<nana-meter-input
label="Water reading"
unit="m³"
previous="1240"
value="1100"
touched
style="max-width: 22rem"
></nana-meter-input> Explaining an anomaly
Section titled “Explaining an anomaly”Set average to the consumption a period on this meter normally comes to, and
the field explains any reading that lands well outside it. tolerance sets the
band — the default 2 flags anything at twice the usual or half of it. A
tolerance of 1 or less would flag everything, so it falls back to the default.
An anomaly is a warning, not an error: an unusual month is still a real month, so the field stays valid and submittable while it says why the number stands out.
<nana-meter-input
label="Unusually high"
unit="m³"
previous="1240"
previous-date="2026-08-03"
average="18"
value="1330"
style="max-width: 22rem"
></nana-meter-input>
<nana-meter-input
label="Meter has not moved"
unit="m³"
previous="1240"
previous-date="2026-08-03"
average="18"
value="1240"
style="max-width: 22rem"
></nana-meter-input> Three cases are worded, and each says what to do about it:
| Anomaly | When | Reads as |
|---|---|---|
high | Consumption at or above average × tolerance | 90 m³ is 5× the usual 18 m³ on this meter. Check the digits before saving. |
low | Consumption at or below average ÷ tolerance | 3 m³ is 17% of the usual 18 m³ on this meter. Check the digits before saving. |
flat | Nothing used since the last reading | Nothing has been used since the reading on 3 Aug. Check that the meter is running and the unit is occupied. |
Your own wording replaces the generated line — a slot on the Web Component, a prop on the framework builds — which is where a rental system puts what it knows about the unit:
<nana-meter-input
label="Water reading"
unit="m³"
previous="1240"
average="18"
value="1330"
style="max-width: 22rem"
>
<span slot="explanation">
Unit 4B has a garden tap — check the outside meter before billing.
</span>
</nana-meter-input> Without an average
Section titled “Without an average”With no average there is nothing to compare against, so nothing is flagged
except a meter that has not moved at all — which is worth saying whether or not
you know the usual figure.
<nana-meter-input
label="Electricity reading"
unit="kWh"
previous="8420"
value="8710"
helper-text="Read the black digits only — ignore the red decimal."
style="max-width: 22rem"
></nana-meter-input> The React and Svelte props, read from the components’ own types:
MeterInput
| Prop | Type | Default |
|---|---|---|
size | MeterInputSize | 'md' |
label | ReactNode | 'Current reading' |
previousThe reading this period is measured from. | number | |
previousDateISO date the previous reading was taken, shown as context. | string | |
unitWhat the meter counts in — `kWh`, `m³`, `units`. | string | '' |
averageConsumption a period on this meter normally comes to. | number | |
toleranceHow many times the average reads as high; its reciprocal reads as low. | number | 2 |
helperText | ReactNode | |
explanationReplaces the generated anomaly wording. | ReactNode | |
touchedWhether the validation message is showing yet. The field decides this itself once the reading has been left; a form that validates on submit can pass `true` to reveal the message early. | boolean | |
value | string | |
defaultValue | string | '' |
onReadingChange | (reading: MeterReading) => void |
Also accepts Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'value' | 'defaultValue'>.
MeterInput
| Prop | Type | Default |
|---|---|---|
size | MeterInputSize | 'md' |
label | string | 'Current reading' |
previousThe reading this period is measured from. | number | |
previousDateISO date the previous reading was taken, shown as context. | string | |
unitWhat the meter counts in — `kWh`, `m³`, `units`. | string | '' |
averageConsumption a period on this meter normally comes to. | number | |
toleranceHow many times the average reads as high; its reciprocal reads as low. | number | 2 |
helperText | string | |
explanationReplaces the generated anomaly wording. | Snippet | |
touchedWhether the validation message is showing yet. The field decides this itself once the reading has been left; a form that validates on submit can pass `true` to reveal the message early. | boolean | |
valuebind: | string | '' |
onReadingChange | (reading: MeterReading) => void |
Also accepts Omit<HTMLInputAttributes, 'class' | 'size' | 'value'>.
The attribute names, events and CSS hooks below are the Web Components reference.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
average | average | number | undefined | — | Consumption a period on this meter normally comes to |
disabled | disabled | boolean | false | |
helperText | helper-text | string | '' | Guidance shown under the field while nothing else needs saying |
inputId | inputId | — | — | |
label | label | string | 'Current reading' | Field label |
name | name | string | '' | |
placeholder | placeholder | string | '' | |
previous | previous | number | undefined | — | The reading this period is measured from |
previousDate | previous-date | string | '' | ISO date the previous reading was taken, shown as context |
readonly | readonly | boolean | false | |
required | required | boolean | false | |
size | size | MeterInputSize | 'md' | |
tolerance | tolerance | number | 2 | How many times the average reads as high; its reciprocal reads as low |
touched | touched | boolean | false | Whether the validation message is showing yet. The field sets this itself once the reading has been left — half a reading is not a mistake — and a form that validates on submit can set it to reveal the message early. |
unit | unit | string | '' | What the meter counts in — kWh, m³, units |
value | value | string | '' | The reading being entered |
Events
| Event | Description |
|---|---|
nana-input | Fired on every keystroke (detail.value) |
nana-change | Fired when the reading is committed (detail.value) |
nana-focus | Fired when the field gains focus |
nana-blur | Fired when the field loses focus |
nana-anomaly | Fired when the reading starts or stops looking wrong (detail.anomaly, detail.consumption, detail.ratio) |
Slots
| Slot | Description |
|---|---|
explanation | Replaces the generated anomaly wording |
suffix | Content after the field, in place of the unit label |
CSS parts
| Part | Description |
|---|---|
::part(base) | The field container |
::part(previous) | The previous-reading line above the field |
::part(wrapper) | The bordered field container |
::part(input) | The native <input> element |
::part(unit) | The unit label inside the field |
::part(consumption) | The consumption readout below the field |
::part(explanation) | The anomaly explanation |
::part(error) | The validation message |
CSS custom properties
| Property | Description |
|---|---|
--nana-input-bg | Field background colour, shared with the other form controls |
--nana-input-color | Field text colour, shared with the other form controls |
--nana-input-height | Field height, shared with the other form controls |
--nana-input-radius | Corner radius, shared with the other form controls |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
nana-input | CustomEvent | Fired on every keystroke (detail.value) |
nana-change | CustomEvent | Fired when the reading is committed (detail.value) |
nana-anomaly | CustomEvent | Fired when the reading starts or stops looking wrong (detail.anomaly, detail.consumption, detail.ratio) |
nana-focus | CustomEvent | Fired on focus |
nana-blur | CustomEvent | Fired on blur |
| Slot | Description |
|---|---|
explanation | Replaces the generated anomaly wording |
suffix | Content after the field, in place of the unit label |
CSS Parts
Section titled “CSS Parts”| Part | Description |
|---|---|
base | The field container |
previous | The previous-reading line above the field |
wrapper | The bordered field container |
input | The native <input> element |
unit | The unit label inside the field |
consumption | The consumption readout below the field |
explanation | The anomaly explanation |
error | The validation message |
Accessibility
Section titled “Accessibility”The previous reading is tied to the field with aria-describedby, so a screen
reader hears what the number is measured from before it is typed. The
explanation is a role="status" region — it announces politely when it appears,
because it is advice, not a failure — while the validation message is a
role="alert".
Sizes follow the same ramp as Input, Select and Combobox, so a meter reading sits on the same line as the rest of a form without adjustment.