inputs: Implement redesigned input component.

This commit serves as the base for the ongoing effort to standardize
redesigned input elements throughout the Zulip Web UI. It introduces a
new Handlebars partial block for inputs, located at
web/templates/components/input.hbs.

The partial can be used with the partial block syntax: {{#> input}},
allowing contributors to pass in the input element as a template. This
approach wraps the input with a consistent structure that includes
support for an icon and an action button. It also applies the necessary
styling to ensure visual and functional consistency across the web UI.

This commit also implements the filter input component at
/devtools/inputs/ showroom page for design discussions and prototyping.
This commit is contained in:
Sayam Samal
2025-05-23 17:56:45 +05:30
committed by Tim Abbott
parent f8d82775d1
commit 71d75532fe
13 changed files with 212 additions and 19 deletions

View File

@@ -125,6 +125,7 @@ EXEMPT_FILES = make_set(
"web/src/info_overlay.ts", "web/src/info_overlay.ts",
"web/src/information_density.ts", "web/src/information_density.ts",
"web/src/input_pill.ts", "web/src/input_pill.ts",
"web/src/inputs.ts",
"web/src/integration_branch_pill.ts", "web/src/integration_branch_pill.ts",
"web/src/integration_url_modal.ts", "web/src/integration_url_modal.ts",
"web/src/invite.ts", "web/src/invite.ts",

View File

@@ -10,6 +10,7 @@ import "../setup.ts";
import "../reload.ts"; import "../reload.ts";
import "../templates.ts"; import "../templates.ts";
import "../zulip_test.ts"; import "../zulip_test.ts";
import "../inputs.ts";
// Import styles // Import styles
import "tippy.js/dist/tippy.css"; import "tippy.js/dist/tippy.css";
@@ -26,6 +27,7 @@ import "../../styles/typeahead.css";
import "../../styles/app_variables.css"; import "../../styles/app_variables.css";
import "../../styles/tooltips.css"; import "../../styles/tooltips.css";
import "../../styles/buttons.css"; import "../../styles/buttons.css";
import "../../styles/inputs.css";
import "../../styles/banners.css"; import "../../styles/banners.css";
import "../../styles/components.css"; import "../../styles/components.css";
import "../../styles/app_components.css"; import "../../styles/app_components.css";

View File

@@ -1,9 +1,12 @@
import "./portico.ts"; import "./portico.ts";
import "../templates.ts";
import "../inputs.ts";
import "../portico/showroom.ts"; import "../portico/showroom.ts";
// Import styles in the required order // Import styles in the required order
import "../../styles/portico/showroom.css"; import "../../styles/portico/showroom.css";
import "../../styles/app_variables.css"; import "../../styles/app_variables.css";
import "../../styles/buttons.css"; import "../../styles/buttons.css";
import "../../styles/inputs.css";
import "../../styles/banners.css"; import "../../styles/banners.css";
import "../../styles/app_components.css"; import "../../styles/app_components.css";

19
web/src/inputs.ts Normal file
View File

@@ -0,0 +1,19 @@
import $ from "jquery";
$("body").on("input", ".input-element", function (this: HTMLInputElement, _e: JQuery.Event) {
if (this.value.length === 0) {
$(this).removeClass("input-element-nonempty");
} else {
$(this).addClass("input-element-nonempty");
}
});
$("body").on(
"click",
".filter-input .input-button",
function (this: HTMLElement, _e: JQuery.Event) {
const $input = $(this).prev(".input-element");
$input.val("").trigger("input");
$input.trigger("blur");
},
);

View File

@@ -3,6 +3,7 @@ import $ from "jquery";
import assert from "minimalistic-assert"; import assert from "minimalistic-assert";
import render_banner from "../../templates/components/banner.hbs"; import render_banner from "../../templates/components/banner.hbs";
import render_filter_input from "../../templates/components/showroom/filter_input.hbs";
import {$t, $t_html} from "../i18n.ts"; import {$t, $t_html} from "../i18n.ts";
import type {HTMLSelectOneElement} from "../types.ts"; import type {HTMLSelectOneElement} from "../types.ts";
@@ -675,4 +676,9 @@ $(window).on("load", () => {
$("#showroom_component_banner_default_wrapper").html(banner_html(custom_normal_banner)); $("#showroom_component_banner_default_wrapper").html(banner_html(custom_normal_banner));
} }
}); });
if (window.location.pathname === "/devtools/inputs/") {
const $filter_input_container = $<HTMLInputElement>(".showroom-filter-input-container");
$filter_input_container.html(render_filter_input());
}
}); });

View File

