Files
WRNexusJS/packages/ui/components/InputNumber.wrn
T
ClintchizandClaude Opus 5 5e65627305
Quality / quality (ubuntu-latest) (push) Failing after 13m39s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(csr,ui): deliver component outputs to parent bindings
An output only reaches a parent @binding when the component calls
output.<name>(). Two separate faults meant most of the library never got
there, and both failed silently at each end.

HTML lowercases attribute names, so a parent's @sizeChange registered under
"sizechange" while the component emitted "sizeChange". The lookup missed, fell
through to a DOM dispatch, and the binding was never invoked. That made all 17
camelCase outputs undeliverable -- DataTable.pageChange and .rowClick,
Map.markerClick, ChatBubble.messageClick, LayoutSplitter.sizeChange and the
rest. invokeComponentOutput now falls back to a case-insensitive lookup, and a
csr test fails without it.

Separately, 18 components dispatched hand-built CustomEvents rather than
calling output.*. A bubbling event on the component's own root never reaches a
binding, because parent handlers live in a registry only the output proxy
reads. Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar,
AvatarGroup, ToggleCount and InputNumber now emit properly; Marquee, Map,
Timeline, List and SearchBox additionally declare the outputs they were
already firing. Dispatches on window are left alone -- that is how Toaster,
Modal and DataTable signal across component boundaries.

Verified in a browser both ways before and after: an AnnouncementBar
dispatching its own bubbling "dismiss" never reached a page-level @dismiss,
and reached it immediately once it called output.dismiss().

This corrects the audit, which called the LayoutSplitter failure "narrow and
unexplained" and read 32 dead outputs as 16 components needing a rebuild.
"Outputs work elsewhere" was an assumption; the components that worked
happened to use lowercase names and output.*. The dead-output ratchet drops
from 32 to 22, and a new test forbids the raw-CustomEvent pattern outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:40:39 +05:30

627 lines
24 KiB
Plaintext

component InputNumber {
outputs {
input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
increment(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
decrement(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
variant: string = "default"
class: string = ""
id: string = ""
name: string = "quantity"
value: number = 0
min: string = ""
max: string = ""
step: number = 1
precision: string = "auto"
label: string = ""
description: string = ""
helpText: string = ""
error: string = ""
invalid: boolean = false
prefix: string = ""
suffix: string = ""
placeholder: string = ""
autocomplete: string = "off"
inputMode: string = "decimal"
ariaLabel: string = ""
required: boolean = false
disabled: boolean = false
inputDisabled: boolean = false
buttonsDisabled: boolean = false
readonly: boolean = false
allowInput: boolean = true
keyboard: boolean = true
wheel: boolean = false
clamp: boolean = true
fullWidth: boolean = false
showButtons: boolean = true
showValidationMessage: boolean = true
decrementLabel: string = "Decrease value"
incrementLabel: string = "Increase value"
controlsLabel: string = "Quantity controls"
requiredMessage: string = "A value is required."
minMessage: string = "Value is below the minimum."
maxMessage: string = "Value is above the maximum."
}
state currentValue = value
state committedValue = value
functions {
shared function inputId() {
if (id !== "") {
return id
}
if (name !== "") {
return name
}
return "input-number"
}
shared function descriptionId() {
return inputId() + "-description"
}
shared function messageId() {
return inputId() + "-message"
}
shared 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)"
}
shared function hasMin() {
return min !== "" && min !== null && min !== undefined
}
shared function hasMax() {
return max !== "" && max !== null && max !== undefined
}
shared function isBlank() {
return currentValue === "" || currentValue === null || currentValue === undefined
}
shared function normalizedStep() {
return Number(step) > 0 ? Number(step) : 1
}
shared 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
}
shared function precisionFactor() {
return Math.pow(10, inferredPrecision())
}
shared function roundValue(nextValue) {
return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
}
shared 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)
}
shared function isBelowMin() {
return !isBlank() && hasMin() && Number(currentValue) < Number(min)
}
shared function isAboveMax() {
return !isBlank() && hasMax() && Number(currentValue) > Number(max)
}
shared function isInvalid() {
return (
invalid ||
error !== "" ||
(required && isBlank()) ||
isBelowMin() ||
isAboveMax()
)
}
shared function validationMessage() {
if (error !== "") {
return error
}
if (required && isBlank()) {
return requiredMessage
}
if (isBelowMin()) {
return minMessage
}
if (isAboveMax()) {
return maxMessage
}
return ""
}
shared function hasMessage() {
return (
helpText !== "" ||
(showValidationMessage && isInvalid() && validationMessage() !== "")
)
}
shared function describedBy() {
if (description !== "" && hasMessage()) {
return descriptionId() + " " + messageId()
}
if (description !== "") {
return descriptionId()
}
if (hasMessage()) {
return messageId()
}
return ""
}
shared function decrementDisabled() {
return (
disabled ||
readonly ||
buttonsDisabled ||
(hasMin() && !isBlank() && Number(currentValue) <= Number(min))
)
}
shared function incrementDisabled() {
return (
disabled ||
readonly ||
buttonsDisabled ||
(hasMax() && !isBlank() && Number(currentValue) >= Number(max))
)
}
// Outputs are resolved by name, so each one is written out. Raw
// CustomEvents dispatched on the root -- what this did before -- never
// reach a parent @binding.
client function dispatchInputNumberEvent(
sourceEvent,
eventName,
action,
previousValue,
payload
) {
payload = {
component: "InputNumber",
name: name,
value: currentValue,
previousValue: previousValue,
action: action,
min: hasMin() ? Number(min) : null,
max: hasMax() ? Number(max) : null,
step: normalizedStep(),
valid: !isInvalid()
}
if (eventName === "input") {
output.input(payload)
} else if (eventName === "change") {
output.change(payload)
} else if (eventName === "increment") {
output.increment(payload)
} else if (eventName === "decrement") {
output.decrement(payload)
}
}
client 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
)
}
client 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)
}
client 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)
}
client 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
)
}
client function handleChange(sourceEvent, previousValue) {
sourceEvent.stopPropagation()
previousValue = committedValue
if (!isBlank()) {
currentValue = clamp
? clampValue(currentValue)
: roundValue(currentValue)
}
committedValue = currentValue
dispatchInputNumberEvent(
sourceEvent,
"change",
"change",
previousValue
)
}
client 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)
}
}
client 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>
}
}