@ternkit/tern 1.5.0 → 1.6.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 (67) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +2 -1
  3. package/dist/runtime/builtin.d.ts +1 -0
  4. package/dist/runtime/builtin.js +140 -0
  5. package/dist/runtime/deferred.d.ts +1 -0
  6. package/dist/runtime/deferred.js +4 -2
  7. package/dist/runtime/i18n.svelte.d.ts +6 -0
  8. package/dist/runtime/i18n.svelte.js +10 -0
  9. package/dist/runtime/passkeys.d.ts +21 -0
  10. package/dist/runtime/passkeys.js +107 -0
  11. package/dist/runtime/router.svelte.js +2 -1
  12. package/dist/runtime/types.d.ts +2 -0
  13. package/dist/runtime/validate.d.ts +2 -1
  14. package/dist/runtime/validate.js +20 -18
  15. package/dist/server/index.d.ts +3 -1
  16. package/dist/server/index.js +8 -4
  17. package/dist/ui/AccountSection.svelte +75 -0
  18. package/dist/ui/AccountSection.svelte.d.ts +12 -0
  19. package/dist/ui/AlertDialog.svelte +5 -2
  20. package/dist/ui/AuthCard.svelte +64 -0
  21. package/dist/ui/AuthCard.svelte.d.ts +12 -0
  22. package/dist/ui/BarChart.svelte +2 -1
  23. package/dist/ui/Breadcrumb.svelte +2 -1
  24. package/dist/ui/Calendar.svelte +3 -2
  25. package/dist/ui/Carousel.svelte +4 -3
  26. package/dist/ui/ChartFrame.svelte +2 -1
  27. package/dist/ui/Combobox.svelte +6 -3
  28. package/dist/ui/Command.svelte +5 -1
  29. package/dist/ui/ConsentScreen.svelte +7 -6
  30. package/dist/ui/DataTable.svelte +7 -5
  31. package/dist/ui/DatePicker.svelte +8 -5
  32. package/dist/ui/Devices.svelte +50 -0
  33. package/dist/ui/Devices.svelte.d.ts +23 -0
  34. package/dist/ui/Dialog.svelte +2 -1
  35. package/dist/ui/DonutChart.svelte +3 -1
  36. package/dist/ui/Field.svelte +1 -1
  37. package/dist/ui/ForgotPassword.svelte +43 -0
  38. package/dist/ui/ForgotPassword.svelte.d.ts +16 -0
  39. package/dist/ui/Form.svelte +8 -3
  40. package/dist/ui/NavigationMenu.svelte +3 -1
  41. package/dist/ui/Pagination.svelte +5 -3
  42. package/dist/ui/Passkeys.svelte +73 -0
  43. package/dist/ui/Passkeys.svelte.d.ts +21 -0
  44. package/dist/ui/PasswordInput.svelte +2 -1
  45. package/dist/ui/ResetPassword.svelte +54 -0
  46. package/dist/ui/ResetPassword.svelte.d.ts +23 -0
  47. package/dist/ui/Resizable.svelte +3 -1
  48. package/dist/ui/SelectMenu.svelte +5 -2
  49. package/dist/ui/Sidebar.svelte +5 -3
  50. package/dist/ui/SignIn.svelte +48 -12
  51. package/dist/ui/SignIn.svelte.d.ts +4 -0
  52. package/dist/ui/SignUp.svelte +10 -8
  53. package/dist/ui/Slider.svelte +2 -1
  54. package/dist/ui/Spinner.svelte +3 -1
  55. package/dist/ui/Switch.svelte +2 -1
  56. package/dist/ui/Table.svelte +3 -1
  57. package/dist/ui/Toaster.svelte +3 -2
  58. package/dist/ui/Toggle.svelte +2 -0
  59. package/dist/ui/ToggleGroup.svelte +2 -0
  60. package/dist/ui/accountDates.d.ts +1 -0
  61. package/dist/ui/accountDates.js +15 -0
  62. package/dist/ui/announce.d.ts +4 -0
  63. package/dist/ui/announce.js +14 -0
  64. package/dist/ui/index.d.ts +4 -0
  65. package/dist/ui/index.js +4 -0
  66. package/dist/vite/native.js +21 -4
  67. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  type Props = {
3
4
  /** The current page, from 1; bind it. */
4
5
  page?: number;
@@ -12,7 +13,8 @@
12
13
  class?: string;
13
14
  onchange?: (page: number) => void;
14
15
  };