@@ -1,5 +1,7 @@
import Handlebars from "handlebars/runtime.js"; import Handlebars from "handlebars/runtime.js";
import render_input from "../templates/components/input.hbs";
import * as common from "./common.ts"; import * as common from "./common.ts";
import {default_html_elements, intl} from "./i18n.ts"; import {default_html_elements, intl} from "./i18n.ts";
import {postprocess_content} from "./postprocess_content.ts"; import {postprocess_content} from "./postprocess_content.ts";
@@ -173,3 +175,13 @@ Handlebars.registerHelper("popover_hotkey_hints", (...args) => {
`<span class="popover-menu-hotkey-hints">${hotkey_hints}</span>`, `<span class="popover-menu-hotkey-hints">${hotkey_hints}</span>`,
); );
}); });
// The below section is for registering global Handlebar partials.
// The "input" partial block located at web/templates/components/input.hbs
// is used to wrap any input element that needs to be styled as a Zulip input.
// Usage example:
// {{#> input . input_type="filter-input" custom_classes="inbox-search-wrapper" icon="search" input_button_icon="close"}}
// <input type="text" id="{{INBOX_SEARCH_ID}}" class="input-element" value="{{search_val}}" autocomplete="off" placeholder="{{t 'Filter' }}" />
// {{/input}}
Handlebars.registerPartial("input", render_input);

View File

@@ -680,6 +680,14 @@
/* Popup banner related variables */ /* Popup banner related variables */
--popup-banner-translate-y-distance: 100px; --popup-banner-translate-y-distance: 100px;
/* Input grid layout variables */
--input-icon-starting-offset: 0.5em; /* 8px at 16px/1em */
--input-icon-width: 1em; /* 16px at 16px/1em */
--input-icon-content-gap: 0.4375em; /* 7px at 16px/1em */
--input-button-content-gap: 0.25em; /* 4px at 16px/1em */
--input-button-width: 1.5em; /* 24px at 16px/1em */
--input-button-ending-offset: 0.125em; /* 2px at 16px/1em */
/* Colors used across the app */ /* Colors used across the app */
--color-date: light-dark(hsl(0deg 0% 15% / 75%), hsl(0deg 0% 100% / 75%)); --color-date: light-dark(hsl(0deg 0% 15% / 75%), hsl(0deg 0% 100% / 75%));
--color-background-private-message-header: light-dark( --color-background-private-message-header: light-dark(
@@ -1407,15 +1415,6 @@
hsl(0deg 0% 80%), hsl(0deg 0% 80%),
hsl(0deg 0% 0% / 60%) hsl(0deg 0% 0% / 60%)
); );
/* TODO: Light mode uses browser-default white
backgrounds; we should extend the use of this
color variable to 1) explicitly set the
background color of inputs, and 2) clean up a
lingering stack of selectors in dark_theme.css. */
--color-background-input: light-dark(
hsl(0deg 0% 100%),
hsl(0deg 0% 0% / 20%)
);
/* Link colors */ /* Link colors */
--color-text-link: light-dark(hsl(210deg 94% 42%), hsl(200deg 100% 50%)); --color-text-link: light-dark(hsl(210deg 94% 42%), hsl(200deg 100% 50%));
--color-text-link-decoration: light-dark( --color-text-link-decoration: light-dark(
@@ -2626,6 +2625,39 @@
hsl(0deg 52% 18%) hsl(0deg 52% 18%)
); );
/* Inputs */
--color-text-input: light-dark(
color-mix(in oklch, hsl(0deg 0% 15%) 50%, transparent),
color-mix(in oklch, hsl(0deg 0% 83%) 50%, transparent)
);
--color-text-input-focus: light-dark(hsl(0deg 0% 15%), hsl(0deg 0% 83%));
/* TODO: Light mode uses browser-default white
backgrounds; we should extend the use of this
color variable to 1) explicitly set the
background color of inputs, and 2) clean up a
lingering stack of selectors in dark_theme.css. */
--color-background-input: light-dark(hsl(0deg 0% 100%), hsl(220deg 6% 10%));
--color-background-input-focus: light-dark(
hsl(0deg 0% 100%),
hsl(240deg 6% 7%)
);
--color-border-input: light-dark(
color-mix(in oklch, hsl(229deg 22% 10%) 30%, transparent),
color-mix(in oklch, hsl(0deg 0% 100%) 20%, transparent)
);
--color-border-input-hover: light-dark(
color-mix(in oklch, hsl(229deg 22% 10%) 30%, transparent),
color-mix(in oklch, hsl(0deg 0% 100%) 20%, transparent)
);
--color-border-input-focus: light-dark(
color-mix(in oklch, hsl(229deg 22% 10%) 80%, transparent),
color-mix(in oklch, hsl(0deg 0% 100%) 50%, transparent)
);
--color-box-shadow-input-focus: light-dark(
color-mix(in oklch, hsl(228deg 10% 20%) 30%, transparent),
color-mix(in oklch, hsl(0deg 0% 100%) 40%, transparent)
);
/* .main-view-banner colors */ /* .main-view-banner colors */
--color-success-main-view-banner: light-dark( --color-success-main-view-banner: light-dark(
hsl(147deg 57% 25%), hsl(147deg 57% 25%),

View File

@@ -105,12 +105,6 @@
opacity: 0.4; opacity: 0.4;
} }
& input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="url"],
input[type="date"],
textarea, textarea,
select, select,
.pill-container, .pill-container,
@@ -122,6 +116,22 @@
border-color: hsl(0deg 0% 0% / 60%); border-color: hsl(0deg 0% 0% / 60%);
} }
input:not(.input-element) {
&[type="text"],
&[type="email"],
&[type="password"],
&[type="number"],
&[type="url"],
&[type="date"] {
background-color: hsl(0deg 0% 0% / 20%);
border-color: hsl(0deg 0% 0% / 60%);
&:focus {
border-color: hsl(0deg 0% 0% / 90%);
}
}
}
.popover-filter-input-wrapper .popover-filter-input:focus { .popover-filter-input-wrapper .popover-filter-input:focus {
background-color: hsl(225deg 6% 7%); background-color: hsl(225deg 6% 7%);
border: 1px solid hsl(0deg 0% 100% / 50%); border: 1px solid hsl(0deg 0% 100% / 50%);
@@ -148,9 +158,6 @@
} }
} }
& input[type="text"]:focus,
input[type="email"]:focus,
input[type="number"]:focus,
textarea:focus, textarea:focus,
textarea.new_message_textarea:focus, textarea.new_message_textarea:focus,
#compose_recipient_box:focus { #compose_recipient_box:focus {

