@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.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/builtin.d.ts +1 -0
- package/dist/runtime/builtin.js +140 -0
- package/dist/runtime/deferred.d.ts +1 -0
- package/dist/runtime/deferred.js +4 -2
- package/dist/runtime/i18n.svelte.d.ts +6 -0
- package/dist/runtime/i18n.svelte.js +10 -0
- package/dist/runtime/passkeys.d.ts +21 -0
- package/dist/runtime/passkeys.js +107 -0
- package/dist/runtime/router.svelte.js +2 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/validate.d.ts +2 -1
- package/dist/runtime/validate.js +20 -18
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +8 -4
- package/dist/ui/AccountSection.svelte +75 -0
- package/dist/ui/AccountSection.svelte.d.ts +12 -0
- package/dist/ui/AlertDialog.svelte +5 -2
- package/dist/ui/AuthCard.svelte +64 -0
- package/dist/ui/AuthCard.svelte.d.ts +12 -0
- package/dist/ui/BarChart.svelte +2 -1
- package/dist/ui/Breadcrumb.svelte +2 -1
- package/dist/ui/Calendar.svelte +3 -2
- package/dist/ui/Carousel.svelte +4 -3
- package/dist/ui/ChartFrame.svelte +2 -1
- package/dist/ui/Combobox.svelte +6 -3
- package/dist/ui/Command.svelte +5 -1
- package/dist/ui/ConsentScreen.svelte +7 -6
- package/dist/ui/DataTable.svelte +7 -5
- package/dist/ui/DatePicker.svelte +8 -5
- package/dist/ui/Devices.svelte +50 -0
- package/dist/ui/Devices.svelte.d.ts +23 -0
- package/dist/ui/Dialog.svelte +2 -1
- package/dist/ui/DonutChart.svelte +3 -1
- package/dist/ui/Field.svelte +1 -1
- package/dist/ui/ForgotPassword.svelte +43 -0
- package/dist/ui/ForgotPassword.svelte.d.ts +16 -0
- package/dist/ui/Form.svelte +8 -3
- package/dist/ui/NavigationMenu.svelte +3 -1
- package/dist/ui/Pagination.svelte +5 -3
- package/dist/ui/Passkeys.svelte +73 -0
- package/dist/ui/Passkeys.svelte.d.ts +21 -0
- package/dist/ui/PasswordInput.svelte +2 -1
- package/dist/ui/ResetPassword.svelte +54 -0
- package/dist/ui/ResetPassword.svelte.d.ts +23 -0
- package/dist/ui/Resizable.svelte +3 -1
- package/dist/ui/SelectMenu.svelte +5 -2
- package/dist/ui/Sidebar.svelte +5 -3
- package/dist/ui/SignIn.svelte +48 -12
- package/dist/ui/SignIn.svelte.d.ts +4 -0
- package/dist/ui/SignUp.svelte +10 -8
- package/dist/ui/Slider.svelte +2 -1
- package/dist/ui/Spinner.svelte +3 -1
- package/dist/ui/Switch.svelte +2 -1
- package/dist/ui/Table.svelte +3 -1
- package/dist/ui/Toaster.svelte +3 -2
- package/dist/ui/Toggle.svelte +2 -0
- package/dist/ui/ToggleGroup.svelte +2 -0
- package/dist/ui/accountDates.d.ts +1 -0
- package/dist/ui/accountDates.js +15 -0
- package/dist/ui/announce.d.ts +4 -0
- package/dist/ui/announce.js +14 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/vite/native.js +21 -4
- 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
|
|
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, '
|
|
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, '
|
|
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=
|
|
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;
|
package/dist/ui/Resizable.svelte
CHANGED
|
@@ -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
|
|
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
|
|
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)}
|
package/dist/ui/Sidebar.svelte
CHANGED
|
@@ -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
|
|
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=
|
|
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=
|
|
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>
|
package/dist/ui/SignIn.svelte
CHANGED
|
@@ -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
|
|
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' ? '
|
|
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
|
|
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)}>
|
|
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=
|
|
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=
|
|
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">
|
|
94
|
-
{#if forgotHref}<a href={forgotHref}>
|
|
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}>
|
|
132
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.signIn.submit')}</Button>
|
|
97
133
|
</Form>
|
|
98
134
|
{/if}
|
|
99
|
-
{#if signUpHref}<p class="switch">
|
|
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. */
|
package/dist/ui/SignUp.svelte
CHANGED
|
@@ -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
|
|
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=
|
|
44
|
+
<Field label={tb('tern.ui.signUp.name')} name="name"><Input autocomplete="name" /></Field>
|
|
43
45
|
{/if}
|
|
44
|
-
<Field label=
|
|
45
|
-
<Field label=
|
|
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=
|
|
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">
|
|
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}>
|
|
56
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.signUp.submit')}</Button>
|
|
55
57
|
</Form>
|
|
56
|
-
{#if signInHref}<p class="switch">
|
|
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
|
|
package/dist/ui/Slider.svelte
CHANGED
|
@@ -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}
|
package/dist/ui/Spinner.svelte
CHANGED
|
@@ -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
|
|
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}>
|
package/dist/ui/Switch.svelte
CHANGED
|
@@ -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>
|
package/dist/ui/Table.svelte
CHANGED
|
@@ -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
|
|
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. -->
|
package/dist/ui/Toaster.svelte
CHANGED
|
@@ -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=
|
|
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=
|
|
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>
|
package/dist/ui/Toggle.svelte
CHANGED
|
@@ -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,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
|
+
}
|