Number helpers
Seven helpers for the arithmetic an admin screen actually does, each guarding the case that would otherwise reach the DOM as NaN.
<script setup lang="ts">
import { clamp, formatBytes, percentage, randomInt, round } from '@shared/utils/number'
import { onNumericKeydown, onNumericPaste } from '@shared/utils/numericInput'
import { Button } from '@shared/components/ui/button'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { Progress } from '@shared/components/ui/progress'
import { computed, ref } from 'vue'
const value = ref(37)
const total = ref(200)
const bytes = ref(1536)
const decimals = ref(2)
const dice = ref(randomInt(1, 6))
const digitsOnly = ref('')
const pct = computed(() => percentage(value.value, total.value))
const clamped = computed(() => clamp(value.value, 0, 100))
</script>
<template>
<div class="flex flex-col gap-5">
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-1">
<Label for="num-value">value</Label>
<Input id="num-value" v-model.number="value" type="number" />
</div>
<div class="grid gap-1">
<Label for="num-total">total</Label>
<Input id="num-total" v-model.number="total" type="number" />
</div>
</div>
<div class="flex flex-col gap-2">
<p class="sa-text-sm">
<code>percentage({{ value }}, {{ total }})</code> → <strong>{{ pct }}</strong>
· <code>clamp({{ value }}, 0, 100)</code> → <strong>{{ clamped }}</strong>
</p>
<Progress :model-value="clamp(pct, 0, 100)" />
<p class="sa-text-xs text-muted-foreground">
Set the total to 0 — the bar goes empty, not full.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-1">
<Label for="num-bytes">bytes</Label>
<Input id="num-bytes" v-model.number="bytes" type="number" />
<p class="sa-text-sm text-muted-foreground">
{{ formatBytes(bytes) }} · {{ formatBytes(bytes, { binary: false }) }}
</p>
</div>
<div class="grid gap-1">
<Label for="num-dec">round(1.005, n)</Label>
<Input id="num-dec" v-model.number="decimals" type="number" min="0" max="6" />
<p class="sa-text-sm text-muted-foreground">
{{ round(1.005, decimals) }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button size="sm" variant="outline" @click="dice = randomInt(1, 6)">
randomInt(1, 6)
</Button>
<span class="sa-text-sm font-medium">{{ dice }}</span>
</div>
<div class="grid gap-1 max-w-sm">
<Label for="num-guard">Numeric guard — try typing letters or "e"</Label>
<Input
id="num-guard"
v-model="digitsOnly"
type="text"
inputmode="numeric"
placeholder="digits and a dot only"
@keydown="onNumericKeydown($event, { decimal: true })"
@paste="onNumericPaste($event, { decimal: true })"
/>
</div>
</div>
</template>Import
import { clamp, formatBytes, percentage, randomInt, round } from '@shared/utils/number'
import { onNumericKeydown, onNumericPaste } from '@shared/utils/numericInput'clamp
function clamp(value: number, min: number, max: number): numberclamp(120, 0, 100) // 100min > max yields min rather than throwing. This runs inside computeds for slider and progress widths, where an exception takes the page down.
round
function round(value: number, precision?: number): numberround(1.005, 2) // 1.01
round(1234.5, -2) // 1200It shifts the decimal point in the string form. Multiplying by 100 moves 1.005's stored floating-point error to where rounding can see it, and yields 1. Halves round away from zero, so a column of mixed signs rounds symmetrically.
precision defaults to 0 and accepts negatives.
formatBytes
function formatBytes(bytes: number, options?: FormatBytesOptions): string
interface FormatBytesOptions {
decimals?: number
binary?: boolean
}formatBytes(1536) // '1.5 KB' — '1,5 KB' in de
formatBytes(1536, { binary: false }) // '1.5 kB'Defaults: decimals 1, binary true (scaling by 1024, KB). binary: false scales by 1000 and labels kB.
The digits follow the app locale, so a German dialog reads 1,5 MB. Scaling is repeated division, not Math.log(n) / Math.log(1024), which reports 1024 bytes as 0.9999… KB. Bytes themselves are always whole.
percentage
function percentage(value: number, total: number, options?: PercentageOptions): number
interface PercentageOptions {
decimals?: number
clamp?: boolean
}percentage(37, 200) // 19
percentage(1, 3, { decimals: 1 }) // 33.3Returns a number, so the caller decides how to render it.
A zero total yields 0. That guard is the reason this exists: NaN as a CSS width silently produces a full bar rather than an empty one.
clamp is off by default, because over 100% is often real.
randomInt
function randomInt(min: number, max: number): numberrandomInt(1, 6) // 1 … 6Inclusive at both ends; reversed bounds are swapped. Not cryptographically secure — for tokens use crypto.getRandomValues.
onNumericKeydown and onNumericPaste
import { onNumericKeydown, onNumericPaste } from '@shared/utils/numericInput'function onNumericKeydown(event: KeyboardEvent, options?: { decimal?: boolean, sign?: boolean }): void
function onNumericPaste(event: ClipboardEvent, options?: { decimal?: boolean, sign?: boolean }): voidA keydown filter, not a value sanitizer. Stripping after the fact fights the caret and loses an IME's composition.
<template>
<input
type="text"
inputmode="numeric"
@keydown="onNumericKeydown($event, { decimal: true })"
@paste="onNumericPaste($event, { decimal: true })"
>
</template>decimal also allows ., sign a leading -; both default off. Editing and navigation keys always pass, as do Ctrl/Cmd shortcuts, so copy, paste and select-all keep working.
Why not type="number"? It accepts e, E, + and -, and then reports itself empty. inputmode="numeric" enforces nothing at all — it only picks the on-screen keyboard.
Related
- Format helpers — rendering a number for a reader.
- Array helpers —
sumBy, which skips non-finite values for the same reason. - File helpers — where
formatBytesusually appears.