15
- let { page = $bindable(1), pages, href, siblings = 1, label = 'Pagination', class: klass = '', onchange }: Props = $props();
16
+ let { page = $bindable(1), pages, href, siblings = 1, label: labelProp, class: klass = '', onchange }: Props = $props();
17
+ const label = $derived(labelProp ?? tb('tern.ui.pagination.label'));
16
18
 
17
19
  // 1 … 4 5 6 … 20: the ends, the current page and its siblings.
18
20
  const shown = $derived.by(() => {
@@ -45,7 +47,7 @@
45
47
 
46
48
  <nav class="tern-pagination {klass}" aria-label={label}>
47
49
  <ul>
48
- <li>{@render link(page - 1, '‹ Previous', 'Previous page', page <= 1, 'step')}</li>
50
+ <li>{@render link(page - 1, tb('tern.ui.pagination.previous'), tb('tern.ui.pagination.previousLabel'), page <= 1, 'step')}</li>
49
51
  {#each shown as p, i (i)}
50
52
  <li>
51
53
  {#if p === 'gap'}
@@ -55,7 +57,7 @@
55
57
  {/if}
56
58
  </li>
57
59
  {/each}
58
- <li>{@render link(page + 1, 'Next ›', 'Next page', page >= pages, 'step')}</li>
60
+ <li>{@render link(page + 1, tb('tern.ui.pagination.next'), tb('tern.ui.pagination.nextLabel'), page >= pages, 'step')}</li>
59
61
  </ul>
60
62
  </nav>
61
63
 
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import { invalidateAll } from '../runtime/router.svelte.js';
4
+ import { passkeysSupported, registerPasskey } from '../runtime/passkeys.js';
5
+ import Alert from './Alert.svelte';
6
+ import AccountSection from './AccountSection.svelte';
7
+ import Button from './Button.svelte';
8
+ import Form from './Form.svelte';
9
+ import { when } from './accountDates.js';
10
+
11
+ type Passkey = { id: string; name: string; createdAt: string; lastUsed: string | null };
12
+ type Props = {
13
+ title?: string;
14
+ description?: string;
15
+ level?: 2 | 3 | 4;
16
+ /** From acc.Passkeys. */
17
+ passkeys: Passkey[];
18
+ /** The action that calls acc.RemovePasskey with the posted id. */
19
+ removeAction?: string;
20
+ /** Where the accounts package mounts its passkey endpoints. */
21
+ path?: string;
22
+ class?: string;
23
+ };
24
+ let { title, description, level = 2, passkeys, removeAction, path, class: klass = '' }: Props = $props();
25
+
26
+ let supported = $state(true);
27
+ $effect(() => {
28
+ supported = passkeysSupported();
29
+ });
30
+ let adding = $state(false);
31
+ let failed = $state(false);
32
+ async function add() {
33
+ adding = true;
34
+ failed = false;
35
+ try {
36
+ await registerPasskey({ path });
37
+ await invalidateAll();
38
+ } catch (e) {
39
+ if (!(e as { cancelled?: boolean }).cancelled) failed = true;
40
+ } finally {
41
+ adding = false;
42
+ }
43
+ }
44
+ </script>
45
+
46
+ <AccountSection id="tern-passkeys-title" title={title ?? tb('tern.ui.passkeys.title')} description={description ?? tb('tern.ui.passkeys.description')} {level} class={klass}>
47
+ {#if failed}<Alert tone="danger" live>{tb('tern.ui.passkeys.failed')}</Alert>{/if}
48
+ {#if passkeys.length}
49
+ <ul>
50
+ {#each passkeys as p (p.id)}
51
+ <li>
52
+ <div class="tern-account-text">
53
+ <span class="tern-account-name">{p.name}</span>
54
+ <span class="tern-account-meta">{tb('tern.ui.passkeys.added', { when: when(p.createdAt) })}{' · '}{p.lastUsed ? tb('tern.ui.passkeys.lastUsed', { when: when(p.lastUsed) }) : tb('tern.ui.passkeys.never')}</span>
55
+ </div>
56
+ {#if removeAction}
57
+ <Form action={removeAction} reset={false}>
58
+ <input type="hidden" name="id" value={p.id} />
59
+ <Button type="submit" variant="ghost" size="sm">{tb('tern.ui.passkeys.remove')}</Button>
60
+ </Form>
61
+ {/if}
62
+ </li>
63
+ {/each}
64
+ </ul>
65
+ {:else}
66
+ <p class="tern-account-empty">{tb('tern.ui.passkeys.empty')}</p>
67
+ {/if}
68
+ {#if supported}
69
+ <div><Button type="button" variant="secondary" size="sm" loading={adding} onclick={add}>{tb('tern.ui.passkeys.add')}</Button></div>
70
+ {:else}
71
+ <p class="tern-account-empty">{tb('tern.ui.passkeys.unsupported')}</p>
72
+ {/if}
73
+ </AccountSection>
@@ -0,0 +1,21 @@
1
+ type Passkey = {
2
+ id: string;
3
+ name: string;
4
+ createdAt: string;
5
+ lastUsed: string | null;
6
+ };
7
+ type Props = {
8
+ title?: string;
9
+ description?: string;
10
+ level?: 2 | 3 | 4;
11
+ /** From acc.Passkeys. */
12
+ passkeys: Passkey[];
13
+ /** The action that calls acc.RemovePasskey with the posted id. */
14
+ removeAction?: string;
15
+ /** Where the accounts package mounts its passkey endpoints. */
16
+ path?: string;
17
+ class?: string;
18
+ };
19
+ declare const Passkeys: import("svelte").Component<Props, {}, "">;
20
+ type Passkeys = ReturnType<typeof Passkeys>;
21
+ export default Passkeys;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { HTMLInputAttributes } from 'svelte/elements';
3
4
  import { getField } from './field.js';
4
5
 
@@ -37,7 +38,7 @@
37
38
  <button
38
39
  type="button"
39
40
  class="toggle"
40
- aria-label="Show password"
41
+ aria-label={tb('tern.ui.password.show')}
41
42
  aria-pressed={visible}
42
43
  aria-controls={id}
43
44
  disabled={field?.disabled || rest.disabled}
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import type { FormField } from '../runtime/validate.js';
4
+ import Alert from './Alert.svelte';
5
+ import AuthCard from './AuthCard.svelte';
6
+ import Button from './Button.svelte';
7
+ import Field from './Field.svelte';
8
+ import Form from './Form.svelte';
9
+ import PasswordInput from './PasswordInput.svelte';
10
+
11
+ type Props = {
12
+ title?: string;
13
+ description?: string;
14
+ level?: 1 | 2 | 3;
15
+ /** The token from the email's link (?token=), posted as token. */
16
+ token: string;
17
+ /** The action that calls acc.ResetPassword with the posted token and password. */
18
+ action?: string;
19
+ /** Its rules; they also set the password hint. */
20
+ rules?: FormField[];
21
+ form?: unknown;
22
+ /** Ask to type the password twice, posted as confirm. */
23
+ confirm?: boolean;
24
+ /** The link has been found invalid or expired: say so instead of showing the form. */
25
+ invalid?: boolean;
26
+ /** Where to ask for a new link. */
27
+ forgotHref?: string;
28
+ class?: string;
29
+ };
30
+ let { title, description, level = 1, token, action, rules, form, confirm = true, invalid = false, forgotHref, class: klass = '' }: Props = $props();
31
+
32
+ let submitting = $state(false);
33
+ const minPassword = $derived(rules?.find((f) => f.name === 'password')?.rules?.find((r) => r.name === 'min')?.arg);
34
+ </script>
35
+
36
+ <AuthCard id="tern-reset-title" title={title ?? tb('tern.ui.resetPassword.title')} {description} {level} class={klass}>
37
+ {#if invalid || !token}
38
+ <Alert tone="danger">{tb('tern.ui.resetPassword.invalid')}</Alert>
39
+ {#if forgotHref}<Button variant="secondary" href={forgotHref}>{tb('tern.ui.resetPassword.again')}</Button>{/if}
40
+ {:else}
41
+ <Form {rules} {form} {action} bind:submitting reset={false}>
42
+ <input type="hidden" name="token" value={token} />
43
+ <Field label={tb('tern.ui.resetPassword.password')} name="password" description={minPassword ? tb('tern.ui.signUp.minPassword', { n: minPassword }) : undefined}>
44
+ <PasswordInput autocomplete="new-password" />
45
+ </Field>
46
+ {#if confirm}
47
+ <Field label={tb('tern.ui.resetPassword.confirm')} name="confirm">
48
+ <PasswordInput autocomplete="new-password" />
49
+ </Field>
50
+ {/if}
51
+ <Button type="submit" loading={submitting}>{tb('tern.ui.resetPassword.submit')}</Button>
52
+ </Form>
53
+ {/if}
54
+ </AuthCard>
@@ -0,0 +1,23 @@
1
+ import type { FormField } from '../runtime/validate.js';
2
+ type Props = {
3
+ title?: string;
4
+ description?: string;
5
+ level?: 1 | 2 | 3;
6
+ /** The token from the email's link (?token=), posted as token. */
7
+ token: string;
8
+ /** The action that calls acc.ResetPassword with the posted token and password. */
9
+ action?: string;
10
+ /** Its rules; they also set the password hint. */
11
+ rules?: FormField[];
12
+ form?: unknown;
13
+ /** Ask to type the password twice, posted as confirm. */
14
+ confirm?: boolean;
15
+ /** The link has been found invalid or expired: say so instead of showing the form. */
16
+ invalid?: boolean;
17
+ /** Where to ask for a new link. */
18
+ forgotHref?: string;
19
+ class?: string;
20
+ };
21
+ declare const ResetPassword: import("svelte").Component<Props, {}, "">;
22
+ type ResetPassword = ReturnType<typeof ResetPassword>;
23
+ export default ResetPassword;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { Snippet } from 'svelte';
3
4
 
4
5
  type Props = {
@@ -13,7 +14,8 @@
13
14
  second: Snippet;
14
15
  class?: string;
15
16
  };
16
- let { size = $bindable(50), min = 15, max = 85, direction = 'horizontal', label = 'Resize', first, second, class: klass = '' }: Props = $props();
17
+ let { size = $bindable(50), min = 15, max = 85, direction = 'horizontal', label: labelProp, first, second, class: klass = '' }: Props = $props();
18
+ const label = $derived(labelProp ?? tb('tern.ui.resizable.label'));
17
19
 
18
20
  const uid = $props.id();
19
21
  let root = $state<HTMLDivElement>();
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { announce } from './announce.js';
3
+ import { tb } from '../runtime/i18n.svelte.js';
2
4
  import { getField } from './field.js';
3
5
  import { float } from './floating.js';
4
6
  import { grouped, type Option } from './options.js';
@@ -18,7 +20,8 @@
18
20
  onchange?: (value: string, option: Option) => void;
19
21
  };
20
22
  const field = getField();
21
- let { options, value = $bindable(field?.value ?? null), placeholder = 'Choose…', label, name: nameProp, disabled = false, class: klass = '', onchange }: Props = $props();
23
+ let { options, value = $bindable(field?.value ?? null), placeholder: placeholderProp, label, name: nameProp, disabled = false, class: klass = '', onchange }: Props = $props();
24
+ const placeholder = $derived(placeholderProp ?? tb('tern.ui.selectMenu.placeholder'));
22
25
  const name = $derived(nameProp ?? field?.name);
23
26
 
24
27
  const uid = $props.id();
@@ -158,7 +161,7 @@
158
161
  <span class="value" class:placeholder={!selected} id="{id}-value">{selected?.label ?? placeholder}</span>
159
162
  <svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M5 6.5l3-3 3 3M5 9.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
160
163
  </button>
161
- {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
164
+ {#if name}<input type="hidden" {name} value={value ?? ''} use:announce={value} />{/if}
162
165
  <div bind:this={list} id="{id}-list" class="tern-selectmenu-list" class:open role="listbox" aria-label={label} aria-labelledby={field?.labelId} tabindex="-1" popover="manual">
163
166
  {#if open}
164
167
  {#each grouped(options) as g, gi (gi)}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import { page } from '../runtime/state.svelte.js';
4
5
  import Dialog from './Dialog.svelte';
@@ -29,7 +30,8 @@
29
30
  children: Snippet;
30
31
  class?: string;
31
32
  };
32
- let { groups, header, footer, collapsed = $bindable(false), label = 'Main', children, class: klass = '' }: Props = $props();
33
+ let { groups, header, footer, collapsed = $bindable(false), label: labelProp, children, class: klass = '' }: Props = $props();
34
+ const label = $derived(labelProp ?? tb('tern.ui.sidebar.label'));
33
35
 
34
36
  let mobileOpen = $state(false);
35
37
  const path = $derived(page.url?.pathname ?? '');
@@ -85,7 +87,7 @@
85
87
  {#if footer && !collapsed}<div class="footer">{@render footer()}</div>{/if}
86
88
  </aside>
87
89
  <div class="mobilebar">
88
- <button type="button" class="collapse" aria-label="Open navigation" aria-expanded={mobileOpen} onclick={() => (mobileOpen = true)}>
90
+ <button type="button" class="collapse" aria-label={tb('tern.ui.sidebar.open')} aria-expanded={mobileOpen} onclick={() => (mobileOpen = true)}>
89
91
  <svg aria-hidden="true" viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11" /></svg>
90
92
  </button>
91
93
  {#if header}<div class="header">{@render header()}</div>{/if}
@@ -93,7 +95,7 @@
93
95
  <div class="main">{@render children()}</div>
94
96
  </div>
95
97
 
96
- <Dialog bind:open={mobileOpen} title="Navigation" side="left" class="tern-sidebar-sheet">
98
+ <Dialog bind:open={mobileOpen} title={tb('tern.ui.sidebar.title')} side="left" class="tern-sidebar-sheet">
97
99
  {@render nav(false)}
98
100
  {#if footer}<div class="footer">{@render footer()}</div>{/if}
99
101
  </Dialog>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { FormField } from '../runtime/validate.js';
4
5
  import Alert from './Alert.svelte';
@@ -9,6 +10,8 @@
9
10
  import Input from './Input.svelte';
10
11
  import PasswordInput from './PasswordInput.svelte';
11
12
  import Separator from './Separator.svelte';
13
+ import { goto } from '../runtime/router.svelte.js';
14
+ import { passkeysSupported, signInWithPasskey } from '../runtime/passkeys.js';
12
15
 
13
16
  type Props = {
14
17
  title?: string;
@@ -19,6 +22,10 @@
19
22
  providers?: { name: string; label: string }[];
20
23
  /** Where plugins/oauth lives. */
21
24
  authPath?: string;
25
+ /** A "Sign in with a passkey" button, for the accounts package. */
26
+ passkey?: boolean;
27
+ /** Where the accounts package mounts its passkey endpoints. */
28
+ passkeyPath?: string;
22
29
  /** Where to go after signing in, a path on this site. */
23
30
  next?: string;
24
31
  /** Show the email and password form, which posts to action. */
@@ -41,12 +48,14 @@
41
48
  class?: string;
42
49
  };
43
50
  let {
44
- title = 'Sign in',
51
+ title: titleProp,
45
52
  description,
46
53
  level = 1,
47
54
  providers = [],
48
55
  authPath = '/auth',
49
56
  next,
57
+ passkey = false,
58
+ passkeyPath,
50
59
  password = true,
51
60
  action,
52
61
  rules,
@@ -58,45 +67,72 @@
58
67
  footer,
59
68
  class: klass = ''
60
69
  }: Props = $props();
70
+ const title = $derived(titleProp ?? tb('tern.ui.signIn.title'));
61
71
 
62
72
  let submitting = $state(false);
63
73
  const errorText = $derived(
64
- !error ? null : error === 'access_denied' ? 'You cancelled the sign-in. Try again when you’re ready.' : 'Signing in didn’t work. Try again, or choose another way.'
74
+ !error ? null : error === 'access_denied' ? tb('tern.ui.signIn.cancelled') : tb('tern.ui.signIn.failed')
65
75
  );
76
+ let el = $state<HTMLElement>();
77
+ let passkeyBusy = $state(false);
78
+ let passkeyError = $state(false);
79
+ // Rendered on the server too, where passkeys can't be checked: shown
80
+ // once the browser says it can use them.
81
+ let canPasskey = $state(false);
82
+ $effect(() => {
83
+ canPasskey = passkey && passkeysSupported();
84
+ });
85
+ async function usePasskey() {
86
+ passkeyBusy = true;
87
+ passkeyError = false;
88
+ try {
89
+ const rememberMe = !!el?.querySelector<HTMLInputElement>('input[name="remember"]')?.checked;
90
+ await signInWithPasskey({ path: passkeyPath, remember: rememberMe });
91
+ await goto(next && next.startsWith('/') && !next.startsWith('//') ? next : '/', { invalidateAll: true, replaceState: true });
92
+ } catch (e) {
93
+ if (!(e as { cancelled?: boolean }).cancelled) passkeyError = true;
94
+ } finally {
95
+ passkeyBusy = false;
96
+ }
97
+ }
66
98
  const login = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
67
99
  </script>
68
100
 
69
- <section class="tern-auth {klass}" aria-labelledby="tern-signin-title">
101
+ <section class="tern-auth {klass}" aria-labelledby="tern-signin-title" bind:this={el}>
70
102
  <header>
71
103
  <svelte:element this={`h${level}`} id="tern-signin-title">{title}</svelte:element>
72
104
  {#if description}<p>{description}</p>{/if}
73
105
  </header>
74
106
  {#if errorText}<Alert tone="danger" live>{errorText}</Alert>{/if}
75
- {#if providers.length}
107
+ {#if passkeyError}<Alert tone="danger" live>{tb('tern.ui.signIn.passkeyFailed')}</Alert>{/if}
108
+ {#if providers.length || canPasskey}
76
109
  <div class="providers">
110
+ {#if canPasskey}
111
+ <Button variant="secondary" type="button" loading={passkeyBusy} onclick={usePasskey}>{tb('tern.ui.signIn.passkey')}</Button>
112
+ {/if}
77
113
  {#each providers as p (p.name)}
78
- <Button variant="secondary" href={login(p.name)}>Continue with {p.label}</Button>
114
+ <Button variant="secondary" href={login(p.name)}>{tb('tern.ui.signIn.continueWith', { provider: p.label })}</Button>
79
115
  {/each}
80
116
  </div>
81
117
  {/if}
82
- {#if providers.length && password}<div class="or"><Separator /><span>or</span><Separator /></div>{/if}
118
+ {#if (providers.length || canPasskey) && password}<div class="or"><Separator /><span>{tb('tern.ui.signIn.or')}</span><Separator /></div>{/if}
83
119
  {#if password}
84
120
  <Form {rules} {form} {action} bind:submitting reset={false}>
85
121
  {#if next}<input type="hidden" name="next" value={next} />{/if}
86
- <Field label="Email" name="email">
122
+ <Field label={tb('tern.ui.signIn.email')} name="email">
87
123
  <Input type="email" autocomplete="username" spellcheck="false" />
88
124
  </Field>
89
- <Field label="Password" name="password">
125
+ <Field label={tb('tern.ui.signIn.password')} name="password">
90
126
  <PasswordInput autocomplete="current-password" />
91
127
  </Field>
92
128
  <div class="row">
93
- {#if remember}<Checkbox name="remember">Keep me signed in</Checkbox>{/if}
94
- {#if forgotHref}<a href={forgotHref}>Forgot your password?</a>{/if}
129
+ {#if remember}<Checkbox name="remember">{tb('tern.ui.signIn.remember')}</Checkbox>{/if}
130
+ {#if forgotHref}<a href={forgotHref}>{tb('tern.ui.signIn.forgot')}</a>{/if}
95
131
  </div>
96
- <Button type="submit" loading={submitting}>Sign in</Button>
132
+ <Button type="submit" loading={submitting}>{tb('tern.ui.signIn.submit')}</Button>
97
133
  </Form>
98
134
  {/if}
99
- {#if signUpHref}<p class="switch">New here? <a href={signUpHref}>Create an account</a></p>{/if}
135
+ {#if signUpHref}<p class="switch">{tb('tern.ui.signIn.new')} <a href={signUpHref}>{tb('tern.ui.signIn.signUp')}</a></p>{/if}
100
136
  {#if footer}<div class="footer">{@render footer()}</div>{/if}
101
137
  </section>
102
138
 
@@ -12,6 +12,10 @@ type Props = {
12
12
  }[];
13
13
  /** Where plugins/oauth lives. */
14
14
  authPath?: string;
15
+ /** A "Sign in with a passkey" button, for the accounts package. */
16
+ passkey?: boolean;
17
+ /** Where the accounts package mounts its passkey endpoints. */
18
+ passkeyPath?: string;
15
19
  /** Where to go after signing in, a path on this site. */
16
20
  next?: string;
17
21
  /** Show the email and password form, which posts to action. */
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { FormField } from '../runtime/validate.js';
4
5
  import Button from './Button.svelte';
@@ -26,7 +27,8 @@
26
27
  footer?: Snippet;
27
28
  class?: string;
28
29
  };
29
- let { title = 'Create an account', description, level = 1, action, rules, form, name = true, confirm = false, termsHref, signInHref, footer, class: klass = '' }: Props = $props();
30
+ let { title: titleProp, description, level = 1, action, rules, form, name = true, confirm = false, termsHref, signInHref, footer, class: klass = '' }: Props = $props();
31
+ const title = $derived(titleProp ?? tb('tern.ui.signUp.title'));
30
32
 
31
33
  let submitting = $state(false);
32
34
  const minPassword = $derived(rules?.find((f) => f.name === 'password')?.rules?.find((r) => r.name === 'min')?.arg);
@@ -39,21 +41,21 @@
39
41
  </header>
40
42
  <Form {rules} {form} {action} bind:submitting reset={false}>
41
43
  {#if name}
42
- <Field label="Name" name="name"><Input autocomplete="name" /></Field>
44
+ <Field label={tb('tern.ui.signUp.name')} name="name"><Input autocomplete="name" /></Field>
43
45
  {/if}
44
- <Field label="Email" name="email"><Input type="email" autocomplete="email" spellcheck="false" /></Field>
45
- <Field label="Password" name="password" description={minPassword ? `At least ${minPassword} characters.` : undefined}>
46
+ <Field label={tb('tern.ui.signUp.email')} name="email"><Input type="email" autocomplete="email" spellcheck="false" /></Field>
47
+ <Field label={tb('tern.ui.signUp.password')} name="password" description={minPassword ? tb('tern.ui.signUp.minPassword', { n: minPassword }) : undefined}>
46
48
  <PasswordInput autocomplete="new-password" />
47
49
  </Field>
48
50
  {#if confirm}
49
- <Field label="Confirm password" name="confirm"><PasswordInput autocomplete="new-password" /></Field>
51
+ <Field label={tb('tern.ui.signUp.confirm')} name="confirm"><PasswordInput autocomplete="new-password" /></Field>
50
52
  {/if}
51
53
  {#if termsHref}
52
- <Checkbox name="terms">I agree to the <a href={termsHref}>terms</a></Checkbox>
54
+ <Checkbox name="terms">{tb('tern.ui.signUp.agree')} <a href={termsHref}>{tb('tern.ui.signUp.terms')}</a></Checkbox>
53
55
  {/if}
54
- <Button type="submit" loading={submitting}>Create account</Button>
56
+ <Button type="submit" loading={submitting}>{tb('tern.ui.signUp.submit')}</Button>
55
57
  </Form>
56
- {#if signInHref}<p class="switch">Already have an account? <a href={signInHref}>Sign in</a></p>{/if}
58
+ {#if signInHref}<p class="switch">{tb('tern.ui.signUp.haveAccount')} <a href={signInHref}>{tb('tern.ui.signUp.signIn')}</a></p>{/if}
57
59
  {#if footer}<div class="footer">{@render footer()}</div>{/if}
58
60
  </section>
59
61
 
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { announce } from './announce.js';
2
3
  import { getField } from './field.js';
3
4
 
4
5
  type Props = {
@@ -88,7 +89,7 @@
88
89
  }
89
90
  </script>
90
91
 
91
- <div class="tern-slider {klass}" class:disabled={off}>
92
+ <div class="tern-slider {klass}" class:disabled={off} use:announce={value}>
92
93
  <!-- svelte-ignore a11y_no_static_element_interactions -->
93
94
  <div
94
95
  bind:this={track}
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  type Props = { label?: string; size?: string; class?: string };
3
- let { label = 'Loading', size = '1.25rem', class: klass = '' }: Props = $props();
4
+ let { label: labelProp, size = '1.25rem', class: klass = '' }: Props = $props();
5
+ const label = $derived(labelProp ?? tb('tern.ui.spinner.label'));
4
6
  </script>
5
7
 
6
8
  <span class="tern-spinner {klass}" role="status" style:--size={size}>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { announce } from './announce.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
5
 
@@ -32,7 +33,7 @@
32
33
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
33
34
  <span class="label" id="tern-switch-{uid}" onclick={() => !disabled && (checked = !checked)}>{@render children()}</span>
34
35
  {/if}
35
- {#if name}<input type="hidden" {name} value={checked ? 'on' : 'off'} />{/if}
36
+ {#if name}<input type="hidden" {name} value={checked ? 'on' : 'off'} use:announce={checked} />{/if}
36
37
  </span>
37
38
 
38
39
  <style>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" generics="Row">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { Column } from './table.js';
4
5
 
@@ -15,7 +16,8 @@
15
16
  empty?: string;
16
17
  class?: string;
17
18
  };
18
- let { columns, rows, caption, hideCaption = false, key = (r) => JSON.stringify(r), empty = 'Nothing to show', class: klass = '' }: Props = $props();
19
+ let { columns, rows, caption, hideCaption = false, key = (r) => JSON.stringify(r), empty: emptyProp, class: klass = '' }: Props = $props();
20
+ const empty = $derived(emptyProp ?? tb('tern.ui.table.empty'));
19
21
  </script>
20
22
 
21
23
  <!-- A semantic table, with a caption, column headers and numeric alignment. -->
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import { dismiss, toasts, type Toast } from './toast.svelte.js';
3
4
 
4
5
  type Props = { position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' };
@@ -36,14 +37,14 @@
36
37
  <p class="message">{t.message}</p>
37
38
  {#if t.description}<p class="description">{t.description}</p>{/if}
38
39
  </div>
39
- <button type="button" aria-label="Dismiss" onclick={() => dismiss(t.id)}>
40
+ <button type="button" aria-label={tb('tern.ui.toaster.dismiss')} onclick={() => dismiss(t.id)}>
40
41
  <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>
41
42
  </button>
42
43
  </li>
43
44
  {/each}
44
45
  {/snippet}
45
46
 
46
- <section class="tern-toaster {position}" aria-label="Notifications">
47
+ <section class="tern-toaster {position}" aria-label={tb('tern.ui.toaster.label')}>
47
48
  <!-- Errors are announced at once; the rest when the reader is idle. -->
48
49
  <ol aria-live="assertive" aria-relevant="additions">{@render list(urgent)}</ol>
49
50
  <ol aria-live="polite" aria-relevant="additions">{@render list(polite)}</ol>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { announce } from './announce.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
5
 
@@ -18,6 +19,7 @@
18
19
  </script>
19
20
 
20
21
  <button
22
+ use:announce={pressed}
21
23
  type="button"
22
24
  class="tern-toggle {variant} {size} {klass}"
23
25
  aria-pressed={pressed}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { announce } from './announce.js';
2
3
  import type { Snippet } from 'svelte';
3
4
  import { getField } from './field.js';
4
5
 
@@ -58,6 +59,7 @@
58
59
  </script>
59
60
 
60
61
  <div
62
+ use:announce={value}
61
63
  class="tern-toggle-group {size} {klass}"
62
64
  role={type === 'single' ? 'radiogroup' : 'group'}
63
65
  aria-label={field ? undefined : label}
@@ -0,0 +1 @@
1
+ export declare function when(iso: string | null | undefined): string;
@@ -0,0 +1,15 @@
1
+ // Dates on the account screens, in the user's locale.
2
+ import { i18n } from '../runtime/i18n.svelte.js';
3
+ export function when(iso) {
4
+ if (!iso)
5
+ return '';
6
+ const d = new Date(iso);
7
+ if (Number.isNaN(d.getTime()))
8
+ return '';
9
+ try {
10
+ return new Intl.DateTimeFormat(i18n.locale || undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
11
+ }
12
+ catch {
13
+ return d.toISOString().slice(0, 16).replace('T', ' ');
14
+ }
15
+ }
@@ -0,0 +1,4 @@
1
+ /** A Svelte action: use:announce={value} fires input when value changes. */
2
+ export declare function announce(node: HTMLElement, _value: unknown): {
3
+ update(): void;
4
+ };
@@ -0,0 +1,14 @@
1
+ // Controls that post through hidden inputs (Combobox, SelectMenu,
2
+ // DatePicker, Slider, …) don't fire input events as the user changes
3
+ // them. announce does: on the element it's on, after each change of its
4
+ // parameter, so a Form checks the field as the user changes it.
5
+ /** A Svelte action: use:announce={value} fires input when value changes. */
6
+ export function announce(node, _value) {
7
+ return {
8
+ update() {
9
+ // After Svelte has written the new value to the DOM, which it does
10
+ // after calling update, so the form reads what the user chose.
11
+ queueMicrotask(() => node.dispatchEvent(new Event('input', { bubbles: true })));
12
+ }
13
+ };
14
+ }