Files
WRNexusJS/packages/ui/components/InputNumber.wrn
T
ClintchizandClaude Opus 5.5 1bed8e4b74 feat(ui): component redesign pass and fixes, release ui 0.8.87
Redesign
- Shared field foundation rules (duplicated across 15 form components and
  emitted globally) are now identical everywhere, so later copies no longer
  silently override earlier redesigns.
- Drawn checkbox and radio controls, white switch thumbs (the thumb was the
  text colour, a black blob in light mode), recessed field wells, keycap
  actions in Modal, Drawer, Popover, Toaster, Pagination, Stepper, DataTable.
- Menus (Dropdown, ContextMenu, Popover, Select dropdown, Navbar panels) share
  one flat raised panel with neutral hover rows and mono section labels.
- Alerts with an accent rail and icon chip; card headers without the grey
  band; accordion, collapse, list, list group, timeline, stats, metrics,
  chat, skeleton, avatar group, clipboard, splitter, section, sidebar,
  navbar and upload progress restyled.
- Chart rebuilt: nice y-axis ticks, gridlines, rounded bars, line and area
  modes, hover tooltips, hidden series leave the scale.

Fixes
- Checkbox and radio labels toggle their input again (empty for="" broke it).
- TimePicker kept every clicked time pressed; only the chosen hour and
  minute are pressed now, and nothing is pressed before a value exists.
- ColorPicker hex readout now follows the picked colour.
- Disabled FileInput no longer shows the native file-input text.
- Date and time panels float instead of pushing content.
- ContextMenu opened at the pointer closes on scroll.
- Drawer left/top/bottom placements slide from their own edge again.
- Image-only Card no longer renders an empty body strip.
- Timeline supports item.time, current/upcoming states, sizes and
  compact/alternate variants; showcase demos use distinct variants.
- ChatBubble: delivery receipts (sending/sent/delivered/read/failed/error)
  and meta aligned to its bubble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:18:13 +05:30

