@meith/theme-raidframe 0.2.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 (39) hide show
  1. package/LICENSE.md +165 -0
  2. package/package.json +33 -0
  3. package/src/index.ts +3 -0
  4. package/src/shared.tsx +133 -0
  5. package/src/slots/announcement.tsx +40 -0
  6. package/src/slots/auth-page.tsx +53 -0
  7. package/src/slots/board-index.tsx +35 -0
  8. package/src/slots/board-stats.tsx +52 -0
  9. package/src/slots/category-block.tsx +37 -0
  10. package/src/slots/discovery-view.tsx +122 -0
  11. package/src/slots/error-notice.tsx +26 -0
  12. package/src/slots/footer.tsx +34 -0
  13. package/src/slots/forum-display.tsx +81 -0
  14. package/src/slots/forum-jump.tsx +45 -0
  15. package/src/slots/forum-row.tsx +66 -0
  16. package/src/slots/header.tsx +67 -0
  17. package/src/slots/latest-posts.tsx +42 -0
  18. package/src/slots/latest-threads.tsx +42 -0
  19. package/src/slots/member-profile.tsx +102 -0
  20. package/src/slots/navigation.tsx +31 -0
  21. package/src/slots/notice.tsx +32 -0
  22. package/src/slots/pagination.tsx +48 -0
  23. package/src/slots/panel-nav.tsx +110 -0
  24. package/src/slots/panel-page.tsx +72 -0
  25. package/src/slots/panel-shell.tsx +33 -0
  26. package/src/slots/post-actions.tsx +29 -0
  27. package/src/slots/post-bit.tsx +225 -0
  28. package/src/slots/post-form.tsx +44 -0
  29. package/src/slots/redirect-notice.tsx +26 -0
  30. package/src/slots/search-form.tsx +102 -0
  31. package/src/slots/search-results.tsx +108 -0
  32. package/src/slots/shell.tsx +40 -0
  33. package/src/slots/subforum-list.tsx +26 -0
  34. package/src/slots/thread-row.tsx +74 -0
  35. package/src/slots/thread-view.tsx +75 -0
  36. package/src/slots/user-panel.tsx +118 -0
  37. package/src/slots/who-is-online.tsx +64 -0
  38. package/src/theme.ts +84 -0
  39. package/src/tokens.ts +104 -0
