diff --git a/bun.lock b/bun.lock
index 2960d3f5..029d0baf 100644
--- a/bun.lock
+++ b/bun.lock
@@ -688,7 +688,7 @@
},
"packages/ui": {
"name": "@wrnexus/ui",
- "version": "0.8.66",
+ "version": "0.8.70",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
diff --git a/packages/ui/components/input.wrn b/packages/ui/components/input.wrn
index b02f2803..5d95a609 100644
--- a/packages/ui/components/input.wrn
+++ b/packages/ui/components/input.wrn
@@ -78,6 +78,7 @@ component Input {
data-inline="{inline}"
data-floating="{variant === 'floating'}"
data-invalid="{error || validationError ? 'true' : 'false'}"
+ data-input-type="{type}"
>
- {#if icon}
+ {#if icon || type === "date" || type === "datetime-local" || type === "time" || type === "month" || type === "week"}
@@ -154,6 +156,7 @@ component Input {
id="{(id || name) + '-error'}"
class="wrn-next__field-error"
data-error="{name}"
+ data-empty="{error || validationError ? 'false' : 'true'}"
aria-live="polite"
>
{error || validationError}
@@ -308,6 +311,12 @@ component Input {
font-size: 0.8125rem;
font-weight: 500;
}
+ /* Input is a block-level control. Shared field sizing must not cap it when
+ it participates in a grid or flex layout owned by the consumer. */
+ .wrn-next--field.wrn-next--input {
+ width: 100%;
+ max-width: none;
+ }
.wrn-next--field.wrn-next--size-sm,
.wrn-next--choice-field.wrn-next--size-sm {
font-size: 0.8125rem;
@@ -346,10 +355,29 @@ component Input {
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
- .wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
+ .wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
- .wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
+ .wrn-next--input[data-input-type="date"] .wrn-next__field-control,
+ .wrn-next--input[data-input-type="datetime-local"] .wrn-next__field-control,
+ .wrn-next--input[data-input-type="time"] .wrn-next__field-control,
+ .wrn-next--input[data-input-type="month"] .wrn-next__field-control,
+ .wrn-next--input[data-input-type="week"] .wrn-next__field-control {
+ color-scheme: light dark;
+ }
+ .wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"]) {
+ min-width: 0;
+ font-variant-numeric: tabular-nums;
+ }
+ .wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"])::-webkit-calendar-picker-indicator {
+ width: 1.1rem;
+ height: 1.1rem;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+ opacity: 0.72;
+ }
+ .wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -433,6 +461,10 @@ component Input {
.wrn-next__field-error:empty {
display: none;
}
+ .wrn-next__field-error[data-empty="true"] {
+ display: none;
+ min-height: 0;
+ }
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
.wrn-next--choice-field[data-invalid="true"] input {
border-color: var(--wrn-color-danger);
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 7ae7b0ee..b7cdb56c 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
- "version": "0.8.66",
+ "version": "0.8.70",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts
index f94caeaf..890b0925 100644
--- a/packages/ui/test/ui.test.ts
+++ b/packages/ui/test/ui.test.ts
@@ -1294,6 +1294,10 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(validation.error).toBe("Enter a valid email address.");
const source = readFileSync(uiComponentPath("Input"), "utf8");
+ const sharedFieldRule = source.lastIndexOf(".wrn-next--field,");
+ const inputWidthRule = source.lastIndexOf(".wrn-next--field.wrn-next--input");
+ expect(inputWidthRule).toBeGreaterThan(sharedFieldRule);
+ expect(source.slice(inputWidthRule, inputWidthRule + 140)).toContain("max-width: none");
const dom = mountHtml(
await renderComponent(source, {
id: "account-email",
@@ -1317,6 +1321,7 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(root.dataset.variant).toBe("floating");
expect(root.dataset.inline).toBe("true");
expect(root.dataset.invalid).toBe("true");
+ expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("false");
expect(input.id).toBe("account-email");
expect(input.name).toBe("email");
expect(input.value).toBe("not-an-email");
@@ -1344,6 +1349,38 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
});
+test("Input does not reserve validation space while its error is empty", async () => {
+ const source = readFileSync(uiComponentPath("Input"), "utf8");
+ const dom = mountHtml(await renderComponent(source, { name: "search", hiddenLabel: true }));
+ expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("true");
+ expect(source).toContain('.wrn-next__field-error[data-empty="true"]');
+});
+
+test("Input only reserves icon space when it renders an icon", async () => {
+ const source = readFileSync(uiComponentPath("Input"), "utf8");
+ const plain = mountHtml(
+ await renderComponent(source, { name: "limit", label: "Limit", placeholder: "Enter limit" }),
+ );
+ const decorated = mountHtml(
+ await renderComponent(source, {
+ name: "email",
+ label: "Email",
+ icon: "icon-[lucide--mail]",
+ }),
+ );
+
+ expect(plain.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
+ "false",
+ );
+ expect(plain.querySelector(".wrn-next__field-icon")).toBeNull();
+ expect(decorated.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
+ "true",
+ );
+ expect(decorated.querySelector(".wrn-next__field-icon")).not.toBeNull();
+ expect(source).toContain('[data-has-icon="true"][data-icon-position="start"]');
+ expect(source).toContain('[data-has-icon="true"][data-icon-position="end"]');
+});
+
test("basic fields suppress native popups and render package constraint messages", async () => {
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(