fse-theme-default 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +55 -0
  2. package/astro.config.mjs +22 -0
  3. package/package.json +38 -0
  4. package/public/favicon.svg +4 -0
  5. package/src/assets/logo.svg +5 -0
  6. package/src/components/Card.astro +31 -0
  7. package/src/components/Head.astro +9 -0
  8. package/src/components/Header.astro +55 -0
  9. package/src/components/Input.astro +56 -0
  10. package/src/components/Logo.astro +17 -0
  11. package/src/components/Modal.astro +78 -0
  12. package/src/components/NavLink.astro +23 -0
  13. package/src/components/Pagination.astro +81 -0
  14. package/src/components/SearchBar.astro +65 -0
  15. package/src/components/Select.astro +60 -0
  16. package/src/components/Sidebar.astro +207 -0
  17. package/src/components/SidebarLinks.astro +10 -0
  18. package/src/components/Table.astro +157 -0
  19. package/src/components/TableFilters.astro +137 -0
  20. package/src/env.d.ts +2 -0
  21. package/src/layouts/EmailLayout.astro +45 -0
  22. package/src/layouts/Layout.astro +95 -0
  23. package/src/pages/emails/password-reset.astro +39 -0
  24. package/src/pages/emails/verify-email-change.astro +39 -0
  25. package/src/pages/emails/verify.astro +39 -0
  26. package/src/pages/error.astro +26 -0
  27. package/src/pages/forgot-password.astro +67 -0
  28. package/src/pages/fse/form.astro +169 -0
  29. package/src/pages/fse/links.astro +104 -0
  30. package/src/pages/fse/list.astro +166 -0
  31. package/src/pages/fse/public-detail.astro +29 -0
  32. package/src/pages/fse/public-list.astro +54 -0
  33. package/src/pages/index.astro +41 -0
  34. package/src/pages/login.astro +88 -0
  35. package/src/pages/public/error.astro +29 -0
  36. package/src/pages/register-success.astro +31 -0
  37. package/src/pages/register.astro +99 -0
  38. package/src/pages/reset-password.astro +117 -0
  39. package/src/pages/settings.astro +188 -0
  40. package/src/pages/user.astro +65 -0
  41. package/src/pages/users.astro +52 -0
  42. package/src/styles/global.css +11 -0
  43. package/src/styles/theme.css +408 -0
  44. package/src/types/pages.ts +58 -0
  45. package/src/types.ts +154 -0
  46. package/theme.json +5 -0
  47. package/tsconfig.json +5 -0
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # fse-theme-default
2
+
3
+ The default theme for [full_stack_engine](https://github.com/StevenUster/full_stack_engine)
4
+ apps: the app shell (sidebar navigation built from your models, dark mode),
5
+ the design system, every auth page and email, and the generic
6
+ metadata-driven CRUD pages. An app with nothing but `#[model]` structs gets
7
+ a complete UI from it.
8
+
9
+ It ships in two forms, built from this one folder:
10
+
11
+ | Package | Contains | Used by |
12
+ | --- | --- | --- |
13
+ | crate `fse-theme-default` | the **built** theme (`dist/`) | every app: `.theme(Theme::embedded(&fse_theme_default::DIST))` |
14
+ | npm `fse-theme-default` | the **Astro sources** | child themes built with Astro |
15
+
16
+ ## Theme contract
17
+
18
+ A built theme is a folder with `theme.json` (`name`, optional `parent`),
19
+ `**/*.html` Tera templates (named by path: `login/index.html` → `login`) and
20
+ static assets. The framework layers the active theme over its parents at
21
+ runtime, so a child theme only contains what it changes. See
22
+ [docs/themes.md](../docs/themes.md).
23
+
24
+ Template names the framework renders: `index`, `error`, `public/error`,
25
+ `login`, `register`, `register-success`, `forgot-password`,
26
+ `reset-password`, `settings`, `users`, `user`, `emails/verify`,
27
+ `emails/verify-email-change`, `emails/password-reset`, `fse/list`,
28
+ `fse/form`, `fse/public-list`, `fse/public-detail`.
29
+
30
+ ## Extension points for Astro child themes
31
+
32
+ A child theme overrides any file under `src/` by creating the same path in
33
+ its own `src/` — the parent's pages then use the child's version:
34
+
35
+ - `src/layouts/Layout.astro` — the whole shell,
36
+ - `src/components/SidebarLinks.astro` — app-specific sidebar entries (empty here),
37
+ - `src/components/Logo.astro` — the logo in the sidebar, mobile header and
38
+ auth pages (or replace just `src/assets/logo.svg`),
39
+ - `src/components/Head.astro` — extra `<head>` tags on every page (favicon,
40
+ fonts, meta tags),
41
+ - `src/styles/global.css` — the Tailwind root (import `src/styles/theme.css`
42
+ and override tokens after it).
43
+
44
+ `@parent/...` imports the parent's original file (e.g. to wrap it).
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ bun install
50
+ bun run dev # astro dev
51
+ bun run build # astro check + build → dist/ (what the crate embeds)
52
+ ```
53
+
54
+ The pages are typed against the framework's base translations
55
+ (`../framework/locales`).
@@ -0,0 +1,22 @@
1
+ // @ts-check
2
+ import tailwindcss from "@tailwindcss/vite";
3
+ import { defineConfig } from "astro/config";
4
+ import fseSsr from "fse-ssr";
5
+
6
+ // The default theme is a standalone Astro project: `bun run build` produces
7
+ // dist/ (templates + assets + theme.json), which the Rust crate in this
8
+ // folder embeds. Child themes don't reuse this config — they have their own
9
+ // and name this theme as `parent` in their theme.json.
10
+ export default defineConfig({
11
+ integrations: [
12
+ fseSsr({
13
+ // The theme's pages use the framework's built-in translations.
14
+ locales: "../framework/locales",
15
+ defaultLocale: "en",
16
+ modulesDir: "./.fse/modules",
17
+ }),
18
+ ],
19
+ vite: {
20
+ plugins: [tailwindcss()],
21
+ },
22
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "fse-theme-default",
3
+ "version": "0.3.0",
4
+ "description": "Default theme for full_stack_engine apps — a complete Astro project: app shell, design system, auth pages, emails and the generic metadata-driven CRUD pages. Extend it with a child theme.",
5
+ "type": "module",
6
+ "license": "MIT OR Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/StevenUster/full_stack_engine.git",
10
+ "directory": "fse-theme-default"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "public",
15
+ "theme.json",
16
+ "astro.config.mjs",
17
+ "tsconfig.json",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "dev": "astro dev",
22
+ "build": "astro check && astro build",
23
+ "preview": "astro preview",
24
+ "astro": "astro"
25
+ },
26
+ "peerDependencies": {
27
+ "astro": ">=7.0.0",
28
+ "fse-ssr": ">=0.2.0"
29
+ },
30
+ "devDependencies": {
31
+ "@astrojs/check": "^0.9.9",
32
+ "@tailwindcss/vite": "^4.3.1",
33
+ "astro": "^7.0.0",
34
+ "fse-ssr": "file:../fse-ssr",
35
+ "tailwindcss": "^4.3.1",
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="8" fill="#6a9b3c"/>
3
+ <path d="M11 23V9h11M11 16h8" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
4
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="120" height="32" viewBox="0 0 120 32" fill="none">
2
+ <rect x="1" y="1" width="30" height="30" rx="8" fill="#6a9b3c"/>
3
+ <path d="M10 22V10h11M10 16h8" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <text x="40" y="22" font-family="ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="800" fill="#282925">fse app</text>
5
+ </svg>
@@ -0,0 +1,31 @@
1
+ ---
2
+ interface Props {
3
+ padding?: boolean;
4
+ tailwind?: string;
5
+ /** Top accent bar colour. Omit for none. */
6
+ accent?: "primary" | "secondary";
7
+ /** Enables a hover lift + primary border (for clickable cards). */
8
+ hover?: boolean;
9
+ }
10
+
11
+ const { padding = true, tailwind = "", accent, hover = false } = Astro.props;
12
+
13
+ const accentBar: Record<string, string> = {
14
+ primary: "bg-linear-to-r from-primary-500 to-primary-300",
15
+ secondary: "bg-linear-to-r from-secondary-500 to-secondary-300",
16
+ };
17
+
18
+ const base =
19
+ "relative w-full flex flex-col bg-white border-[1.5px] border-secondary-200 rounded-xl shadow-xs transition-all duration-300";
20
+ const hoverCls = hover
21
+ ? "hover:-translate-y-1 hover:border-primary-500 hover:shadow-[0_18px_48px_rgba(40,41,37,0.12)]"
22
+ : "";
23
+ // Accent bar bleeds to the rounded edge, so clip overflow when present.
24
+ const clip = accent ? "overflow-hidden" : padding ? "" : "overflow-hidden";
25
+ const pad = padding ? "p-6 gap-6" : "";
26
+ ---
27
+
28
+ <div class={`${base} ${hoverCls} ${clip} ${pad} ${tailwind}`}>
29
+ {accent && <div class={`absolute inset-x-0 top-0 h-1 ${accentBar[accent]}`} />}
30
+ <slot />
31
+ </div>
@@ -0,0 +1,9 @@
1
+ ---
2
+ /**
3
+ * Extension point: extra <head> tags for every page (favicon, fonts, meta
4
+ * tags). A child theme overrides this file; its version replaces this one,
5
+ * so it should link its own favicon too.
6
+ */
7
+ ---
8
+
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -0,0 +1,55 @@
1
+ ---
2
+ import type { SsrValue } from "fse-ssr/ssr";
3
+
4
+ interface Props {
5
+ backlink?: string;
6
+ action?: string;
7
+ actionLabel?: string | SsrValue<string>;
8
+ /** Server-side condition gating the action button (pass an SSR value). */
9
+ actionWhen?: unknown;
10
+ }
11
+
12
+ const { backlink, action, actionLabel, actionWhen = true } = Astro.props;
13
+ ---
14
+
15
+ <header
16
+ class="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center mb-8 gap-3"
17
+ >
18
+ <div class="flex items-center gap-2.5">
19
+ {
20
+ backlink && (
21
+ <a
22
+ href={backlink}
23
+ class="p-1.5 text-secondary-500 hover:text-secondary-900 hover:bg-secondary-100 transition-colors rounded-lg"
24
+ >
25
+ <svg
26
+ xmlns="http://www.w3.org/2000/svg"
27
+ width="18"
28
+ height="18"
29
+ viewBox="0 0 24 24"
30
+ >
31
+ <path
32
+ fill="none"
33
+ stroke="currentColor"
34
+ stroke-linecap="round"
35
+ stroke-linejoin="round"
36
+ stroke-width="2"
37
+ d="M21 12H3m0 0l8.5-8.5M3 12l8.5 8.5"
38
+ />
39
+ </svg>
40
+ </a>
41
+ )
42
+ }
43
+ <h1 class="section-title text-secondary-900">
44
+ <slot />
45
+ </h1>
46
+ </div>
47
+ {
48
+ action &&
49
+ actionWhen && (
50
+ <a href={action} class="btn-primary w-full sm:w-auto">
51
+ {actionLabel ?? "Send"}
52
+ </a>
53
+ )
54
+ }
55
+ </header>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import type { SsrValue } from "fse-ssr/ssr";
3
+
4
+ interface Props {
5
+ id: string;
6
+ type:
7
+ | "text"
8
+ | "email"
9
+ | "password"
10
+ | "date"
11
+ | "tel"
12
+ | "datetime-local"
13
+ | "number"
14
+ | "hidden";
15
+ placeholder?: string | SsrValue<string>;
16
+ required?: boolean;
17
+ /**
18
+ * Appends a literal "*" after the placeholder label. Kept as a separate
19
+ * prop/child rather than having callers write `` `${value}*` `` themselves:
20
+ * a template literal forces the SSR proxy to flatten to a plain string via
21
+ * its own toString() (emitting e.g. `{{ t.x | default(value='y') }}*`),
22
+ * and a plain string reaching this label's JSX children isn't tagged raw
23
+ * HTML, so Astro HTML-escapes it — turning the embedded `'` into `&#39;`
24
+ * and breaking Tera's parser. Rendering the proxy and the "*" as separate
25
+ * children avoids the flattening entirely.
26
+ */
27
+ requiredMark?: boolean;
28
+ step?: string | number;
29
+ min?: string | number;
30
+ max?: string | number;
31
+ name?: string;
32
+ /** Prefill; pass an SSR value (e.g. `email ?? ""`) for server data. */
33
+ value?: string | SsrValue<string>;
34
+ }
35
+
36
+ const { id, type, placeholder = " ", requiredMark, required, step, min, max, name, value } = Astro.props;
37
+ ---
38
+
39
+ <div class="relative w-full">
40
+ <input
41
+ id={id}
42
+ type={type}
43
+ name={name ?? id}
44
+ placeholder={placeholder}
45
+ required={required}
46
+ step={step}
47
+ min={min}
48
+ max={max}
49
+ value={value}
50
+ class="relative w-full h-10 px-3.5 text-sm placeholder-transparent transition-all border rounded-lg outline-hidden peer border-secondary-300 text-secondary-900 bg-white autofill:bg-white focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-hidden"
51
+ />
52
+ <label
53
+ for={id}
54
+ class="absolute left-2 -top-2 z-1 px-1.5 text-xs text-secondary-500 transition-all before:absolute before:top-0 before:left-0 before:z-[-1] before:block before:h-full before:w-full before:bg-white before:transition-all peer-placeholder-shown:top-2.5 peer-placeholder-shown:text-sm peer-focus:-top-2 peer-focus:text-xs peer-focus:text-primary-600 peer-disabled:cursor-not-allowed peer-disabled:text-secondary-400 peer-disabled:before:bg-transparent"
55
+ >{placeholder}{requiredMark && "*"}</label>
56
+ </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ /**
3
+ * Extension point: the logo shown in the sidebar, the mobile header and the
4
+ * auth pages. A child theme either replaces src/assets/logo.svg or
5
+ * overrides this file (e.g. separate light and dark images).
6
+ */
7
+ import { Image } from "astro:assets";
8
+ import logo from "../assets/logo.svg";
9
+
10
+ interface Props {
11
+ class?: string;
12
+ }
13
+
14
+ const { class: className = "h-9 w-auto" } = Astro.props;
15
+ ---
16
+
17
+ <Image src={logo} alt="Logo" class={className} />
@@ -0,0 +1,78 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+ import type { SsrValue } from "fse-ssr/ssr";
4
+
5
+ interface Props {
6
+ id: string;
7
+ title: string | SsrValue<string>;
8
+ }
9
+
10
+ const { id, title } = Astro.props;
11
+ const { t } = ssr();
12
+ ---
13
+
14
+ <dialog
15
+ id={id}
16
+ class="m-auto backdrop:bg-black/50 backdrop:backdrop-blur-xs bg-white rounded-xl shadow-lg p-0 w-[95%] max-w-md max-h-[calc(100dvh-2rem)] open:flex flex-col overflow-hidden border border-secondary-200"
17
+ >
18
+ <div class="flex items-center justify-between px-6 py-4 border-b border-secondary-100 shrink-0">
19
+ <h3 class="text-base font-semibold text-secondary-900">{title}</h3>
20
+ <button
21
+ type="button"
22
+ class="p-1.5 text-secondary-400 hover:text-secondary-700 hover:bg-secondary-100 transition-colors rounded-lg modal-close-btn"
23
+ data-modal-id={id}
24
+ aria-label={t.common.close_modal}
25
+ >
26
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
27
+ <line x1="18" y1="6" x2="6" y2="18"></line>
28
+ <line x1="6" y1="6" x2="18" y2="18"></line>
29
+ </svg>
30
+ </button>
31
+ </div>
32
+ <div class="p-6 overflow-y-auto min-h-0">
33
+ <slot />
34
+ </div>
35
+ </dialog>
36
+
37
+ <script>
38
+ document.addEventListener('click', (e) => {
39
+ const target = e.target as HTMLElement;
40
+ const closeBtn = target.closest('.modal-close-btn');
41
+ if (closeBtn) {
42
+ const modalId = closeBtn.getAttribute('data-modal-id');
43
+ if (modalId) {
44
+ const modal = document.getElementById(modalId) as HTMLDialogElement;
45
+ if (modal) modal.close();
46
+ }
47
+ }
48
+
49
+ if (target.tagName === 'DIALOG') {
50
+ const modal = target as HTMLDialogElement;
51
+ const rect = modal.getBoundingClientRect();
52
+ const clickedOutside =
53
+ e.clientX < rect.left || e.clientX > rect.right ||
54
+ e.clientY < rect.top || e.clientY > rect.bottom;
55
+ if (clickedOutside) modal.close();
56
+ }
57
+ });
58
+ </script>
59
+
60
+ <style>
61
+ dialog::backdrop {
62
+ animation: fadeIn 0.25s ease;
63
+ }
64
+ dialog[open] {
65
+ animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
66
+ }
67
+ @keyframes fadeIn {
68
+ from { opacity: 0; }
69
+ to { opacity: 1; }
70
+ }
71
+ @keyframes slideUp {
72
+ from { transform: translateY(8px); opacity: 0; }
73
+ to { transform: translateY(0); opacity: 1; }
74
+ }
75
+ :global(body:has(dialog[open])) {
76
+ overflow: hidden;
77
+ }
78
+ </style>
@@ -0,0 +1,23 @@
1
+ ---
2
+ /**
3
+ * One sidebar entry. Put an 18px icon in the default slot. Child themes use
4
+ * it for their own entries (see SidebarLinks.astro).
5
+ */
6
+ import type { SsrValue } from "fse-ssr/ssr";
7
+
8
+ interface Props {
9
+ href: string | SsrValue<string>;
10
+ label: string | SsrValue<string>;
11
+ }
12
+
13
+ const { href, label } = Astro.props;
14
+ ---
15
+
16
+ <a
17
+ href={href}
18
+ data-nav-link
19
+ class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-secondary-700 hover:bg-secondary-100 hover:text-secondary-900 aria-[current=page]:bg-primary-50 aria-[current=page]:text-primary-800 aria-[current=page]:font-semibold"
20
+ >
21
+ <span class="shrink-0 w-[18px] h-[18px] grid place-items-center"><slot /></span>
22
+ <span class="sidebar-label truncate">{label}</span>
23
+ </a>
@@ -0,0 +1,81 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+
4
+ const { page, total_pages, total_count, per_page } = ssr<{
5
+ page?: number;
6
+ total_pages?: number;
7
+ total_count?: number;
8
+ per_page?: number;
9
+ }>();
10
+ ---
11
+
12
+ <nav
13
+ id="pagination-nav"
14
+ class="flex items-center justify-between px-4 py-3 border-t border-secondary-200 text-sm"
15
+ data-page={page ?? 1}
16
+ data-total-pages={total_pages ?? 1}
17
+ data-total-count={total_count ?? 0}
18
+ data-per-page={per_page ?? 20}
19
+ >
20
+ </nav>
21
+
22
+ <script>
23
+ function setupPagination() {
24
+ const nav = document.getElementById("pagination-nav");
25
+ if (!nav) return;
26
+
27
+ const page = parseInt(nav.dataset.page || "1");
28
+ const totalPages = parseInt(nav.dataset.totalPages || "1");
29
+ const totalCount = parseInt(nav.dataset.totalCount || "0");
30
+ const perPage = parseInt(nav.dataset.perPage || "20");
31
+
32
+ if (totalPages <= 1) {
33
+ nav.style.display = "none";
34
+ return;
35
+ }
36
+ nav.style.display = "";
37
+
38
+ function buildUrl(p: number) {
39
+ const url = new URL(window.location.href);
40
+ url.searchParams.set("page", p.toString());
41
+ return url.pathname + "?" + url.searchParams.toString();
42
+ }
43
+
44
+ const start = (page - 1) * perPage + 1;
45
+ const end = Math.min(page * perPage, totalCount);
46
+
47
+ const base =
48
+ "inline-flex items-center justify-center min-w-8 h-8 px-2 rounded-lg text-sm transition-colors";
49
+ const active = `${base} bg-primary-600 text-white font-medium`;
50
+ const normal = `${base} border border-secondary-200 text-secondary-700 hover:bg-secondary-100 hover:border-secondary-300`;
51
+ const disabled = `${base} text-secondary-300 cursor-not-allowed pointer-events-none`;
52
+
53
+ const pageNums: (number | "…")[] = [];
54
+ for (let i = 1; i <= totalPages; i++) {
55
+ if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) {
56
+ pageNums.push(i);
57
+ } else if (pageNums[pageNums.length - 1] !== "…") {
58
+ pageNums.push("…");
59
+ }
60
+ }
61
+
62
+ const pageButtons = pageNums
63
+ .map((p) =>
64
+ p === "…"
65
+ ? `<span class="px-1 text-secondary-400 self-center">…</span>`
66
+ : `<a href="${buildUrl(p as number)}" class="${p === page ? active : normal}">${p}</a>`
67
+ )
68
+ .join("");
69
+
70
+ nav.innerHTML = `
71
+ <span class="text-secondary-500">${start}–${end} / ${totalCount}</span>
72
+ <div class="flex items-center gap-1">
73
+ ${page > 1 ? `<a href="${buildUrl(page - 1)}" class="${normal}" aria-label="Previous">‹</a>` : `<span class="${disabled}" aria-disabled="true">‹</span>`}
74
+ ${pageButtons}
75
+ ${page < totalPages ? `<a href="${buildUrl(page + 1)}" class="${normal}" aria-label="Next">›</a>` : `<span class="${disabled}" aria-disabled="true">›</span>`}
76
+ </div>
77
+ `;
78
+ }
79
+
80
+ document.addEventListener("astro:page-load", setupPagination);
81
+ </script>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+ import type { SsrValue } from "fse-ssr/ssr";
4
+
5
+ interface Props {
6
+ placeholder?: string;
7
+ name?: string;
8
+ /** Prefill; defaults to the server-side `search` context value. */
9
+ value?: string | SsrValue<string>;
10
+ /** Optional id on the input, for JS hooks. */
11
+ inputId?: string;
12
+ /** Show the bottom divider (when sitting atop card content). */
13
+ bordered?: boolean;
14
+ }
15
+
16
+ const { t, search } = ssr<{ search?: string }>();
17
+
18
+ const {
19
+ placeholder = "Suchen...",
20
+ name = "search",
21
+ value,
22
+ inputId,
23
+ bordered = true,
24
+ } = Astro.props;
25
+
26
+ const inputValue = value ?? search ?? "";
27
+ ---
28
+
29
+ <form
30
+ method="GET"
31
+ class:list={[
32
+ "flex flex-wrap items-center gap-2 px-4 py-3 bg-white",
33
+ bordered && "border-b border-secondary-200",
34
+ ]}
35
+ >
36
+ <div class="relative flex-1 min-w-48">
37
+ <input
38
+ type="text"
39
+ name={name}
40
+ id={inputId}
41
+ value={inputValue}
42
+ placeholder={placeholder}
43
+ class="w-full h-9 px-3.5 pr-9 text-sm border border-secondary-200 rounded-lg outline-hidden focus:border-primary-500 focus:ring-1 focus:ring-primary-500 bg-white text-secondary-900"
44
+ />
45
+ <svg
46
+ xmlns="http://www.w3.org/2000/svg"
47
+ width="14"
48
+ height="14"
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ stroke-width="2"
53
+ stroke-linecap="round"
54
+ stroke-linejoin="round"
55
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 pointer-events-none"
56
+ >
57
+ <circle cx="11" cy="11" r="8"></circle>
58
+ <path d="m21 21-4.35-4.35"></path>
59
+ </svg>
60
+ </div>
61
+
62
+ <button type="submit" class="btn-primary h-9 py-0">
63
+ {t.filters.search ?? "Suchen"}
64
+ </button>
65
+ </form>
@@ -0,0 +1,60 @@
1
+ ---
2
+ import type { SsrValue } from "fse-ssr/ssr";
3
+
4
+ interface Option {
5
+ value: string;
6
+ label: string | SsrValue<string>;
7
+ }
8
+
9
+ interface Props {
10
+ options: Option[];
11
+ id: string;
12
+ placeholder: string | SsrValue<string>;
13
+ /** Server-side value that marks the matching option as selected. */
14
+ selected?: SsrValue<string>;
15
+ required?: boolean;
16
+ name?: string;
17
+ }
18
+
19
+ const { options, id, placeholder, selected, required = false, name } = Astro.props;
20
+ ---
21
+
22
+ <div class="relative w-full">
23
+ <select
24
+ id={id}
25
+ name={name ?? id}
26
+ required={required}
27
+ class="relative w-full h-10 px-3.5 text-sm transition-all bg-white border border-secondary-300 rounded-lg outline-hidden appearance-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 peer text-secondary-900 autofill:bg-white"
28
+ >
29
+ {
30
+ options.map((option) => (
31
+ <option
32
+ value={option.value}
33
+ selected={selected !== undefined && selected === option.value}
34
+ >
35
+ {option.label}
36
+ </option>
37
+ ))
38
+ }
39
+ </select>
40
+ <label
41
+ for={id}
42
+ class="pointer-events-none absolute top-2.5 left-2 z-1 px-1.5 text-sm text-secondary-500 transition-all before:absolute before:top-0 before:left-0 before:z-[-1] before:block before:h-full before:w-full before:bg-white before:transition-all peer-valid:-top-2 peer-valid:text-xs peer-focus:-top-2 peer-focus:text-xs peer-focus:text-primary-600"
43
+ >
44
+ {placeholder}
45
+ </label>
46
+ <svg
47
+ xmlns="http://www.w3.org/2000/svg"
48
+ width="14"
49
+ height="14"
50
+ viewBox="0 0 24 24"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400"
57
+ >
58
+ <path d="m6 9 6 6 6-6"></path>
59
+ </svg>
60
+ </div>