From 71d75532fed5292560344033e2b37a3a1a8fb63f Mon Sep 17 00:00:00 2001 From: Sayam Samal Date: Fri, 23 May 2025 17:56:45 +0530 Subject: [PATCH] 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. --- tools/test-js-with-node | 1 + web/src/bundles/app.ts | 2 + web/src/bundles/showroom.ts | 3 + web/src/inputs.ts | 19 ++++ web/src/portico/showroom.ts | 6 ++ web/src/templates.ts | 12 +++ web/styles/app_variables.css | 50 ++++++++-- web/styles/dark_theme.css | 25 +++-- web/styles/inputs.css | 95 +++++++++++++++++++ web/styles/portico/showroom.css | 4 + web/styles/zulip.css | 2 +- web/templates/components/input.hbs | 9 ++ .../components/showroom/filter_input.hbs | 3 + 13 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 web/src/inputs.ts create mode 100644 web/styles/inputs.css create mode 100644 web/templates/components/input.hbs create mode 100644 web/templates/components/showroom/filter_input.hbs diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 77eb6345df..504e94ac59 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -125,6 +125,7 @@ EXEMPT_FILES = make_set( "web/src/info_overlay.ts", "web/src/information_density.ts", "web/src/input_pill.ts", + "web/src/inputs.ts", "web/src/integration_branch_pill.ts", "web/src/integration_url_modal.ts", "web/src/invite.ts", diff --git a/web/src/bundles/app.ts b/web/src/bundles/app.ts index 4936bb173d..11bacdfd1b 100644 --- a/web/src/bundles/app.ts +++ b/web/src/bundles/app.ts @@ -10,6 +10,7 @@ import "../setup.ts"; import "../reload.ts"; import "../templates.ts"; import "../zulip_test.ts"; +import "../inputs.ts"; // Import styles import "tippy.js/dist/tippy.css"; @@ -26,6 +27,7 @@ import "../../styles/typeahead.css"; import "../../styles/app_variables.css"; import "../../styles/tooltips.css"; import "../../styles/buttons.css"; +import "../../styles/inputs.css"; import "../../styles/banners.css"; import "../../styles/components.css"; import "../../styles/app_components.css"; diff --git a/web/src/bundles/showroom.ts b/web/src/bundles/showroom.ts index 29bac3e765..451968e7e2 100644 --- a/web/src/bundles/showroom.ts +++ b/web/src/bundles/showroom.ts @@ -1,9 +1,12 @@ import "./portico.ts"; +import "../templates.ts"; +import "../inputs.ts"; import "../portico/showroom.ts"; // Import styles in the required order import "../../styles/portico/showroom.css"; import "../../styles/app_variables.css"; import "../../styles/buttons.css"; +import "../../styles/inputs.css"; import "../../styles/banners.css"; import "../../styles/app_components.css"; diff --git a/web/src/inputs.ts b/web/src/inputs.ts new file mode 100644 index 0000000000..786ae2f858 --- /dev/null +++ b/web/src/inputs.ts @@ -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"); + }, +); diff --git a/web/src/portico/showroom.ts b/web/src/portico/showroom.ts index 5e024beb48..625d530763 100644 --- a/web/src/portico/showroom.ts +++ b/web/src/portico/showroom.ts @@ -3,6 +3,7 @@ import $ from "jquery"; import assert from "minimalistic-assert"; 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 type {HTMLSelectOneElement} from "../types.ts"; @@ -675,4 +676,9 @@ $(window).on("load", () => { $("#showroom_component_banner_default_wrapper").html(banner_html(custom_normal_banner)); } }); + + if (window.location.pathname === "/devtools/inputs/") { + const $filter_input_container = $(".showroom-filter-input-container"); + $filter_input_container.html(render_filter_input()); + } }); diff --git a/web/src/templates.ts b/web/src/templates.ts index c58b8fec8c..68672051a5 100644 --- a/web/src/templates.ts +++ b/web/src/templates.ts @@ -1,5 +1,7 @@ import Handlebars from "handlebars/runtime.js"; +import render_input from "../templates/components/input.hbs"; + import * as common from "./common.ts"; import {default_html_elements, intl} from "./i18n.ts"; import {postprocess_content} from "./postprocess_content.ts"; @@ -173,3 +175,13 @@ Handlebars.registerHelper("popover_hotkey_hints", (...args) => { `${hotkey_hints}`, ); }); + +// 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}} +Handlebars.registerPartial("input", render_input); diff --git a/web/styles/app_variables.css b/web/styles/app_variables.css index e3ad09875d..e8f91061e1 100644 --- a/web/styles/app_variables.css +++ b/web/styles/app_variables.css @@ -680,6 +680,14 @@ /* Popup banner related variables */ --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 */ --color-date: light-dark(hsl(0deg 0% 15% / 75%), hsl(0deg 0% 100% / 75%)); --color-background-private-message-header: light-dark( @@ -1407,15 +1415,6 @@ hsl(0deg 0% 80%), 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 */ --color-text-link: light-dark(hsl(210deg 94% 42%), hsl(200deg 100% 50%)); --color-text-link-decoration: light-dark( @@ -2626,6 +2625,39 @@ 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 */ --color-success-main-view-banner: light-dark( hsl(147deg 57% 25%), diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index 054c62d5e7..4dc1ab41e1 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -105,12 +105,6 @@ opacity: 0.4; } - & input[type="text"], - input[type="email"], - input[type="password"], - input[type="number"], - input[type="url"], - input[type="date"], textarea, select, .pill-container, @@ -122,6 +116,22 @@ 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 { background-color: hsl(225deg 6% 7%); 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.new_message_textarea:focus, #compose_recipient_box:focus { diff --git a/web/styles/inputs.css b/web/styles/inputs.css new file mode 100644 index 0000000000..0e592d1fc0 --- /dev/null +++ b/web/styles/inputs.css @@ -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; + } +} diff --git a/web/styles/portico/showroom.css b/web/styles/portico/showroom.css index 588070f927..b7acec5f54 100644 --- a/web/styles/portico/showroom.css +++ b/web/styles/portico/showroom.css @@ -137,3 +137,7 @@ body { width: min(100%, 800px); margin: 0 auto; } + +.showroom-filter-input-container { + width: clamp(200px, 100%, 400px); +} diff --git a/web/styles/zulip.css b/web/styles/zulip.css index 250ab00b74..dc414c463c 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -702,7 +702,7 @@ i.zulip-icon:focus-visible, } /* List of text-like input types taken from Bootstrap */ -input { +input:not(.input-element) { &[type="text"], &[type="password"], &[type="datetime"], diff --git a/web/templates/components/input.hbs b/web/templates/components/input.hbs new file mode 100644 index 0000000000..99a4a2bb7d --- /dev/null +++ b/web/templates/components/input.hbs @@ -0,0 +1,9 @@ +
+ {{#if icon}} + + {{/if}} + {{> @partial-block .}} + {{#if input_button_icon}} + {{> icon_button custom_classes="input-button" squared=true icon=input_button_icon intent="neutral" }} + {{/if}} +
diff --git a/web/templates/components/showroom/filter_input.hbs b/web/templates/components/showroom/filter_input.hbs new file mode 100644 index 0000000000..1f97a4cc90 --- /dev/null +++ b/web/templates/components/showroom/filter_input.hbs @@ -0,0 +1,3 @@ +{{#> input input_type="filter-input" icon="search" input_button_icon="close"}} + +{{/input}}