95
web/styles/inputs.css Normal file
View File

@@ -0,0 +1,95 @@
.input-active-styles {
color: var(--color-text-input-focus);
background-color: var(--color-background-input-focus);
border-color: var(--color-border-input-focus);
}
.input-element-wrapper {
display: grid;
grid-template:
[input-element-start] "icon-starting-offset input-icon icon-content-gap content button-content-gap input-button button-ending-offset" auto [input-element-end] / [input-element-start] var(
--input-icon-starting-offset
)
var(--input-icon-width) var(--input-icon-content-gap) minmax(0, 1fr)
var(--input-button-content-gap) var(--input-button-width) var(
--input-button-ending-offset
)
[input-element-end];
align-items: center;
.input-element {
grid-area: input-element;
box-sizing: border-box;
padding: 0.1875em 0.5em; /* 3px at 16px/1em and 8px at 16px/1em */
font-size: var(--base-font-size-px);
font-family: "Source Sans 3 VF", sans-serif;
line-height: 1.25;
text-overflow: ellipsis;
color: var(--color-text-input);
background: var(--color-background-input);
border: 1px solid var(--color-border-input);
border-radius: 4px;
outline: none;
transition: 0.1s linear;
transition-property: border-color, box-shadow;
&:hover {
border-color: var(--color-border-input-hover);
}
&:focus {
box-shadow: 0 0 5px var(--color-box-shadow-input-focus);
}
&:focus,
&.input-element-nonempty {
@extend .input-active-styles;
}
}
&.has-input-icon .input-element {
padding-left: calc(
var(--input-icon-starting-offset) + var(--input-icon-width) +
var(--input-icon-content-gap)
);
}
&.has-input-button .input-element {
padding-right: calc(
var(--input-button-content-gap) + var(--input-button-width) +
var(--input-button-ending-offset)
);
}
}
.input-icon {
grid-area: input-icon;
color: var(--color-text-input);
/* We need to set the z-index, since the input icon
comes before the input element in the DOM, but we
want to display it over the input element in the UI. */
z-index: 1;
pointer-events: none;
}
.input-button {
grid-area: input-button;
padding: 0.25em; /* 4px at 16px/1em */
}
.filter-input .input-element {
&:placeholder-shown {
/* In case of filter inputs, when the input field
is empty, we hide the input button and adjust
the right padding to compensate for the same. */
padding-right: 0.5em;
~ .input-button {
visibility: hidden;
}
}
&:not(:placeholder-shown) {
@extend .input-active-styles;
}
}

View File

@@ -137,3 +137,7 @@ body {
width: min(100%, 800px); width: min(100%, 800px);
margin: 0 auto; margin: 0 auto;
} }
.showroom-filter-input-container {
width: clamp(200px, 100%, 400px);
}

View File

@@ -702,7 +702,7 @@ i.zulip-icon:focus-visible,
} }
/* List of text-like input types taken from Bootstrap */ /* List of text-like input types taken from Bootstrap */
input { input:not(.input-element) {
&[type="text"], &[type="text"],
&[type="password"], &[type="password"],
&[type="datetime"], &[type="datetime"],

View File

@@ -0,0 +1,9 @@
<div {{#if id}}id="{{id}}"{{/if}} class="input-element-wrapper{{#if input_type}} {{input_type}}{{/if}}{{#if custom_classes}} {{custom_classes}}{{/if}}{{#if icon}} has-input-icon{{/if}}{{#if input_button_icon}} has-input-button{{/if}}">
{{#if icon}}
<i class="input-icon zulip-icon zulip-icon-{{icon}}" aria-hidden="true"></i>
{{/if}}
{{> @partial-block .}}
{{#if input_button_icon}}
{{> icon_button custom_classes="input-button" squared=true icon=input_button_icon intent="neutral" }}
{{/if}}
</div>

View File

@@ -0,0 +1,3 @@
{{#> input input_type="filter-input" icon="search" input_button_icon="close"}}
<input class="input-element" type="text" placeholder="{{t 'Filter component' }}" />
{{/input}}