@ternkit/tern 1.5.0 → 1.7.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 (73) 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 +182 -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 +27 -4
  40. package/dist/ui/LinkedAccounts.svelte +54 -0
  41. package/dist/ui/LinkedAccounts.svelte.d.ts +27 -0
  42. package/dist/ui/NavigationMenu.svelte +3 -1
  43. package/dist/ui/Pagination.svelte +5 -3
  44. package/dist/ui/Passkeys.svelte +73 -0
  45. package/dist/ui/Passkeys.svelte.d.ts +21 -0
  46. package/dist/ui/PasswordInput.svelte +2 -1
  47. package/dist/ui/ResetPassword.svelte +54 -0
  48. package/dist/ui/ResetPassword.svelte.d.ts +23 -0
  49. package/dist/ui/Resizable.svelte +3 -1
  50. package/dist/ui/SelectMenu.svelte +5 -2
  51. package/dist/ui/Sidebar.svelte +5 -3
  52. package/dist/ui/SignIn.svelte +61 -13
  53. package/dist/ui/SignIn.svelte.d.ts +5 -1
  54. package/dist/ui/SignUp.svelte +10 -8
  55. package/dist/ui/Slider.svelte +2 -1
  56. package/dist/ui/Spinner.svelte +3 -1
  57. package/dist/ui/Switch.svelte +2 -1
  58. package/dist/ui/Table.svelte +3 -1
  59. package/dist/ui/Toaster.svelte +3 -2
  60. package/dist/ui/Toggle.svelte +2 -0
  61. package/dist/ui/ToggleGroup.svelte +2 -0
  62. package/dist/ui/TwoStep.svelte +155 -0
  63. package/dist/ui/TwoStep.svelte.d.ts +31 -0
  64. package/dist/ui/TwoStepCode.svelte +70 -0
  65. package/dist/ui/TwoStepCode.svelte.d.ts +16 -0
  66. package/dist/ui/accountDates.d.ts +1 -0
  67. package/dist/ui/accountDates.js +15 -0
  68. package/dist/ui/announce.d.ts +4 -0
  69. package/dist/ui/announce.js +14 -0
  70. package/dist/ui/index.d.ts +7 -0
  71. package/dist/ui/index.js +7 -0
  72. package/dist/vite/native.js +21 -4
  73. package/package.json +1 -1
@@ -0,0 +1,16 @@
1
+ import type { FormField } from '../runtime/validate.js';
2
+ type Props = {
3
+ title?: string;
4
+ description?: string;
5
+ level?: 1 | 2 | 3;
6
+ /** The action that calls acc.RequestPasswordReset with the posted email. */
7
+ action?: string;
8
+ rules?: FormField[];
9
+ /** The page's form prop. An action returning { sent: true } shows that the email is on its way. */
10
+ form?: unknown;
11
+ signInHref?: string;
12
+ class?: string;
13
+ };
14
+ declare const ForgotPassword: import("svelte").Component<Props, {}, "">;
15
+ type ForgotPassword = ReturnType<typeof ForgotPassword>;
16
+ export default ForgotPassword;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import { tick, type Snippet } from 'svelte';
3
4
  import type { HTMLFormAttributes } from 'svelte/elements';
4
5
  import { enhance as enhanceAction } from '../runtime/forms.js';
@@ -67,14 +68,24 @@
67
68
  // A field and the fields that must match it, such as a password and its confirmation.
68
69
  const related = (name: string) => [name, ...rules.filter((f) => f.rules?.some((r) => r.name === 'same' && r.arg === name)).map((f) => f.name)];
69
70
 
71
+ // A control's name: its own, or for controls posting through hidden
72
+ // inputs, its Field's.
73
+ const nameOf = (t: EventTarget | null) => (t as HTMLInputElement | null)?.name || (t as HTMLElement | null)?.closest?.('[data-tern-field]')?.getAttribute('data-tern-field') || '';
74
+
75
+ // What was last posted, by name: a field whose value is still that
76
+ // hasn't been edited, though a change event fires as focus leaves it.
77
+ let posted: Record<string, string> = {};
78
+ const valueOf = (name: string) => (el ? new FormData(el).getAll(name).map(String).join('\u0000') : '');
79
+
70
80
  function oninput(e: Event) {
71
- const name = (e.target as HTMLInputElement | null)?.name;
81
+ const name = nameOf(e.target);
72
82
  if (!name) return;
83
+ if (name in posted && valueOf(name) === posted[name]) return;
73
84
  edited = { ...edited, [name]: true };
74
85
  if (touched[name] || submitted) check(related(name).filter((n) => touched[n] || submitted || n === name));
75
86
  }
