page InputNumbercomponent { seo { title = "InputNumber component" description = "Theme-aware, responsive input number component." } view {
W WRNexusJS
advanced-forms component

InputNumber

Theme-aware, responsive input number component.

@wrnexus/ui 0.5.040 props0 slots4 events

Usage

Components are auto-discovered. Use the component directly in any .wrn view:

<InputNumber
  size="default"
  color="primary"
  variant="default"
  id="id"
  name="quantity"
/>

Legacy mount name: data-component="InputNumber".

Props and attributes

PropTypeRequirementDefault
sizestringOptional"default"
colorstringOptional"primary"
variantstringOptional"default"
classstringOptional""
idstringOptional""
namestringOptional"quantity"
valuenumberOptional0
minstringOptional""
maxstringOptional""
stepnumberOptional1
precisionstringOptional"auto"
labelstringOptional""
descriptionstringOptional""
helpTextstringOptional""
errorstringOptional""
invalidbooleanOptionalfalse
prefixstringOptional""
suffixstringOptional""
placeholderstringOptional""
autocompletestringOptional"off"
inputModestringOptional"decimal"
ariaLabelstringOptional""
requiredbooleanOptionalfalse
disabledbooleanOptionalfalse
inputDisabledbooleanOptionalfalse
buttonsDisabledbooleanOptionalfalse
readonlybooleanOptionalfalse
allowInputbooleanOptionaltrue
keyboardbooleanOptionaltrue
wheelbooleanOptionalfalse
clampbooleanOptionaltrue
fullWidthbooleanOptionalfalse
showButtonsbooleanOptionaltrue
showValidationMessagebooleanOptionaltrue
decrementLabelstringOptional"Decrease value"
incrementLabelstringOptional"Increase value"
controlsLabelstringOptional"Quantity controls"
requiredMessagestringOptional"A value is required."
minMessagestringOptional"Value is below the minimum."
maxMessagestringOptional"Value is above the maximum."

Slots

This component does not declare a slot.

Events

  • input
  • change
  • increment
  • decrement

Source contract

Installed source: components/InputNumber.wrn

component InputNumber {
    props {
        size = "default"
        color = "primary"
        variant = "default"
        class = ""

        id = ""
        name = "quantity"
        value = 0
        min = ""
        max = ""
        step = 1
        precision = "auto"

        label = ""
        description = ""
        helpText = ""
        error = ""
        invalid = false

        prefix = ""
        suffix = ""
        placeholder = ""
        autocomplete = "off"
        inputMode = "decimal"
        ariaLabel = ""

        required = false
        disabled = false
        inputDisabled = false
        buttonsDisabled = false
        readonly = false
        allowInput = true
        keyboard = true
        wheel = false
        clamp = true
        fullWidth = false
        showButtons = true
        showValidationMessage = true

        decrementLabel = "Decrease value"
        incrementLabel = "Increase value"
        controlsLabel = "Quantity controls"
        requiredMessage = "A value is required."
        minMessage = "Value is below the minimum."
        maxMessage = "Value is above the maximum."

        @event input = function
        @event change = function
        @event increment = function
        @event decrement = function
    }

    state currentValue = value
    state committedValue = value

    functions {
        function inputId() {
            if (id !== "") {
                return id
            }

            if (name !== "") {
                return name
            }

            return "input-number"
        }

        function descriptionId() {
            return inputId() + "-description"
        }

        function messageId() {
            return inputId() + "-message"
        }

        function componentColor() {
            if (color === "secondary") {
                return "var(--wire-color-secondary)"
            }

            if (color === "success") {
                return "var(--wire-color-success)"
            }

            if (color === "warning") {
                return "var(--wire-color-warning)"
            }

            if (color === "danger") {
                return "var(--wire-color-danger)"
            }

            if (color === "info") {
                return "var(--wire-color-info)"
            }

            return "var(--wire-color-primary)"
        }

        function hasMin() {
            return min !== "" && min !== null && min !== undefined
        }

        function hasMax() {
            return max !== "" && max !== null && max !== undefined
        }

        function isBlank() {
            return currentValue === "" || currentValue === null || currentValue === undefined
        }

        function normalizedStep() {
            return Number(step) > 0 ? Number(step) : 1
        }

        function inferredPrecision() {
            if (precision !== "auto" && precision !== "") {
                return Math.max(0, Number(precision) || 0)
            }

            if (String(normalizedStep()).includes(".")) {
                return String(normalizedStep()).split(".")[1].length
            }

            return 0
        }

        function precisionFactor() {
            return Math.pow(10, inferredPrecision())
        }

        function roundValue(nextValue) {
            return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
        }

        function clampValue(nextValue) {
            nextValue = Number(nextValue)

            if (hasMin() && nextValue < Number(min)) {
                nextValue = Number(min)
            }

            if (hasMax() && nextValue > Number(max)) {
                nextValue = Number(max)
            }

            return roundValue(nextValue)
        }

        function isBelowMin() {
            return !isBlank() && hasMin() && Number(currentValue) < Number(min)
        }

        function isAboveMax() {
            return !isBlank() && hasMax() && Number(currentValue) > Number(max)
        }

        function isInvalid() {
            return (
                invalid ||
                error !== "" ||
                (required && isBlank()) ||
                isBelowMin() ||
                isAboveMax()
            )
        }

        function validationMessage() {
            if (error !== "") {
                return error
            }

            if (required && isBlank()) {
                return requiredMessage
            }

            if (isBelowMin()) {
                return minMessage
            }

            if (isAboveMax()) {
                return maxMessage
            }

            return ""
        }

        function hasMessage() {
            return (
                helpText !== "" ||
                (showValidationMessage && isInvalid() && validationMessage() !== "")
            )
        }

        function describedBy() {
            if (description !== "" && hasMessage()) {
                return descriptionId() + " " + messageId()
            }

            if (description !== "") {
                return descriptionId()
            }

            if (hasMessage()) {
                return messageId()
            }

            return ""
        }

        function decrementDisabled() {
            return (
                disabled ||
                readonly ||
                buttonsDisabled ||
                (hasMin() && !isBlank() && Number(currentValue) <= Number(min))
            )
        }

        function incrementDisabled() {
            return (
                disabled ||
                readonly ||
                buttonsDisabled ||
                (hasMax() && !isBlank() && Number(currentValue) >= Number(max))
            )
        }

        function dispatchInputNumberEvent(
            sourceEvent,
            eventName,
            action,
            previousValue,
            root,
            customEvent
        ) {
            root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")

            if (!root && sourceEvent.target) {
                root = sourceEvent.target.closest("[data-wrn-input-number]")
            }

            if (!root) {
                return
            }

            customEvent = document.createEvent("CustomEvent")
            customEvent.initCustomEvent(eventName, true, false, {
                component: "InputNumber",
                name: name,
                value: currentValue,
                previousValue: previousValue,
                action: action,
                min: hasMin() ? Number(min) : null,
                max: hasMax() ? Number(max) : null,
                step: normalizedStep(),
                valid: !isInvalid()
            })
            root.dispatchEvent(customEvent)
        }

        function applyControlValue(nextValue, action, sourceEvent, previousValue) {
            previousValue = currentValue
            currentValue = clampValue(nextValue)
            committedValue = currentValue

            dispatchInputNumberEvent(
                sourceEvent,
                "input",
                action,
                previousValue
            )
            dispatchInputNumberEvent(
                sourceEvent,
                "change",
                action,
                previousValue
            )
            dispatchInputNumberEvent(
                sourceEvent,
                action,
                action,
                previousValue
            )
        }

        function incrementValue(sourceEvent, nextValue) {
            if (incrementDisabled()) {
                return
            }

            if (isBlank()) {
                nextValue = hasMin() ? Number(min) : normalizedStep()
            } else {
                nextValue =
                    Number(currentValue) +
                    normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
            }

            applyControlValue(nextValue, "increment", sourceEvent)
        }

        function decrementValue(sourceEvent, nextValue) {
            if (decrementDisabled()) {
                return
            }

            if (isBlank()) {
                nextValue = hasMax() ? Number(max) : -normalizedStep()
            } else {
                nextValue =
                    Number(currentValue) -
                    normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
            }

            applyControlValue(nextValue, "decrement", sourceEvent)
        }

        function handleInput(sourceEvent, previousValue, nextValue) {
            sourceEvent.stopPropagation()
            previousValue = currentValue
            nextValue = sourceEvent.target.value

            if (nextValue === "") {
                currentValue = ""
            } else if (!Number.isNaN(Number(nextValue))) {
                currentValue = roundValue(Number(nextValue))
            }

            dispatchInputNumberEvent(
                sourceEvent,
                "input",
                "input",
                previousValue
            )
        }

        function handleChange(sourceEvent, previousValue) {
            sourceEvent.stopPropagation()
            previousValue = committedValue

            if (!isBlank()) {
                currentValue = clamp
                    ? clampValue(currentValue)
                    : roundValue(currentValue)
            }

            committedValue = currentValue

            dispatchInputNumberEvent(
                sourceEvent,
                "change",
                "change",
                previousValue
            )
        }

        function handleKeydown(sourceEvent) {
            if (
                !keyboard ||
                disabled ||
                readonly ||
                inputDisabled ||
                !allowInput
            ) {
                return
            }

            if (sourceEvent.key === "ArrowUp") {
                sourceEvent.preventDefault()
                incrementValue(sourceEvent)
            } else if (sourceEvent.key === "ArrowDown") {
                sourceEvent.preventDefault()
                decrementValue(sourceEvent)
            } else if (sourceEvent.key === "Home" && hasMin()) {
                sourceEvent.preventDefault()
                applyControlValue(Number(min), "decrement", sourceEvent)
            } else if (sourceEvent.key === "End" && hasMax()) {
                sourceEvent.preventDefault()
                applyControlValue(Number(max), "increment", sourceEvent)
            }
        }

        function handleWheel(sourceEvent) {
            if (
                !wheel ||
                disabled ||
                readonly ||
                inputDisabled ||
                !allowInput
            ) {
                return
            }

            sourceEvent.preventDefault()

            if (sourceEvent.deltaY < 0) {
                incrementValue(sourceEvent)
            } else if (sourceEvent.deltaY > 0) {
                decrementValue(sourceEvent)
            }
        }
    }

    view {
        <div
            {...attrs}
            data-wrn-input-number
            data-variant='{variant}'
            data-size='{size}'
            data-color='{color}'
            data-value='{currentValue}'
            data-invalid='{isInvalid() ? "true" : "false"}'
            data-disabled='{disabled ? "true" : "false"}'
            style='--input-number-accent: {componentColor()};'
            class='relative flex flex-col gap-1.5 text-[var(--wire-color-text)] {fullWidth ? "w-full" : variant === "compact" ? "w-fit max-w-full" : "w-full max-w-sm"} {disabled ? "opacity-60" : ""} {class}'
        >
            {#if label !== "" && variant !== "labeled" && variant !== "seat"}
            <label
                for='{inputId()}'
                class='inline-flex items-center gap-1 text-sm font-semibold leading-5 text-[var(--wire-color-text)]'
            >
        <span>{label}</span>
                {#if required}
                <span
                    aria-hidden="true"
                    class='text-[var(--wire-color-danger)]'
                >*</span>
                {/if}
            </label>
            {/if}

            {#if description !== "" && variant !== "labeled" && variant !== "seat"}
            <p
                id='{descriptionId()}'
                class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
            >
                {description}
            </p>
            {/if}

            <div
                class='group flex min-w-0 overflow-hidden border bg-[var(--wire-color-surface)] text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] transition-[border-color,box-shadow,background-color] duration-[var(--wire-motion-base)] ease-[var(--wire-ease-standard)] focus-within:border-[var(--input-number-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--input-number-accent)_18%,transparent)] {variant === "compact" ? "rounded-full" : "rounded-[var(--wire-radius-sm)]"} {size === "xs" ? "min-h-8 text-xs" : size === "sm" ? "min-h-9 text-sm" : size === "lg" ? "min-h-12 text-base" : size === "xl" ? "min-h-14 text-lg" : "min-h-10 text-sm"} {isInvalid() ? "border-[var(--wire-color-danger)] focus-within:border-[var(--wire-color-danger)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--wire-color-danger)_18%,transparent)]" : "border-[var(--wire-color-border)]"} {disabled ? "cursor-not-allowed bg-[var(--wire-color-surface-2)]" : ""}'
            >
                {#if variant === "horizontal" && showButtons}
                <button
                    type="button"
                    aria-label='{decrementLabel}'
                    aria-controls='{inputId()}'
                    disabled='{decrementDisabled()}'
                    @click='decrementValue(event)'
                    class='inline-flex shrink-0 items-center justify-center border-r border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
                >
        <span
                        aria-hidden="true"
                        class='icon-[lucide--minus] size-4'
                    >
        </span>
        </button>
                {/if}

                <div
                    class='flex min-w-0 flex-1 {variant === "labeled" ? "flex-col items-stretch justify-center gap-0.5" : variant === "seat" ? "items-center justify-between gap-3" : "items-center"} {size === "xs" ? "px-2" : size === "sm" ? "px-2.5" : size === "lg" ? "px-4" : size === "xl" ? "px-5" : "px-3"}'
                >
                    {#if variant === "labeled" || variant === "seat"}
                    <div class='min-w-0 flex-1'>
                        {#if label !== ""}
                        <label
                            for='{inputId()}'
                            class='block truncate font-semibold leading-4 text-[var(--wire-color-text)] {variant === "labeled" ? "text-xs font-medium text-[var(--wire-color-muted)]" : "text-sm"}'
                        >
                            {label}
                            {#if required}
                            <span
                                aria-hidden="true"
                                class='ml-0.5 text-[var(--wire-color-danger)]'
                            >*</span>
                            {/if}
                        </label>
                        {/if}

                        {#if description !== ""}
                        <span
                            id='{descriptionId()}'
                            class='block truncate text-xs leading-4 text-[var(--wire-color-muted)]'
                        >
                            {description}
                        </span>
                        {/if}
                    </div>
                    {/if}

                    <div
                        class='flex min-w-0 items-center {variant === "seat" ? "w-auto shrink-0" : "w-full"}'
                    >
                        {#if prefix !== ""}
                        <span
                            aria-hidden="true"
                            class='shrink-0 pr-1.5 text-[var(--wire-color-muted)]'
                        >
                            {prefix}
                        </span>
                        {/if}

                        <input
                            id='{inputId()}'
                            name='{name}'
                            type="number"
                            value='{currentValue}'
                            min='{min}'
                            max='{max}'
                            step='{normalizedStep()}'
                            placeholder='{placeholder}'
                            autocomplete='{autocomplete}'
                            inputmode='{inputMode}'
                            aria-label='{ariaLabel !== "" ? ariaLabel : label !== "" ? label : name}'
                            aria-describedby='{describedBy()}'
                            aria-invalid='{isInvalid() ? "true" : "false"}'
                            aria-required='{required ? "true" : "false"}'
                            aria-disabled='{disabled || inputDisabled ? "true" : "false"}'
                            required='{required}'
                            disabled='{disabled}'
                            readonly='{readonly || inputDisabled || !allowInput}'
                            tabindex='{inputDisabled ? "-1" : "0"}'
                            @input='handleInput(event)'
                            @change='handleChange(event)'
                            @keydown='handleKeydown(event)'
                            @wheel='handleWheel(event)'
                            class='min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-medium leading-none text-[var(--wire-color-text)] outline-none placeholder:text-[var(--wire-color-muted)] read-only:cursor-default disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {variant === "horizontal" || variant === "compact" || variant === "seat" ? "text-center" : "text-left"} {variant === "seat" ? "w-10 flex-none" : "w-full"}'
                        />

                        {#if suffix !== ""}
                        <span
                            aria-hidden="true"
                            class='shrink-0 pl-1.5 text-[var(--wire-color-muted)]'
                        >
                            {suffix}
                        </span>
                        {/if}
                    </div>
        </div>

                {#if variant === "horizontal" && showButtons}
                <button
                    type="button"
                    aria-label='{incrementLabel}'
                    aria-controls='{inputId()}'
                    disabled='{incrementDisabled()}'
                    @click='incrementValue(event)'
                    class='inline-flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
                >
        <span
                        aria-hidden="true"
                        class='icon-[lucide--plus] size-4'
                    >
        </span>
        </button>
                {:else}
                {#if showButtons}
                <div
                    role="group"
                    aria-label='{controlsLabel}'
                    class='flex shrink-0 border-l border-[var(--wire-color-border)] {variant === "vertical" ? "flex-col" : "flex-row"}'
                >
        <button
                        type="button"
                        aria-label='{decrementLabel}'
                        aria-controls='{inputId()}'
                        disabled='{decrementDisabled()}'
                        @click='decrementValue(event)'
                        class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1 border-b border-[var(--wire-color-border)]" : "border-r border-[var(--wire-color-border)]"} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
                    >
        <span
                            aria-hidden="true"
                            class='icon-[lucide--minus] size-4'
                        >
        </span>
        </button>
        <button
                        type="button"
                        aria-label='{incrementLabel}'
                        aria-controls='{inputId()}'
                        disabled='{incrementDisabled()}'
                        @click='incrementValue(event)'
                        class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1" : ""} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
                    >
        <span
                            aria-hidden="true"
                            class='icon-[lucide--plus] size-4'
                        >
        </span>
        </button>
        </div>
                {/if}
                {/if}
            </div>

            {#if showValidationMessage && isInvalid() && validationMessage() !== ""}
            <p
                id='{messageId()}'
                role="alert"
                aria-live="polite"
                class='m-0 flex items-center gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)]'
            >
        <span
                    aria-hidden="true"
                    class='icon-[lucide--circle-alert] size-3.5 shrink-0'
                >
        </span>
        <span>{validationMessage()}</span>
        </p>
            {/if}

            {#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
            <p
                id='{messageId()}'
                class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
            >
                {helpText}
            </p>
            {/if}
        </div>
    }
}
} }