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}}