From 4db7c64ba329dd6fa7e3679ea7d989d65e854e4c Mon Sep 17 00:00:00 2001 From: whereareiam Date: Sat, 3 Oct 2026 13:14:31 +0200 Subject: [PATCH] Search: Use one shared field across pages Profile, project, package, release, tag, Actions and diff pages each wrote their own search input and restyled it in their page stylesheet, next to two shared implementations that declared the same box. components/common/search now renders all of them. Kind selects the variant (submit, icon or plain), Local marks a client-side filter, and Class carries the page's layout hook. The native query/mode/submit group keeps its opt-in class but its rules move beside the component, so one stylesheet owns search fields. A test fails when a template writes its own search input. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 11 +- components/common/search/search.css | 117 +++++++++++++++++- components/common/search/search.test.ts | 28 +++++ components/common/search/search.tmpl | 10 +- public/actions-list.css | 22 ---- public/actions-run.css | 12 +- public/assets/js/actions-run.js | 6 +- styles/components/repo/diff_view.ts | 4 +- styles/components/repo/release.ts | 48 ------- styles/pages/profile-subpages.css | 10 +- styles/pages/user-profile.css | 18 --- styles/public/input.ts | 95 +------------- templates/repo/actions/list_inner.tmpl | 2 +- templates/repo/diff/box.tmpl | 4 +- templates/repo/release/list.tmpl | 2 +- templates/repo/tag/list.tmpl | 5 +- templates/user/profile_package_versions.tmpl | 2 +- templates/user/profile_packages.tmpl | 2 +- templates/user/profile_projects.tmpl | 2 +- templates/user/profile_repository_search.tmpl | 3 +- 20 files changed, 182 insertions(+), 221 deletions(-) create mode 100644 components/common/search/search.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64b570e0..8e83fa5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -214,9 +214,14 @@ controls. `shared/theme/sidebar_toggle` inside the sidebar and a button with `data-theme-sidebar-toggle` and `data-theme-sidebar-expand` beside the page title. The controller preserves desktop collapse preferences and starts collapsed on mobile. See `templates/repo/issue/sidebar.tmpl` and `templates/org/repository_nav.tmpl`. -- **Search:** call `components/common/search/search` with `Value` and `Placeholder` inside the existing form. Keep - hidden filter inputs in that form. The common search field should be a direct child of the form or have a parent that - fills the available width; an unconstrained wrapper can shrink it. +- **Search:** call `components/common/search/search` with `Value` and `Placeholder` inside the existing form; do not + write a search input in a page template. `Kind` selects the variant: `submit` (default, trailing search button), + `icon` (leading search icon, as on releases and Actions), or `plain`. `Name` defaults to `q`, `Label` overrides the + accessible name, and `Class` adds a page layout hook to the field. For a client-side filter that is never submitted, + pass `Local` with an `ID` for its controller. Keep hidden filter inputs in the form and size the field from the page + stylesheet through its `Class` or parent; an unconstrained wrapper can shrink it. A control built by a script uses the + `theme-search-field` class directly. Forgejo's native query/mode/submit group (`shared/search/combo_multi`) opts in + with `theme-search-control` on its form. - **Dropdowns:** call `components/common/dropdown/dropdown` with `Kind` (`single`, `multiple`, or `actions`), `Label`, `Items` (the content template name), and `Data` (the page context). `Title`, `Icon`, `IconOnly`, `UpdateLabel`, and `Quiet` customize the header and trigger. Use `components/common/dropdown/option` for radio or checkbox rows; action diff --git a/components/common/search/search.css b/components/common/search/search.css index 5b0d2128..daf4316a 100644 --- a/components/common/search/search.css +++ b/components/common/search/search.css @@ -4,6 +4,7 @@ height: 32px; width: 100%; min-width: 0; + margin: 0; border: 1px solid var(--color-light-border); border-radius: var(--border-radius); background: var(--color-body); @@ -15,10 +16,12 @@ outline-offset: -1px; } -.ui.form .theme-search-field > input[type="search"] { +/* :is() keeps one specificity for fields inside and outside Forgejo's .ui.form. */ +:is(.ui.form, body) .theme-search-field > input[type="search"] { flex: 1; width: 100%; min-width: 0; + height: 30px; margin: 0; padding: 5px 12px; border: 0; @@ -30,6 +33,27 @@ outline: none; } +:is(.ui.form, body) .theme-search-field > input[type="search"]::placeholder { + color: var(--color-text-light-1); + opacity: 1; +} + +.theme-search-field-icon { + align-items: center; + padding-left: 7px; + color: var(--color-text-light-1); + cursor: text; +} + +.theme-search-field-icon > svg { + flex: none; + margin-right: 9px; +} + +:is(.ui.form, body) .theme-search-field-icon > input[type="search"] { + padding-left: 0; +} + .theme-search-field > .theme-search-submit { flex: none; width: 32px; @@ -47,3 +71,94 @@ .theme-search-submit:hover { background: var(--color-hover); } + +/* Forgejo's native query/mode/submit group (shared/search/combo_multi), opted in on its form. */ +.ui.form.theme-search-control > .ui.action.input { + display: flex; + align-items: stretch; + height: 32px; + min-width: 0; + border: 1px solid var(--color-light-border); + border-radius: var(--border-radius); + background: var(--color-body); + box-shadow: none; + &:has(> input:focus-visible) { + outline: 2px solid var(--github-fgColor-accent); + outline-offset: -1px; + } + & > input[type="search"] { + min-width: 0; + width: 0; + flex: 1 1 0; + height: 30px; + padding: 5px 12px; + font-size: 14px; + line-height: 20px; + background: transparent; + border: 0; + border-radius: inherit; + box-shadow: none; + outline: none; + } + & > .ui.selection.dropdown, + & > .ui.button { + flex: 0 0 auto; + height: 30px; + min-height: 30px; + margin: 0; + border: 0; + border-left: 1px solid var(--color-light-border); + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--color-text-light-1); + &:hover { + background: var(--color-hover); + } + &:focus-visible { + outline: 2px solid var(--github-fgColor-accent); + outline-offset: -2px; + } + } + & > .ui.selection.dropdown { + display: flex; + align-items: center; + width: auto; + min-width: 88px; + padding: 5px 28px 5px 8px; + font-size: 12px; + line-height: 20px; + & > .text { + margin: 0; + line-height: 20px; + } + & > .dropdown.icon { + top: 50%; + transform: translateY(-50%); + right: 8px; + padding: 0; + margin: 0; + width: 16px; + height: 16px; + } + & > .menu { + left: auto; + right: 0; + width: 200px; + min-width: 200px; + margin-top: 4px; + } + } + & > .ui.button { + display: grid; + place-items: center; + width: 32px; + padding: 0 !important; + border-radius: 0 var(--border-radius) var(--border-radius) 0; + & > svg { + width: 16px; + height: 16px; + margin: 0; + } + } +} diff --git a/components/common/search/search.test.ts b/components/common/search/search.test.ts new file mode 100644 index 00000000..f1fa71a8 --- /dev/null +++ b/components/common/search/search.test.ts @@ -0,0 +1,28 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const root = fileURLToPath(new URL("../../..", import.meta.url)); +const component = "components/common/search/search.tmpl"; + +const templates = ["templates", "components"].flatMap(directory => + readdirSync(join(root, directory), { recursive: true }) + .map(file => `${directory}/${String(file)}`) + .filter(file => file.endsWith(".tmpl")) +); + +describe("shared search field", () => { + it("is the only template that writes a search input", () => { + const own = templates.filter(file => /type="search"/.test(readFileSync(join(root, file), "utf8"))); + expect(own).toEqual([component]); + }); + + it("renders every kind from one field", () => { + const source = readFileSync(join(root, component), "utf8"); + expect(source.match(/ - - - +{{/* Kind: submit (default, trailing search button), icon (leading search icon), or plain. Local marks a client-side filter that is never submitted. Class adds page layout hooks. */}} +{{$icon := eq .Kind "icon"}} +{{if $icon}}