737 lines
28 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(--wrn-color-secondary)"
}
if (color === "success") {
return "var(--wrn-color-success)"
}
if (color === "warning") {
return "var(--wrn-color-warning)"
}
if (color === "danger") {
return "var(--wrn-color-danger)"
}
if (color === "info") {
return "var(--wrn-color-info)"
}
return "var(--wrn-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"}'
data-full-width='{fullWidth ? "true" : "false"}'
style='--input-number-accent: {componentColor()};'
class='wrn-input-number {class}'
>
{#if label !== "" && variant !== "labeled" && variant !== "seat"}
<label
for='{inputId()}'
class='wrn-input-number__label'
>
<span>{label}</span>
{#if required}
<span
aria-hidden="true"
class='wrn-input-number__required'
>*</span>
{/if}
</label>
{/if}
{#if description !== "" && variant !== "labeled" && variant !== "seat"}
<p
id='{descriptionId()}'
class='wrn-input-number__description'
>
{description}
</p>
{/if}
<div
class='wrn-input-number__control'
>
{#if variant === "horizontal" && showButtons}
<button
type="button"
aria-label='{decrementLabel}'
aria-controls='{inputId()}'
disabled='{decrementDisabled()}'
@click='decrementValue(event)'
class='wrn-input-number__button wrn-input-number__button--start'
>
<span
aria-hidden="true"
class='icon-[lucide--minus] wrn-input-number__icon'
></span>
</button>
{/if}
<div
class='wrn-input-number__content'
>
{#if variant === "labeled" || variant === "seat"}
<div class='wrn-input-number__copy'>
{#if label !== ""}
<label
for='{inputId()}'
class='wrn-input-number__inner-label'
>
{label}
{#if required}
<span
aria-hidden="true"
class='wrn-input-number__required'
>*</span>
{/if}
</label>
{/if}
{#if description !== ""}
<span
id='{descriptionId()}'
class='wrn-input-number__inner-description'
>
{description}
</span>
{/if}
</div>
{/if}
<div
class='wrn-input-number__field'
>
{#if prefix !== ""}
<span
aria-hidden="true"
class='wrn-input-number__prefix'
>
{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='wrn-input-number__input'
/>
{#if suffix !== ""}
<span
aria-hidden="true"
class='wrn-input-number__suffix'
>
{suffix}
</span>
{/if}
</div>
</div>
{#if variant === "horizontal" && showButtons}
<button
type="button"
aria-label='{incrementLabel}'
aria-controls='{inputId()}'
disabled='{incrementDisabled()}'
@click='incrementValue(event)'
class='wrn-input-number__button wrn-input-number__button--end'
>
<span
aria-hidden="true"
class='icon-[lucide--plus] wrn-input-number__icon'
></span>
</button>
{:else}
{#if showButtons}
<div
role="group"
aria-label='{controlsLabel}'
class='wrn-input-number__buttons'
>
<button
type="button"
aria-label='{decrementLabel}'
aria-controls='{inputId()}'
disabled='{decrementDisabled()}'
@click='decrementValue(event)'
class='wrn-input-number__button wrn-input-number__button--decrement'
>
<span
aria-hidden="true"
class='icon-[lucide--minus] wrn-input-number__icon'
></span>
</button>
<button
type="button"
aria-label='{incrementLabel}'
aria-controls='{inputId()}'
disabled='{incrementDisabled()}'
@click='incrementValue(event)'
class='wrn-input-number__button wrn-input-number__button--increment'
>
<span
aria-hidden="true"
class='icon-[lucide--plus] wrn-input-number__icon'
></span>
</button>
</div>
{/if}
{/if}
</div>
{#if showValidationMessage && isInvalid() && validationMessage() !== ""}
<p
id='{messageId()}'
role="alert"
aria-live="polite"
class='wrn-input-number__message wrn-input-number__message--error'
>
<span
aria-hidden="true"
class='icon-[lucide--circle-alert] wrn-input-number__error-icon'
></span>
<span>{validationMessage()}</span>
</p>
{/if}
{#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
<p
id='{messageId()}'
class='wrn-input-number__message'
>
{helpText}
</p>
{/if}
</div>
}
style {
.wrn-input-number { position: relative; display: flex; width: 100%; max-width: 24rem; flex-direction: column; gap: 0.375rem; color: var(--wrn-color-text); }
.wrn-input-number[data-full-width="true"] { max-width: none; }
.wrn-input-number[data-variant="compact"] { width: fit-content; max-width: 100%; }
.wrn-input-number[data-disabled="true"] { opacity: 0.6; }
.wrn-input-number__label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--wrn-color-text);
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.25rem;
}
.wrn-input-number__required { margin-left: 0.125rem; color: var(--wrn-color-danger); }
.wrn-input-number__description, .wrn-input-number__message { margin: 0; color: var(--wrn-color-muted); font-size: 0.75rem; line-height: 1.25rem; }
.wrn-input-number__control {
display: flex;
min-width: 0;
min-height: 2.5rem;
overflow: hidden;
border: 1px solid var(--wrn-color-input-border, var(--wrn-color-border));
border-radius: var(--wrn-radius-sm);
color: var(--wrn-color-text);
background: var(--wrn-color-input-background, var(--wrn-color-surface));
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.04);
transition: border-color var(--wrn-motion-base), box-shadow var(--wrn-motion-base), background var(--wrn-motion-base);
}
.wrn-input-number__control:focus-within { border-color: var(--input-number-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--input-number-accent) 18%, transparent); }
.wrn-input-number[data-invalid="true"] .wrn-input-number__control { border-color: var(--wrn-color-danger); }
.wrn-input-number[data-invalid="true"] .wrn-input-number__control:focus-within { box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 18%, transparent); }
.wrn-input-number[data-disabled="true"] .wrn-input-number__control { cursor: not-allowed; background: var(--wrn-color-surface-2); }
.wrn-input-number[data-variant="compact"] .wrn-input-number__control { border-radius: 999px; }
.wrn-input-number[data-size="xs"] .wrn-input-number__control { min-height: 2rem; font-size: 0.75rem; }
.wrn-input-number[data-size="sm"] .wrn-input-number__control { min-height: 2.25rem; font-size: 0.875rem; }
.wrn-input-number[data-size="lg"] .wrn-input-number__control { min-height: 3rem; font-size: 1rem; }
.wrn-input-number[data-size="xl"] .wrn-input-number__control { min-height: 3.5rem; font-size: 1.125rem; }
.wrn-input-number__content { display: flex; min-width: 0; flex: 1; align-items: center; padding-inline: 0.75rem; }
.wrn-input-number[data-size="xs"] .wrn-input-number__content { padding-inline: 0.5rem; }
.wrn-input-number[data-size="sm"] .wrn-input-number__content { padding-inline: 0.625rem; }
.wrn-input-number[data-size="lg"] .wrn-input-number__content { padding-inline: 1rem; }
.wrn-input-number[data-size="xl"] .wrn-input-number__content { padding-inline: 1.25rem; }
.wrn-input-number[data-variant="labeled"] .wrn-input-number__content { flex-direction: column; align-items: stretch; justify-content: center; gap: 0.125rem; }
.wrn-input-number[data-variant="seat"] .wrn-input-number__content { justify-content: space-between; gap: 0.75rem; }
.wrn-input-number__copy { min-width: 0; flex: 1; }
.wrn-input-number__inner-label, .wrn-input-number__inner-description { display: block; overflow: hidden; color: var(--wrn-color-text); font-size: 0.875rem; font-weight: 600; line-height: 1rem; text-overflow: ellipsis; white-space: nowrap; }
.wrn-input-number__inner-description { color: var(--wrn-color-muted); font-size: 0.75rem; font-weight: 400; }
.wrn-input-number[data-variant="labeled"] .wrn-input-number__inner-label { color: var(--wrn-color-muted); font-size: 0.75rem; font-weight: 500; }
.wrn-input-number__field { display: flex; width: 100%; min-width: 0; align-items: center; }
.wrn-input-number[data-variant="seat"] .wrn-input-number__field { width: auto; flex: none; }
.wrn-input-number__prefix, .wrn-input-number__suffix { flex: none; color: var(--wrn-color-muted); }
.wrn-input-number__prefix { padding-right: 0.375rem; } .wrn-input-number__suffix { padding-left: 0.375rem; }
.wrn-input-number__input {
width: 100%;
min-width: 0;
flex: 1;
padding: 0;
border: 0;
outline: none;
color: var(--wrn-color-text);
background: transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 550;
font-variant-numeric: tabular-nums;
line-height: 1;
appearance: textfield;
}
.wrn-input-number__input::-webkit-inner-spin-button, .wrn-input-number__input::-webkit-outer-spin-button { appearance: none; }
.wrn-input-number__input::placeholder { color: var(--wrn-color-muted); }
.wrn-input-number__input:read-only { cursor: default; }
.wrn-input-number__input:disabled { cursor: not-allowed; }
.wrn-input-number:is([data-variant="horizontal"], [data-variant="compact"], [data-variant="seat"]) .wrn-input-number__input { text-align: center; }
.wrn-input-number[data-variant="seat"] .wrn-input-number__input { width: 2.5rem; flex: none; }
.wrn-input-number__buttons {
display: flex;
flex: none;
border-left: 1px solid var(--wrn-color-border);
background: var(--wrn-color-surface-soft, transparent);
}
.wrn-input-number[data-variant="vertical"] .wrn-input-number__buttons { flex-direction: column; }
.wrn-input-number__button {
display: inline-flex;
width: 2.5rem;
flex: none;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
color: var(--wrn-color-text-muted);
background: transparent;
cursor: pointer;
transition: color var(--wrn-motion-fast), background var(--wrn-motion-fast);
}
.wrn-input-number[data-size="xs"] .wrn-input-number__button { width: 2rem; } .wrn-input-number[data-size="sm"] .wrn-input-number__button { width: 2.25rem; } .wrn-input-number[data-size="lg"] .wrn-input-number__button { width: 3rem; } .wrn-input-number[data-size="xl"] .wrn-input-number__button { width: 3.5rem; }
.wrn-input-number__button--start { border-right: 1px solid var(--wrn-color-border); } .wrn-input-number__button--end { border-left: 1px solid var(--wrn-color-border); }
.wrn-input-number__button--decrement { border-right: 1px solid var(--wrn-color-border); }
.wrn-input-number[data-variant="vertical"] .wrn-input-number__button--decrement { border-right: 0; border-bottom: 1px solid var(--wrn-color-border); }
.wrn-input-number__button:hover {
color: var(--wrn-color-text);
background: var(--wrn-color-surface-muted, var(--wrn-color-surface-2));
}
.wrn-input-number__button:focus-visible { z-index: 1; outline: 2px solid var(--input-number-accent); outline-offset: -2px; }
.wrn-input-number__button:disabled { cursor: not-allowed; opacity: 0.4; }
.wrn-input-number__icon { width: 1rem; height: 1rem; }
.wrn-input-number__message--error { display: flex; align-items: center; gap: 0.375rem; color: var(--wrn-color-danger); }
.wrn-input-number__error-icon { width: 0.875rem; height: 0.875rem; flex: none; }
}
}