@ternkit/tern 1.2.1 → 1.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 (63) hide show
  1. package/dist/goscript/index.svelte.d.ts +32 -2
  2. package/dist/goscript/index.svelte.js +257 -31
  3. package/dist/goscript/patch.d.ts +6 -0
  4. package/dist/goscript/patch.js +25 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/ui/Accordion.svelte +84 -0
  8. package/dist/ui/Accordion.svelte.d.ts +20 -0
  9. package/dist/ui/Alert.svelte +76 -0
  10. package/dist/ui/Alert.svelte.d.ts +12 -0
  11. package/dist/ui/Avatar.svelte +48 -0
  12. package/dist/ui/Avatar.svelte.d.ts +10 -0
  13. package/dist/ui/Badge.svelte +54 -0
  14. package/dist/ui/Badge.svelte.d.ts +10 -0
  15. package/dist/ui/Button.svelte +136 -0
  16. package/dist/ui/Button.svelte.d.ts +18 -0
  17. package/dist/ui/Card.svelte +74 -0
  18. package/dist/ui/Card.svelte.d.ts +18 -0
  19. package/dist/ui/Checkbox.svelte +100 -0
  20. package/dist/ui/Checkbox.svelte.d.ts +14 -0
  21. package/dist/ui/Dialog.svelte +160 -0
  22. package/dist/ui/Dialog.svelte.d.ts +20 -0
  23. package/dist/ui/Field.svelte +77 -0
  24. package/dist/ui/Field.svelte.d.ts +17 -0
  25. package/dist/ui/Input.svelte +61 -0
  26. package/dist/ui/Input.svelte.d.ts +7 -0
  27. package/dist/ui/Kbd.svelte +24 -0
  28. package/dist/ui/Kbd.svelte.d.ts +7 -0
  29. package/dist/ui/Menu.svelte +232 -0
  30. package/dist/ui/Menu.svelte.d.ts +22 -0
  31. package/dist/ui/Progress.svelte +73 -0
  32. package/dist/ui/Progress.svelte.d.ts +12 -0
  33. package/dist/ui/RadioGroup.svelte +105 -0
  34. package/dist/ui/RadioGroup.svelte.d.ts +21 -0
  35. package/dist/ui/Select.svelte +76 -0
  36. package/dist/ui/Select.svelte.d.ts +18 -0
  37. package/dist/ui/Separator.svelte +19 -0
  38. package/dist/ui/Separator.svelte.d.ts +7 -0
  39. package/dist/ui/Skeleton.svelte +29 -0
  40. package/dist/ui/Skeleton.svelte.d.ts +9 -0
  41. package/dist/ui/Spinner.svelte +42 -0
  42. package/dist/ui/Spinner.svelte.d.ts +8 -0
  43. package/dist/ui/Switch.svelte +90 -0
  44. package/dist/ui/Switch.svelte.d.ts +12 -0
  45. package/dist/ui/Tabs.svelte +112 -0
  46. package/dist/ui/Tabs.svelte.d.ts +19 -0
  47. package/dist/ui/Textarea.svelte +51 -0
  48. package/dist/ui/Textarea.svelte.d.ts +7 -0
  49. package/dist/ui/Toaster.svelte +146 -0
  50. package/dist/ui/Toaster.svelte.d.ts +6 -0
  51. package/dist/ui/Tooltip.svelte +77 -0
  52. package/dist/ui/Tooltip.svelte.d.ts +15 -0
  53. package/dist/ui/field.d.ts +10 -0
  54. package/dist/ui/field.js +8 -0
  55. package/dist/ui/index.d.ts +24 -0
  56. package/dist/ui/index.js +27 -0
  57. package/dist/ui/theme.css +151 -0
  58. package/dist/ui/toast.svelte.d.ts +19 -0
  59. package/dist/ui/toast.svelte.js +18 -0
  60. package/dist/vite/goscript.d.ts +14 -0
  61. package/dist/vite/goscript.js +45 -16
  62. package/dist/vite/index.js +5 -2
  63. package/package.json +7 -1
