Shadcn IPv4Address Input Svelte
An implementation of an IPv4Address Input component built with all the wonderful keyboard behaviors you would expect.
Setup
Install shadcn-svelte via CLI
Run the shadcn-svelte
init command
to setup your project:
npx shadcn-svelte@latest init
Add Component
Automatic
npx jsrepo add github/ieedan/shadcn-ipv4address-input-svelte/ui/ipv4address-input
or
Manual
Copy the code
You can find the code here. Or copy it below.
`src/lib/components/ui/ipv4address-input`
<script lang="ts">
import { cn } from '$lib/utils';
import { isNumber, safeParseIPv4Address } from '.';
import Input from './ipv4address-input-input.svelte';
type Props = {
separator?: string;
/** An IP Address placeholder `0.0.0.0` or `0_0_0_0` or `0 0 0 0` */
placeholder?: string;
value?: [number | null, number | null, number | null, number | null];
class?: string;
};
let {
separator = '.',
value = $bindable([null, null, null, null]),
placeholder,
class: className
}: Props = $props();
let parsedPlaceholder = safeParseIPv4Address(placeholder);
let firstInput = $state<HTMLInputElement>();
let secondInput = $state<HTMLInputElement>();
let thirdInput = $state<HTMLInputElement>();
let fourthInput = $state<HTMLInputElement>();
const paste = (e: ClipboardEvent) => {
const data = e.clipboardData?.getData('text');
if (!data) return;
const parsed = safeParseIPv4Address(data);
if (!parsed) return;
// validates each octet if invalid then sets to null
value[0] = validate(parsed[0]);
value[1] = validate(parsed[1]);
value[2] = validate(parsed[2]);
value[3] = validate(parsed[3]);
};
const validate = (octet: string | null): number | null => {
if (octet == null) return null;
if (!isNumber(octet)) return null;
const val = parseInt(octet);
if (val < 0 || val > 255) return null;
return val;
};
</script>
<div
class={cn(
'flex h-10 place-items-center rounded-md border border-border px-3 font-serif',
className
)}
>
<Input
bind:ref={firstInput}
goNext={() => secondInput?.focus()}
bind:value={value[0]}
placeholder={parsedPlaceholder ? parsedPlaceholder[0] : undefined}
onpaste={paste}
/>
<span class="font-serif">{separator}</span>
<Input
bind:ref={secondInput}
goNext={() => thirdInput?.focus()}
goPrevious={() => firstInput?.focus()}
bind:value={value[1]}
placeholder={parsedPlaceholder ? parsedPlaceholder[1] : undefined}
onpaste={paste}
/>
<span class="font-serif">{separator}</span>
<Input
bind:ref={thirdInput}
goNext={() => fourthInput?.focus()}
goPrevious={() => secondInput?.focus()}
bind:value={value[2]}
placeholder={parsedPlaceholder ? parsedPlaceholder[2] : undefined}
onpaste={paste}
/>
<span class="font-serif">{separator}</span>
<Input
bind:ref={fourthInput}
goPrevious={() => thirdInput?.focus()}
bind:value={value[3]}
placeholder={parsedPlaceholder ? parsedPlaceholder[3] : undefined}
onpaste={paste}
/>
</div>
<script lang="ts">
import { isNumber } from '.';
import type { HTMLAttributes } from 'svelte/elements';
type Props = {
value?: number | null;
goNext?: () => void;
goPrevious?: () => void;
ref?: HTMLInputElement;
};
let {
value = $bindable(null),
goPrevious,
goNext,
ref = $bindable(),
...rest
}: Props & HTMLAttributes<HTMLInputElement> = $props();
/** Runs after input (this is here because safari/firefox treat the `setTimeout` function differently than chrome) */
let after: (() => void) | undefined = undefined;
const onKeydown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) return;
// just continue as normal
if (e.key == 'Tab' || e.key == 'Delete') return;
// for backspace we goPrevious if the value is empty
if (e.key == 'Backspace') {
if (value == null || value.toString().length == 0) {
// the 2 ensures consistent behavior in all browsers
setTimeout(() => goPrevious?.(), 2);
}
return;
}
// we want to go forward for `.` or ` `
if (['.', ' '].includes(e.key) && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
goNext?.();
return;
}
const target = e.target as HTMLInputElement;
if (e.key == 'ArrowRight') {
// only go to next box if at end
if (target.selectionStart == target.value.length) {
e.preventDefault();
goNext?.();
}
return;
}
if (e.key == 'ArrowLeft') {
// only go to previous box if at start
if (target.selectionStart == 0) {
e.preventDefault();
goPrevious?.();
}
return;
}
// disallow any non numbers
// By default this prevents any undefined behavior
// so make sure anything that can happen is defined.
if (!isNumber(e.key)) {
e.preventDefault();
return;
}
const newValue = (e.target as HTMLInputElement).value + e.key;
if (newValue.length > 3) {
e.preventDefault();
goNext?.();
return;
}
const integerValue = parseInt(newValue);
// we will try to advance if its greater
if (integerValue > 255) {
e.preventDefault();
goNext?.();
return;
}
// this should be impossible but in any case
if (integerValue < 0) {
e.preventDefault();
return;
}
if (newValue.length == 3) {
// go next after input
after = () => goNext?.();
return;
}
};
const onInput = () => {
after?.();
after = undefined;
};
</script>
<input
bind:this={ref}
min={0}
max={255}
maxlength={3}
bind:value
oninput={onInput}
onkeydown={onKeydown}
type="text"
class="hide-ramp h-full w-9 border-0 bg-transparent text-center outline-none placeholder:text-muted-foreground focus:outline-none"
{...rest}
/>
<style lang="postcss">
.hide-ramp::-webkit-inner-spin-button,
.hide-ramp::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
import IPv4AddressInput from './ipv4address-input.svelte';
export const isNumber = (num: unknown): boolean => {
if (typeof num === 'number') {
return num - num === 0;
}
if (typeof num === 'string' && num.trim() !== '') {
return Number.isFinite ? Number.isFinite(+num) : isFinite(+num);
}
return false;
};
/** Attempts to parse the provided address into a valid IP. Returns undefined for
* undefined returns a valid IP in array form for a valid IP and returns a 0 filled array for a incomplete IP.
*
* **This is used only for parsing the placeholder**
*
* @param ipv4Address IP Address string to be parsed can be `0.0.0.0` or `0 0 0 0` or `0_0_0_0` or `0 0 0` (partially complete)
* @returns
*/
export const safeParseIPv4Address = (
ipv4Address: string | undefined
): [string | null, string | null, string | null, string | null] | undefined => {
if (ipv4Address === undefined) return undefined;
let ip = ipv4Address.trim();
ip = ip.replaceAll('_', '.');
ip = ip.replaceAll(' ', '.');
const segments: (string | null)[] = ip.split('.');
while (segments.length < 4) {
segments.push(null);
}
// @ts-expect-error We know this is 4 we just made sure
return segments;
};
export type IPv4Address = [number | null, number | null, number | null, number | null];
export { IPv4AddressInput };
Examples
Basic
. . .
All the keyboard actions you'd expect just work. Try using Tab
, ArrowRight
, ArrowLeft
. It even supports Space
, and `.`
.
Placeholder
. . .