76
87
  function onfocusout(e: FocusEvent) {
77
- const name = (e.target as HTMLInputElement | null)?.name;
88
+ const name = nameOf(e.target);
78
89
  // Don't complain about a field the user only passed through.
79
90
  if (!name || !byName.has(name) || (!edited[name] && !submitted)) return;
80
91
  touched = { ...touched, [name]: true };
@@ -91,12 +102,17 @@
91
102
  summary = '';
92
103
  return true;
93
104
  }
94
- summary = names.length === 1 ? '1 field needs attention.' : `${names.length} fields need attention.`;
105
+ summary = tb('tern.ui.form.summary', { n: names.length });
95
106
  void tick().then(() => el?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus());
96
107
  return false;
97
108
  }
98
109
 
99
- const submit = ({ formData, cancel }: { formData: FormData; cancel(): void }) => {
110
+ // The newest post wins: a submit while another is in flight, such as
111
+ // Enter right after a code completed itself, aborts the older one, and
112
+ // an older answer that still arrives is dropped.
113
+ let latest = 0;
114
+ let inflight: AbortController | null = null;
115
+ const submit = ({ formData, cancel, controller }: { formData: FormData; cancel(): void; controller: AbortController }) => {
100
116
  const errors = validate(rules, formData);
101
117
  if (Object.keys(errors).length > 0) {
102
118
  cancel();
@@ -107,7 +123,14 @@
107
123
  client = {};
108
124
  summary = '';
109
125
  submitting = true;
126
+ inflight?.abort();
127
+ inflight = controller;
128
+ const mine = ++latest;
129
+ posted = {};
130
+ for (const k of new Set(formData.keys())) posted[k] = formData.getAll(k).map(String).join('\u0000');
110
131
  return async ({ update, result }: { update: (o?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: unknown } }) => {
132
+ if (mine !== latest) return;
133
+ inflight = null;
111
134
  await update({ reset });
112
135
  submitting = false;
113
136
  if (result.type === 'success') {
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import AccountSection from './AccountSection.svelte';
4
+ import Button from './Button.svelte';
5
+ import Form from './Form.svelte';
6
+ import { when } from './accountDates.js';
7
+
8
+ type Linked = { provider: string; email: string; createdAt: string };
9
+ type Props = {
10
+ title?: string;
11
+ description?: string;
12
+ level?: 2 | 3 | 4;
13
+ /** From acc.Identities. */
14
+ identities: Linked[];
15
+ /** plugins/oauth's providers, such as plugin.Providers(): each one not linked yet gets a Connect button. */
16
+ providers: { name: string; label: string }[];
17
+ /** Where plugins/oauth lives. */
18
+ authPath?: string;
19
+ /** Where to come back to after connecting: this page. */
20
+ next?: string;
21
+ /** The action that calls acc.Unlink with the posted provider. */
22
+ unlinkAction?: string;
23
+ class?: string;
24
+ };
25
+ let { title, description, level = 2, identities, providers, authPath = '/auth', next, unlinkAction, class: klass = '' }: Props = $props();
26
+ const label = (name: string) => providers.find((p) => p.name === name)?.label ?? name;
27
+ const linked = $derived(new Set(identities.map((i) => i.provider)));
28
+ const connect = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
29
+ </script>
30
+
31
+ <AccountSection id="tern-linked-title" title={title ?? tb('tern.ui.linked.title')} description={description ?? tb('tern.ui.linked.description')} {level} class={klass}>
32
+ <ul>
33
+ {#each identities as i (i.provider)}
34
+ <li>
35
+ <div class="tern-account-text">
36
+ <span class="tern-account-name">{label(i.provider)}</span>
37
+ <span class="tern-account-meta">{i.email}{i.email ? ' · ' : ''}{tb('tern.ui.linked.since', { when: when(i.createdAt) })}</span>
38
+ </div>
39
+ {#if unlinkAction}
40
+ <Form action={unlinkAction} reset={false}>
41
+ <input type="hidden" name="provider" value={i.provider} />
42
+ <Button type="submit" variant="ghost" size="sm">{tb('tern.ui.linked.disconnect')}</Button>
43
+ </Form>
44
+ {/if}
45
+ </li>
46
+ {/each}
47
+ {#each providers.filter((p) => !linked.has(p.name)) as p (p.name)}
48
+ <li>
49
+ <div class="tern-account-text"><span class="tern-account-name">{p.label}</span></div>
50
+ <Button variant="secondary" size="sm" href={connect(p.name)}>{tb('tern.ui.linked.connect')}</Button>
51
+ </li>
52
+ {/each}
53
+ </ul>
54
+ </AccountSection>
@@ -0,0 +1,27 @@
1
+ type Linked = {
2
+ provider: string;
3
+ email: string;
4
+ createdAt: string;
5
+ };
6
+ type Props = {
7
+ title?: string;
8
+ description?: string;
9
+ level?: 2 | 3 | 4;
10
+ /** From acc.Identities. */
11
+ identities: Linked[];
12
+ /** plugins/oauth's providers, such as plugin.Providers(): each one not linked yet gets a Connect button. */
13
+ providers: {
14
+ name: string;
15
+ label: string;
16
+ }[];
17
+ /** Where plugins/oauth lives. */
18
+ authPath?: string;
19
+ /** Where to come back to after connecting: this page. */
20
+ next?: string;
21
+ /** The action that calls acc.Unlink with the posted provider. */
22
+ unlinkAction?: string;
23
+ class?: string;
24
+ };
25
+ declare const LinkedAccounts: import("svelte").Component<Props, {}, "">;
26
+ type LinkedAccounts = ReturnType<typeof LinkedAccounts>;
27
+ export default LinkedAccounts;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
2
3
  import { page } from '../runtime/state.svelte.js';
3
4
  import { float } from './floating.js';
4
5
 
@@ -9,7 +10,8 @@
9
10
  label?: string;
10
11
  class?: string;
11
12
  };
12
- let { items, label = 'Main', class: klass = '' }: Props = $props();
13
+ let { items, label: labelProp, class: klass = '' }: Props = $props();
14
+ const label = $derived(labelProp ?? tb('tern.ui.navigationMenu.label'));
13
15
 
14
16
  const uid = $props.id();
15
17
  let open = $state<number | null>(null);
@@ -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. */
@@ -28,7 +35,7 @@
28
35
  rules?: FormField[];
29
36
  /** The page's form prop, for the server's errors. */
30
37
  form?: unknown;
31
- /** The ?error= code plugins/oauth sends back after a failed sign-in. */
38
+ /** The ?error= code plugins/oauth sends back after a failed sign-in, such as denied or unverified. */
32
39
  error?: string | null;
33
40
  /** A "Keep me signed in" checkbox, posted as remember. */
34
41
  remember?: boolean;
@@ -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,84 @@
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
75
+ ? null
76
+ : error === 'access_denied' || error === 'denied'
77
+ ? tb('tern.ui.signIn.cancelled')
78
+ : error === 'unverified'
79
+ ? tb('tern.ui.signIn.unverified')
80
+ : error === 'taken'
81
+ ? tb('tern.ui.signIn.taken')
82
+ : error === 'no_email'
83
+ ? tb('tern.ui.signIn.noEmail')
84
+ : error === 'limited'
85
+ ? tb('tern.accounts.error.tooMany')
86
+ : tb('tern.ui.signIn.failed')
65
87
  );
88
+ let el = $state<HTMLElement>();
89
+ let passkeyBusy = $state(false);
90
+ let passkeyError = $state(false);
91
+ // Rendered on the server too, where passkeys can't be checked: shown
92
+ // once the browser says it can use them.
93
+ let canPasskey = $state(false);
94
+ $effect(() => {
95
+ canPasskey = passkey && passkeysSupported();
96
+ });
97
+ async function usePasskey() {
98
+ passkeyBusy = true;
99
+ passkeyError = false;
100
+ try {
101
+ const rememberMe = !!el?.querySelector<HTMLInputElement>('input[name="remember"]')?.checked;
102
+ await signInWithPasskey({ path: passkeyPath, remember: rememberMe });
103
+ await goto(next && next.startsWith('/') && !next.startsWith('//') ? next : '/', { invalidateAll: true, replaceState: true });
104
+ } catch (e) {
105
+ if (!(e as { cancelled?: boolean }).cancelled) passkeyError = true;
106
+ } finally {
107
+ passkeyBusy = false;
108
+ }
109
+ }
66
110
  const login = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
67
111
  </script>
68
112
 
69
- <section class="tern-auth {klass}" aria-labelledby="tern-signin-title">
113
+ <section class="tern-auth {klass}" aria-labelledby="tern-signin-title" bind:this={el}>
70
114
  <header>
71
115
  <svelte:element this={`h${level}`} id="tern-signin-title">{title}</svelte:element>
72
116
  {#if description}<p>{description}</p>{/if}
73
117
  </header>
74
118
  {#if errorText}<Alert tone="danger" live>{errorText}</Alert>{/if}
75
- {#if providers.length}
119
+ {#if passkeyError}<Alert tone="danger" live>{tb('tern.ui.signIn.passkeyFailed')}</Alert>{/if}
120
+ {#if providers.length || canPasskey}
76
121
  <div class="providers">
122
+ {#if canPasskey}
123
+ <Button variant="secondary" type="button" loading={passkeyBusy} onclick={usePasskey}>{tb('tern.ui.signIn.passkey')}</Button>
124
+ {/if}
77
125
  {#each providers as p (p.name)}
78
- <Button variant="secondary" href={login(p.name)}>Continue with {p.label}</Button>
126
+ <Button variant="secondary" href={login(p.name)}>{tb('tern.ui.signIn.continueWith', { provider: p.label })}</Button>
79
127
  {/each}
80
128
  </div>
81
129
  {/if}
82
- {#if providers.length && password}<div class="or"><Separator /><span>or</span><Separator /></div>{/if}
130
+ {#if (providers.length || canPasskey) && password}<div class="or"><Separator /><span>{tb('tern.ui.signIn.or')}</span><Separator /></div>{/if}
83
131
  {#if password}
84
132
  <Form {rules} {form} {action} bind:submitting reset={false}>
85
133
  {#if next}<input type="hidden" name="next" value={next} />{/if}
86
- <Field label="Email" name="email">
134
+ <Field label={tb('tern.ui.signIn.email')} name="email">
87
135
  <Input type="email" autocomplete="username" spellcheck="false" />
88
136
  </Field>
89
- <Field label="Password" name="password">
137
+ <Field label={tb('tern.ui.signIn.password')} name="password">
90
138
  <PasswordInput autocomplete="current-password" />
91
139
  </Field>
92
140
  <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}
141
+ {#if remember}<Checkbox name="remember">{tb('tern.ui.signIn.remember')}</Checkbox>{/if}
142
+ {#if forgotHref}<a href={forgotHref}>{tb('tern.ui.signIn.forgot')}</a>{/if}
95
143
  </div>
96
- <Button type="submit" loading={submitting}>Sign in</Button>
144
+ <Button type="submit" loading={submitting}>{tb('tern.ui.signIn.submit')}</Button>
97
145
  </Form>
98
146
  {/if}
99
- {#if signUpHref}<p class="switch">New here? <a href={signUpHref}>Create an account</a></p>{/if}
147
+ {#if signUpHref}<p class="switch">{tb('tern.ui.signIn.new')} <a href={signUpHref}>{tb('tern.ui.signIn.signUp')}</a></p>{/if}
100
148
  {#if footer}<div class="footer">{@render footer()}</div>{/if}
101
149
  </section>
102
150
 
@@ -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. */
@@ -21,7 +25,7 @@ type Props = {
21
25
  rules?: FormField[];
22
26
  /** The page's form prop, for the server's errors. */
23
27
  form?: unknown;
24
- /** The ?error= code plugins/oauth sends back after a failed sign-in. */
28
+ /** The ?error= code plugins/oauth sends back after a failed sign-in, such as denied or unverified. */
25
29
  error?: string | null;
26
30
  /** A "Keep me signed in" checkbox, posted as remember. */
27
31
  remember?: boolean;