@bunnyland/ui-web 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.
- package/LICENSE +661 -0
- package/README.md +211 -0
- package/assets/bunnyland-api.js +345 -0
- package/assets/bunnyland-play.js +1223 -0
- package/assets/bunnyland-ui.css +1634 -0
- package/assets/bunnyland-ui.js +795 -0
- package/dist/admin-widgets.d.ts +31 -0
- package/dist/admin-widgets.d.ts.map +1 -0
- package/dist/admin-widgets.js +100 -0
- package/dist/admin-widgets.js.map +1 -0
- package/dist/api.d.ts +37 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +125 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/play.d.ts +265 -0
- package/dist/play.d.ts.map +1 -0
- package/dist/play.js +806 -0
- package/dist/play.js.map +1 -0
- package/dist/player-widgets.d.ts +11 -0
- package/dist/player-widgets.d.ts.map +1 -0
- package/dist/player-widgets.js +21 -0
- package/dist/player-widgets.js.map +1 -0
- package/dist/preact/auth.d.ts +37 -0
- package/dist/preact/auth.d.ts.map +1 -0
- package/dist/preact/components.d.ts +58 -0
- package/dist/preact/components.d.ts.map +1 -0
- package/dist/preact/theme.d.ts +16 -0
- package/dist/preact/theme.d.ts.map +1 -0
- package/dist/preact.d.ts +4 -0
- package/dist/preact.d.ts.map +1 -0
- package/dist/preact.js +488 -0
- package/dist/preact.js.map +1 -0
- package/dist/theme.d.ts +31 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +165 -0
- package/dist/theme.js.map +1 -0
- package/dist/widgets.d.ts +7 -0
- package/dist/widgets.d.ts.map +1 -0
- package/dist/widgets.js +31 -0
- package/dist/widgets.js.map +1 -0
- package/docs/admin/custom-web-themes.md +112 -0
- package/docs/developer/design-language.md +116 -0
- package/package.json +101 -0
- package/src/admin-widgets.ts +189 -0
- package/src/api.ts +193 -0
- package/src/index.ts +6 -0
- package/src/play.ts +1151 -0
- package/src/player-widgets.ts +29 -0
- package/src/preact/auth.tsx +338 -0
- package/src/preact/components.tsx +249 -0
- package/src/preact/theme.tsx +72 -0
- package/src/preact.ts +3 -0
- package/src/theme.ts +225 -0
- package/src/widgets.ts +41 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { escapeHtml } from './widgets';
|
|
2
|
+
|
|
3
|
+
export interface GalleryItem {
|
|
4
|
+
id: string;
|
|
5
|
+
src: string;
|
|
6
|
+
title: string;
|
|
7
|
+
detail: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mergeGalleryItems(items: GalleryItem[], item: GalleryItem, limit = 36): GalleryItem[] {
|
|
13
|
+
const existing = items.find(entry => entry.id === item.id || entry.src === item.src);
|
|
14
|
+
const next = existing
|
|
15
|
+
? items.map(entry => entry === existing ? { ...existing, ...item } : entry)
|
|
16
|
+
: [...items, item];
|
|
17
|
+
return next.sort((a, b) => a.createdAt - b.createdAt).slice(-limit);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderGalleryItems(items: GalleryItem[]): string {
|
|
21
|
+
return items.length
|
|
22
|
+
? items.slice().reverse().map(item => `
|
|
23
|
+
<button class="gallery-item" type="button" data-gallery-id="${escapeHtml(item.id)}">
|
|
24
|
+
<img src="${escapeHtml(item.src)}" alt="${escapeHtml(item.title)}">
|
|
25
|
+
<span class="gallery-caption">${escapeHtml(item.title)}</span>
|
|
26
|
+
</button>
|
|
27
|
+
`).join('')
|
|
28
|
+
: 'No photos yet.';
|
|
29
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { ComponentChildren, JSX } from 'preact';
|
|
2
|
+
import { createContext } from 'preact';
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ApiError,
|
|
7
|
+
authMe,
|
|
8
|
+
login as createSession,
|
|
9
|
+
logout as deleteSession,
|
|
10
|
+
rotateAuth,
|
|
11
|
+
setPlayerAuth,
|
|
12
|
+
subscribePlayerAuth,
|
|
13
|
+
} from '../api';
|
|
14
|
+
import { Button, EmptyState, StatusText } from './components';
|
|
15
|
+
|
|
16
|
+
export const AUTH_SCOPES = [
|
|
17
|
+
'character:profile',
|
|
18
|
+
'character:chat',
|
|
19
|
+
'world:play',
|
|
20
|
+
'world:admin',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type AuthScope = typeof AUTH_SCOPES[number];
|
|
24
|
+
export type AuthStatus = 'anonymous' | 'authenticated' | 'checking' | 'error' | 'idle';
|
|
25
|
+
|
|
26
|
+
export interface AuthSession {
|
|
27
|
+
expires_at: number;
|
|
28
|
+
rotate_after: number | null;
|
|
29
|
+
rotation_eligible: boolean;
|
|
30
|
+
scopes: AuthScope[];
|
|
31
|
+
subject: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuthContextValue {
|
|
35
|
+
closeLogin: () => void;
|
|
36
|
+
error: string;
|
|
37
|
+
hasScopes: (scopes: readonly AuthScope[]) => boolean;
|
|
38
|
+
login: (username: string, password: string) => Promise<void>;
|
|
39
|
+
logout: () => Promise<void>;
|
|
40
|
+
openLogin: (scopes: readonly AuthScope[]) => void;
|
|
41
|
+
refresh: () => Promise<void>;
|
|
42
|
+
session: AuthSession | null;
|
|
43
|
+
status: AuthStatus;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AuthProviderProps {
|
|
47
|
+
base: string;
|
|
48
|
+
children: ComponentChildren;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface AuthGateProps {
|
|
52
|
+
children: ComponentChildren;
|
|
53
|
+
scopes: readonly AuthScope[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SCOPE_LABELS: Record<AuthScope, string> = {
|
|
57
|
+
'character:profile': 'Character profile',
|
|
58
|
+
'character:chat': 'Character chat',
|
|
59
|
+
'world:play': 'World play',
|
|
60
|
+
'world:admin': 'World administration',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
64
|
+
|
|
65
|
+
function isAuthScope(value: unknown): value is AuthScope {
|
|
66
|
+
return typeof value === 'string' && (AUTH_SCOPES as readonly string[]).includes(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseSession(value: unknown): AuthSession {
|
|
70
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid authentication response');
|
|
71
|
+
const record = value as Record<string, unknown>;
|
|
72
|
+
if (typeof record.subject !== 'string' || !Array.isArray(record.scopes)) {
|
|
73
|
+
throw new Error('Invalid authentication response');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
subject: record.subject,
|
|
77
|
+
scopes: record.scopes.filter(isAuthScope),
|
|
78
|
+
expires_at: Number(record.expires_at || 0),
|
|
79
|
+
rotate_after: record.rotate_after == null ? null : Number(record.rotate_after),
|
|
80
|
+
rotation_eligible: Boolean(record.rotation_eligible),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function effectiveAuthScopes(scopes: readonly AuthScope[]): ReadonlySet<AuthScope> {
|
|
85
|
+
const effective = new Set(scopes);
|
|
86
|
+
if (effective.has('world:admin')) effective.add('world:play');
|
|
87
|
+
if (effective.has('world:play')) {
|
|
88
|
+
effective.add('character:profile');
|
|
89
|
+
effective.add('character:chat');
|
|
90
|
+
}
|
|
91
|
+
return effective;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hasAuthScopes(
|
|
95
|
+
granted: readonly AuthScope[],
|
|
96
|
+
required: readonly AuthScope[],
|
|
97
|
+
): boolean {
|
|
98
|
+
const effective = effectiveAuthScopes(granted);
|
|
99
|
+
return required.every(scope => effective.has(scope));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function errorMessage(error: unknown): string {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function LoginDialog({
|
|
107
|
+
close,
|
|
108
|
+
login,
|
|
109
|
+
scopes,
|
|
110
|
+
}: {
|
|
111
|
+
close: () => void;
|
|
112
|
+
login: (username: string, password: string) => Promise<void>;
|
|
113
|
+
scopes: readonly AuthScope[];
|
|
114
|
+
}): JSX.Element {
|
|
115
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
116
|
+
const [username, setUsername] = useState('');
|
|
117
|
+
const [password, setPassword] = useState('');
|
|
118
|
+
const [error, setError] = useState('');
|
|
119
|
+
const [submitting, setSubmitting] = useState(false);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const dialog = dialogRef.current;
|
|
123
|
+
if (!dialog) return;
|
|
124
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
125
|
+
else dialog.setAttribute('open', '');
|
|
126
|
+
return () => {
|
|
127
|
+
if (typeof dialog.close === 'function' && dialog.open) dialog.close();
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const submit = async (event: JSX.TargetedSubmitEvent<HTMLFormElement>): Promise<void> => {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
if (!username.trim() || !password || submitting) return;
|
|
134
|
+
setSubmitting(true);
|
|
135
|
+
setError('');
|
|
136
|
+
try {
|
|
137
|
+
await login(username.trim(), password);
|
|
138
|
+
close();
|
|
139
|
+
} catch (loginError) {
|
|
140
|
+
setError(errorMessage(loginError));
|
|
141
|
+
} finally {
|
|
142
|
+
setSubmitting(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<dialog
|
|
148
|
+
aria-labelledby="bl-auth-title"
|
|
149
|
+
class="bl-auth-dialog"
|
|
150
|
+
onCancel={(event): void => { event.preventDefault(); close(); }}
|
|
151
|
+
ref={dialogRef}
|
|
152
|
+
>
|
|
153
|
+
<form class="bl-auth-form" onSubmit={(event): void => { void submit(event); }}>
|
|
154
|
+
<h2 id="bl-auth-title">Sign in to Bunnyland</h2>
|
|
155
|
+
<label>
|
|
156
|
+
<span>Username</span>
|
|
157
|
+
<input
|
|
158
|
+
autocomplete="username"
|
|
159
|
+
autofocus
|
|
160
|
+
disabled={submitting}
|
|
161
|
+
onInput={(event): void => setUsername(event.currentTarget.value)}
|
|
162
|
+
required
|
|
163
|
+
value={username}
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
<label>
|
|
167
|
+
<span>Password</span>
|
|
168
|
+
<input
|
|
169
|
+
autocomplete="current-password"
|
|
170
|
+
disabled={submitting}
|
|
171
|
+
onInput={(event): void => setPassword(event.currentTarget.value)}
|
|
172
|
+
required
|
|
173
|
+
type="password"
|
|
174
|
+
value={password}
|
|
175
|
+
/>
|
|
176
|
+
</label>
|
|
177
|
+
{error && <StatusText role="alert" tone="error">{error}</StatusText>}
|
|
178
|
+
<p class="bl-auth-scopes">
|
|
179
|
+
Requested access: {scopes.map(scope => `${SCOPE_LABELS[scope]} (${scope})`).join(', ')}.
|
|
180
|
+
Your account determines the access granted.
|
|
181
|
+
</p>
|
|
182
|
+
<div class="bl-auth-actions">
|
|
183
|
+
<Button disabled={submitting} onClick={close}>Cancel</Button>
|
|
184
|
+
<Button disabled={submitting || !username.trim() || !password} type="submit" variant="primary">
|
|
185
|
+
{submitting ? 'Signing in…' : 'Sign in'}
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</form>
|
|
189
|
+
</dialog>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function AuthProvider({ base, children }: AuthProviderProps): JSX.Element {
|
|
194
|
+
const [status, setStatus] = useState<AuthStatus>(base ? 'checking' : 'idle');
|
|
195
|
+
const [session, setSession] = useState<AuthSession | null>(null);
|
|
196
|
+
const [error, setError] = useState('');
|
|
197
|
+
const [loginScopes, setLoginScopes] = useState<readonly AuthScope[] | null>(null);
|
|
198
|
+
const requestGeneration = useRef(0);
|
|
199
|
+
const rotationAttempt = useRef('');
|
|
200
|
+
|
|
201
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
202
|
+
const generation = ++requestGeneration.current;
|
|
203
|
+
if (!base) {
|
|
204
|
+
setSession(null);
|
|
205
|
+
setError('');
|
|
206
|
+
setStatus('idle');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
setStatus('checking');
|
|
210
|
+
setError('');
|
|
211
|
+
try {
|
|
212
|
+
const next = parseSession(await authMe(base));
|
|
213
|
+
if (generation !== requestGeneration.current) return;
|
|
214
|
+
setSession(next);
|
|
215
|
+
setStatus('authenticated');
|
|
216
|
+
} catch (authError) {
|
|
217
|
+
if (generation !== requestGeneration.current) return;
|
|
218
|
+
setSession(null);
|
|
219
|
+
if (authError instanceof ApiError && authError.status === 401) {
|
|
220
|
+
setStatus('anonymous');
|
|
221
|
+
} else {
|
|
222
|
+
setError(errorMessage(authError));
|
|
223
|
+
setStatus('error');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, [base]);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
void refresh();
|
|
230
|
+
return subscribePlayerAuth(() => { void refresh(); });
|
|
231
|
+
}, [refresh]);
|
|
232
|
+
|
|
233
|
+
const login = useCallback(async (username: string, password: string): Promise<void> => {
|
|
234
|
+
const next = parseSession(await createSession(base, username, password));
|
|
235
|
+
setPlayerAuth('');
|
|
236
|
+
requestGeneration.current += 1;
|
|
237
|
+
setSession(next);
|
|
238
|
+
setError('');
|
|
239
|
+
setStatus('authenticated');
|
|
240
|
+
}, [base]);
|
|
241
|
+
|
|
242
|
+
const logout = useCallback(async (): Promise<void> => {
|
|
243
|
+
await deleteSession(base);
|
|
244
|
+
setPlayerAuth('');
|
|
245
|
+
requestGeneration.current += 1;
|
|
246
|
+
setSession(null);
|
|
247
|
+
setError('');
|
|
248
|
+
setStatus('anonymous');
|
|
249
|
+
}, [base]);
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (status !== 'authenticated' || !session) return;
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
const expiresDelay = Math.max(0, session.expires_at * 1000 - now);
|
|
255
|
+
const rotateDelay = session.rotate_after == null
|
|
256
|
+
? Number.POSITIVE_INFINITY
|
|
257
|
+
: Math.max(0, session.rotate_after * 1000 - now);
|
|
258
|
+
const delay = Math.min(expiresDelay, rotateDelay, 2147483647);
|
|
259
|
+
const timer = window.setTimeout(() => {
|
|
260
|
+
if (rotateDelay <= expiresDelay && session.rotate_after != null) {
|
|
261
|
+
const attempt = `${base}:${session.subject}:${session.rotate_after}`;
|
|
262
|
+
if (rotationAttempt.current === attempt) return;
|
|
263
|
+
rotationAttempt.current = attempt;
|
|
264
|
+
void rotateAuth(base)
|
|
265
|
+
.then(value => {
|
|
266
|
+
const next = parseSession(value);
|
|
267
|
+
setSession(next);
|
|
268
|
+
setStatus('authenticated');
|
|
269
|
+
})
|
|
270
|
+
.catch(() => { void refresh(); });
|
|
271
|
+
} else {
|
|
272
|
+
void refresh();
|
|
273
|
+
}
|
|
274
|
+
}, delay);
|
|
275
|
+
return () => window.clearTimeout(timer);
|
|
276
|
+
}, [base, refresh, session, status]);
|
|
277
|
+
|
|
278
|
+
const hasScopes = useCallback((scopes: readonly AuthScope[]): boolean => (
|
|
279
|
+
Boolean(session && hasAuthScopes(session.scopes, scopes))
|
|
280
|
+
), [session]);
|
|
281
|
+
const openLogin = useCallback((scopes: readonly AuthScope[]): void => setLoginScopes([...scopes]), []);
|
|
282
|
+
const closeLogin = useCallback((): void => setLoginScopes(null), []);
|
|
283
|
+
const value = useMemo<AuthContextValue>(() => ({
|
|
284
|
+
closeLogin,
|
|
285
|
+
error,
|
|
286
|
+
hasScopes,
|
|
287
|
+
login,
|
|
288
|
+
logout,
|
|
289
|
+
openLogin,
|
|
290
|
+
refresh,
|
|
291
|
+
session,
|
|
292
|
+
status,
|
|
293
|
+
}), [closeLogin, error, hasScopes, login, logout, openLogin, refresh, session, status]);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<AuthContext.Provider value={value}>
|
|
297
|
+
{children}
|
|
298
|
+
{loginScopes && <LoginDialog close={closeLogin} login={login} scopes={loginScopes} />}
|
|
299
|
+
</AuthContext.Provider>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function useAuth(): AuthContextValue {
|
|
304
|
+
const auth = useContext(AuthContext);
|
|
305
|
+
if (!auth) throw new Error('useAuth must be used within an AuthProvider');
|
|
306
|
+
return auth;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function AuthGate({ children, scopes }: AuthGateProps): JSX.Element {
|
|
310
|
+
const auth = useAuth();
|
|
311
|
+
if (auth.status === 'authenticated' && auth.hasScopes(scopes)) return <>{children}</>;
|
|
312
|
+
if (auth.status === 'checking') return <EmptyState class="bl-auth-gate">Checking access…</EmptyState>;
|
|
313
|
+
if (auth.status === 'idle') return <EmptyState class="bl-auth-gate">Choose a server to continue.</EmptyState>;
|
|
314
|
+
if (auth.status === 'error') {
|
|
315
|
+
return (
|
|
316
|
+
<EmptyState class="bl-auth-gate">
|
|
317
|
+
<StatusText tone="error">Authentication check failed: {auth.error}</StatusText>
|
|
318
|
+
<Button onClick={(): void => { void auth.refresh(); }}>Retry</Button>
|
|
319
|
+
</EmptyState>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (auth.status === 'authenticated') {
|
|
323
|
+
const missing = scopes.filter(scope => !effectiveAuthScopes(auth.session?.scopes || []).has(scope));
|
|
324
|
+
return (
|
|
325
|
+
<EmptyState class="bl-auth-gate">
|
|
326
|
+
<StatusText tone="error">Insufficient access for {auth.session?.subject || 'this account'}.</StatusText>
|
|
327
|
+
<small>Missing: {missing.map(scope => `${SCOPE_LABELS[scope]} (${scope})`).join(', ')}.</small>
|
|
328
|
+
<Button onClick={(): void => auth.openLogin(scopes)}>Sign in as another user</Button>
|
|
329
|
+
</EmptyState>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return (
|
|
333
|
+
<EmptyState class="bl-auth-gate">
|
|
334
|
+
<span>Sign in to continue.</span>
|
|
335
|
+
<Button onClick={(): void => auth.openLogin(scopes)} variant="primary">Login</Button>
|
|
336
|
+
</EmptyState>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { ComponentChildren, JSX } from 'preact';
|
|
2
|
+
import { useEffect, useId, useMemo, useState } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
export type Tone = 'error' | 'info' | 'muted' | 'ok' | 'warn';
|
|
5
|
+
export type ButtonVariant = 'danger' | 'primary' | 'quiet' | 'secondary';
|
|
6
|
+
|
|
7
|
+
function classNames(...values: Array<string | false | null | undefined>): string {
|
|
8
|
+
return values.filter(Boolean).join(' ');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
12
|
+
variant?: ButtonVariant;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Button({ class: className, variant, type = 'button', ...props }: ButtonProps): JSX.Element {
|
|
16
|
+
return <button {...props} type={type} class={classNames(className as string, variant && `bl-button-${variant}`)} />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StatusTextProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
|
20
|
+
children: ComponentChildren;
|
|
21
|
+
tone?: Tone;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function StatusText({ children, class: className, tone = 'muted', ...props }: StatusTextProps): JSX.Element {
|
|
25
|
+
return <span {...props} class={classNames(className as string, 'bl-status', `bl-status-${tone}`)}>{children}</span>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Toolbar({ children, class: className, ...props }: JSX.HTMLAttributes<HTMLElement>): JSX.Element {
|
|
29
|
+
return <header {...props} class={classNames(className as string, 'bl-toolbar')}>{children}</header>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ToolbarRow({ children, class: className, ...props }: JSX.HTMLAttributes<HTMLDivElement>): JSX.Element {
|
|
33
|
+
return <div {...props} class={classNames(className as string, 'toolbar-row')}>{children}</div>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToolbarBrandProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
|
37
|
+
icon?: ComponentChildren;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ToolbarBrand({ children, class: className, icon = '🐰', ...props }: ToolbarBrandProps): JSX.Element {
|
|
41
|
+
return <span {...props} class={classNames(className as string, 'toolbar-brand')}>{icon}{children}</span>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PaneProps extends Omit<JSX.HTMLAttributes<HTMLElement>, 'title'> {
|
|
45
|
+
children: ComponentChildren;
|
|
46
|
+
title?: ComponentChildren;
|
|
47
|
+
tools?: ComponentChildren;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Pane({ children, class: className, title, tools, ...props }: PaneProps): JSX.Element {
|
|
51
|
+
return (
|
|
52
|
+
<section {...props} class={classNames(className as string, 'pane')}>
|
|
53
|
+
{(title || tools) && (
|
|
54
|
+
<header class="pane-header">
|
|
55
|
+
{title && <span class="pane-title">{title}</span>}
|
|
56
|
+
{tools && <span class="pane-tools">{tools}</span>}
|
|
57
|
+
</header>
|
|
58
|
+
)}
|
|
59
|
+
{children}
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FieldProps extends JSX.HTMLAttributes<HTMLLabelElement> {
|
|
65
|
+
children: ComponentChildren;
|
|
66
|
+
description?: ComponentChildren;
|
|
67
|
+
label: ComponentChildren;
|
|
68
|
+
wide?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Field({ children, class: className, description, label, wide, ...props }: FieldProps): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<label {...props} class={classNames(className as string, 'field', wide && 'wide')}>
|
|
74
|
+
<span class="field-label">{label}</span>
|
|
75
|
+
{children}
|
|
76
|
+
{description && <span class="hint">{description}</span>}
|
|
77
|
+
</label>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function Pill({ children, class: className, ...props }: JSX.HTMLAttributes<HTMLSpanElement>): JSX.Element {
|
|
82
|
+
return <span {...props} class={classNames(className as string, 'pill')}>{children}</span>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function EmptyState({ children, class: className, ...props }: JSX.HTMLAttributes<HTMLDivElement>): JSX.Element {
|
|
86
|
+
return <div {...props} class={classNames(className as string, 'empty')}>{children}</div>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SearchOption {
|
|
90
|
+
label: string;
|
|
91
|
+
value: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SearchSelectProps {
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
emptyLabel?: string;
|
|
97
|
+
id?: string;
|
|
98
|
+
label?: string;
|
|
99
|
+
onChange: (value: string, option: SearchOption | null) => void;
|
|
100
|
+
options: SearchOption[];
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
value?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function SearchSelect({
|
|
106
|
+
disabled = false,
|
|
107
|
+
emptyLabel = 'No matches',
|
|
108
|
+
id,
|
|
109
|
+
label,
|
|
110
|
+
onChange,
|
|
111
|
+
options,
|
|
112
|
+
placeholder = 'search...',
|
|
113
|
+
value = '',
|
|
114
|
+
}: SearchSelectProps): JSX.Element {
|
|
115
|
+
const generatedId = useId();
|
|
116
|
+
const inputId = id || generatedId;
|
|
117
|
+
const listboxId = `${inputId}-listbox`;
|
|
118
|
+
const selected = options.find(option => option.value === value) || null;
|
|
119
|
+
const [query, setQuery] = useState(selected?.label || '');
|
|
120
|
+
const [open, setOpen] = useState(false);
|
|
121
|
+
const [active, setActive] = useState(0);
|
|
122
|
+
const filtered = useMemo(() => {
|
|
123
|
+
const needle = query.trim().toLowerCase();
|
|
124
|
+
if (!needle) return options;
|
|
125
|
+
return options.filter(option => option.label.toLowerCase().includes(needle) || option.value.toLowerCase().includes(needle));
|
|
126
|
+
}, [options, query]);
|
|
127
|
+
const selectedLabel = selected?.label || '';
|
|
128
|
+
useEffect(() => setQuery(selectedLabel), [selectedLabel, value]);
|
|
129
|
+
const choose = (option: SearchOption): void => {
|
|
130
|
+
setQuery(option.label);
|
|
131
|
+
setOpen(false);
|
|
132
|
+
onChange(option.value, option);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<span class="search-dropdown">
|
|
137
|
+
{label && <label for={inputId}>{label}</label>}
|
|
138
|
+
<input
|
|
139
|
+
id={inputId}
|
|
140
|
+
class="search-dropdown-input"
|
|
141
|
+
type="text"
|
|
142
|
+
value={query}
|
|
143
|
+
placeholder={placeholder}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
autocomplete="off"
|
|
146
|
+
role="combobox"
|
|
147
|
+
aria-autocomplete="list"
|
|
148
|
+
aria-controls={listboxId}
|
|
149
|
+
aria-expanded={open}
|
|
150
|
+
onFocus={(): void => setOpen(true)}
|
|
151
|
+
onBlur={(): void => setOpen(false)}
|
|
152
|
+
onInput={(event): void => {
|
|
153
|
+
setQuery(event.currentTarget.value);
|
|
154
|
+
setActive(0);
|
|
155
|
+
setOpen(true);
|
|
156
|
+
if (!event.currentTarget.value) onChange('', null);
|
|
157
|
+
}}
|
|
158
|
+
onKeyDown={(event): void => {
|
|
159
|
+
if (event.key === 'ArrowDown') {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
setActive(index => Math.min(index + 1, filtered.length - 1));
|
|
162
|
+
} else if (event.key === 'ArrowUp') {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
setActive(index => Math.max(index - 1, 0));
|
|
165
|
+
} else if (event.key === 'Enter' && filtered[active]) {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
choose(filtered[active]);
|
|
168
|
+
} else if (event.key === 'Escape') {
|
|
169
|
+
setOpen(false);
|
|
170
|
+
}
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
{open && (
|
|
174
|
+
<div id={listboxId} class="search-dropdown-menu" role="listbox">
|
|
175
|
+
{filtered.length ? filtered.map((option, index) => (
|
|
176
|
+
<button
|
|
177
|
+
key={option.value}
|
|
178
|
+
type="button"
|
|
179
|
+
class={classNames('search-dropdown-option', index === active && 'active')}
|
|
180
|
+
role="option"
|
|
181
|
+
aria-selected={option.value === value}
|
|
182
|
+
onMouseDown={(event): void => event.preventDefault()}
|
|
183
|
+
onClick={(): void => choose(option)}
|
|
184
|
+
>
|
|
185
|
+
{option.label}
|
|
186
|
+
</button>
|
|
187
|
+
)) : <div class="search-dropdown-empty">{emptyLabel}</div>}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</span>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface TagEditorProps {
|
|
195
|
+
addLabel?: string;
|
|
196
|
+
disabled?: boolean;
|
|
197
|
+
emptyLabel?: string;
|
|
198
|
+
onChange: (tags: string[]) => void;
|
|
199
|
+
placeholder?: string;
|
|
200
|
+
value: string[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function TagEditor({
|
|
204
|
+
addLabel = 'Add Tag',
|
|
205
|
+
disabled = false,
|
|
206
|
+
emptyLabel = 'No tags.',
|
|
207
|
+
onChange,
|
|
208
|
+
placeholder = 'add tag...',
|
|
209
|
+
value,
|
|
210
|
+
}: TagEditorProps): JSX.Element {
|
|
211
|
+
const [draft, setDraft] = useState('');
|
|
212
|
+
const add = (): void => {
|
|
213
|
+
const tag = draft.trim();
|
|
214
|
+
if (!tag) return;
|
|
215
|
+
if (!value.includes(tag)) onChange([...value, tag]);
|
|
216
|
+
setDraft('');
|
|
217
|
+
};
|
|
218
|
+
return (
|
|
219
|
+
<div class="tag-editor">
|
|
220
|
+
<div class="tag-list">
|
|
221
|
+
{value.length ? value.map(tag => (
|
|
222
|
+
<span class="tag-pill" key={tag}>
|
|
223
|
+
<span>{tag}</span>
|
|
224
|
+
<Button
|
|
225
|
+
disabled={disabled}
|
|
226
|
+
aria-label={`Remove tag ${tag}`}
|
|
227
|
+
onClick={(): void => onChange(value.filter(item => item !== tag))}
|
|
228
|
+
>x</Button>
|
|
229
|
+
</span>
|
|
230
|
+
)) : <span class="tiny">{emptyLabel}</span>}
|
|
231
|
+
</div>
|
|
232
|
+
<div class="tag-entry">
|
|
233
|
+
<input
|
|
234
|
+
type="text"
|
|
235
|
+
value={draft}
|
|
236
|
+
placeholder={placeholder}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
onInput={(event): void => setDraft(event.currentTarget.value)}
|
|
239
|
+
onKeyDown={(event): void => {
|
|
240
|
+
if (event.key !== 'Enter') return;
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
add();
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
<Button disabled={disabled} onClick={add}>{addLabel}</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { JSX } from 'preact';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_THEME,
|
|
6
|
+
THEME_CHANGE_EVENT,
|
|
7
|
+
initTheme,
|
|
8
|
+
setTheme,
|
|
9
|
+
themeOptions,
|
|
10
|
+
type ThemeOption,
|
|
11
|
+
} from '../theme';
|
|
12
|
+
|
|
13
|
+
export interface ThemeController {
|
|
14
|
+
options: ThemeOption[];
|
|
15
|
+
setTheme: (theme: string) => void;
|
|
16
|
+
theme: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useTheme(
|
|
20
|
+
root: HTMLElement = document.documentElement,
|
|
21
|
+
defaultTheme = DEFAULT_THEME,
|
|
22
|
+
): ThemeController {
|
|
23
|
+
const [theme, setCurrentTheme] = useState(() => initTheme(root, defaultTheme));
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleThemeChange = (event: Event): void => {
|
|
27
|
+
const detail = (event as CustomEvent<{ theme?: string }>).detail;
|
|
28
|
+
if (detail?.theme) setCurrentTheme(detail.theme);
|
|
29
|
+
};
|
|
30
|
+
root.addEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
|
31
|
+
return () => root.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
|
32
|
+
}, [root]);
|
|
33
|
+
|
|
34
|
+
const updateTheme = useCallback((value: string): void => {
|
|
35
|
+
setCurrentTheme(setTheme(value, root));
|
|
36
|
+
}, [root]);
|
|
37
|
+
|
|
38
|
+
return { options: themeOptions(), setTheme: updateTheme, theme };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ThemeSelectProps extends Omit<JSX.HTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> {
|
|
42
|
+
controller?: ThemeController;
|
|
43
|
+
defaultTheme?: string;
|
|
44
|
+
onChange?: (theme: string) => void;
|
|
45
|
+
root?: HTMLElement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ThemeSelect({
|
|
49
|
+
controller,
|
|
50
|
+
defaultTheme = DEFAULT_THEME,
|
|
51
|
+
onChange,
|
|
52
|
+
root,
|
|
53
|
+
...props
|
|
54
|
+
}: ThemeSelectProps): JSX.Element {
|
|
55
|
+
const fallback = useTheme(root || document.documentElement, defaultTheme);
|
|
56
|
+
const themes = controller || fallback;
|
|
57
|
+
return (
|
|
58
|
+
<select
|
|
59
|
+
{...props}
|
|
60
|
+
value={themes.theme}
|
|
61
|
+
onChange={(event): void => {
|
|
62
|
+
const value = event.currentTarget.value;
|
|
63
|
+
themes.setTheme(value);
|
|
64
|
+
onChange?.(value);
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{themes.options.map(option => (
|
|
68
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
69
|
+
))}
|
|
70
|
+
</select>
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/preact.ts
ADDED