@@ -1,3 +1,5 @@
1
+ import { applyPatch, type PatchOp } from './patch.js';
2
+ export { applyPatch, type PatchOp };
1
3
  type Instance = {
2
4
  call(name: string, args: string): string | null;
3
5
  destroy(): void;
@@ -30,9 +32,38 @@ export declare function useGo(id: string, initial: Record<string, unknown>, runt
30
32
  declare global {
31
33
  var __tern_live_initial: ((id: string, props: string) => string) | undefined;
32
34
  }
33
- export interface LiveComponent extends GoComponent {
35
+ /** A Func of a live component, as the markup sees it. */
36
+ export interface LiveFunc<A extends unknown[] = unknown[], R = unknown> {
37
+ (...args: A): Promise<R>;
38
+ /** Whether a call is waiting for the server. */
39
+ readonly pending: boolean;
40
+ /** The last call's error message (from goscript.Fail), or null. */
41
+ readonly error: string | null;
42
+ /** Calls the Func, showing values for these state names until it returns. */
43
+ optimistic(values: Record<string, unknown>, ...args: A): Promise<R>;
44
+ /** A version that waits until calls stop for ms (default 200), for input events. */
45
+ debounce(ms?: number): (...args: A) => void;
46
+ }
47
+ export interface LiveComponent<S extends Record<string, unknown> = Record<string, unknown>> extends GoComponent {
48
+ readonly state: S;
34
49
  /** The server-rendered state, embedded in the page for hydration. */
35
50
  readonly stateScript: string;
51
+ /** Whether the page is connected to the server. */
52
+ readonly connected: boolean;
53
+ /** Whether any call is waiting for the server. */
54
+ readonly pending: boolean;
55
+ /** Why the component failed to mount (such as goscript Reject's message), or null. */
56
+ readonly error: string | null;
57
+ /** Whether the state has arrived; the markup renders once it has. */
58
+ readonly loaded: boolean;
59
+ /** Everything the parent passed, such as children snippets and onevent handlers. */
60
+ readonly props: Record<string, any>;
61
+ /** Bindable state: bind:value={live.bind.name} for a State declared with goscript.Bind. */
62
+ readonly bind: S;
63
+ /** Sets a State declared with goscript.Bind; the page shows value at once. */
64
+ set<K extends keyof S & string>(name: K, value: S[K]): Promise<void>;
65
+ /** The Func named name. */
66
+ fn<A extends unknown[] = unknown[], R = unknown>(name: string): LiveFunc<A, R>;
36
67
  }
37
68
  /**
38
69
  * Mounts a live Go component; code generated from <script lang="go" live>
@@ -41,4 +72,3 @@ export interface LiveComponent extends GoComponent {
41
72
  * /_tern/live, where its Go instance runs.
42
73
  */
43
74
  export declare function useLive(id: string, uid: string, props: () => Record<string, unknown>): LiveComponent;
44
- export {};
@@ -1,7 +1,10 @@
1
1
  // The browser side of the experimental <script lang="go">: loads the app's
2
2
  // Go WebAssembly module once, mounts a Go instance per component, and keeps
3
3
  // the values Go publishes in Svelte state. See docs/experimental-go-script.md.
4
+ import { untrack } from 'svelte';
4
5
  import { getBase } from '../runtime/base.js';
6
+ import { applyPatch } from './patch.js';
7
+ export { applyPatch };
5
8
  let loading = null;
6
9
  function load(runtime, wasm) {
7
10
  loading ??= (async () => {
@@ -65,6 +68,87 @@ export function useGo(id, initial, runtime, wasm, props) {
65
68
  }
66
69
  };
67
70
  }
71
+ function clone(v) {
72
+ return v === undefined ? v : JSON.parse(JSON.stringify(v));
73
+ }
74
+ /** The browser side of one live component instance. */
75
+ class LiveInstance {
76
+ id;
77
+ props;
78
+ /** What the markup shows: the server's values, or optimistic ones while a call is pending. */
79
+ state;
80
+ /** The server's values, as last sent. */
81
+ #server;
82
+ #holds = new Map();
83
+ calls = new Map();
84
+ pendingCalls = $state({});
85
+ errors = $state({});
86
+ error = $state(null);
87
+ /** Whether the component has its state: from the server render, or the first message. */
88
+ loaded = $state(false);
89
+ mounted = false;
90
+ constructor(id, props, initial) {
91
+ this.id = id;
92
+ this.props = props;
93
+ const { __tern_error: error, ...values } = initial ?? {};
94
+ this.state = $state(clone(values));
95
+ this.#server = clone(values);
96
+ this.loaded = initial !== null;
97
+ if (typeof error === 'string')
98
+ this.error = error;
99
+ }
100
+ apply(m) {
101
+ this.loaded = true;
102
+ for (const [name, v] of Object.entries(m.set ?? {})) {
103
+ this.#server[name] = clone(v);
104
+ if (!this.#holds.has(name))
105
+ this.state[name] = v;
106
+ }
107
+ for (const [name, ops] of Object.entries(m.patch ?? {})) {
108
+ this.#server[name] = applyPatch(this.#server[name], clone(ops));
109
+ if (!this.#holds.has(name))
110
+ this.state[name] = applyPatch(this.state[name], ops);
111
+ }
112
+ }
113
+ /** Shows value for name until the operation holding it settles. */
114
+ hold(name, value) {
115
+ this.#holds.set(name, (this.#holds.get(name) ?? 0) + 1);
116
+ this.state[name] = value;
117
+ }
118
+ release(names) {
119
+ for (const name of names) {
120
+ const n = (this.#holds.get(name) ?? 1) - 1;
121
+ if (n > 0) {
122
+ this.#holds.set(name, n);
123
+ continue;
124
+ }
125
+ this.#holds.delete(name);
126
+ this.state[name] = clone(this.#server[name]);
127
+ }
128
+ }
129
+ begin(seq, call) {
130
+ this.calls.set(seq, call);
131
+ this.pendingCalls[call.name] = (this.pendingCalls[call.name] ?? 0) + 1;
132
+ }
133
+ settle(seq, value, error) {
134
+ const call = this.calls.get(seq);
135
+ if (!call)
136
+ return;
137
+ this.calls.delete(seq);
138
+ this.pendingCalls[call.name] = (this.pendingCalls[call.name] ?? 1) - 1;
139
+ this.release(call.holds);
140
+ this.errors[call.name] = error ?? null;
141
+ if (error)
142
+ call.reject(new Error(error));
143
+ else
144
+ call.resolve(value);
145
+ }
146
+ /** The values to restore after reconnecting to a new server process. */
147
+ snapshot() {
148
+ return $state.snapshot(this.state);
149
+ }
150
+ }
151
+ const status = $state({ connected: false });
68
152
  class LiveConnection {
69
153
  #ws = null;
70
154
  #refs = new Map();
@@ -76,6 +160,12 @@ class LiveConnection {
76
160
  constructor(url) {
77
161
  this.#url = url;
78
162
  }
163
+ #mountMessage(ref, r) {
164
+ const props = untrack(() => r.props());
165
+ // After a reconnect, the server has forgotten the instance: values
166
+ // declared with Bind or Restore come back from what the page shows.
167
+ return JSON.stringify({ op: 'mount', ref, id: r.id, props, restore: r.mounted ? r.snapshot() : undefined });
168
+ }
79
169
  #connect() {
80
170
  const u = new URL(this.#url, location.href);
81
171
  u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -83,40 +173,71 @@ class LiveConnection {
83
173
  this.#ws = ws;
84
174
  ws.onopen = () => {
85
175
  this.#attempt = 0;
86
- // After a reconnect, the server has forgotten every instance.
87
- for (const [ref, r] of this.#refs)
88
- ws.send(JSON.stringify({ op: 'mount', ref, id: r.id, props: r.props() }));
89
- for (const m of this.#queue.splice(0))
90
- ws.send(m);
176
+ status.connected = true;
177
+ for (const [ref, r] of this.#refs) {
178
+ ws.send(this.#mountMessage(ref, r));
179
+ r.mounted = true;
180
+ }
181
+ for (const m of this.#queue.splice(0)) {
182
+ if (m.ref !== undefined && !this.#refs.has(m.ref))
183
+ continue;
184
+ ws.send(m.data);
185
+ if (m.seq !== undefined)
186
+ this.#markSent(m.ref, m.seq);
187
+ }
91
188
  };
92
189
  ws.onmessage = (e) => {
93
190
  const m = JSON.parse(e.data);
94
191
  const r = this.#refs.get(m.ref);
95
192
  if (!r)
96
193
  return;
97
- if (m.op === 'state' && m.name)
98
- r.state[m.name] = m.value;
99
- else if (m.op === 'result') {
100
- r.pending.get(m.seq)?.(m.error ? Promise.reject(new Error(m.error)) : m.value);
101
- r.pending.delete(m.seq);
194
+ if (m.op === 'state')
195
+ r.apply(m);
196
+ else if (m.op === 'result')
197
+ r.settle(m.seq, m.value, m.error);
198
+ else if (m.op === 'event') {
199
+ const handler = untrack(() => r.props())['on' + m.name];
200
+ if (typeof handler === 'function')
201
+ handler(m.value);
102
202
  }
103
- else if (m.op === 'error')
203
+ else if (m.op === 'error') {
204
+ r.error = m.error ?? 'error';
205
+ r.loaded = true;
104
206
  console.error(`[tern] live component ${r.id}: ${m.error}`);
207
+ }
105
208
  };
106
209
  ws.onclose = () => {
107
- this.#ws = null;
210
+ if (this.#ws === ws)
211
+ this.#ws = null;
212
+ status.connected = false;
213
+ // Calls that reached the old connection have no answer coming.
214
+ for (const r of this.#refs.values()) {
215
+ for (const [seq, call] of r.calls)
216
+ if (call.sent)
217
+ r.settle(seq, undefined, 'connection lost');
218
+ }
108
219
  if (this.#refs.size === 0)
109
220
  return;
110
- const delay = Math.min(10_000, 300 * 2 ** this.#attempt++);
111
- setTimeout(() => this.#refs.size && this.#connect(), delay);
221
+ const delay = Math.min(10_000, 250 * 2 ** this.#attempt++) * (0.75 + Math.random() / 2);
222
+ setTimeout(() => this.#refs.size && !this.#ws && this.#connect(), delay);
112
223
  };
113
224
  }
225
+ #markSent(ref, seq) {
226
+ const call = this.#refs.get(ref)?.calls.get(seq);
227
+ if (call)
228
+ call.sent = true;
229
+ }
114
230
  #send(m) {
115
231
  const data = JSON.stringify(m);
116
- if (this.#ws?.readyState === WebSocket.OPEN)
232
+ const seq = m.seq;
233
+ const ref = m.ref;
234
+ if (this.#ws?.readyState === WebSocket.OPEN) {
117
235
  this.#ws.send(data);
236
+ if (seq !== undefined)
237
+ this.#markSent(ref, seq);
238
+ }
118
239
  else {
119
- this.#queue.push(data);
240
+ this.#queue.push({ data, seq, ref });
120
241
  if (!this.#ws)
121
242
  this.#connect();
122
243
  }
@@ -124,7 +245,12 @@ class LiveConnection {
124
245
  mount(r) {
125
246
  const ref = this.#nextRef++;
126
247
  this.#refs.set(ref, r);
127
- this.#send({ op: 'mount', ref, id: r.id, props: r.props() });
248
+ if (this.#ws?.readyState === WebSocket.OPEN) {
249
+ this.#ws.send(this.#mountMessage(ref, r));
250
+ r.mounted = true;
251
+ }
252
+ else if (!this.#ws)
253
+ this.#connect();
128
254
  return ref;
129
255
  }
130
256
  unmount(ref) {
@@ -132,19 +258,44 @@ class LiveConnection {
132
258
  if (this.#ws?.readyState === WebSocket.OPEN)
133
259
  this.#ws.send(JSON.stringify({ op: 'unmount', ref }));
134
260
  if (this.#refs.size === 0) {
135
- this.#ws?.close(1000);
261
+ const ws = this.#ws;
136
262
  this.#ws = null;
263
+ this.#queue = [];
264
+ ws?.close(1000);
137
265
  }
138
266
  }
139
- call(ref, name, args) {
267
+ props(ref, props) {
268
+ this.#send({ op: 'props', ref, props });
269
+ }
270
+ call(ref, name, args, optimistic) {
140
271
  const r = this.#refs.get(ref);
141
272
  if (!r)
142
- return Promise.resolve(undefined);
273
+ return Promise.reject(new Error('not mounted'));
143
274
  const seq = this.#seq++;
144
- return new Promise((resolve) => {
145
- r.pending.set(seq, resolve);
146
- this.#send({ op: 'call', ref, seq, name, args });
275
+ const holds = Object.keys(optimistic ?? {});
276
+ for (const k of holds)
277
+ r.hold(k, optimistic[k]);
278
+ const p = new Promise((resolve, reject) => {
279
+ r.begin(seq, { resolve, reject, name, holds, sent: false });
147
280
  });
281
+ this.#send({ op: 'call', ref, seq, name, args });
282
+ // The error is also in fn.error; don't report unawaited calls as
283
+ // unhandled rejections.
284
+ p.catch(() => { });
285
+ return p;
286
+ }
287
+ set(ref, name, value) {
288
+ const r = this.#refs.get(ref);
289
+ if (!r)
290
+ return Promise.reject(new Error('not mounted'));
291
+ const seq = this.#seq++;
292
+ r.hold(name, value);
293
+ const p = new Promise((resolve, reject) => {
294
+ r.begin(seq, { resolve, reject, name: '$set:' + name, holds: [name], sent: false });
295
+ });
296
+ this.#send({ op: 'set', ref, seq, name, value: $state.snapshot(value) });
297
+ p.catch(() => { });
298
+ return p;
148
299
  }
149
300
  }
150
301
  let connection = null;
@@ -159,7 +310,9 @@ function escapeJSON(s) {
159
310
  */
160
311
  export function useLive(id, uid, props) {
161
312
  const elementID = `tern-live-${uid}`;
162
- let initial = {};
313
+ // After a client-side navigation there is no server-rendered state: the
314
+ // markup waits for the first message (see the preprocessor).
315
+ let initial = null;
163
316
  let stateScript = '';
164
317
  if (typeof window === 'undefined') {
165
318
  const raw = globalThis.__tern_live_initial?.(id, JSON.stringify(props())) ?? '{}';
@@ -173,14 +326,13 @@ export function useLive(id, uid, props) {
173
326
  stateScript = el.outerHTML;
174
327
  }
175
328
  }
176
- const state = $state(initial);
329
+ const inst = new LiveInstance(id, props, initial);
177
330
  let ref = 0;
178
331
  let ready = $state(false);
179
332
  if (typeof window !== 'undefined') {
180
333
  $effect(() => {
181
334
  connection ??= new LiveConnection(`${getBase()}/_tern/live`);
182
- const live = { id, props, state, pending: new Map() };
183
- ref = connection.mount(live);
335
+ ref = connection.mount(inst);
184
336
  ready = true;
185
337
  return () => {
186
338
  connection?.unmount(ref);
@@ -188,17 +340,91 @@ export function useLive(id, uid, props) {
188
340
  ready = false;
189
341
  };
190
342
  });
343
+ // New props from the parent go to the server without remounting.
344
+ let sent = JSON.stringify(untrack(props));
345
+ $effect(() => {
346
+ const next = JSON.stringify(props());
347
+ if (next !== sent && ref) {
348
+ sent = next;
349
+ connection.props(ref, JSON.parse(next));
350
+ }
351
+ });
191
352
  }
353
+ const call = (name, args, optimistic) => {
354
+ if (!ref || !connection)
355
+ return Promise.reject(new Error('not mounted'));
356
+ return connection.call(ref, name, args.map(toArg), optimistic);
357
+ };
358
+ const funcs = new Map();
359
+ const fn = (name) => {
360
+ let f = funcs.get(name);
361
+ if (f)
362
+ return f;
363
+ const debounced = new Map();
364
+ f = Object.assign((...args) => call(name, args), {
365
+ optimistic: (values, ...args) => call(name, args, values),
366
+ debounce: (ms = 200) => {
367
+ let d = debounced.get(ms);
368
+ if (!d) {
369
+ let timer;
370
+ d = (...args) => {
371
+ const converted = args.map(toArg); // read input values now
372
+ clearTimeout(timer);
373
+ timer = setTimeout(() => void call(name, converted), ms);
374
+ };
375
+ debounced.set(ms, d);
376
+ }
377
+ return d;
378
+ }
379
+ });
380
+ Object.defineProperties(f, {
381
+ pending: { get: () => (inst.pendingCalls[name] ?? 0) > 0 },
382
+ error: { get: () => inst.errors[name] ?? null }
383
+ });
384
+ funcs.set(name, f);
385
+ return f;
386
+ };
387
+ const set = (name, value) => {
388
+ if (!ref || !connection) {
389
+ inst.state[name] = value;
390
+ return Promise.resolve();
391
+ }
392
+ return connection.set(ref, name, value).then(() => undefined);
393
+ };
394
+ const bind = new Proxy({}, {
395
+ get: (_, name) => (typeof name === 'string' ? inst.state[name] : undefined),
396
+ set: (_, name, value) => {
397
+ if (typeof name === 'string')
398
+ void set(name, value);
399
+ return true;
400
+ }
401
+ });
192
402
  return {
193
- state,
403
+ state: inst.state,
194
404
  stateScript,
405
+ bind,
406
+ fn: fn,
407
+ set: set,
195
408
  get ready() {
196
409
  return ready;
197
410
  },
411
+ get props() {
412
+ return props();
413
+ },
414
+ get connected() {
415
+ return status.connected;
416
+ },
417
+ get pending() {
418
+ return Object.values(inst.pendingCalls).some((n) => n > 0);
419
+ },
420
+ get error() {
421
+ return inst.error;
422
+ },
423
+ get loaded() {
424
+ return inst.loaded;
425
+ },
198
426
  call(name, ...args) {
199
- if (!ref || !connection)
200
- return undefined;
201
- return connection.call(ref, name, args.map(toArg));
427
+ return call(name, args);
202
428
  }
203
429
  };
204
430
  }
@@ -0,0 +1,6 @@
1
+ type Key = string | number;
2
+ /** One patch operation from the server. */
3
+ export type PatchOp = ['s', Key[], unknown] | ['d', Key[]] | ['a', Key[], number, number, unknown[]];
4
+ /** Applies a patch to root and returns the new root. */
5
+ export declare function applyPatch(root: unknown, ops: PatchOp[]): unknown;
6
+ export {};
@@ -0,0 +1,25 @@
1
+ // Applying the patches live components send; see internal/jsondiff.
2
+ /** Applies a patch to root and returns the new root. */
3
+ export function applyPatch(root, ops) {
4
+ for (const op of ops) {
5
+ const path = op[1];
6
+ if (path.length === 0) {
7
+ if (op[0] === 's')
8
+ root = op[2];
9
+ else if (op[0] === 'a')
10
+ root.splice(op[2], op[3], ...op[4]);
11
+ continue;
12
+ }
13
+ let parent = root;
14
+ for (const k of path.slice(0, -1))
15
+ parent = parent[k];
16
+ const last = path[path.length - 1];
17
+ if (op[0] === 's')
18
+ parent[last] = op[2];
19
+ else if (op[0] === 'd')
20
+ delete parent[last];
21
+ else
22
+ parent[last].splice(op[2], op[3], ...op[4]);
23
+ }
24
+ return root;
25
+ }
package/dist/index.d.ts CHANGED
@@ -28,4 +28,4 @@ export type { MessageKey } from './runtime/i18n.svelte.js';
28
28
  export interface Messages {
29
29
  }
30
30
  /** The version of the Tern client runtime. */
31
- export declare const VERSION = "1.2.1";
31
+ export declare const VERSION = "1.3.0";
package/dist/index.js CHANGED
@@ -16,4 +16,4 @@ export { stream, socket, EventStreamState, SocketState } from './runtime/realtim
16
16
  export { default as Image } from './image/Image.svelte';
17
17
  export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
18
18
  /** The version of the Tern client runtime. */
19
- export const VERSION = '1.2.1';
19
+ export const VERSION = '1.3.0';
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ type Item = { id: string; title: string };
5
+ type Props = {
6
+ items: Item[];
7
+ /** The open items' ids; bind it. */
8
+ open?: string[];
9
+ /** Let several items be open at once. */
10
+ multiple?: boolean;
11
+ /** The heading level of the item titles, to fit the page's outline. */
12
+ level?: 2 | 3 | 4 | 5 | 6;
13
+ class?: string;
14
+ /** Renders an item's content. */
15
+ content: Snippet<[string]>;
16
+ };
17
+ let { items, open = $bindable([]), multiple = false, level = 3, class: klass = '', content }: Props = $props();
18
+ const uid = $props.id();
19
+
20
+ function toggle(id: string) {
21
+ if (open.includes(id)) open = open.filter((x) => x !== id);
22
+ else open = multiple ? [...open, id] : [id];
23
+ }
24
+ </script>
25
+
26
+ <div class="tern-accordion {klass}">
27
+ {#each items as item (item.id)}
28
+ {@const expanded = open.includes(item.id)}
29
+ <div class="item">
30
+ <div role="heading" aria-level={level}>
31
+ <button type="button" id="tern-acc-{uid}-{item.id}" aria-expanded={expanded} aria-controls="tern-acc-{uid}-{item.id}-panel" onclick={() => toggle(item.id)}>
32
+ {item.title}
33
+ <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
34
+ </button>
35
+ </div>
36
+ <div class="panel" role="region" id="tern-acc-{uid}-{item.id}-panel" aria-labelledby="tern-acc-{uid}-{item.id}" hidden={!expanded}>
37
+ {#if expanded}{@render content(item.id)}{/if}
38
+ </div>
39
+ </div>
40
+ {/each}
41
+ </div>
42
+
43
+ <style>
44
+ .tern-accordion {
45
+ border-top: 1px solid var(--tern-border);
46
+ }
47
+ .item {
48
+ border-bottom: 1px solid var(--tern-border);
49
+ }
50
+ button {
51
+ display: flex;
52
+ width: 100%;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ gap: 1rem;
56
+ padding: 0.85rem 0.25rem;
57
+ border: 0;
58
+ background: transparent;
59
+ font: inherit;
60
+ font-size: 0.9375rem;
61
+ font-weight: 550;
62
+ color: var(--tern-fg);
63
+ text-align: start;
64
+ cursor: pointer;
65
+ }
66
+ button:focus-visible {
67
+ outline: 2px solid var(--tern-ring);
68
+ outline-offset: -2px;
69
+ border-radius: var(--tern-radius-sm);
70
+ }
71
+ svg {
72
+ flex: none;
73
+ color: var(--tern-muted);
74
+ transition: rotate var(--tern-duration);
75
+ }
76
+ [aria-expanded='true'] svg {
77
+ rotate: 180deg;
78
+ }
79
+ .panel {
80
+ padding: 0 0.25rem 1rem;
81
+ color: var(--tern-muted);
82
+ font-size: 0.875rem;
83
+ }
84
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Item = {
3
+ id: string;
4
+ title: string;
5
+ };
6
+ type Props = {
7
+ items: Item[];
8
+ /** The open items' ids; bind it. */
9
+ open?: string[];
10
+ /** Let several items be open at once. */
11
+ multiple?: boolean;
12
+ /** The heading level of the item titles, to fit the page's outline. */
13
+ level?: 2 | 3 | 4 | 5 | 6;
14
+ class?: string;
15
+ /** Renders an item's content. */
16
+ content: Snippet<[string]>;
17
+ };
18
+ declare const Accordion: import("svelte").Component<Props, {}, "open">;
19
+ type Accordion = ReturnType<typeof Accordion>;
20
+ export default Accordion;
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ type Props = {
5
+ tone?: 'info' | 'success' | 'warning' | 'danger';
6
+ title?: string;
7
+ /** Announce it when it appears: true for messages shown in response to something, false for ones on the page from the start. */
8
+ live?: boolean;
9
+ class?: string;
10
+ children?: Snippet;
11
+ };
12
+ let { tone = 'info', title, live = false, class: klass = '', children }: Props = $props();
13
+ const role = $derived(live ? (tone === 'danger' || tone === 'warning' ? 'alert' : 'status') : undefined);
14
+ const icons = {
15
+ info: 'M8 7v4M8 5h.01',
16
+ success: 'M5 8.5l2 2 4-4.5',
17
+ warning: 'M8 5v3.5M8 11h.01',
18
+ danger: 'M6 6l4 4M10 6l-4 4'
19
+ };
20
+ </script>
21
+
22
+ <div class="tern-alert {tone} {klass}" {role}>
23
+ <svg class="icon" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true">
24
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4" />
25
+ <path d={icons[tone]} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
26
+ </svg>
27
+ <div class="text">
28
+ {#if title}<p class="title">{title}</p>{/if}
29
+ {#if children}<div class="body">{@render children()}</div>{/if}
30
+ </div>
31
+ </div>
32
+
33
+ <style>
34
+ .tern-alert {
35
+ display: flex;
36
+ gap: 0.7rem;
37
+ padding: 0.85rem 1rem;
38
+ border-radius: var(--tern-radius);
39
+ border: 1px solid;
40
+ font-size: 0.875rem;
41
+ }
42
+ .icon {
43
+ flex: none;
44
+ margin-top: 0.05rem;
45
+ }
46
+ .title {
47
+ margin: 0;
48
+ font-weight: 650;
49
+ }
50
+ .body {
51
+ color: var(--tern-fg);
52
+ }
53
+ .title + .body {
54
+ margin-top: 0.2rem;
55
+ }
56
+ .info {
57
+ background: var(--tern-accent-soft);
58
+ border-color: color-mix(in oklch, var(--tern-accent) 30%, transparent);
59
+ color: var(--tern-accent);
60
+ }
61
+ .success {
62
+ background: var(--tern-success-soft);
63
+ border-color: color-mix(in oklch, var(--tern-success) 30%, transparent);
64
+ color: color-mix(in oklch, var(--tern-success) 80%, var(--tern-fg));
65
+ }
66
+ .warning {
67
+ background: var(--tern-warning-soft);
68
+ border-color: color-mix(in oklch, var(--tern-warning) 35%, transparent);
69
+ color: color-mix(in oklch, var(--tern-warning) 55%, var(--tern-fg));
70
+ }
71
+ .danger {
72
+ background: var(--tern-danger-soft);
73
+ border-color: color-mix(in oklch, var(--tern-danger) 30%, transparent);
74
+ color: color-mix(in oklch, var(--tern-danger) 85%, var(--tern-fg));
75
+ }
76
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Props = {
3
+ tone?: 'info' | 'success' | 'warning' | 'danger';
4
+ title?: string;
5
+ /** Announce it when it appears: true for messages shown in response to something, false for ones on the page from the start. */
6
+ live?: boolean;
7
+ class?: string;
8
+ children?: Snippet;
9
+ };
10
+ declare const Alert: import("svelte").Component<Props, {}, "">;
11
+ type Alert = ReturnType<typeof Alert>;
12
+ export default Alert;