@meith/theme-phasebook 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/theme-phasebook",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A theme for Meith with a familiar social shape, built on the default theme's slots.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -20,9 +20,9 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/theme-default": "^0.3.0",
24
- "@meith/ui": "^0.3.0",
25
- "@meith/theme-kit": "^0.3.0"
23
+ "@meith/theme-kit": "^0.4.0",
24
+ "@meith/theme-default": "^0.4.0",
25
+ "@meith/ui": "^0.4.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": "^19.2.0"
@@ -3,14 +3,20 @@ import type { PanelPageModel, PanelSectionModel } from '@meith/theme-kit'
3
3
 
4
4
  import { MUTED_LINK } from '../shared'
5
5
 
6
- export function PanelPage({ title, back, width, gap, regions, children }: PanelPageModel) {
6
+ export function PanelPage({ title, back, frame, width, gap, regions, children }: PanelPageModel) {
7
7
  return (
8
8
  <main
9
9
  id="board-content"
10
10
  tabIndex={-1}
11
11
  className={cn(
12
12
  'flex w-full flex-col',
13
- width === 'wide' ? 'max-w-none' : 'max-w-4xl',
13
+ frame === 'standalone'
14
+ ? `mx-auto flex-1 px-3 py-4 sm:px-4 lg:py-6 ${
15
+ width === 'wide' ? 'max-w-6xl' : 'max-w-4xl'
16
+ }`
17
+ : width === 'wide'
18
+ ? 'max-w-none'
19
+ : 'max-w-4xl',
14
20
  gap === 'loose' ? 'gap-4' : 'gap-3',
15
21
  )}
16
22
  >
@@ -1,4 +1,4 @@
1
- import type { OptionModel, SearchFormModel } from '@meith/theme-kit'
1
+ import type { OptionModel, SearchAdvancedModel, SearchFormModel } from '@meith/theme-kit'
2
2
 
3
3
  import { PILL_PRIMARY } from '../shared'
4
4
 
@@ -36,6 +36,62 @@ function Choice({
36
36
  )
37
37
  }
38
38
 
39
+ function Advanced({ label, isOpen, author, toggles, choices }: SearchAdvancedModel) {
40
+ return (
41
+ <details open={isOpen} className="rounded-lg border border-border bg-surface/60">
42
+ <summary className="cursor-default px-3 py-2 text-sm font-semibold select-none">
43
+ {label}
44
+ </summary>
45
+
46
+ <div className="flex flex-col gap-4 border-t border-border px-3 py-3">
47
+ <div>
48
+ <label htmlFor="search-author" className={LABEL}>
49
+ {author.label}
50
+ </label>
51
+ <input
52
+ id="search-author"
53
+ type="text"
54
+ name={author.field}
55
+ defaultValue={author.value}
56
+ placeholder={author.placeholder}
57
+ autoComplete="off"
58
+ aria-describedby="search-author-hint"
59
+ className="w-full rounded-full border border-input bg-surface px-4 py-2.5 text-sm text-foreground transition-colors focus-visible:border-ring"
60
+ />
61
+ <p id="search-author-hint" className="mt-1.5 text-xs text-muted-foreground">
62
+ {author.hint}
63
+ </p>
64
+ </div>
65
+
66
+ {toggles.map((toggle) => (
67
+ <label key={toggle.field} className="flex items-center gap-2 text-sm select-none">
68
+ <input
69
+ type="checkbox"
70
+ name={toggle.field}
71
+ value={toggle.value}
72
+ defaultChecked={toggle.isOn}
73
+ className="size-4 rounded border border-input accent-primary"
74
+ />
75
+ {toggle.label}
76
+ </label>
77
+ ))}
78
+
79
+ <div className="grid gap-4 sm:grid-cols-3">
80
+ {choices.map((choice) => (
81
+ <Choice
82
+ key={choice.field}
83
+ id={`search-${choice.field}`}
84
+ label={choice.label}
85
+ name={choice.field}
86
+ options={choice.options}
87
+ />
88
+ ))}
89
+ </div>
90
+ </div>
91
+ </details>
92
+ )
93
+ }
94
+
39
95
  export function SearchForm({
40
96
  action,
41
97
  fields,
@@ -45,6 +101,7 @@ export function SearchForm({
45
101
  sorts,
46
102
  hint,
47
103
  errorMessage,
104
+ advanced,
48
105
  }: SearchFormModel) {
49
106
  const described = [hint === null ? null : 'search-hint', errorMessage === null ? null : 'search-error']
50
107
  .filter((id): id is string => id !== null)
@@ -88,6 +145,8 @@ export function SearchForm({
88
145
  <Choice id="search-sort" label="Sort by" name={fields.sort} options={sorts} />
89
146
  </div>
90
147
 
148
+ {advanced !== undefined && <Advanced {...advanced} />}
149
+
91
150
  <div>
92
151
  <button type="submit" className={PILL_PRIMARY}>
93
152
  Search
@@ -1,4 +1,4 @@
1
- import type { SearchResultsModel } from '@meith/theme-kit'
1
+ import type { OptionModel, SearchRefineModel, SearchResultsModel } from '@meith/theme-kit'
2
2
 
3
3
  import { FEED, LINK, PAGE, PILL, PILL_PRIMARY, Stamp } from '../shared'
4
4
 
@@ -10,6 +10,7 @@ export function SearchResults({
10
10
  nextLabel,
11
11
  newSearchHref,
12
12
  within,
13
+ refine,
13
14
  }: SearchResultsModel) {
14
15
  return (
15
16
  <main id="board-content" tabIndex={-1} className={`${PAGE} flex-1 py-4 sm:py-6`}>
@@ -24,6 +25,8 @@ export function SearchResults({
24
25
  </p>
25
26
  </header>
26
27
 
28
+ {refine !== undefined && <Refine {...refine} />}
29
+
27
30
  {hits.length === 0 ? (
28
31
  <p className="rounded-lg border border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground shadow-elevation">
29
32
  Nothing matched — or nothing you can see does. Try fewer words, or a different
@@ -63,6 +66,15 @@ export function SearchResults({
63
66
  action={within.action}
64
67
  className="flex flex-col gap-3 px-4 py-4"
65
68
  >
69
+ {(within.hidden ?? []).map((field) => (
70
+ <input
71
+ key={`${field.name}-${field.value}`}
72
+ type="hidden"
73
+ name={field.name}
74
+ value={field.value}
75
+ />
76
+ ))}
77
+
66
78
  <div>
67
79
  <label
68
80
  htmlFor="search-within"
@@ -99,3 +111,114 @@ export function SearchResults({
99
111
  </main>
100
112
  )
101
113
  }
114
+
115
+ function Refine({
116
+ action,
117
+ label,
118
+ summary,
119
+ note,
120
+ sorts,
121
+ sortsLabel,
122
+ choices,
123
+ submitLabel,
124
+ applied,
125
+ clearHref,
126
+ }: SearchRefineModel) {
127
+ return (
128
+ <section
129
+ aria-label={label}
130
+ className="flex flex-col gap-2.5 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation"
131
+ >
132
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
133
+ <p className="text-sm font-semibold text-foreground">{summary}</p>
134
+
135
+ <nav
136
+ aria-label={sortsLabel}
137
+ className="flex items-center rounded-full border border-border p-0.5"
138
+ >
139
+ {sorts.map((sort) => (
140
+ <a
141
+ key={sort.label}
142
+ href={sort.href}
143
+ {...(sort.isCurrent ? { 'aria-current': 'true' as const } : {})}
144
+ className={
145
+ sort.isCurrent
146
+ ? 'rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary'
147
+ : 'rounded-full px-3 py-1 text-xs font-semibold text-muted-foreground hover:bg-muted hover:text-foreground'
148
+ }
149
+ >
150
+ {sort.label}
151
+ </a>
152
+ ))}
153
+ </nav>
154
+
155
+ {applied.map((chip) => (
156
+ <a
157
+ key={chip.label}
158
+ href={chip.removeHref}
159
+ className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-3 py-1 text-xs font-semibold text-foreground hover:bg-primary/20"
160
+ >
161
+ {chip.label}
162
+ <span aria-hidden="true">×</span>
163
+ <span className="sr-only">— remove this filter</span>
164
+ </a>
165
+ ))}
166
+ </div>
167
+
168
+ <form
169
+ method="get"
170
+ action={action}
171
+ className="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border pt-2.5"
172
+ >
173
+ {choices.map((choice) => (
174
+ <RefineChoice key={choice.field} {...choice} />
175
+ ))}
176
+
177
+ <button type="submit" className={`${PILL} h-8 px-3 text-xs`}>
178
+ {submitLabel}
179
+ </button>
180
+
181
+ {clearHref !== null && (
182
+ <a href={clearHref} className={`text-xs font-semibold ${LINK}`}>
183
+ Clear filters
184
+ </a>
185
+ )}
186
+ </form>
187
+
188
+ {note !== null && <p className="text-xs text-muted-foreground">{note}</p>}
189
+ </section>
190
+ )
191
+ }
192
+
193
+ function RefineChoice({
194
+ field,
195
+ label,
196
+ options,
197
+ }: {
198
+ field: string
199
+ label: string
200
+ options: readonly OptionModel[]
201
+ }) {
202
+ const selected = options.find((option) => option.isSelected)
203
+ const id = `search-refine-${field}`
204
+
205
+ return (
206
+ <span className="inline-flex items-center gap-1.5">
207
+ <label htmlFor={id} className="text-xs font-semibold text-muted-foreground">
208
+ {label}
209
+ </label>
210
+ <select
211
+ id={id}
212
+ name={field}
213
+ defaultValue={selected?.value ?? ''}
214
+ className="h-8 rounded-lg border border-input bg-surface px-2 text-xs text-foreground transition-colors focus-visible:border-ring"
215
+ >
216
+ {options.map((option) => (
217
+ <option key={option.value} value={option.value}>
218
+ {option.label}
219
+ </option>
220
+ ))}
221
+ </select>
222
+ </span>
223
+ )
224
+ }
@@ -79,6 +79,8 @@ export function UserPanel({
79
79
  links,
80
80
  unreadNotifications,
81
81
  unreadMessages,
82
+ notificationsHref,
83
+ messagesHref,
82
84
  children,
83
85
  }: UserPanelModel) {
84
86
  if (viewer.isGuest) {
@@ -94,8 +96,6 @@ export function UserPanel({
94
96
  }
95
97
 
96
98
  const name = viewer.username ?? 'Signed in'
97
- const hrefFor = (label: string): string | null =>
98
- links.find((link) => link.label === label)?.href ?? null
99
99
 
100
100
  return (
101
101
  <div className="flex items-center gap-2">
@@ -107,14 +107,14 @@ export function UserPanel({
107
107
  />
108
108
 
109
109
  {unreadNotifications > 0 && (
110
- <IconButton href={hrefFor('Notifications')} title="Notifications">
110
+ <IconButton href={notificationsHref ?? null} title="Notifications">
111
111
  <BellIcon />
112
112
  <CountBadge value={unreadNotifications} label="unread notifications" />
113
113
  </IconButton>
114
114
  )}
115
115
 
116
116
  {unreadMessages > 0 && (
117
- <IconButton href={hrefFor('Messages')} title="Messages">
117
+ <IconButton href={messagesHref ?? null} title="Messages">
118
118
  <MessageIcon />
119
119
  <CountBadge value={unreadMessages} label="unread messages" />
120
120
  </IconButton>