@@ -0,0 +1,110 @@
1
+ import type { PanelNavItemModel, PanelNavModel } from '@meith/theme-kit'
2
+
3
+ import { MICRO, NUMERIC, RULE } from '../shared'
4
+
5
+ const ITEM =
6
+ 'flex items-baseline gap-2 border-l-2 px-2.5 py-1.5 text-[0.8125rem] tracking-[0.02em]'
7
+
8
+ const HERE = 'border-l-primary bg-secondary font-semibold text-foreground'
9
+ const OPEN = 'border-l-primary/40 font-semibold text-foreground'
10
+ const ELSEWHERE = 'border-l-transparent text-muted-foreground hover:border-l-border hover:text-primary'
11
+
12
+ function Count({ count }: { count: number }) {
13
+ return (
14
+ <>
15
+ <span
16
+ aria-hidden="true"
17
+ className={`ml-auto border border-primary/50 bg-primary/15 px-1.5 text-[0.625rem] font-bold text-primary ${NUMERIC}`}
18
+ >
19
+ {count > 99 ? '99+' : count}
20
+ </span>
21
+ <span className="sr-only">({count} waiting)</span>
22
+ </>
23
+ )
24
+ }
25
+
26
+ function Item({ item, className }: { item: PanelNavItemModel; className: string }) {
27
+ const body = (
28
+ <>
29
+ <span className="min-w-0 flex-1">{item.title}</span>
30
+ {item.count !== null && <Count count={item.count} />}
31
+ </>
32
+ )
33
+
34
+ if (item.isRecord) {
35
+ return (
36
+ <span className={`${ITEM} ${HERE} ${className}`} aria-current="page">
37
+ {body}
38
+ </span>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <a
44
+ href={item.href}
45
+ className={`${ITEM} ${className}`}
46
+ {...(item.current === null
47
+ ? {}
48
+ : { 'aria-current': item.current === 'here' ? ('page' as const) : ('true' as const) })}
49
+ >
50
+ {body}
51
+ </a>
52
+ )
53
+ }
54
+
55
+ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>) {
56
+ return (
57
+ <nav aria-label={label}>
58
+ <ul className="flex flex-col">
59
+ {sections.map((section) => (
60
+ <li
61
+ key={section.href}
62
+ className={section.isOverview ? 'mb-1 border-b border-border pb-1' : undefined}
63
+ >
64
+ <Item
65
+ item={section}
66
+ className={section.current === 'here' ? HERE : section.isOpen ? OPEN : ELSEWHERE}
67
+ />
68
+
69
+ {section.isOpen && section.children.length > 0 && (
70
+ <ul className="my-0.5 ml-3 flex flex-col border-l border-border pl-1">
71
+ {section.children.map((child) => (
72
+ <li key={child.href}>
73
+ <Item item={child} className={child.current === 'here' ? HERE : ELSEWHERE} />
74
+ </li>
75
+ ))}
76
+ </ul>
77
+ )}
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </nav>
82
+ )
83
+ }
84
+
85
+ export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
86
+ return (
87
+ <>
88
+ <details className="border border-border bg-card lg:hidden">
89
+ <summary className="flex cursor-pointer items-center justify-between gap-2 bg-surface px-3 py-2">
90
+ <span className={`${MICRO} text-foreground`}>{label}</span>
91
+ {currentTitle !== null && <span className={MICRO}>{currentTitle}</span>}
92
+ </summary>
93
+ <div className={RULE} aria-hidden="true" />
94
+ <div className="p-2">
95
+ <Sections label={label} sections={sections} />
96
+ </div>
97
+ </details>
98
+
99
+ <div className="hidden border border-border bg-card shadow-elevation lg:block">
100
+ <p className={`${MICRO} border-b border-border bg-surface px-3 py-2 text-foreground`}>
101
+ {label}
102
+ </p>
103
+ <div className={RULE} aria-hidden="true" />
104
+ <div className="p-2">
105
+ <Sections label={label} sections={sections} />
106
+ </div>
107
+ </div>
108
+ </>
109
+ )
110
+ }
@@ -0,0 +1,72 @@
1
+ import type { PanelKind, PanelPageModel, PanelSectionModel } from '@meith/theme-kit'
2
+
3
+ import { HEADING, MICRO, RULE } from '../shared'
4
+
5
+ const LABEL: Record<PanelKind, string> = {
6
+ usercp: 'your control panel',
7
+ modcp: 'moderator control panel',
8
+ admincp: 'control panel',
9
+ }
10
+
11
+ export function PanelPage({ panel, title, back, width, gap, regions, children }: PanelPageModel) {
12
+ return (
13
+ <main
14
+ id="board-content"
15
+ tabIndex={-1}
16
+ className={`flex w-full flex-col p-4 ${width === 'wide' ? 'max-w-none' : 'max-w-4xl'} ${
17
+ gap === 'loose' ? 'gap-6' : 'gap-4'
18
+ }`}
19
+ >
20
+ <div className="border border-border bg-card shadow-elevation">
21
+ <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 px-4 py-3">
22
+ <div className="flex min-w-0 flex-col gap-1">
23
+ {back !== null && (
24
+ <a href={back.href} className={`${MICRO} hover:text-primary`}>
25
+ ← {back.label}
26
+ </a>
27
+ )}
28
+
29
+ {panel !== null && back === null && <p className={MICRO}>{LABEL[panel]}</p>}
30
+
31
+ <h1 className={`${HEADING} text-xl text-foreground`}>{title}</h1>
32
+
33
+ {regions.lede !== undefined && (
34
+ <p className="text-sm text-muted-foreground">{regions.lede}</p>
35
+ )}
36
+ {regions.meta !== undefined && <p className={`${MICRO} normal-case`}>{regions.meta}</p>}
37
+ </div>
38
+
39
+ {regions.actions !== undefined && (
40
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
41
+ )}
42
+ </div>
43
+ <div className={RULE} aria-hidden="true" />
44
+ </div>
45
+
46
+ {children}
47
+ </main>
48
+ )
49
+ }
50
+
51
+ export function PanelSection({ title, headingId, regions, children }: PanelSectionModel) {
52
+ return (
53
+ <section aria-labelledby={headingId} className="flex flex-col gap-3">
54
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 border-b border-border pb-1.5">
55
+ <div className="flex min-w-0 flex-col gap-0.5">
56
+ <h2 id={headingId} className={`${HEADING} text-base text-foreground`}>
57
+ {title}
58
+ </h2>
59
+ {regions.description !== undefined && (
60
+ <p className="text-sm text-muted-foreground">{regions.description}</p>
61
+ )}
62
+ </div>
63
+
64
+ {regions.actions !== undefined && (
65
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
66
+ )}
67
+ </div>
68
+
69
+ {children}
70
+ </section>
71
+ )
72
+ }
@@ -0,0 +1,33 @@
1
+ import type { PanelShellModel } from '@meith/theme-kit'
2
+
3
+ import { MICRO } from '../shared'
4
+
5
+ export function PanelShell({ panel, links, linksLabel, regions, children }: PanelShellModel) {
6
+ return (
7
+ <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col lg:flex-row">
8
+ <aside
9
+ className={`flex flex-col gap-3 p-4 lg:sticky lg:w-56 lg:shrink-0 lg:self-start lg:pr-0 ${
10
+ panel === 'admincp' ? 'lg:top-14' : 'lg:top-4'
11
+ }`}
12
+ >
13
+ {regions.nav}
14
+
15
+ {links.length > 0 && (
16
+ <nav aria-label={linksLabel} className="border-t border-border pt-3">
17
+ <ul className="flex flex-col gap-1">
18
+ {links.map((link) => (
19
+ <li key={link.href}>
20
+ <a href={link.href} className={`${MICRO} block hover:text-primary`}>
21
+ {link.label}
22
+ </a>
23
+ </li>
24
+ ))}
25
+ </ul>
26
+ </nav>
27
+ )}
28
+ </aside>
29
+
30
+ <div className="min-w-0 flex-1">{children}</div>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,29 @@
1
+ import type { PostActionsSlotModel } from '@meith/theme-kit'
2
+
3
+ const ACTION =
4
+ 'font-mono text-[0.625rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-primary'
5
+
6
+ export function PostActions({ actions, children }: PostActionsSlotModel) {
7
+ const items = [
8
+ { href: actions.quoteHref, label: 'quote' },
9
+ { href: actions.editHref, label: 'edit' },
10
+ { href: actions.restoreHref, label: 'restore' },
11
+ { href: actions.rateHref, label: 'rate' },
12
+ { href: actions.reportHref, label: 'report' },
13
+ { href: actions.warnHref, label: 'warn' },
14
+ { href: actions.moderateHref, label: 'moderate' },
15
+ ].filter((item): item is { href: string; label: string } => item.href !== null)
16
+
17
+ if (items.length === 0 && children === undefined) return null
18
+
19
+ return (
20
+ <nav aria-label="Post actions" className="flex flex-wrap items-center gap-x-4 gap-y-1">
21
+ {items.map((item) => (
22
+ <a key={item.label} href={item.href} className={ACTION}>
23
+ {item.label}
24
+ </a>
25
+ ))}
26
+ {children}
27
+ </nav>
28
+ )
29
+ }
@@ -0,0 +1,225 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import type { PostBitSlotModel } from '@meith/theme-kit'
4
+
5
+ import { HEADING, MICRO, NUMERIC, Stamp, UserRef } from '../shared'
6
+
7
+ function GroupBadge({ badge }: { badge: NonNullable<PostBitSlotModel['post']['author']['badge']> }) {
8
+ const image = (
9
+ <img
10
+ src={badge.src}
11
+ alt=""
12
+ aria-hidden="true"
13
+ className="h-4 w-auto max-w-full object-contain"
14
+ loading="lazy"
15
+ decoding="async"
16
+ />
17
+ )
18
+
19
+ if (badge.darkSrc === null) return image
20
+
21
+ return (
22
+ <picture>
23
+ <source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
24
+ {image}
25
+ </picture>
26
+ )
27
+ }
28
+
29
+ function StatusBanner({ visibility }: { visibility: PostBitSlotModel['post']['visibility'] }) {
30
+ if (visibility === 'visible') return null
31
+
32
+ return (
33
+ <p
34
+ className={`${MICRO} border-b border-border px-3 py-1.5 ${
35
+ visibility === 'deleted' ? 'bg-thread-deleted/10' : 'bg-post-unapproved'
36
+ }`}
37
+ >
38
+ {visibility === 'deleted'
39
+ ? 'deleted — visible to moderators only'
40
+ : 'awaiting approval — visible to moderators only'}
41
+ </p>
42
+ )
43
+ }
44
+
45
+ function StatLine({ label, children }: { label: string; children: ReactNode }) {
46
+ return (
47
+ <div className="flex items-baseline justify-between gap-2 border-t border-border/60 py-0.5">
48
+ <dt className={MICRO}>{label}</dt>
49
+ <dd className={`${NUMERIC} text-[0.6875rem] text-foreground`}>{children}</dd>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ export function PostBit({ post, select, regions }: PostBitSlotModel) {
55
+ return (
56
+ <article
57
+ id={`post-${post.number}`}
58
+ data-post-id={post.id}
59
+ className="relative border border-border bg-card shadow-elevation"
60
+ >
61
+ <span
62
+ aria-hidden="true"
63
+ className="pointer-events-none absolute -top-px -left-px size-2 border-t-2 border-l-2 border-primary/70"
64
+ />
65
+ <span
66
+ aria-hidden="true"
67
+ className="pointer-events-none absolute -right-px -bottom-px size-2 border-r-2 border-b-2 border-primary/70"
68
+ />
69
+
70
+ <StatusBanner visibility={post.visibility} />
71
+
72
+ <div className="grid grid-cols-1 sm:grid-cols-[12rem_1fr]">
73
+ <div className="border-b border-border bg-surface px-3 py-3 sm:border-r sm:border-b-0">
74
+ <div className="flex items-start gap-2">
75
+ {select !== null && (
76
+ <label className="flex items-center pt-1">
77
+ <span className="sr-only">{select.label}</span>
78
+ <input
79
+ type="checkbox"
80
+ name={select.name}
81
+ value={select.value}
82
+ form={select.formId}
83
+ className="size-3.5 accent-primary"
84
+ />
85
+ </label>
86
+ )}
87
+
88
+ {post.author.avatarUrl !== null && (
89
+ <span className="relative inline-block shrink-0 border border-primary/50 p-0.5">
90
+ <img
91
+ src={post.author.avatarUrl}
92
+ alt=""
93
+ width={48}
94
+ height={48}
95
+ className="size-12 object-cover"
96
+ />
97
+ </span>
98
+ )}
99
+
100
+ <div className="min-w-0">
101
+ <p>
102
+ <UserRef
103
+ user={post.author}
104
+ className={`${HEADING} text-sm ${
105
+ post.author.profileHref === null ? '' : 'hover:text-primary'
106
+ }`}
107
+ />
108
+ </p>
109
+ {post.author.title !== null && (
110
+ <p className={`${MICRO} mt-0.5 text-primary/90`}>{post.author.title}</p>
111
+ )}
112
+ {post.author.isOnline && (
113
+ <p className="mt-1 inline-flex items-center gap-1.5">
114
+ <span
115
+ aria-hidden="true"
116
+ className="size-1.5 bg-moderation-approved shadow-[0_0_6px_var(--color-moderation-approved)]"
117
+ />
118
+ <span
119
+ className={`${MICRO} text-moderation-approved`}
120
+ >
121
+ online
122
+ </span>
123
+ </p>
124
+ )}
125
+ </div>
126
+ </div>
127
+
128
+ {regions.pluginBadges}
129
+
130
+ {post.author.badge != null && (
131
+ <p className="mt-2">
132
+ <GroupBadge badge={post.author.badge} />
133
+ </p>
134
+ )}
135
+
136
+ <dl className="mt-2.5">
137
+ <StatLine label="posts">{post.author.postCount}</StatLine>
138
+ {post.author.reputation != null && (
139
+ <StatLine label="rep">{post.author.reputation}</StatLine>
140
+ )}
141
+ {post.author.joinedAt !== null && (
142
+ <StatLine label="joined">
143
+ <Stamp at={post.author.joinedAt} />
144
+ </StatLine>
145
+ )}
146
+ {post.author.fields.map((field) => (
147
+ <StatLine key={field.label} label={field.label}>
148
+ {field.value}
149
+ </StatLine>
150
+ ))}
151
+ </dl>
152
+ </div>
153
+
154
+ <div className="min-w-0">
155
+ <div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-1.5">
156
+ <Stamp at={post.postedAt} className={`${MICRO} normal-case`} />
157
+ <a
158
+ href={post.permalink}
159
+ className={`${NUMERIC} text-[0.6875rem] font-bold text-primary hover:underline`}
160
+ >
161
+ #{post.number}
162
+ </a>
163
+ </div>
164
+
165
+ {post.ignored !== null ? (
166
+ <p className="px-3 py-3 text-sm text-muted-foreground">
167
+ Post from {post.ignored.authorUsername}, who you are ignoring.{' '}
168
+ <a href={post.ignored.revealHref} className="text-primary hover:underline">
169
+ Show it
170
+ </a>
171
+ </p>
172
+ ) : (
173
+ <>
174
+ <div
175
+ className="prose-md px-3 py-3 text-sm"
176
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
177
+ />
178
+
179
+ {post.attachments.length > 0 && (
180
+ <ul className="flex flex-wrap gap-2 border-t border-border px-3 py-2">
181
+ {post.attachments.map((attachment) => (
182
+ <li key={attachment.id}>
183
+ <a
184
+ href={attachment.href}
185
+ className="inline-flex items-center gap-2 border border-border bg-surface px-2 py-1 font-mono text-[0.625rem] tracking-[0.1em] uppercase hover:border-primary/60 hover:text-primary"
186
+ >
187
+ {attachment.filename}
188
+ <span className={`${NUMERIC} text-muted-foreground`}>
189
+ {attachment.size}
190
+ </span>
191
+ </a>
192
+ </li>
193
+ ))}
194
+ </ul>
195
+ )}
196
+
197
+ {post.author.signatureHtml !== null && (
198
+ <div
199
+ className="border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
200
+ dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
201
+ />
202
+ )}
203
+ </>
204
+ )}
205
+
206
+ {post.editedNote !== null && (
207
+ <p className={`${MICRO} border-t border-border px-3 py-1.5 normal-case`}>
208
+ {post.editedNote}
209
+ </p>
210
+ )}
211
+
212
+ {regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
213
+ <div className="border-t border-border px-3 py-1.5 text-xs">{regions.pluginFooter}</div>
214
+ )}
215
+
216
+ {regions.actions !== null && (
217
+ <div className="border-t border-border bg-surface/60 px-3 py-1.5 empty:hidden">
218
+ {regions.actions}
219
+ </div>
220
+ )}
221
+ </div>
222
+ </div>
223
+ </article>
224
+ )
225
+ }
@@ -0,0 +1,44 @@
1
+ import type { PostFormModel } from '@meith/theme-kit'
2
+
3
+ import { BUTTON, HEADING, MICRO, RULE } from '../shared'
4
+
5
+ export function PostForm({
6
+ mode,
7
+ heading,
8
+ cancelHref,
9
+ cancelLabel,
10
+ errorMessage,
11
+ regions,
12
+ }: PostFormModel) {
13
+ return (
14
+ <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
15
+ {errorMessage !== null && (
16
+ <p
17
+ role="alert"
18
+ className="border border-l-[3px] border-border border-l-destructive bg-card px-3 py-2 text-sm"
19
+ >
20
+ <span className={`${MICRO} mr-2 text-destructive`}>cannot post</span>
21
+ {errorMessage}
22
+ </p>
23
+ )}
24
+
25
+ <section className="border border-border bg-card shadow-elevation">
26
+ <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
27
+ <div>
28
+ <p className={MICRO}>{mode === 'edit' ? 'edit' : mode === 'reply' ? 'reply' : 'new thread'}</p>
29
+ <h1 className={`${HEADING} text-lg text-foreground`}>{heading}</h1>
30
+ </div>
31
+ <a href={cancelHref} className={BUTTON}>
32
+ {cancelLabel}
33
+ </a>
34
+ </div>
35
+
36
+ <div className={RULE} aria-hidden="true" />
37
+
38
+ {regions.toolbar}
39
+
40
+ <div className="px-4 py-4">{regions.form}</div>
41
+ </section>
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,26 @@
1
+ import type { RedirectNoticeModel } from '@meith/theme-kit'
2
+
3
+ import { BUTTON_PRIMARY, Frame, HEADING, MICRO, NUMERIC } from '../shared'
4
+
5
+ export function RedirectNotice({ message, targetHref, delaySeconds }: RedirectNoticeModel) {
6
+ return (
7
+ <Frame className="w-full max-w-lg">
8
+ <div className="px-5 py-5">
9
+ <p className={`${MICRO} text-primary`}>redirecting</p>
10
+ <h1 className={`${HEADING} mt-1 text-2xl text-foreground`}>Please wait</h1>
11
+ <p className="mt-2 text-sm text-muted-foreground">{message}</p>
12
+
13
+ <div className="mt-5 flex flex-wrap items-center gap-3">
14
+ <a href={targetHref} className={BUTTON_PRIMARY}>
15
+ continue now
16
+ </a>
17
+ <span className={`${MICRO} normal-case`}>
18
+ <span className="uppercase">continuing in</span>{' '}
19
+ <span className={NUMERIC}>{delaySeconds}</span>
20
+ {delaySeconds === 1 ? ' second' : ' seconds'}
21
+ </span>
22
+ </div>
23
+ </div>
24
+ </Frame>
25
+ )
26
+ }
@@ -0,0 +1,102 @@
1
+ import type { OptionModel, SearchFormModel } from '@meith/theme-kit'
2
+
3
+ import { BUTTON_PRIMARY, Frame, MICRO, PanelHead } from '../shared'
4
+
5
+ const CONTROL =
6
+ 'w-full border border-input bg-surface px-3 py-2 text-sm text-foreground focus-visible:border-ring'
7
+
8
+ function Choice({
9
+ id,
10
+ label,
11
+ name,
12
+ options,
13
+ }: {
14
+ id: string
15
+ label: string
16
+ name: string
17
+ options: readonly OptionModel[]
18
+ }) {
19
+ const selected = options.find((option) => option.isSelected)
20
+
21
+ return (
22
+ <div>
23
+ <label htmlFor={id} className={`${MICRO} mb-1 block`}>
24
+ {label}
25
+ </label>
26
+ <select id={id} name={name} defaultValue={selected?.value ?? ''} className={CONTROL}>
27
+ {options.map((option) => (
28
+ <option key={option.value} value={option.value}>
29
+ {option.label}
30
+ </option>
31
+ ))}
32
+ </select>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ export function SearchForm({
38
+ action,
39
+ fields,
40
+ query,
41
+ maxQueryLength,
42
+ forums,
43
+ sorts,
44
+ hint,
45
+ errorMessage,
46
+ }: SearchFormModel) {
47
+ const described = [
48
+ hint === null ? null : 'search-hint',
49
+ errorMessage === null ? null : 'search-error',
50
+ ]
51
+ .filter((id): id is string => id !== null)
52
+ .join(' ')
53
+
54
+ return (
55
+ <Frame aria-labelledby="search-heading">
56
+ <PanelHead id="search-heading" title="Search" />
57
+
58
+ <form method="get" action={action} className="flex flex-col gap-4 px-4 py-4">
59
+ <div>
60
+ <label htmlFor="search-query" className={`${MICRO} mb-1 block`}>
61
+ Search for
62
+ </label>
63
+ <input
64
+ id="search-query"
65
+ type="search"
66
+ name={fields.query}
67
+ defaultValue={query}
68
+ maxLength={maxQueryLength}
69
+ autoComplete="off"
70
+ placeholder="Words in a subject or a post"
71
+ aria-invalid={errorMessage === null ? undefined : true}
72
+ {...(described === '' ? {} : { 'aria-describedby': described })}
73
+ className={CONTROL}
74
+ />
75
+
76
+ {hint !== null && (
77
+ <p id="search-hint" className="mt-1.5 text-xs text-muted-foreground">
78
+ {hint}
79
+ </p>
80
+ )}
81
+
82
+ {errorMessage !== null && (
83
+ <p id="search-error" className={`${MICRO} mt-1.5 text-destructive`}>
84
+ {errorMessage}
85
+ </p>
86
+ )}
87
+ </div>
88
+
89
+ <div className="grid gap-4 sm:grid-cols-2">
90
+ <Choice id="search-forum" label="In" name={fields.forum} options={forums} />
91
+ <Choice id="search-sort" label="Sort by" name={fields.sort} options={sorts} />
92
+ </div>
93
+
94
+ <div>
95
+ <button type="submit" className={BUTTON_PRIMARY}>
96
+ search
97
+ </button>
98
+ </div>
99
+ </form>
100
+ </Frame>
101
+ )
102
+ }